모던 자바 인 액션 -5-
컬렉터
Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다. Stream에 컬렉터 파라미터를 collect 메소드에 전달함으로써 원하는 연산을 간결하게 구할 수 있다.
컬렉터의 장점은 collect로 결과를 수잡하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있게 한다.
구체적으로 스트림에 collect을 호출하면 스트림의 요소에 (컬렉터로 파라미터화된) 리듀싱 연산이 수행된다. 즉, collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다
ex) 트랜잭션이라는 객체 안에 통화라는 정책이 존재한다. 통화 별로 트랜잭션을 그룹화할 때 일어나는 일
Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 메서드를 제공한다.
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분된다.
- 스트림 요소를 하나의 값으로 리듀스하고 요약
- 요소 그룹화
- 요소 분할
리듀싱과 요약 (Collectors 에서 제공하는 메서드, Collectors.{...}
컬렉터로 스트림의 항목을 컬렉션으로 재구성 할 수 있다
스트림값에서 최댓값과 최솟값 검색 - maxBy, minBy
두개의 메서드를 이용해서 스트림의 최댓값과 최소값을 계산할 수 있다
// maxBy
Comparator<Dish> dishCalories = comparingInt(Dish::getCalories);
Optional<Dish> maxDish = menu.stream().collect(maxBy(dishCalories));
// Comparator, stream 내에서.
Optional<Dish> maxDish_innerStream = menu.stream().collect(maxBy(comparing(Dish::getCalories)));
// minBy
Optional<Dish> minDish_innerStream = menu.stream().collect(minBy(comparing(Dish::getCalories)));
요약 연산 - summingInt, summingLong, averagingInt, summarizingInt, summarizingDouble
스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약 연산이라고 부른다
// 요약연산
// summingInt int -> Integer
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
// averagingInt int -> Double
double averageCalories = menu.stream().collect(averagingInt(Dish::getCalories));
// summarizingInt int-> IntSummaryStatistics 안에 count, sum, min, max 와 average 제공
IntSummaryStatistics statisticsCalories = menu.stream().collect(summarizingInt(Dish::getCalories));
문자열 연결 - joining
스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다. joining 메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만들기 때문에 매 연산마다 문자 리터럴이 생기지 않기 때문에 효율적이다
// 문자열 연결
// CharSequence -> String
String meueToString = menu.stream().map(Object::toString).collect(joining());
// 구분자 "," 가 들어간다
String meueToStringDeimiter = menu.stream().map(Object::toString).collect(joining(","));
범용 리듀싱 요약 연산 - reducing
지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.
// 리듀싱 연산 // 칼로리 합계 계산
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, Integer::sum));
- 첫번째 인수 : 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환 값이다
- 두번째 인수 : 요리를 칼로리 정수로 반환할 때 사용하는 변환 함수
- 세번째 인수 : 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator다
그룹화 - groupingBy
자바8의 함수형을 이용하면 가독성 있는 한 줄 코드로 그룹화를 구현할 수 있다.
Map<Dish, Type, List<Dish>> dishsByType = menu.stream().collect(groupingBy(Dish::getType));
//dishsByType : {FISH=[prawns, salmon], OTHERS=[french fries, rice, pizza], MEAT[pork, beef, chicken]}
스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupbingBy 메서드로 전달했다.
이 함수를 기준으로 스트림이 그룹화되므로 이를 분류함수라고 부른다. 그룹화 연산의 결과로 그룹화 함수가 반환하는 키 그리고 각 키에 대응하는 스트림의 모든 항목 리스트를 값으로 갖는 맵이 반환된다.
그룹화된 요소 조작 - filtering, mapping, flatMapping
요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.
ex) 500칼로리가 넘는 요리만 필터한다고 가정한다. filtering
// 그룹화를 하기 전에 프레디케이트로 필터를 적용했을 때
Map<Dish.Type, List<Dish>> collect = menu.stream()
.filter(dish -> dish.getCalories() > 500)
.collect(groupingBy(Dish::getType));
// 결과 caloricDishesByType : {OTHER=[french fries, pizza], MEAT=[pork, beef]}
// groupingBy는 Collector 받는다, Collections.filtering 함수 Collector 반환
Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream().
collect(groupingBy(Dish::getType, filtering(dish -> dish.getCalories() > 500, toList())));
//결과 caloricDishesByType : {OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}
위 코드로 문제를 해결할 수 있지만, 필터 프레디케이트를 만족하는 FISH 종류 요리는 없으므로 결과 맵에서 해당 키 자체가 사라진다.
이러한 문제를 필터 프레디케이트를 Collector안으로 이동함으로써 해결할 수 있다. filtering 메서드는 Collectors 클래스의 또다른 정적 팩토리 메서드로 프레디케이트를 인수로 받는다. 이 프레디케이트로 각 그룹의 요소와 필터링된 요소를 재그룹화한다. 그 결과 목록이 비어 있는 FISH도 항목으로 추가 된다.
그룹화된 항목을 조작하는 다른 유용한 기능 중 하나는 매핑 함수를 이용해 요소를 변환하는 작업이다. mapping
// Mapping 사용, DishType과 Name으로 그룹화
Map<Dish.Type, List<String>> groupDishTypeAndName = menu.stream().
collect(groupingBy(Dish::getType, mapping(Dish::getName, toList())));
각 그룹이 리스트 형태라면 flatMap 변환을 사용해서 추출할 수도 있다. flatMapping
// dishTags를 참조해서 그룹화된 요리들의 tag값들을 얻고 싶을 때
Map<String, List<String>> dishTags = new HashMap<>();
dishTags.put("pork", Arrays.asList("greasy", "salty"));
dishTags.put("beef", Arrays.asList("salty", "roasted"));
dishTags.put("chicken", Arrays.asList("fried", "crisp"));
dishTags.put("rice", Arrays.asList("light", "natural"));
Map<Dish.Type, Set<String>> dishNamesByType = menu.stream()
.collect(groupingBy(
Dish::getType,
flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())
)
);
다수준 그룹화
컴포지트 패턴을 사용해서 그룹화 조건을 중첩해서 적용할 수 있다
// 다수준 그룹화 Type과 칼로리로 그룹화
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishTypeAndCalories = menu.stream().collect(groupingBy(
Dish::getType,
groupingBy(dish -> {
if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() > 700)
return CaloricLevel.FAT;
return CaloricLevel.NORMAL;
})
));
그룹화 된 각 서브그룹으로 데이터 수집
그룹화로 각 요소들을 분류한후 분류한 요소끼리 요약 연산을 할 수 있다.
Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));
//{MEAT=3, FISH=2, OTHER=4}
컬렉터 결과를 다른 형식에 적용하기 - collectingAndThen
컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.
// 각 서브그룹에서 가장 칼로리 높은 그룹 찾기,
// 각 그룹의 인자는 적어도 1개 이상 존재 -> Optional 객체로 받을 필요가 없다
Map<Dish.Type, Dish> highestCaloricEachGroup = menu.stream().collect(groupingBy(Dish::getType,
collectingAndThen(
maxBy(comparing(Dish::getCalories)),
Optional::get
)));
원하는 형식을 결과를 제어하기 - toCollection
Set의 형식을 정하고 싶은 경우 toCollection으로 형식을 정할 수 있다
menu.stream().collect(groupingBy(Dish::getType,
mapping(dish->{
if(dish.getCalories()<=400) return CaloricLevel.DIET;
if(dish.getCalories()>700) return CaloricLevel.FAT;
return CaloricLevel.NORMAL;
},
toCollection(HashSet::new))));
분할 - partitioningBy
분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다.
분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이며, 참 또는 거짓을 갖는 두 개의 그룹으로 분류된다.
// 채식 요리 분할
Map<Boolean, List<Dish>> vegetarianMap = menu.stream().collect(partitioningBy(Dish::isVegetarian));
// 결과
// {false=[pork, beef, chicken, prawns, salmon],
// true=[french fires, rice, season fruit, pizza]}
분할의 장점은 분할 함수가 반환하는 참, 거짓 두가지 요소의 스트림 리스트를 모두 유지한다는 것이다.
// 채식 요리
List<Dish> dishesT = vegetarianMap.get(true);
// 채식 요리 X
List<Dish> dishesF = vegetarianMap.get(false);
partitioningBy 컬렉터는 groupBy와 마찬가지로 다른 컬렉터와 조합해서 사용할 수 있다.
팩토리 메서드 | 반환 형식 | 비고 |
---|---|---|
toList | List<T> | 스트림 항목을 리스트로 수집 |
toSet | Set<T> | 스트림 항목을 집합으로 수집 |
toCollection | Collection<T> | 스트림의 모든 항목을 발행자가 제공하는 컬렉션으로 수집 |
counting | Long | 스트림 항목 수 계산 |
summingInt | Integer | 스트림 항목의 정수 프로퍼티 값 더함 |
averagingInt | Double | 스트림 항목의 정수 프로퍼티 평균값 계산 |
summarizingInt | IntSummaryStatistics | 최댓값, 최솟값, 합계, 평균 등의 정수 정보 통계 수집 |
joining | String | 스트림 각 항목에 toString 메서드를 호출한 결과 문자열 연결 |
maxBy | Optional<T> | 최댓값 요소를 Optaionl로 감싼 값으로 반환. 요소가 없을때는 Optional.empty()반환 |
minBy | Optional<T> | 최소값 요소를 Optaionl로 감싼 값으로 반환. 요소가 없을때는 Optional.empty()반환 |
reducing | produced by the reduction operation |
누적자(accumulator)를 초깃값으로 설정한 다음에 BinaryOperator로 스트림의 각 요소를 반복적으로 누적자와 합쳐 스트림을 하나의 값으로 리듀싱 |
collectingAndThen | returned by transforming function |
다른 컬렉터를 감싸고 그 결과에 변환 함수 적용 |
groupingBy | Map<K, List<T>> | 하나의 프로퍼티값을 기준으로 스트림의 항목을 그룹화하여 기준 프로퍼티값을 키로 사용 |
partitionBy | Map<Boolean, List<T>> | Predicate를 스트림의 각 항목에 적용한 결과로 항목 분할 |
Collector 인터페이스
Collector 인터페이스는 리듀싱 연산(즉, 컬렉터)을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다.
Collector 인터페이스
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
Function<A, R> finisher();
BinaryOperator<A> combiner();
Set<Characteristics> characteristics();
}
- T는 수집될 스트림 항목의 제네릭 형식이다.
- A는 누적자. 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식이다.
- R은 수집 연산 결과 객체의 형식(항상 그런것은 아니지만 대게 컬렉션 형식)이다.
예를 들어 Stream<T>의 모든 요소를 List<T>로 수집하는 ToListCollector<T>라는 클래스를 구현할 수 있다.
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
supplier 메서드 : 새로운 결과 컨테이너 만들기
supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야 한다. supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다.
accumulator 메서드 : 결과 컨터이너에 요소 추가하기
accumlator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 요소를 탐색할 때 두 인수, 즉 누적자(n-1번째 항목까지 수집한 상태)와 n번째 요소를 함수에 적용한다.
함수의 반환값은 void, 즉 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부 상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다.
combiner 메서드 : 두 결과 컨테이너 병합
combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다.
finisher 메서드 : 최종 변환값을 결과 컨테이너로 적용하기
finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 객체로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다.
Characteristics 메서드
characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다.
Characteristics는 스트림을 병렬로 리듀스 할지와 병렬로 리듀스한다면 어떤 최적화를 선택해야할지 힌트를 제공한다.
Characteristics 메서드 속성
- UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
- CONCURRENT : 다중 스레드에서 accumulator 함수를 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
- IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있으며, 누적자 A를 결과 R로 안전하게 형변환할 수 있다.