[이펙티브자바] item46. 스트림에서는 부작용 없는 함수를 사용하라

wally·2022년 7월 8일

1. Stream

1. side effect 를 제거하라

  • 스트림 은 함수형 프로그래밍에 기초한 패러다임이다.
  • 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
    • 순수 함수 : 오직 입력만이 결과에 영향을 주는 함수, 다른 가변상태를 참조않고, 함수 스스로도 다른 상태를 변경하지 않는다.

스트림 연산에 건네는 함수 객체는 모두 side effect(부작용)이 없어야 한다!!

2. 잘못된 코드 -> 좋은 코드

Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()){
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}
  • forEach는 연산 결과를 보여주는 역할을 가진 종단 연산인데, 외부 상태인 freq를 수정하고 있다.
  • 즉, 함수 스스로 외부 상태를 변경하고 연산 결과를 보여주는 일 이상을 하고 있다.
  • 이는 스트림을 가장한 반복문이다.
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()){
    freq = words.collect(groupingBy(String::toLowerCase, counting()))
}
  • forEach는 종단 연산 중 기능이 가장 적고 가장 덜 스트림스럽다.
  • 또한, 병렬화할 수도 없다.
  • 따라서 forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산할 때는 사용하지 말자.

2. 수집기(collector)

1. 수집기(collector)란

  • 수집기는 java.util.stream.Collectors 클래스의 메서드를 사용하는데, 스트림의 원소들을 축소해서 객체 하나에 모아주는 역할을 한다.
  • 익숙해지기 전까지 그저 축소 전략(스트림 원소를 객체 하나에 취합)을 캡슐화한 블랙박스 객체라고 생각하자.
  • 수집기가 생성하는 객체는 주로 컬렉션이며,
    toList() : 스트림 원소를 list 에 담는다.
    toSet() : 스트림 원소를 set 에 담는다.
    toCollection(collectionFactory) : 프로그래머가 지정한 타입에 담는다.
    이렇게 총 3가지의 수집기가 있다.

2. toList()

List<String> topTen = freq.keySet().stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());
 
  • 스트림 원소를 List 로 모아준다.

위의 toList()는 Collectors의 메서드로 정적 임포트하여 사용하고 있다. 이렇듯 정적 임포트를 사용하면 코드 가독성이 좋아진다. collect(Collectors.toList()) → collect(toList())

3. toMap()

  • 스트림 원소를 key - value 형태로 재생산한다.

toMap(keyMapper, valueMapper) - 인수 2개

  • toMap(keyMapper, valueMapper)
    • Key에 매핑되는 keyMapper(함수)Value에 매핑되는 valueMapper(함수)를 인수로 받는다.
  • toMap()은 스트림 원소가 다수의 같은 키를 사용하는 경우 IllegalStateException을 던진다. 주의하자.
public class Artist {
    private String name;
    private String nickname;

    public Artist(String name, String nickname) {
        this.name = name;
        this.nickname = nickname;
    }

    public String getName() {
        return name;
    }

    public String getNickname() {
        return nickname;
    }

    @Override
    public String toString() {
        return name;
    }
}
public class Album {
    private Artist artist;
    private int sales;

    public Album(Artist artist, int sales) {
        this.artist = artist;
        this.sales = sales;
    }

    public Artist artist() {
        return artist;
    }

    public int sales() {
        return sales;
    }

    @Override
    public String toString() {
        return Integer.toString(sales);
    }
}
public class StreamToMapMain {
    public static void main(String[] args) {
        Artist a = new Artist("a", "11");
        Artist b = new Artist("a", "22");
        Artist c = new Artist("c", "22");

        List<Artist> artists = List.of(a,b,c);
        artists.stream()
            .collect(toMap(Artist::getName, Artist::getNickname));

    }
}

toMap(keyMapper, valueMapper, mergeFunction) - 인수 3개

  • 같은 키를 공유하는 값들이 있는 경우 병함 함수를 통해 기존 값에 합쳐진다.
  • 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들 때 유용하다.
public class StreamToMapMain {
    public static void main(String[] args) {
        Artist a = new Artist("a", "11");
        Artist b = new Artist("b", "22");
        Artist c = new Artist("c", "22");

        List<Album> albums = List.of(
            new Album(a, 21),
            new Album(a, 11),
            new Album(b, 12),
            new Album(c, 28)
        );

        Map<Artist, Album> topHits = albums.stream().collect(
            toMap(Album::artist,
                album -> album,
                maxBy(comparing(Album::sales))
            )
        );
        System.out.println(topHits);
    }
}

  • 위코드는 비교자로는 BinaryOperator에서 정적 임포트한 maxBy라는 정적 팩토리 메서드를 사용했습니다. maxByComparator<T>를 입력받아 BinaryOperator<T>를 반환합니다.

  • 인수가 3개인 toMap은 충돌이 나면 마지막 값을 취하는(last-write-wins) 수집기를 만들 때도 유용합니다.

toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
public class StreamToMapMain {
    public static void main(String[] args) {
        Artist a = new Artist("a", "11");
        Artist b = new Artist("b", "22");
        Artist c = new Artist("c", "22");

        List<Album> albums = List.of(
            new Album(a, 21),
            new Album(a, 11),
            new Album(b, 12),
            new Album(c, 28)
        );

        Map<Artist, Album> topHits = albums.stream().collect(
            toMap(Album::artist,
                album -> album,
                (aa, bb)-> bb
            )
        );
        System.out.println(topHits);

toMap(keyMapper, valueMapper, mergeFunction, Map의 구현체) - 인수 4개

  • toMap()은 마지막 인수로 맵 팩토리를 받습니다. 이 인수로는 EnumMap이나 TreeMap처럼 원하는 특정 맵 구현체를 직접 지정할 수 있습니다.
        TreeMap<Artist, Album> topHitsTree = albums.stream().collect(
            toMap(Album::artist,
                album -> album,
                maxBy(comparing(Album::sales)),
                TreeMap::new
            )
        );

3. groupingBy

groupingBy

  • 분류 함수를 입력받고 원소들을 카테고리별로 모아 놓은 Map을 반환한다. 반환된 Map에 담긴 각각의 값은 List다.
class BlogPost {
    String title;
    String author;
    BlogPostType type;
    int likes;
}

enum BlogPostType {
    NEWS,
    REVIEW,
    GUIDE
}

List<BlogPost> posts = Arrays.asList( ... );
Map<BlogPostType, List<BlogPost>> postsPerType = posts.stream()
  .collect(groupingBy(BlogPost::getType));

값을 리스트외에 다른 타입으로 반환하는 방법

  • 값을 리스트 외 다른 타입으로 반환하기 위해서는 다운스트림(3번째 인자)을 명시해야 한다.
  • 다운스트림의 역할은 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일이다.
public class StreamToMapMain {
    public static void main(String[] args) {
        List<BlogPost> posts = List.of(
            new BlogPost("제목1","저자1",  NEWS, 1),
            new BlogPost("제목2","저자2",  REVIEW, 1),
            new BlogPost("제목3","저자3",  GUIDE, 1),
            new BlogPost("제목4" ,"저자4",  GUIDE, 1)
        );

		// list 로 매핑
        Map<BlogPostType, List<BlogPost>> collect1 = posts.stream()
            .collect(groupingBy(BlogPost::getType));

		// set 으로 매핑
        Map<BlogPostType, Set<BlogPost>> collect2 =posts.stream()
            .collect(groupingBy(BlogPost::getType, toSet()));
		
        //  Collection 으로 custom 매핑
        Map<BlogPostType, LinkedHashSet<BlogPost>> collect3 = posts.stream()
             .collect(groupingBy(BlogPost::getType, toCollection(LinkedHashSet::new)));
             
		// 원소의 개수로 매핑
        Map<BlogPostType, Long> collect4 = posts.stream()
            .collect(groupingBy(BlogPost::getType, counting()));
    }
}

다운스트림 수집기에 더해 맵 팩토리도 지정 가능

  • 맵 팩터리를 지정해 맵과 그 안에 담긴 컬렉션의 타입을 모두 지정할 수 있게 만들 수 있다.
  • 일반적으로 3번째 인자로 맵 팩터리가 와야 하지만, 맵 팩터리(mapFactory) 매개변수가 다운스트림 매개변수보다 앞에 놓인다.
        TreeMap<BlogPostType, Long> collect5 = posts.stream()
            .collect(groupingBy(BlogPost::getType, TreeMap::new, counting()));
  • counting() 메서드가 반환하는 수집기는 다운스트림 수집기 전용입니다. Stream의 count 메서드를 직접 사용하여 같은 기능을 수행할 수 있으니 collect(counting()) 형태로 사용할 일은 전혀 없습니다.

4. joining

  • joining() 메서드는 (문자열 등의) CharSequence 인스턴스의 스트림에만 적용할 수 있습니다.

  • 인수가 없는 joining은 단순히 원소들을 연결(concatenate)하는 수집기를 반환합니다.

  • 인수가 하나짜리 joining은 CharSequence 타입의 구분문자(delimiter)를 매개변수로 받습니다. 연결 부위에 이 구분문자를 삽입하는데, 구분문자로 쉼표(,)를 입력하면 CSV 형태의 문자열을 만들어줍니다(단, 스트림에 쉼표를 이미 포함한 원소가 있다면 구분문자와 구별되지 않으니 유의해야 합니다).

  • 인수가 3개짜리 joining은 구분문자에 더해 접두문자(prefix)와 접미문자(suffix)도 받습니다.

public class Test {
    public static void main(String[] args) {
        List<String> stringList = List.of("Hello", "World", "!!");
        // 인수가 없는 경우
        String collect1 = stringList.stream().collect(joining());
        System.out.println(collect1);

		// 인수가 1개인 경우
        String collect2 = stringList.stream().collect(joining(","));
        System.out.println(collect2);

		// 인수가 3개인 경우
        String collect3 = stringList.stream().collect(joining(",","[","]"));
        System.out.println(collect3);
    }
}

5. 정리

스트림 파이프라인 프로그래밍의 핵심은 부작용(side effect)없는 함수 객체이다!!

스트림을 올바르게 사용하려면 수집기를 잘 알아두자!!(toList, toSet, toMap, groupingBy, joining)

profile
클린코드 지향

0개의 댓글