Item46. 스트림에서는 부작용 없는 함수를 사용하라!
Intro
- 스트림은 처음 봐서는 이해하기 어려울 수 있다. 원하는 작업을 스트림 파이프라인으로 표현하는 것조차 어려울지 모른다.
- 스트림은 그저 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이다.
- 스트림이 제공하는 표현력, 속도, 병렬성을 얻으려면 API는 말할 것도 없고 이 패러다임까지 받아들여야 한다.
스트림
스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.
각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
순수함수 : 오직 입력만이 결과에 영향을 주는 함수
이렇게 구성하기 위해서는 (중간 단계든 종단 단계든) 스트림 연산에 건네는 함수 객체는 모두 부작용이 없어야 한다.
스트림 패러다임을 이해하지 못한 예
1
2
3
4
5
6
Map<String, String> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
- 스트림, 람다, 메서드 참조를 사용했다. 결과도 올바르다. 하지만 절대 스트림 코드라 할 수 없다.
- 스트림을 가장한 반복적 코드이기 때문이다.
- 종단 연산인 forEach에서 그저 스트림이 수행한 연산 결과를 보여주는 일 이상을 실행한다.
1
2
3
4
Map<String, String> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words.collect(groupingBy(String::toLowerCase, counting()));
}
- 스트림 API를 제대로 사용하는 형태로 변환했다.
- 자바프로그래머라면 for-each 반복문 사용에 익숙할 것이라 스트림의 forEach 종단 연산을 잘못 사용하는 경우가 더러 있을 것이다.
- forEach 종단 연산은 종단 연산 중 기능이 가장 적고 덜 스트림스럽다. 대놓고 반복적이라 병렬화도 할 수 없다.
- forEach 연산은 스트림 계산 결과를 보고할 때만 사용하자. 계산에는 사용하지 말자.
- forEach 종단 연산은 종단 연산 중 기능이 가장 적고 덜 스트림스럽다. 대놓고 반복적이라 병렬화도 할 수 없다.
수집기(Collector)
- 스트림을 사용하려면 꼭 배워야 하는 새로운 개념이다.
- java.util.stream.Collectors 클래스는 메서드를 무려 39개나 가지고 있고, 그 중 타입 매개변수가 5개나 되는 것도 있다.
- 익숙해 지기 전까지는 Collector 인터페이스를 잠시 잊자. 그저 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 생각하자.
- 수집기가 생성하는 객체는 일반적으로 컬렉션이라
collector
라는 이름을 쓴다.
개념
- 수집기를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다.
- 수집기는 총 세가지다.
- toList() : 리스트 반환
- toSet() : 집합 반환
- toCollection(collectionFactory) : 프로그래머가 지정한 컬렉션 타입 반환
예시
1
2
3
4
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
comparing(freq::get).reversed()
- comparing 메서드는 키 추출 함수를 받는 비교자 생성 메서드다.
- 한정적 메서드 참조인 freq::get은 입력받은 단어(키)를 빈도효에서 찾아(추출) 그 빈도를 반환한다.
- reversed 메서드는 가장 흔한 단어가 위로 오도록 역순으로 정렬한다.
Collections의 여러 메서드 소개
toMap
- 스트림 원소를 키에 매핑하는 함수와 같에 매핑하는 함수를 인수로 받는다.
1 2
private static final Map<String, Operation> stringToEnum = Stream.of(values()).collect(toMap(Object::toString, e -> e));
- toMap 수집기를 사용해 문자열을 열거 타입 상수에 매핑한 예이다.
- toMap 형태는 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다.
- 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던진다.
1 2
Map<Artist, Album> topHits = albums.collect( toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
- 각 키와 해당 키의 특정 원소를 연관 짓는 맵을 생성하는 수집기 예제다.
- 예를 들어, 다양한 음악가의 앨범들을 담은 스트림에서, 음악가와 그 음악가의 베스트 앨범을 연관짓고 싶은 경우
1
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
- 마지막에 쓴 값을 취하는 수집기 예제다.
- 인수가 3개인 toMap은 충돌이 나면 마지막 값을 취하는 Collector를 만들 때 유용하다.
groupingBy
- 입력으로 분류 함수를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.
- 분류함수는 입력받은 원소가 속하는 카테고리를 반환한다. 이 카테고리는 맵의 키로 쓰인다.
1
words.collect(groupingBy(word -> alphaetize(word))); // 아이템 45에서 다룬 예제
groupingBy가 반환하는 수집기가 리스트 외의 값을 같는 맵을 생성하게 하려면, 분류 함수와 함께 다운스트림 수집기도 명시해야 한다.
- 다운스트림 수집기의 역할은 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일이다.
1
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting())); // 다운스트림 예제
- 다운스트림 수집기로 counting()을 건네는 예제다. 이렇게 하면 각 카테고리(키)를 해당 카테고리의 원소 개수와 매핑한 맵을 얻는다.
- counting 메서드가 반환하는 수집기는 다운스트림 전용이다. Stream의 count 메서드를 직접 사용해 같은 기능을 수행할 수 있으니 collect(counting()) 형태를 사용할 일은 전혀 없다.
- 입력으로 분류 함수를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.
minBy, maxBy
- Collectors에 정의되어 있지만 ‘수집’과는 전혀 상관 없다.
- 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작거나 큰 원소를 찾아 반환한다.
joining
- 이 메서드는 CharSequence 인스턴스의 스트림에만 적용할 수 있다.
- 매개변수가 없는 joining은 단순히 원소들을 연결(concatenate)하는 수집기를 반환한다.
- 인수 하나짜리 joining은 구분문자(delimiter)를 매개변수로 받는다.
- 인수 세개짜리 joining은 구분문자, 접두문자(prefix), 접미문자(suffix)를 받는다.
- 예를 들어 구분을 ‘,’로, 접두를 ‘[‘로, 접미를 ‘]’로 지정하면, ‘[사과, 배, 포도]’와 같이 마치 컬렉션을 보는 듯한 문자열 생성도 가능하다.
핵심 정리
- 스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다.
- 스트림 뿐만 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다.
- 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다. 계산 자체에는 이용하지 말자.
- 스트림을 올바르게 사용하려면 수집기를 잘 알아둬야 한다.
- 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.
Comments powered by Disqus.