메뉴에서 가장 높은 칼로리와 낮은 칼로리의 요리를 찾는다고 가정, Collectors.maxBy ,Collectors.MinBy 두 개의 메서드를 활용해서 스트림의 최댓값과 최솟값을 계산할 수 있다.
두 컬렉터는 스트림의 요소를 비교하는데 사용하는 Comparator 를 인수로 받는다.
java Comparator dishComparator = Comparator.comparingInt(Dish::getCalories);
Optional mostCalorieDish = menu.stream() .collect(maxBy(dishComparator));
* Optional 객체로 반환된 이유를 생각해보자. 만약 menu가 비어 있다면 어떤 요리도 반환되지 않을것이기 때문에 Optional로 반환한다.
* 스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 사용된다. 이러한 연산을 요약(리듀스)연산이라고 부른다.
이러한 요약 연산은 내부적으로 reducing 연산이 실행되며 초깃값을 기준으로 스트림을 탐색하여 값을 더하게 된다. Collectors.summingLong , Collectors.summingDouble 메서드는 같은 방식으로 동작하며 long, double 형으로 데이터를 요약하는것만 다르다. 컬렉터를 활용해서 최댓값, 최솟값, 합계, 평균등을 계산하는 방식을 살펴보았는데 두개 이상의 연산을 한번에 수행해야 하는 경우 summarizingInt를 사용하게 된다.
IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
// IntSummaryStatistics[count=4, sum=1200, min=100, average=200, max=300]
컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출하여 추출된 문자열을 하나로 연결해 반환한다.
String shortMenu = menu.stream()
.map(Dish::getName)
.collect(joining());
joining 메서드는 내부적으로 StringBuilder를 이용해 문자열을 하나로 만든다. Dish 클래스가 toString을 구현하였다면 아래와 같이 생략하여 정의한 toString으로 추출할 수 있다.
String shortMenu = menu.stream()
.collect(joining());
연결된 두 요소 사이에 구분 문자열을 넣거나 prefix, suffix를 넣을수도 있다.
String shortMenu = menu.stream()
.map(Dish::getName)
.collect(joining(", ", "[", "]"));
컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.
Integer total = menu.stream()
.collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
리듀싱 연산은 세가지 인수를 받는다.
다음 처럼 한 개의 인수를 가진 reducing 버전을 이용해 가장 칼로리가 높은 요리를 찾을 수도 있다.
Optional<Dish> collect1 = menu.stream()
.collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
데이터를 하나 이상의 집합으로 분류하여 그룹화 하는것도 데이터 베이스에서 많이 사용되는 작업이다. 스트림에서 제공하는 팩토리 메서드 (Collectors.gruopingBy)를 사용하여 메뉴를 그룹화 해보자.
java Map<DishType, List> dishByType = menu.stream() .collect(groupingBy(Dish::getDishType));
Map<CaloricLevel, List> dishByCaloricLevel =
menu.stream() .collect(groupingBy(
dish -> { if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() <= 700)
return CaloricLevel.NORMAL;
else return CaloricLevel.FAT; }));
Map<DishType, List<Dish>> dishByType = menu.stream()
.filter(dish -> dish.getCalories() > 500)
.collect(groupingBy(Dish::getDishType));
위와 같이 작성시 조건에 맞는 요소가 하나도 존재하지 않을시 Map의 key가 존재 하지 않는다. 이러한 경우 아래와 같이 작성한다.
Map<DishType, List<Dish>> dishByType = menu.stream()
.collect(groupingBy(Dish::getDishType, filtering(dish -> dish.getCalories() > 500, toList())));
그룹화된 항목을 조작하는 다른 유용한 기능 중 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다. filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다
Map<DishType, List<String>> dishByName = menu.stream()
.collect(groupingBy(Dish::getDishType, mapping(Dish::getName, toList())));
컬렉터를 사용하면 일반 맵이 아닌 flatMap 변환을 수행할 수 있다.
Map<String, List<String>> dishTags = new HashMap<>();
dishTags.put("pork", Arrays.asList("greasy", "salty"));
Map<DishType, Set<String>> dishByDishTag = menu.stream()
.collect(groupingBy(Dish::getDishType,
flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));
두 인수를 받는 팩토리 메서드 Collectors.groupingBy 를 이용해 항목을 다수준으로 그룹화 할 수 있다. Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다.
Map<DishType, Map<CaloricLevel, List<Dish>>> dishTypeMapMap = menu.stream()
.collect(groupingBy(Dish::getDishType, groupingBy(dish -> {
if (dish.getCalories() > 400) return CaloricLevel.DIET;
else return CaloricLevel.FAT;
})));
groupBy로 넘겨주는 컬렉터의 형식은 제한이 없다. 아래와 같이 두번째 인수로 counting 컬렉터를 전달해 메뉴에서 요리의 수를 종류별로 계산 가능하다.
Map<DishType, Long> dishTypeLongMap = menu.stream()
.collect(groupingBy(Dish::getDishType, counting()));
분류 함수 한개의 인수를 받는 groupingBy(f)는 groupingBy(f, toList())의 축약형이다. 가장 높은 칼로리를 가지는 메뉴도 구현 가능하다.
Map<DishType, Optional<Dish>> dishTypeOptionalMap = menu.stream()
.collect(groupingBy(Dish::getDishType, maxBy(Comparator.comparingInt(Dish::getCalories))));
팩토리 메서드 maxBy 가 생성하는 컬렉터의 형식에 따라 Optional 형식으로 바인딩 되었다. 실제 메뉴의 요리중 Optional.empty()를 값으로 가지는 메뉴는 없으나 groupingBy 컬렉터는 스트림의 첫번째 요소를 찾은 이후에 그룹화 맵에 새로운 키를 추가한다. (lazy binging)
Optional로 값을 감쌀 필요가 없으므로 Optional을 삭제 할 수있다. CollectingAndThen을 활용하는 것이다.
Map<DishType, Dish> dishTypeDishMap = menu.stream()
.collect(groupingBy(Dish::getDishType, collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));
일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 이용한다. 예를들어 모든 요리의 칼로리합을 구할때는 아래와 같이 사용한다.
Map<DishType, Integer> dishTypeIntegerMap = menu.stream()
.collect(groupingBy(Dish::getDishType, summingInt(Dish::getCalories)));
분할은 분할함수라 불리는 Predicate를 분류 함수로 사용하는 특수한 그룹화 기능이다.
Map<Boolean, List<Dish>> partitionedMenu = menu.stream()
.collect(partitioningBy(Dish::isVegetarian)); <- 분할 함수
// filter를 사용할 수도 있다.
List<Dish> vegetarianDishes = menu.stream()
.filter(Dish::isVegetarian)
.collect(toList());
분할 함수를 사용하면 참, 거짓 두가지 요소의 스트림 리스트를 모두 유지할 수 있다는것이 장점이다. 컬렉터를 두번째 인수로 전달할 수 있는 오버로드된 partioningBy 메서드도 존재한다.
Map<Boolean, Map<DishType, List<Dish>>> vegetarianDishesByType = menu.stream()
.collect(partitioningBy(Dish::isVegetarian, groupingBy(Dish::getDishType)));
// 채식 중 가장 높은 칼로리의 음식과 채식이 아닌 음식중 가장 높은 칼로리의 음식
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian = menu.stream()
.collect(partitioningBy(Dish::isVegetarian,
collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));