본문 바로가기

독서찰기(讀書札記)/이펙티브 자바

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

 [Why]

스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다.

  • 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
  • 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않아야 한다.
  • 이렇게 하려면 (중간 단계든 종단 단계든) 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)가 없어야 한다.
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}
  • 텍스트 파일에서 단어별 수를 세어 빈도표를 만드는 코드이다.
    • 스트림, 람다, 메서드 참조를 사용했고 결과도 올바르지만, 절대 스트림 코드라 할 수 없다. 스트림 코드를 가장한 반복적 코드다.
    • 스트림 API의 이점을 살리지 못하여 같은 기능의 반복적 코드보다 길고, 읽기 어렵고, 유지보수에도 좋지 않다.
  • 모든 작업이 종단 연산인 forEach에서 일어나는데, 이때 외부 상태(빈도표)를 수정하는 람다를 실행하면서 문제가 생긴다.
    • for-each 반복문은 forEach 종단 연산과 비슷하게 생겼다.
    • 하지만 forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.

 

※ 순수 함수 : 오직 입력만이 결과에 영향을 주는 함수

 

[How]

Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words
        .collect(groupingBy(String::toLowerCase, counting()));
}
  • 이번에는 스트림 API를 제대로 사용했다. 그뿐만 아니라 짧고 명확하다.
  • 이 코드는 수집기(collector)를 사용하는데, 스트림을 사용하려면 꼭 배워야 하는 새로운 개념이다.
    • 익숙해지기 전까지는 Collector 인터페이스를 잠시 잊고, 그저 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 생각하자.
    • 여기서 축소는 스트림의 원소들을 객체 하나에 취합한다는 뜻이다.
    • 수집기를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다.
    • 수집기는 총 세 가지로, toList(), toSet(), toCollection(collectionFactory)가 있다.

 

toMap(keyMapper, valueMapper) 수집기

  • 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료될 것이다.
    • 더 복잡한 형태의 toMap이나 groupingBy는 이런 충돌을 다루는 다양한 전략을 제공한다.
    • 예컨대 toMap에 키 매퍼와 값 매퍼는 물론 병합(merge) 함수까지 제공할 수 있다.
    • 병합 함수의 형태는 BinaryOperator<U>이며, 여기서 U는 해당 맵의 값 타입이다.
    • 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다.
      예컨대 병합 함수가 곱셈이라면 키가 같은 모든 값(키/값 매퍼가 정한다)을 곱한 결과를 얻는다.
Map<Artist, Album> topHits = albums.collect(
    toMap(Album::artist, a -> a, maxBy(comparing(Album::sales))));
  • 다양한 음악가의 앨범들을 담은 스트림을 가지고, 음악가와 그 음악가의 베스트 앨범을 연관짓는 수집기다.
  • 여기서 비교자로는 BinaryOperator에서 정적 임포트한 maxBy라는 정적 팩터리 메서드를 사용했다.
    • maxBy는 Comparator<T>를 입력받아 BinaryOperator<T>를 돌려준다.
    • 비교자 생성 메서드인 comparing이 maxBy에 넘겨줄 비교자를 반환하는데, 자신의 키 추출 함수로는 Album::sales를 받았다.
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
  • 인수가 3개인 toMap은 충돌이 나면 마지막 값을 취하는(last-write-wins) 수집기를 만들 때도 유용하다.

 

Collectors가 제공하는 메서드들

groupingBy 메서드

  • 이 메서드는 입력으로 분류 함수(classifier)를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.
words.collect(groupingBy(word -> alphabetize(word)))
  • groupingBy가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생성하게 하려면, 분류 함수와 함께 다운스트림(downstream) 수집기도 명시해야 한다.
  • 다운스트림 수집기의 역할은 모든 원소를 담은 스트림으로부터 값을 생성하는 일이다.

1. 이 매개변수를 사용하는 가장 간단한 방법은 toSet()을 넘기는 것이다.

  • 그러면 groupingBy는 원소들의 리스트가 아닌 집합(Set)을 값으로 갖는 맵을 만들어낸다.
    toSet() 대신 toCollection(collectionFactory)를 건네면, 리스트나 집합 대신 컬렉션을 값으로 갖는 맵을 생성한다.
Map<String, Long> freq = words
        .collect(groupingBy(String::toLowerCase, counting()));

2. 다운스트림 수집기로 counting()을 건네면, 해당 키에 속하는 원소 개수(값)와 매핑한 맵을 얻는다.

 

3. groupingBy의 세 번째 버전은 다운스트림 수집기에 더해 맵 팩터리도 지정할 수 있게 해준다.

  • 이 메서드는 점층적 인수 목록 패턴(telescoping argument list pattern)에 어긋난다.
  • 즉, mapFactory 매개변수가 downStream 매개변수보다 앞에 놓인다.
  • 이 버전의 groupingBy를 사용하면 맵과 그 안에 담긴 컬렉션의 타입을 모두 지정할 수 있다.
    예컨대 값이 TreeSet인 TreeMap을 반환하는 수집기를 만들 수 있다.

※ 많이 쓰이진 않지만 groupingBy의 사촌격인 partitioningBy도 있다.

 

counting 메서드

  • counting 메서드가 반환하는 수집기는 다운스트림 수집기 전용이다.
  • Stream의 count 메서드를 직접 사용하여 같은 기능을 수행할 수 있으니 collect(counting()) 형태로 사용할 일은 전혀 없다.
  • Collections에는 이런 속성의 메서드가 16개나 더 있다.
    • 9개는 이름이 summing, averaging, summarizing으로 시작하며 각각 int, long, double 스트림용으로 하나씩 존재한다.
    • 다중정의된 reducing 메서드들, filtering, mapping, flatMapping, collectingAndThen 메서드가 있는데, 몰라도 된다.

 

남은 3개의 Collectors 메서드

  • 남은 3개의 메서드들은 특이하게도 Collectors에 정의되어 있지만 '수집'과는 관련이 없다.
  • minBy, maxBy는 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작은 혹은 가장 큰 원소를 찾아 반환한다.
  • joining은 (문자열 등의) CharSequence 인스턴스 스트림에만 적용할 수 있다.
    • 매개변수가 없는 joining은 단순히 원소들을 연결(concatenate)하는 수집기를 반환한다.
    • 인수 하나짜리 joining은 CharSequence 타입의 구분문자(delimiter)를 매개변수로 받는다.
    • 인수 3개짜리 joining은 구분문자에 더해 접두문자(prefix)와 접미문자(suffix)도 받는다.