[Java8 In Action] #6 스트림으로 데이터 수집 |
Java8 In Action 내용을 보며 정리한 내용입니다.
정리자는 기존에 Java8 을 한차례 rough 하게 공부한 적이 있고, Kotlin 역시 공부한 적이 있습니다.
위의 prerequisite 가 있는 상태에서 추가적인 내용만 정리한 내용이므로, 제대로 공부를 하고 싶다면 책을 구매해서 보길 권장합니다!
6.1. 컬렉터란 무엇인가?
-
Collector 인터페이스 구현은 스트림 요소를 어떤 식으로 도출할지 지정한다.
-
다수준(multilevel)로 그룹화를 수행할 때 명령형 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러진다.
6.1.1. 고급 리듀싱 기능을 수행하는 컬렉터
-
스트림에 collect 를 호출하면 스트림의 요소에(컬렉터로 파라미터화된) 리듀싱 연산이 수행된다.
6.1.2. 미리 정의된 컬렉터
-
Collectors 에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.
스트림 요소를 하나의 값으로 리듀스하고 요약(summarize)
요소 그룹화
요소 분할(partitioning)
6.2. 리듀싱과 요약
-
Collectors.counting() 을 통해 갯수를 셀 수 있다.
이를 생략해서 stream 에 바로 count() 함수를 날릴 수도 있다.
counting 컬렉터는 다른 컬렉터와 함께 사용할 때 위력을 발휘한다.
6.2.1. 스트림값에서 최댓값과 최솟값 검색
-
Collectors.maxBy, Collectors.minBy 를 이용할 수 있다.
두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator 를 인수로 받는다.
그리고 collect 된 return 값은 Optional 이다.
-
스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다.
이런 연산을 요약(summarization) 연산이라 부른다.
6.2.2. 요약 연산
-
Collectors 클래스는 Collectors.summingInt 라는 특별한 요약 팩토리 메서드를 제공한다.
summingInt 는 객체를 int 로 매핑하는 함수를 인수로 받는다.
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
-
Collectors.summingLong, Collectors.summingDouble 도 summingInt 와 같은 방식으로 동작하며, 각각 long 또는 double 형식의 데이터로 요약한다는 점만 다르다.
-
Collectors.averagingInt, averagingLong, averagingDouble 등으로 평균을 구할 수 있다.
-
summarizingInt 류를 이용하면 count, sum, average, max, min 을 한번에 구할 수 있다.
return 값은 IntSummaryStatistics 이다.
summarizingLong, summarizingDouble 등이 있고 이에 매핑하는 LongSummaryStatistics, DoubleSummaryStatistics 가 있다.
6.2.3. 문자열 연결
-
Collectors.joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.
이는 내부적으로 StringBuilder 를 이용한다.
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
-
joining 은 param 으로 delimiter 를 넣어 줄 수도 있다.
6.2.4. 범용 리듀싱 요약 연산
-
당연히 앞서 본 모든 컬렉터는 reducing 팩토리 메서드로 정의할 수 있다.
-
reducing 은 세 개의 인수를 받는다.
첫번째 인수는 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때 반환되는 값
두번째 인수는 변환 함수
세번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator.
-
한 개의 인수를 받는 reducing 도 있다.
이 경우 스트림의 3개의 인수를 받는 경우로 고려해보면, 시작요소를 첫 번째 인수로 받으며, 자신을 그대로 반환하는 항등 함수(identity function)을 두 번째 인수로 받는 상황에 해당한다.
따라서 갯수가 없으면 첫번째 인수로 넘겨줄 것이 없기 때문에 Optional 이 return 된다.
-
Stream<Integer> stream = Arrays.asList(1,2,3,4,5,6).stream(); List<Integer> numbers = stream.reduce(new ArrayList<Integer>(), (List<Integer> l, Integer e) -> { l.add(e); return l; }, (List<Integer> l1, List<Integer> l2) -> { l1.addAll(l2); return l1; }
위 코드에는 의미론적인 문제와 실용성 문제 등 두 가지 문제가 발생한다.
collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드인 반면 reduce 는 두 값을 하나로 도출하는 불변형 연산이라는 점에서 의미론적 문제가 일어난다. ( 이는 나중에 다시 다룬다 )
위의 코드에서는 여러 스레드가 동시에 같은 데이터 구조체를 고치면 리스트 자체가 망가져버리므로, 리듀싱 연산을 병렬로 수행할 수 없다는 점도 문제다. ( 이도 나중에 다시 다룬다 )
컬렉션 프레임워크 유연성: 같은 연산도 다양한 방식으로 수행할 수 있다!
-
메서드 레퍼런스를 써서 더 간단히 구현할 수 있다.
int totalCalories = menu.stream().collect( reducing(0, Dish::getCalories, Integer::sum) );
-
counting 도 다음과 같이 1 로 매핑해서 구할 수 있다.
reducing(0L, e -> 1L, Long::sum);
자신의 상황에 맞는 최적의 해법 선택
6.3. 그룹화
-
groupBy 를 호출할 때 전달하는 함수를 분류 함수(classification function) 이라고 부른다.
6.3.1. 다수준 그룹화
-
Collectors.groupingBy 는 일반적인 분류 함수와 컬렉터를 인수로 받는다.
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream().collect( groupingBy(Dish::getType, groupingBy(dish ->{ if(dish.getCalories() <= 400){ return CaloricLevel.DIET; } else if( dish.getCalories() <= 700){ return CaloricLevel.NORMAL; } else{ return CaloricLevel.FAT; } });
-
다수준 그룹화 연산은 다양한 수준으로 확장할 수 있다.
즉, n수준 그룹화의 결과는 n수준 트리 구조로 표현되는 n수준 맵이 된다.
보통 groupingBy 연산을 버킷 개념으로 생각하면 쉽다.
첫 번째 groupingBy 는 각 키의 버킷을 만든다. 그리고 준비된 각각의 버킷을 서브스트림 컬렉터로 채워가기를 반복하면서 n 수준 그룹화를 달성한다
6.3.2. 서브그룹으로 데이터 수집
-
Map<Dish.Type, Long> typesCount = menu.stream().collect( groupingBy(Dish::getType, counting()) );
한개의 인수를 갖는 groupingBy(f) 는 사실 groupingBy( f, toList() ) 의 축약형이다.
컬렉터 결과를 다른 형식에 적용하기
-
collector 를 통할 때 Optional 이 나오는 경우 확신이 있어 Optional mapping 을 없애고 싶다면..
collectingAndThen 을 사용하면 좋다.
Map<Dish.Type, Dish> mostCalricByType = menu.stream().collect( groupingBy(Dish::getType, collectingAndThen(maxBy(compaingInt(Dish::getCalories)), Optional::get)));
groupingBy 와 함께 사용하는 다른 컬렉터 예제
-
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType = menu.stream().collect( groupingBy(Dish::getType, mapping(dish -> { if(dish.getCalories() <= 400){ return CaloricLevel.DIET; } else if( dish.getCalories() <= 700){ return CaloricLevel.NORMAL; } else{ return CaloricLevel.FAT; } }, toCollection(HashSet::new))) );
6.4. 분할
-
분할은 분할 함수(partitioning function)라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다.
Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));
6.4.1. 분할의 장점
-
참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 장점이다.
컬렉터를 두 번째 인수로 전달할 수 있는 오버로드된 버전의 partitioningBy 메서드도 있다.
Map<Boolean, Map<Dish.Type, List<DIsh>> vegetarianDishesByType = menu.stream().collect( partitioningBy(Dish::isVeritarian, groupingBy(Dish::getType)));
6.4.2. 숫자를 소수와 비소수로 분할하기
-
Collectors 클래스의 정적 팩토리 메서드
toList -> List<T>
toSet -> Set<T>
toCollection -> Collection<T>
counting -> Long
summingInt -> Integer
averagingInt -> Double
summarizingInt -> IntSummaryStatistics
joining -> String
maxBy -> Optional<T>
minBy -> Optional<T>
reducing -> 연산에서 형식을 결정
collectingAndThen -> 반환 함수가 형식을 반환
groupingBy -> Map<K, List<T>>
partitioningBy -> Map<Boolean, List<T>>
6.5. Collector 인터페이스
-
Collector 인터페이스는 리듀싱 연산(즉, 컬렉터)을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다.
우리가 Collector 인터페이스를 구현하는 리듀싱 연산을 만들 수도 있다.
public interface Collector<T, A, R>{ Supplier<A> supplier(); BiConsume<A, T> accumulator(); Function<A, R> finisher(); BinaryOperator<A> combiner(); Set<Characteristics> characteristics(); }
T는 수집될 스트림 항목의 제네릭 형식
A는 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식이다.
R은 수집 연산 결과 객체의 형식(항상 그런 것은 아니지만 대게 컬렉션 형식)이다.
6.5.1. Collector 인터페이스의 메서드 살펴보기
-
아래에 있는 것은 ToListCollector 의 구현 내용이다.
supplier 메서드: 새로운 결과 컨테이너 만들기
-
supplier 메서드는 빈 결과로 이루어진 Supplier 를 반환해야 한다. 즉, supplier 는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다.
public Supplier<List<T>> supplier(){ return () -> new ArrayList<T>(); // ArrayList::new; }
accumulator 메서드: 결과 컨테이너에 요소 추가하기
-
accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 인수를 탐색할 때 두 인수, 즉 누적자(스트림의 첫 n-1개 항목을 수집한 상태)와 n번째 요소를 함수에 적용한다.
함수의 반환값은 void, 즉 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부 상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다.
public BiConsumer<List<T>, T> accumulator(){ return (list, item) -> list.add(item); // List::add; }
finisher 메서드: 최종 변환값을 결과 컨테이너로 적용하기
-
finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다.
때로는 누적자 자체가 이미 최종 결과인 상황도 있다.
public Function<List<T>, List<T>> finisher(){ return Function.identity(); }
combiner 메서드: 두 결과 컨테이너 병합
-
combiner 는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다.
public BinaryOperator<List<T>> combiner(){ return (list1, list2) -> { list1.addAll(list2); return list1; } }
-
스트림의 리듀싱을 병렬로 수행할 때 자바7의 포크/조인 프레임워크와 Spliterator 를 사용한다.
병렬 리듀싱 수행 과정은 다음과 같다.
스트림을 분할해야 하는지 정의하는 조건이 거짓으로 바뀌기 전까지 원래 스트림을 재귀적으로 분할한다. ( 보통 분산된 작업의 크기가 너무 작아지면 병렬 수행의 속도는 순차 수행의 속도보다 느려진다. 즉, 병렬 수행의 효과가 상쇄된다. 일반적으로 프로세싱 코어의 개수를 초과하는 병렬 작업은 효율적이지 않다.)
서브 스트림(substream)의 각 요소에 리듀싱 연산을 순차적으로 적용해서 서브스트림을 병렬로 처리할 수 있다.
마지막에는 컬렉터의 combiner 메서드가 반환하는 함수로 모든 부분결과를 쌍으로 합친다. 즉, 분할된 모든 서브스트림의 결과를 합치면서 연산이 완료된다.
Characteristics 메서드
-
Characteristics 는 스트림을 병렬로 리듀스할 것인지 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다.
이는 다음 세 항목을 포함하는 열거형이다.
UNORDERED
리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
CONCURRENT
다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다.
컬렉터의 플래그에 UNORDERED 를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
IDENTITY_FINISH
finisher 메서드가 반환하는 함수는 단순히 identity 를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과가 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환할 수 있다.
6.5.2. 응용하기
컬렉터 구현을 만들지 않고도 커스텀 수집 수행하기
-
IDENTITY_FINISH 수집 연산에서는 Collector 인터페이스를 완전히 새로 구현하지 않고도 같은 결과를 얻을 수 있다.
Stream 은 세 함수(supplier, accumulator, combiner)를 인수로 받는 collect 메서드를 오버로드하며 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수와 같은 기능을 수행한다.
List<Dish> dishes = menuStream.collect( ArrayList::new, List::add, List::addAll )
위의 collector 는 IDENTITY_FINISH 와 CONCURRENT 지만 UNORDERED 는 아닌 컬렉터로만 동작한다.
6.6. 커스텀 컬렉터를 구현해서 성능 개선하기
6.6.1. 소수로만 나누기
6.6.2. 컬렉터 성능 비교
6.7. 요약
-
collect 는 스트림의 요소를 요약 결과로 누적하는 다양한 방법(컬렉터라 불리는)을 인수로 갖는 최종 연산이다.
-
스트림의 요소를 하나의 값으로 리듀스하고 요약하는 컬렉터뿐 아니라 최솟값, 최댓값, 평균값을 계산하는 컬렉터 등이 미리 정의되어 있다.
-
미리 정의된 컬렉터인 groupingBy로 스트림의 요소를 그룹화하거나, partitioningBy로 스트림의 요소를 분할할 수 있다.
-
컬렉터는 다수준의 그룹화, 분할, 리듀싱 연산에 적합하게 설계되어 있다.
-
Collector 인터페이스에 정의된 메서드를 구현해서 커스텀 컬렉터를 개발할 수 있다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
[Java8 In Action] #8 리팩토링, 테스팅, 디버깅 (0) | 2018.12.28 |
---|---|
[Java8 In Action] #7 병렬 데이터 처리와 성능 (0) | 2018.12.27 |
[Java8 In Action] #5 스트림 활용 (0) | 2018.12.25 |
[Java8 In Action] #4 스트림 소개 (0) | 2018.12.24 |
[Java8 In Action] #3 람다 표현식 (0) | 2018.12.23 |
댓글