✏ 학습 목표
Collector 인터페이스에 정의된 다양한 요소 누적 방식에 대해 알아본다.
컬렉션, 컬렉터, collect를 헷갈리지 않도록 주의해야 한다.
collect
와 컬렉터로 구현할 수 있는 질의 예제를 먼저 살펴보겠다.
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();
for(Transaction transaction : transactions){
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
if(transactionsForCurrency == null){
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency, transactionsForCurrency);
}
transactionsForCurrency.add(transaction);
}
간단한 작업임에도 불구하고 코드가 너무 길다. 구현은 했지만 이해하기 어려운 코드가 되었다. '통화별로 트랜잭션 리스트를 그룹화하시오'라고 간단히 표현할 수 있지만 코드가 무엇을 실행하는지 한눈에 파악하기 어렵다.
Stream
에 toList
를 사용하는 대신 더 범용적인 컬렉터 파라미터를 collect
메소드에 전달함으로써 원하는 연산을 간결하게 구현할 수 있음을 배울 예정이다.
Map<currency, List<Transaction>> transactiosByCurrencies =
transactions.stream().collect(groupingBy(Transaction::getCurrency));
첫 번째 구현 코드와 비교하면 굉장히 간결해진 코드이다.
위 예제는 명령형 프로그래밍에 비해 함수형 프로그래밍이 얼마나 편리한지 보여준다. 함수형 프로그래밍에서는 '무엇'을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을 것인지는 신경 쓸 필요가 없다.
이전 예제에서 collect
메소드로 Collector
인터페이스 구현을 전달했다.
Collector
인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.
groupingBy
를 이용해서 '각 키 버킷 그리고 각 키 버킷에 대응하는 요소 리스트를 값으로 포함하는 맵을 만들라'는 동작을 수행한다.
다수준으로 그룹화를 수행할 때 명령형 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러진다. 명령형 코드에서는 문제를 해결하는 과정에서 다중 루프와 조건문을 추가하며 가독성과 유지보수성이 크게 떨어진다.
반면 함수형 프로그래밍에서는 필요한 컬렉터를 쉽게 추가할 수 있다.
함수형 API의 또 다른 장점으로 높은 수준의 조합성과 재사용성을 꼽을 수 있다. collect
로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다.
구체적으로 설명하자면 스트림에 collect
를 호출하면 스트림의 요소에 리듀싱 연산이 수행된다. 내부적으로 리듀싱 연산이 일어난다. 명령형 프로그래밍에서는 우리가 직접 구현해야 했던 작업이 자동으로 수행되는 것이다. collect
에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하며 컬렉터가 작업을 처리한다.
Collector
인터페이스의 메소드를 어떻게 구현하느냐에 따라 스트림에 어떤 리듀싱 연산을 수행할지 결정된다. Collectors
유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메소드를 제공한다.
예를 들어 가장 많이 사용하는 직관적인 정적 메소드로 toList
를 꼽을 수 있다. toList
는 스트림의 모든 요소를 리스트로 수집한다.
Collectors
에서 제공하는 메소드의 기능은 크게 세 가지로 구분할 수 있다.
- 스트림 요소를 하나의 값으로 리듀스하고 요약
이전 예제에서 트랜잭션 리스트에서 트랜잭션 총합을 찾는 등의 다양한 계산을 수행할 때 유용하게 사용- 요소 그룹화
다수준으로 그룹화하거나 각각의 결과 서브그룹에 추가로 리듀싱 연산을 적용할 수 있도록 다양한 컬렉터를 조합하여 사용- 요소 분할
그룹화의 특별한 연산, 한 개의 인수를 받아 불리언을 반환한다. 즉, 프레디케이트를 그룹화 함수로 사용한다.
예제를 통해 Collector
팩토리 클래스로 만든 컬렉터 인스턴스로 어떤 일을 할 수 있는지 살펴보겠다.
컬렉터로 스트림의 항목을 컬렉션으로 재구성할 수 있다. 좀 더 일반적으로 말해 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다.
트리를 구성하는 다수준 맵, 메뉴의 칼로리 합계를 가리키는 단순한 정수 등 다양한 형식의 결과가 도출될 수 있다.
예제) 메뉴의 요리 수 구하기
long howManyDishes = menu.stream().collect(Collectors.counting());
위 코드에서 불필요한 과정을 생략할 수 있다.
long howManyDishes = menu.stream().count();
counting()
이라는 팩토리 메소드가 반환하는 컬렉터를 이용하여 스트림 요소의 개수를 구할 수 있다.
예제) 메뉴 중 칼로리가 가장 높은 / 낮은 요리 찾기
칼로리로 요리를 비교하는
Comparator
를 구현Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Collectors.maxBy
로 전달Optional<Dish> mostCalorieDish = menu.stream().collect(maxBy(dishCaloriesComparator));
Collectors.maxBy
, Collectors.minBy
메소드를 이용하여 최댓값과 최솟값을 구할 수 있다.
두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator
를 인수로 받는다.
스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약 연산이라고 부른다.
Collectors
클래스는 Collectors.summingInt
라는 특별한 요약 팩토리 메소드를 제공한다.
예제) 메뉴 리스트의 총 칼로리 계산
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
칼로리로 매핑된각 요리의 값을 탐색하면서 초깃값(예제에서는 0)으로 설정되어 있는 누적자에 칼로리를 더한다.
summingInt
는 객체를 int로 매핑하는 함수를 인수로 받는다. 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt
가 collect
메소드로 전달되면 요약 작업을 수행한다.
Collectors.summingLong
, Collectors.summingDouble
메소드도 같은 방식으로 동작하며 각각 long / double 형식의 데이터로 요약된다는 점만 다르다.
예제) 메뉴 리스트의 평균 칼로리 계산
double avgCalories = menu.stream().collect(averagingDouble(Dish::getCalories));
averagingInt
, averagingLong
, averagingDouble
등을 이용하여 다양한 형식으로 이루어진 숫자 집합을 평균을 구할 수 있다.
예제) 메뉴의 요소 수, 칼로리 합계, 평균, 최댓값, 최솟값 등을 계산
IntSummaryStatistics intSummaryStatistics = menu.stream().collect(summarizingInt(Dish::getCalories)); // IntSummaryStatistics{count=9, sum=4200, min=120, average=466.666667, max=800}
위 코드를 실행하면 IntSummaryStatistics
클래스로 모든 정보가 수집된다.
long과 double에 대응하는 메소드/클래스인 summarizingLong
/LongSummaryStatistics
와 summarizingDouble
/DoubleSummaryStatistics
도 있다.
컬렉터에 joining
팩토리 메소드를 이용하면 스트림의 각 객체에 toString
메소드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.
예제) 메뉴의 모든 요리명을 연결
// 1. 인수가 없는 경우 String fullName = menu.stream().map(Dish::getName).collect(joining()); // porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon // 2. 인수를 받는 경우 String fullName = menu.stream().map(Dish::getName).collect(joining(",", "{", "}")); // {pork,beef,chicken,french fries,rice,season fruit,pizza,prawns,salmon}
joining
메소드는 내부적으로 StringBuilder
를 이용해서 문자열을 하나로 만든다.
Dish 클래스에 요리명을 반환하는 toString
메소드가 구현되어 있다면 map
으로 요리명을 추출하는 과정을 생략할 수 있다.
joining
을 오버로딩하는 메소드도 구현되어 있다.
Collectors.reducing
팩토리 멕소드가 제공하는 범용 리듀싱 컬렉터로도 위에 구현한 리듀싱을 구현할 수 있다. 그럼에도 이전 예제에서 범용 팩토리 메소드 대신 특화된 컬렉터를 사용한 이유는 프로그래밍적 편의성 때문이다. (하지만 프로그래머의 편의성 뿐 아니라 가독성도 중요하다는 사실을 기억해야 한다.)
예제) 메뉴 리스트의 총 칼로리 계산
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
reducing
은 인수 세 개를 받는다.
BinaryOperator
이다.또한 한 개의 인수를 가진 reducing
팩토리 메소드도 있다. 이 메소드는 Optional
객체를 반환한다.
예제) 가장 칼로리가 높은 요리 찾기
Optional<Dish> mostCalorieDish = menu.stream().collect(reducing( (d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
한 개의 인수를 갖는 reducing
팩토리 메소드는 세 개의 인수를 갖는 reducing
메소드에서 스트림의 첫 번째 요소를 시작 요소, 즉 첫 번째 인수로 받으며, 자신을 그대로 반환하는 항등 함수를 두 번째 요소로 받는다.
즉, 한 개의 인수를 갖는 reducing
컬렉터는 시작값이 없으므로 빈 스트림이 넘겨졌을 때 시작값이 설정되지 않는 상황이 발생한다. 때문에 Optional
객체를 반환한다.
collect와 reduce
collect
와reduce
로 같은 기능을 구현할 수 있다는 것을 배웠다. 그렇다면 둘은 무엇이 다를까?
collect(toList())
를reduce
를 사용해서 구현할 수 있다. 그러나 의미론적인 문제와 실용성 문제가 발생한다.
collect
메소드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메소드이지만
reduce
메소드는 두 값을 하나로 도출하는 불변형 연산이다.때문에 의미론적 문제가 발생한다. 즉,
collect(toList())
를reduce
로 구현하는 것은 누적자로 사용된 리스트를 변환시키므로 잘못 활용된 예이다.또한
reduce
메소드를 잘못 사용하게 된다면 실용성 문제도 발생한다. 여러 스레드가 동시에 같은 데이터 구조체를 고치면 리스트 자체가 망가져버리므로 리듀싱 연산을 병렬로 수행할 수 없다는 문제도 발생한다. 이 문제를 해결하기 위해서는 매번 새로운 리스트를 할당해야 하므로 객체를 할당하느라 성능이 저하된다.가변 컨테이너 관련 작업이면서 병렬성을 확보해야 한다면
collect
메소드로 리듀싱 연산을 구현하는 것이 바람직하다.
메뉴 리스트의 총 칼로리 계산 예제에서 람다 표현식 대신 Integer
클래스의 sum
메소드 참조를 활용하면 코드를 좀 더 단순화 할 수 있다.
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, Integer::sum));
메뉴의 요리 수 구하기 예제도 3개의 인수를 가지는 reducing
팩토리 메소드를 이용해서 구현할 수 있다.
long howManyDishes = menu.stream().collect(counting());
public static <T> Collector<T, ?, Long> counting(){
return reducing(0L, e -> 1L, Long::sum);
}
위 코드처럼 스트림의 Long 객체 형식의 요소를 1로 변환한 다음에 모두 더할 수 있다.
제네릭 와일드카드 '?' 사용법
위 예제에서counting
팩토리 메소드가 반환하는 컬렉터 시그니처의 두 번째 제네릭 형식으로 와일드카드 ?이 사용되었다.
?는 컬렉터의 누적자 형식이 알려지지 않았음을, 즉 누적자의 형식이 자유로움을 의미한다.
함수형 프로그래밍에서는 하나의 연산을 다양한 방법으로 해결할 수 있다.
스트림 인터페이스에서 직접 제공하는 메소드를 이용하는 것보다 컬렉터를 이용하는 코드가 더 복잡하다. 코드가 좀 더 복잡한 대신 재사용성과 커스터마이징 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있다.
문제를 해결할 수 있는 다양한 해결 방법을 확인한 다음에 가장 일반적으로 문제에 특화된 해결책을 고르는 것이 바람직하다.
데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다. 명령형으로 그룹화를 구현하려면 까다롭고, 할 일이 많으며, 에러도 많이 발생한다. 하지만 자바 8의 함수형을 이용하면 가동성 있는 한 줄의 코드로 그룹화를 구현할 수 있다.
예제) 메뉴 그룹화 - 메소드 참조
Map<Dish.Type, List<Dish>> groupByType = menu.stream().collect(groupingBy(Dish::getType)); // {OTHER=[french fries, rice, season fruit, pizza], FISH=[prawns, salmon], MEAT=[pork, beef, chicken]}
스트림의 각 요리에서 Dish.Type
과 일치하는 모든 요리를 추출하는 함수를 groupingBy
메소드로 전달했다.
이 함수를 기준으로 스트림이 그룹화되는 것이므로 이를 분류 함수라고 한다.
그룹화 연산의 결과로 그룹화 함수가 반환하는 키 그리고 각 기에 대응하는 스트림의 모든 항목 리스트를 값으로 갖는 맵이 반환된다.
단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메소드 참조를 분류 함수로 사용할 수 없다.
이런 경우에는 람다 표현식으로 필요한 로직을 구현할 수 있다.
예제) 메뉴 그룹화 - 람다 표현식
enum CaloricLevel{DIET, NOMAl, FAT} Map<CaloricLevel, List<Dish>> groupByCaloricLevel = menu.stream().collect(groupingBy(dish -> { if(dish.getCalories() <= 400) return CaloricLevel.DIET; else if(dish.getCalories() <= 700) return CaloricLevel.NOMAl; else return CaloricLevel.FAT; })); // {FAT=[pork], DIET=[chicken, rice, season fruit, prawns], NOMAl=[beef, french fries, pizza, salmon]}
위 예제를 통해 메뉴의 요리 종류 또는 칼로리로 그룹화하는 방법을 살펴봤다. 그렇다면 두 가지 기준으로 동시에 그룹화하는 방법은 무엇일까?
요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.
예제) 500칼로리가 넘는 요리만 필터링
Map<Dish.Type, List<Dish>> groupFilter = menu.stream() .filter(d -> d.getCalories() > 500) .collect(groupingBy(Dish::getType)); // {MEAT=[pork, beef], OTHER=[french fries, pizza]}
위 코드처럼 그룹화 전에 프레디케이트 필터를 적용해 문제를 해결하고자 했을 때 문제가 발생한다. 바로 500 칼로리 이상의 FISH
타입의 음식이 없으므로 결과 맵에서 FISH
키 자체가 사라진다.
Collectors
클래스는 일반적인 분류 함수에 Collector
형식의 두 번째 인수를 갖도록 groupingBy
팩토리 메소드를 오버로드해 이 문제를 해결한다
예제) 500칼로리가 넘는 요리만 필터링
Map<Dish.Type, List<Dish>> groupFilter = menu.stream() .collect(groupingBy(Dish::getType, filtering(d -> d.getCalories() > 500, toList()))); // {OTHER=[french fries, pizza], FISH=[], MEAT=[pork, beef]}
filtering
메소드는 Collectors
클래스의 또 다른 정적 팩토리 메소드로 프레디케이트를 인수로 받는다.
이 프레디케이트로 각 그룹의 요소와 필터링 된 요소를 재그룹화한다. 때문에 요소가 존재하지 않은 FISH
도 항목에 포함된다.
예제) 그룹의 각 요리를 관련 이름 목록으로 변환
Map<Dish.Type, List<String>> dishNamesByType = menu.stream().collect(groupingBy(Dish::getType, mapping(Dish::getName, toList()))); // {OTHER=[french fries, rice, season fruit, pizza], FISH=[prawns, salmon], MEAT=[pork, beef, chicken]}
그룹화된 항목을 조작하는 유용한 기능 중 하나인 매핑 함수를 이용해 요소를 변환하는 작업을 수행할 수 있다. filtering
컬렉터와 같은 이유로 Collectors
클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping
메소드를 제공한다.
결과 맵의 각 그룹은 요리 객체가 아니라 문자열 리스트이다.
예제) flatMap을 이용한 요리 태그 추출
요리 태그 맵 생성
Map<String, List<String>> dishTags = new HashMap<>(); dishTags.put("pork", asList("greasy", "salty")); dishTags.put("beef", asList("salty", "roasted")); dishTags.put("chicken", asList("fried", "crisp")); dishTags.put("french fries", asList("greasy", "fried")); dishTags.put("rice", asList("light", "natural")); dishTags.put("season fruit", asList("fresh", "fried")); dishTags.put("pizza", asList("taste", "salty")); dishTags.put("prawns", asList("taste", "roasted")); dishTags.put("salmon", asList("delicious", "fresh"));
요리 태그 추출
Map<Dish.Type, Set<String>> dishNamesByType = menu.stream() .collect(groupingBy(Dish::getType, flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet()))); // {MEAT=[salty, greasy, roasted, fried, crisp], FISH=[taste, roasted, fresh, delicious], OTHER=[salty, greasy, natural, light, taste, fresh, fried]}
각 요리에서 태그 리스트를 얻기 위해서 두 수준의 리스트를 한 수준으로 평면화하는 flatMap
을 수행한다.
각 그룹에 수행한 flatMapping
연산 결과를 구집해서 리스트가 아닌 집합으로 그룹화하기 때문에 중복 태그가 제거된다.
그룹화의 장점은 조합을 통해 두 가지 이상의 기준을 동시에 적용할 수 있다는 것이다.
두 인수를 받는 팩토리 메소드 Collectors.groupingBy
를 이용해서 항목을 다수준으로 그룹화할 수 있다. 이 메소드는 일반적인 분류 함수와 컬렉터를 인수로 받는다. 즉, 바깥쪽 groupingBy
메소드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy
를 전달해서 두 수준을 스트림의 항목으로 그룹화할 수 있다.
예제) 다수준 그룹화
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream().collect( groupingBy(Dish::getType , groupingBy(d -> { if(d.getCalories() <= 400) return CaloricLevel.DIET; else if(d.getCalories() <= 700) return CaloricLevel.NOMAl; else return CaloricLevel.FAT; }) ) ); // {OTHER={DIET=[rice, season fruit], NOMAl=[french fries, pizza]} // , FISH={DIET=[prawns], NOMAl=[salmon]} // , MEAT={FAT=[pork], DIET=[chicken], NOMAl=[beef]}}
다수준 그룹화 연산은 다양한 수준으로 확장할 수 있다. 즉, n수준 그룹화의 결과는 n수준 트리 구조로 표현되는 n수준 맵이 된다.
보통 groupingBy
의 연산을 버킷개념으로 생각하면 쉽다. 첫 번째 groupingBy
는 각 키의 버킷을 만든다. 그리고 준비된 각각의 버킷을 서브스트림 컬렉터로 채워가기를 반복하면서 n수준 그룹화를 달성한다.
첫 번째 groupingBy
로 넘겨주는 컬렉터의 형식에는 제한이 없다.
예제) 메뉴의 요리 수를 종류별로 계산
Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting())); // {FISH=2, MEAT=3, OTHER=4}
위 코드에서는 groupingBy
컬렉터에 두 번째 인수로 counting
컬렉터를 전달해서 메뉴에서 요리의 수를 종류별로 계산한다.
분류 함수 하나를 인수로 갖는 groupingBy(f)
는 groupingBy(f, toList())
의 축약형이다.
예제) 요리 종류 별 가장 높은 칼로리의 요리 찾기
Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream().collect(groupingBy(Dish::getType, maxBy(Comparator.comparingInt(Dish::getCalories)))); // {OTHER=Optional[pizza], FISH=Optional[salmon], MEAT=Optional[pork]}
위 예제에서 maxBy
가 생성하는 컬렉터의 결과 형식에 따라 맵의 값이 Optional
형식이 되었지만 메뉴의 요리 중 Optional.empty()
를 값으로 가지는 요리는 존재하지 않는다.
맵의 모든 값을 Optional
로 감쌀 필요가 없으므로 Optional
을 삭제할 수 있다.
Map<Dish.Type, Dish> mostCaloricByType =
menu.stream().collect(groupingBy(Dish::getType, collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));
// {FISH=salmon, MEAT=pork, OTHER=pizza}
collectingAndThen
팩토리 메소드로 컬렉터가 반환한 결과를 다른 형식으로 활용한다.
적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다. 반환되는 컬렉터는 기존 컬렉터의 래퍼 역할을 하며 collect
의 마지막 과정에서 변환 함수로 자신이 반환하는 값을 매핑한다.
이 예제에서는 maxBy
로 만들어진 컬렉터가 감싸지는 컬렉터이며 변환 함수 Optional::get
으로 반환된 Optional
에 포함된 값을 추출한다.
리듀싱 컬렉터는 절대 Optional.empty()
를 반환하지 않으므로 안전한 코드다.
일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메소드 groupingBy
에 두 번째 인수로 전달한 컬렉터를 사용한다.
예제) 종류별 요리의 칼로리 합계
Map<Dish.Type, Integer> totalCaloriesByType = menu.stream().collect(groupingBy(Dish::getType, summingInt(Dish::getCalories))); // {FISH=750, MEAT=1900, OTHER=1550}
이 외에도 mapping
메소드로 만들어진 컬렉터도 groupingBy
와 자주 사용된다. mapping
메소드는 스트림의 인수를 변환하는 함수와 변환 함수의 결과 객체를 누적하는 컬렉터를 인수로 받는다. mapping
은 입력 요소를 누적하기 전에 매핑 함수를 적용해서 다양한 형식의 객체를 주어진 형식의 컬렉터에 맞게 변환하는 역할을 한다.
예제) 각 요리 형식에 존재하는 모든
CaloricLevel
값 구하기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.NOMAl; else return CaloricLevel.FAT; }, toSet()))); // {MEAT=[DIET, FAT, NOMAl], FISH=[DIET, NOMAl], OTHER=[DIET, NOMAl]}
groupingBy
와 mapping
컬렉터를 합쳐서 기능을 구현했다.
mapping
메소드에 전달한 변환 함수는 Dish
를 CaloricLevel
로 매핑한다. 그리고 CaloricLevel
결과 스트림은 toSet
컬렉터로 전달되면서 리스트가 아닌 집합으로 스트림의 요소가 누적된다.
위 예제에서는 Set
의 형식이 정해져 있지 않다. 만약 형식을 지정하고자 한다면 toCollection
을 이용해 원하는 방식으로 결과를 제어할 수 있다.
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.NOMAl;
else return CaloricLevel.FAT;
}, toCollection(HashSet::new))));
이 글은 모던 인 자바 액션 책을 실습하며 참고하여 작성한 글입니다.