[Effective Java 3/E] ITEM 46. 스트림에서는 부작용 없는 함수를 사용하라.

    Effective Java 표지. Source : https://blog.insightbook.co.kr/

    *알림 : *
    Effective Java 3판은 Java 9까지 도입된 언어적 기능을 중심으로 서술되어 있습니다. 10버젼 이후의 Java 개발을 하시는 분들은 우회적인 접근법 대신 Java 언어 내 새로 도입된 기능이 더 간결하고 좋을 수 있습니다.

    해당 포스팅은 SSAFY 내 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
    https://github.com/kjsu0209/JavaBook
    https://medium.com/javabook

     

    kjsu0209/JavaBook

    책읽기 스터디. Contribute to kjsu0209/JavaBook development by creating an account on GitHub.

    github.com

     

    JavaBook – Medium

    Documentation space of our book study.

    medium.com

    작성자 주 : 해당 아이템의 '부작용'이란 단어는 원문의 'side-effect'에 해당합니다. 함수형 프로그래밍 패러다임에서 부작용의 의미는, 순수 함수와 연관됩니다.
    참고하기 : 함수형 프로그래밍의 순수 함수 by recordboy

    스트림 패러다임의 핵심은 계산을 변환으로 재구성하는 부분이다. 변환을 처리하는 함수 객체는 순수 함수여야 한다. 순수 함수는 프로그램의 상태(state)에 영향을 받지 않고 주지 않으며, 항상 동일 input에 동일 output을 반환하는 함수이다. 순수 함수는 함수형 프로그래밍 패러다임의 핵심이며, 스트림은 함수형 프로그래밍 패러다임에 기초하였기 때문이다.

    예제 1 : 순수 함수를 사용하지 않은 사례 (DO NOT!)

    public static void main(String[] args) throws FileNotFoundException {
            File file = new File(args[0]);
    
            Map<String, Long> freq = new HashMap<>();
            try (Stream<String> words = new Scanner(file).tokens()) {
                words.forEach(word -> {
                    freq.merge(word.toLowerCase(), 1L, Long::sum);
                });
            }
    }

    문제 없이 작동하지만 스트림 외부의 freq에 조작을 가하여 프로그램의 상태에 영향을 주지 않는다는 순수 함수의 원칙을 어겼다.

    예제 2 : 가장 스트림 다운 코드

            Map<String, Long> freq;
            try (Stream<String> words = new Scanner(file).tokens()) {
                freq = words
                .collect(groupingBy(String::toLowerCase, counting()));
          }

    groupingBy는 Map 객체를 리턴한다. 코드가 깔끔해졌다. 함수 외부의 상태를 조작하거나 그로부터 영향을 받지도 않는다.

    forEach 연산의 맹점

    1. 순회는 병렬화가 어렵다.
    2. 스트림 중 기능이 가장 적다.
    3. 스트림 답지 않다.

    forEach 연산은 스트림 계산 결과를 순회할 때만 사용하고, 계산할 때는 사용하지 말자.

    java.util.stream.Collectors

    스트림의 원소들을 객체 하나에 모으는 reduction(축소)를 캡슐화한 블랙박스 객체로 생각하자.

    • toList()
    • toSet()
    • toCollection(collectionFactory)

    예제 3 : Collector중 하나인 toList()를 이용한 구현

    List<String> topTen = freq.keySet().stream()
                    .sorted(comparing(freq::get).reversed())
                    .limit(10)
                    .collect(toList());
    • toMap(keyMapper, valueMapper)
    private static final Map<String, Operation> stringToEnum = 
        Stream.of(values()).collect(
            toMap(Object::toString, e->e));

    각 원소가 고유한 KEY에 매핑되어 있을 때 적합하다. 만약 하나의 키의 다수의 값이 매핑되어 있다면 IllegalStateException을 던질 것이다.

    Key 충돌을 피하기 위해 merge 함수와 같이 쓸 수도 있다. BinaryOperator 인터페이스를 가지고 있는 merge 함수는 같은 key에 여러 value가 있을 때 하나의 key에 값을 모으는 람다를 구현함으로써 사용할 수 있다. 예를 들면 하나의 키에 있는 여러 value를 다 곱하는 식으로 key-value 1:1 관계를 만들 수 있다.

    toMap이 인자 3개를 받는 경우에는, 하나의 key에 여러 value가 mapping되어 있을 경우 value중 하나를 고르는 방법까지 지정할 수 있다. 성공한 음악가는 여러 앨범이 있을 것이며, 그 중 베스트 앨범만을 매핑하도록 하고싶다고 가정해보자.

    예제 4 : 여러 value 중 하나만 골라 key와 매핑하기

    Map<Artist, Album> topHits = albums.collect(
    toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));

    sales가 최대인 value를 뽑아 map하는 코드다.

    만약 하나의 key에 value가 모두 같거나, 값이 서로 unique 하더라도 하나를 고를 수 없다면, 마지막 값을 취하는 collector를 만들 수도 있다. (last-write-wins)

    예제 5 : last-write-wins를 구현한 toMap

    toMap(keyMapper, valueMapper, (v1, v2) -> v2);

    그 외에도 4개의 인자를 받는 toMap이 있는데, 4번째 인수로 MapFactory를 받는데 EnumMap이나 TreeMap과 같은 원하는 특정 맵 구현체를 직접 지정할 수 있다.

    예제 6 : 원하는 트리 구현체를 직접 지정하는 parameter 4개짜리 toMap

    Map<Integer, String> map4 = Arrays.asList("this", "is", "just", "an", "example").stream()
        .collect(Collectors.toMap(w -> w.length(),
                                  w -> w,
                                (existing, replacement) -> replacement,
                                () -> new TreeMap<Integer, String>(Comparator.reverseOrder())));

    예제 2부터 예제 6까지는 toConcurrentMap이란 변종이 있으며, 병렬로 실행된 후 ConcurrentHashMap 인스턴스를 제공한다.

    groupingBy()

    입력으로 분류 함수(classifier)를 받고 원소들을 카테고리 별로 모아놓은 맵을 담은 수집기를 반환한다. 분류 함수는 원소 입력→원소가 속하는 카테고리를 반환하고, 이 반환된 카테고리가 맵의 key로 쓰인다. value는 같은 key를 공유하는 value들의 리스트다.

    예제 7 : 애너그램 프로그램

    words.collect(groupingBy(word -> alphabetize(word)))

    만약 리스트가 아닌 다른 값을 갖도록 하고 싶다면, 분류 함수와 더불어 다운스트림(downstream) collector도 명시하여야 한다. toSet()이 올 수도 있고, toCollection(collectionFactory)를 사용하여 원하는 컬렉션 타입을 선택할 수 있는 유연성도 가져갈 수 있다. counting()을 건네는 방법도 있으며, 원소의 갯수가 key가 된다.

    세번째 인자로 mapFactory를 지정하면, map 구현체와 value의 collection 모두를 지정할 수 있다. 역시 groupingByConcurrent 메서드도 있다.

    partitoningBy()를 이용하면 분류 함수로 predicate를 제공하고 key가 boolean인 맵을 반환하도록 할 수도 있다. 마찬가지로 downstream을 입력받는 overloading 메서드도 있다.

    counting 메서드가 반환하는 Collector는 다운스트림 Collector 전용이지만, Stream의 count 메서드를 직접 사용할 수도 있다. summing, averaging, summarizing + int, long, double = 9개의 메서드가 더 있다. filtering, mapping, flatMapping, collecting, AndThen 메서드도 있다.

    minBy와 maxBy는 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작거나 큰 원소를 반환한다.

    joining은 CharSqeuence 인스턴스의 스트림에만 적용할 수 있다. 매개변수가 없는 joining은 concatenate 역할을 수행하는 collector를 반환한다. parameter 하나 짜리는 delimiter를 매개변수로 받아 연결하는 부위별로 구분문자를 삽입한다. 3개짜리는 prefix와 suffix도 받는 버젼이 있다.

    참고 : Guide to Java 8’s Collectors from Baeldung

    댓글