-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Chapter : 7. 람다와 스트림
Item : 46. 스트림에서는 부작용 없는 함수를 사용하라
Assignee : heon118
🍑 서론
스트림 패러다임
- 스트림은 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임
- 스트림이 제공하는 표현력, 속도, (상황에 따라) 병렬성을 얻으려면 API와 이 패러다임까지 함께 받아들여야 한다.
- 계산을 일련의 변환으로 재구성하는 부분이 핵심
- 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
- 즉, 스트림 연산에 건네는 함수 객체는 모두 부작용이 없어야 한다.
순수 함수 : 오직 입력만이 결과에 영향을 주는 함수
다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
🍑 본론
스트림 패러다임의 사용
스트림 패러다임을 이해하지 못한 채 API만 사용한 경우
- 텍스트 파일에서 단어별 수를 세는 메서드
// Key : 단어, Value : 해당 단어가 나타난 횟수(빈도)
Map<String, Long> freq = new HashMap<>();
// 단어를 읽어 스트림 생성. tokens() : 단어 단위로 토큰을 생성하는 스트림 반환.
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
// freq.merge : 주어진 Key(단어)에 대해 Value(빈도수)를 맵에 추가 및 업데이트
// merge(맵에 추가할 키, 키가 맵에 존재하지 않을 때 사용할 초기값, 키가 이미 맵에 존재할 경우 기존 값과 1L의 합)
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}- 올바른 결과를 도출하지만 스트림 코드라고 할 수 없다.
- 스트림을 가장한 반복문
- forEach는 스트림이 수행한 연산 결과를 보여주는 일 이상을 한기에 나쁜 코드(람다가 상태를 수정함)
스트림을 제대로 사용한 경우
- 텍스트 파일에서 단어별 수를 세는 메서드
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
// Collectors 메서드 사용
// 스트림의 각 요소(단어)를 소문자로 변환 후, 그것들을 그룹화하고 각 그룹의 요소 수(단어의 빈도) 계산
freq = words
.collect(groupingBy(String::toLowerCase, counting()));
}- forEach 연산은 대놓고 반복적이라 병렬화할 수 없다.
- forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 사용하지 말자.
Scanner의 스트림 메서드인 tokens를 사용해 스트림을 얻었다. tokens는 자바9부터 지원하므로, 그 이전 버전을 쓰는 사람은 어댑터를 이용하여(Iterator를 구현한) Scanner를 스트림으로 변환할 수 있다. 코드47-3에서 사용하는 streamof(Iterable)가좋은예다.
collector
- 자주 사용하는 작업은 java.util.stream.Collectors 클래스에서 제공
- 39개의 메서드
- 복잡한 세부 내용을 몰라도 해당 API 장점 대부분 활용 가능
- 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 생각하자
- 축소 : 스트림의 원소들을 객체 하나에 취합
- 일반적으로 컬렉션 객체를 생성하며 collector라고 한다
- 종단 연산으로 forEach보단 Collectors 권장
toList() : 리스트 수집
toSet() : 집합 수집
toCollection(collectionFactory) : 지정한 컬렉션 타입 수집
1. toList
- freq 맵을 사용해 가장 빈도수 높은 단어 10개 선택 및 리스트로 수집
List<String> topTen = freq.keySet().stream()
// sorted : 스트림의 요소 정렬
// comparing(freq::get) : freq 맵에서 각 Key(단어)에 대응하는 Value(빈도수) 기준으로 정렬한 Comparator 생성
// reversed() : 빈도수 높은 단어부터 낮은 단어 순 정렬
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());toList는 Collectors의 메서드로 Collectors의 멤버를 정적 임포트하여 사용하면 스트림 파이프라인 가독성이 좋아진다.
- Collectors의 나머지 36개 메서드들 중 대부분은 스트림을 맵으로 취합하는 기능으로 진짜 컬렉션에 취합하는 것보다 훨씬 복잡하다.
- 스트림의 각 원소는 키 하나와 값 하나에 연관되어 있다. 또한 다수의 스트림 원소가 같은 키에 연관될 수 있다.
이제부터 수십 개의 메서드를 요약해 설명할 것이다. 따라서 본문의 글만으로는 내용이 헷갈리고 머리에 잘 남지 않을 수 있다. 흐름을 좇기 어렵다면 java.util.stream.Collectors의 API문서(http://bit.ly/2MvTOAR)를 펼쳐놓고 하나씩 짚어가며 읽어보길 추천한다.
2. toMap
인수가 2개인 toMap
- toMap(keyMapper, valueMapper)
- toMap(스트림 원소를 키에 매핑하는 함수, 값에 매핑하는 함수)
문자열을 열거 타입 상수에 매핑
private static final Map<String, item34.Operation> stringToEnum =
// Object::toString : enum 상수를 문자열로 변환
// e -> e : 값 매핑 함수로, 스트림의 각 요소를 그대로 값으로 사용
Stream.of(values()).collect(
toMap(Object::toString, e -> e));-
이 형태는 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합
-
스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료
-
더 복잡한 형태의 toMap이나 groupingBy는 이런 충돌을 다루는 다양한 전략 제공
-
toMap에 키 매퍼와 값 매퍼는 물론 병합(merge) 함수까지 제공
-
병합 함수의 형태는
BinaryOperator<U>, U는 맵의 값 타입 -
ex) 병합 함수가 곱셈이라면 키가 같은 모든 값을 곱한 결과
인수가 3개인 toMap
- 어떤 Key와 그에 연관된 Value들 중 하나를 골라 연관 짓는 맵을 만들 때 유용
- 세 번째 인수 : 병합함수(merge function)
- 두 Value가 같은 Key에 맵핑될 경우, 어떻게 병합할지 결정
각 키와 해당 키의 특정 원소를 연관짓는 맵 생성
- 다양한 음악가와 그 음악가의 베스트 앨범을 연관 짓기
Map<Artist, Album> topHits = albums.collect(
// Album::artist : 앨범의 아티스트가 Key
// a -> a : 앨범이 Value
// maxBy(comparing(Album::sales)) : 두 값이 같은 Key에 맵핑될 경우, 두 앨범 중 판매량이 더 높은 앨범을 선택
toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));마지막에 쓴 값을 취하는 맵 생성
- 맵핑 함수가 Key 하나에 연결해준 Value들이 모두 같을 때, 혹은 다르더라도 모두 허용되는 값일 때 필요
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)인수가 4개인 toMap
- 네 번째 인수 : 맵 팩터리
- EnumMap이나 TreeMap처럼 원하는 특정 맵 구현체를 직접 지정 가능
맵 팩터리 : Map 인스턴스를 쉽고 간편하게 생성할 수 있도록 도와주는 메서드 제공(자바9 이상에서 java.util.Map 인터페이스에 추가된 정적 메소드들)
toConcurrentMap
- toMap의 변종
- 병렬 실행된 결과로 ConcurrentHashMap 인스턴스 생성
- 동시성을 지원하는 Map
- 주로 병렬 스트림에서 안전하게 요소를 수집할 때 유용
- 여러 스레드에서 동시에 맵을 수정해야 할 때
3. groupingBy
- 입력 : 분류 함수(classifier) -> 입력받은 원소가 속하는 카테고리 반환하며 Key로 사용
- 출력 : 원소들을 카테고리별로 모아 놓은 맵을 담은 Collector
인수가 1개인 groupingBy
- 인수 : 분류 함수
- item45의 아나그램 프로그램
words.collect(groupingBy(word -> alphabetize(word)));인수가 2개인 groupingBy
- 두 번째 인수 : 다운스트림 컬렉터
- groupingBy가 반환하는 Collector가 리스트 외의 값을 갖는 맵을 생성하게 하려면 분류 함수와 함께 다운스트림 컬렉터도 명시해야한다.
다운스트림 컬렉터 : 스트림의 요소를 그룹화(grouping), 분할(partitioning), 또는 다른 복잡한 집계(aggregation) 연산을 수행할 때, 하나의 컬렉터가 결과를 수집하는 과정에서 추가적으로 다른 컬렉터를 사용할 때 내부적으로 사용하는 컬렉터
- toSet() : groupingBy 원소들의 리스트가 아닌 Set을 값으로 갖는 맵 생성
- toCollection(collectionFactory) : 리스트나 집합 대신 컬렉션을 값으로 갖는 맵 생성
- counting() : 각 카테고리(Key)를 해당 카테고리에 속하는 원소의 개수(Value)와 맵핑한 맵 생성
Map<String, Long> freq = words
.collect(groupingBy(String::toLowerCase, counting()));인수가 3개인 groupingBy
- 두 번째 인수 : 맵 팩터리
- 세 번째 인수 : 다운스트림 컬렉터
- 점층적 인수 목록 패턴에 어긋난다.(맵 팩터리 매개변수가 다운스트림 매개변수보다 앞에 위치)
- 값이 TreeSet인 TreeMap을 반환하는 collector 생성 가능
// gpt
public class TreeMapTreeSetCollector {
public static void main(String[] args) {
List<String> items = List.of("banana", "apple", "cherry", "apple", "banana", "cherry", "orange");
// 분류 함수로 첫 글자를 사용하고, 결과를 TreeMap에 저장하며, 각 그룹의 값들을 TreeSet으로 수집하는 Collector 생성
Map<Character, TreeSet<String>> groupedItems = items.stream()
.collect(Collectors.groupingBy(
item -> item.charAt(0), // 분류 함수: 첫 글자로 그룹화
TreeMap::new, // 결과를 TreeMap으로 저장
Collectors.toCollection(TreeSet::new) // 각 그룹의 값들을 TreeSet으로 수집
));
System.out.println(groupedItems);
}
}groupingByConcurrent
- 메서드의 동시 수행 버전으로 ConcurrentHashMap 인스턴스 생성
- 병렬 스트림에서 thread safe하게 요소를 그룹화할 때 사용
4. 기타 메서드
partitioningBy
- 많이 사용 X
- groupingBy의 사촌격
- 분류 함수 자리에 프레디키트를 받고 Key가 Boolean인 맵 반환
Predicate : 자바에서 조건을 표현하는데 사용되는 함수형 인터페이스
사용할 일 없는 메서드
-
counting 메서드가 반환하는 collector는 다운스트림 컬렉터 전용
-
Stream의 count 메서드를 직접 사용하여 같은 기능을 수행할 수 있으니 collect(counting()) 형태로 사용할 일은 전혀 없다.
-
Collections에 이런 속성의 메서드가 16개 더 있다.
-
그 중 9개는 summing, averaging, summarizing으로 시작하며, 각각 int, long, double 스트림용으로 하나씩 존재
-
설계 관점에서 스트림 기능의 일부를 복제하여 다운스트림 컬렉터를 작은 스트림처럼 동작하게 하는 메서드로 몰라도 된다.
- 다중정의된 reducing, filtering, mapping, flatMapping, collectingAndThen
Collectors에 정의되어 있지만 '수집'과는 관련 없는 메서드
minBy / maxBy
- 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작은 / 큰 원소를 찾아 반환
joining
- CharSequence 인스턴스의 스트림에만 적용 가능
- 매개변수가 없는 joining : 단순 원소들을 연결하는 collector 반환
- 인수 한 개인 joining : CharSequence 타입의 구분문자를 매개변수로 받는다.
- 인수 세 개인 joining : 접두(prefix), 구분, 접미문자(suffix)
- '[', ',', ']' -> [came, saw, conquered]처럼 컬렉션을 출력하는 듯한 문자열 생성
🍑 결론
스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다.
스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다.