Skip to content

item 46 dodo4513

황도영 edited this page Apr 24, 2020 · 1 revision

아이템46 스트림에서는 부작용 없는 함수를 사용하라

  • 스트림은 처음 봐서는 이해하기 어려울 수 있다. 원하는 작업을 스트림 파이프라인으로 표현하는 것조차 어려울지 모른다. 성공하여 프로그램이 동작하더라도 장점이 무엇인지 쉽게 와 닿지 않을 수도 있다. 스트림은 그저 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이기 때문이다. 스트림이 제공하는 표현력, 속도, (상황에 따라서는) 병렬성을 얻으려면 API는 말할 것 도 없고 이 패러다임까지 함께 받아들여야 한다.
// 스트림 패러다임을 이해하지 못한 채 API만 사용했다 - 따라 하지 말 것!
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가 그저 스트림이 수행한 연산 결과를 보여주는 일 이상을 하는 것(이 예에서는 람다가 상태를 수정함)을 보니 나쁜 코드일 것 같은 냄새가 난다.
// 스트림을 제대로 활용해 빈도표를 초기화한다.
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words
        .collect(groupingBy(String::toLowerCase, counting()));
}
  • forEach 연산은 종단 연산 중 기능이 가장 적고 가장 ‘덜’ 스트림답다. 대놓고 반복적이라서 병렬화할 수도 없다. forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자. 물론 가끔은 스트림 계산 결과를 기존 컬렉션에 추가하는 등의 다른 용도로도 쓸 수 있다.

java.util.stream.Collectors

  • 이 코드는 수집기(collector)를 사용하는데, 스트림을 사용하려면 꼭 배워야 하는 새로운 개념이다. java.util.stream.Collectors 클래스는 메서드를 무려 39개나 가지고 있고, 그중에는 타입 매개변수가 5개나 되는 것도 있다.

  • 처음에는 쉽게 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 생각하기 바란다. 여기서 축소는 스트림의 원소들을 객체 하나에 취합한다는 뜻이다. 수집기가 생성하는 객체는 일반적으로 컬렉션이며, 그래서 “collector”라는 이름을 쓴다.

  • 수집기를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다. 수집기는 총 세 가지로, toList(), toSet(), toCollection(collectionFactory)다.

다양한 예시를 살펴보자

1. 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인

List<String> topTen = freq.keySet().stream()
                                  .sorted(comparing(freq::get).reversed())
                                  .limit(10)
                                  .collect(toList());
  • comparing 메서드는 키 추출 함수를 받는 비교자 생성 메서드(아이템 14)다.
  • 한정적 메서드 참조이자, 여기서 키 추출 함수로 쓰인 freq::get은 입력받은 단어(키)를 빈도표에서 찾아(추출) 그 빈도를 반환한다.
  • 가장 흔한 단어가 위로 오도록 비교자(comparing)를 역순(reversed)으로 정렬한다(sorted).
  • 마지막 toList는 Collectors의 메서드다. 이처럼 Collectors의 멤버를 정적 임포트하여 쓰면 스트림 파이프라인 가독성이 좋아져, 흔히들 이렇게 사용한다.

2. toMap 수집기를 사용하여 문자열을 열거 타입 상수에 매핑

private static final Map<String, Operation> stringToEnum =
        Stream.of(values()).collect(toMap(Object::toString, e -> e));
  • 이 간단한 toMap 형태는 스트림의 각 원소가 고유한 키에 매핑되어 있을 때적합하다. 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료될 것이다.
  • 더 복잡한 형태의 toMap이나 groupingBy는 이런 충돌을 다루는 다양한 전략을 제공한다.

3. 각 키와 해당 키의 특정 원소를 연관 짓는 맵을 생성하는 수집기

Map<Artist, Album> topHits = albums.collect(toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
  • 인수 3개를 받는 toMap은 어떤 키와 그 키에 연관된 원소들 중 하나를 골라연관 짓는 맵을 만들 때 유용하다.
  • 여기서 비교자로는 BinaryOperator에서 정적 임포트한 maxBy라는 정적 팩터리 메서드를 사용했다. maxBy는 Comparator를 입력받아 BinaryOperator를 돌려준다. 이 경우 비교자 생성 메서드인 comparing이 maxBy에 넘겨줄 비교자를 반환하는데, 자신의 키 추출 함수로는 Album::sales를 받았다.
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
  • 인수가 3개인 toMap은 충돌이 나면 마지막 값을 취하는(last-write-wins) 수집기를 만들 때도 유용하다. 많은 스트림의 결과가 비결정적이다. 하지만 매핑 함수가 키 하나에 연결해준 값들이 모두 같을 때, 혹은 값이 다르더라도 모두 허용되는 값일 때 이렇게 동작하는 수집기가 필요하다.

4. 알파벳화한 단어를 알파벳화 결과가 같은 단어들의 리스트로 매핑하는 맵을 생성

// alphabetize 기준으로 그루핑
Map<String, List<String> req = words.collect(groupingBy(word -> alphabetize(word)))

// map value의 list를 축소
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));

// 다른 map 구현체를 사용한다.
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, TreeMap::new, counting()));
  • groupingBy는 분류 함수(classifier)를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.

5. 그 외

  1. 많이 쓰이진 않지만 groupingBy의 사촌격인 partitioningBy도 있다. 분류 함수 자리에 프레디키트(predicate)를 받고 키가 Boolean인 맵을 반환한다.

  2. Stream의 count 메서드를 직접 사용하여 같은 기능을 수행할 수 있으니 collect (counting()) 형태로 사용할 일은 전혀 없다. Collections에는 이런 속성의 메 서드가 16개나 더 있다. 그중 9개는 이름이 summing, averaging, summarizing 으로 시작하며 각각 int, long, double 스트림용으로 하나씩 존재한다.

  3. minBy와 maxBy는 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작은 혹은 가장 큰 원소를 찾아 반환한다. Stream 인터페이스의 min과 max 메서드를 살짝 일반화한 것이다.

  4. joining은 (문자열 등의) CharSequence 인스턴스의 스트림에만 적용할 수 있다. 이 중 매개변수가 없는 joining은 단순히 원소들을 연결(concatenate)하는 수집기를 반환한다.

핵심정리

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다.
스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다.
종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다.
계산 자체에는 이용하지 말자.
스트림을 올바로 사용하려면 수집기를 잘 알아둬야 한다.
가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

Clone this wiki locally