6.1 컬렉터란 무엇인가?
Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정
함수형 프로그래밍은 ‘무엇'을 원하는지 직접 명시할 수 있어 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없다. 이는 다수준으로 그룹화를 수행할 때 명령형 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러진다. 명령형 코드에서는 문제를 해결하는 과정에서 다중 루프와 조건문을 추가하며 가독성과 유지보수성이 떨어지지만 함수형 프로그래밍에서는 컬렉터를 쉽게 추가할 수 있다.
강점
- collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다
- 명령형 프로그래밍에서 직접 구현해야 했던 작업이 자동으로 수행
- collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업 처리
Collectors 에서 제공하는 메서드의 기능은 크게 세 가지로 구분이 가능하다.
- 스트림 요소를 하나의 값으로 리듀스하고 요약
- 다양한 계산을 수행할 때 유용하게 활용 가능
- 요소 그룹화
- 다수준으로 그룹화 하거나 각각의 결과 서브그룹에 추가로 리듀싱 연산을 적용
- 요소 분할
- 그룹화의 특별한 연산 분할 사용
- 한 개의 인수를 받아 불리언을 반환하는 함수, 즉 프리디케이트를 그룹화 함수로 사용
6.2 리듀싱 요약
5장에서 사용한 자동차 리스트를 활용해서 Collector 팩토리 클래스로 만든 컬렉터 인스턴스로 어떤 일을 할 수 있는지 알아봅시다.
첫 번째로 counting() 이라는 팩토리 메서드가 반환하는 컬렉터로 메뉴에서 요리 수를 계산한다.
Long howManyCars = carList.stream().collect(Collectors.counting());
불필요한 과정 생략
Long howManyCars = carList.stream().count();
import static java.util.stream.Collectors.*;
이제는 정적 팩토리 메서드를 모두 import 했다고 가정
Long howManyCars = carList.stream().collect(counting());
으로도 표현이 가능하다.
6.2.1 스트림값에서 최대,최소 검색
자동차에서 가장 비싼 차를 찾는다고 가정해봅시다.
Collectors.maxBy, Collectors.minBy 두 메서드를 이용해 최대, 최소 값을 계산할 수 있습니다.
두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator를 인수로 받습니다.
최대 가격 차량
Optional<Car> highPriceCar = carList.stream().
collect(maxBy(Comparator.comparingInt(Car::getPrice)));
최소 가격 차량
Optional<Car> highPriceCar = carList.stream().
collect(minBy(Comparator.comparingInt(Car::getPrice)));
6.2.2 요약 연산
Collectors 클래스는 Collectors.summingInt 라는 특별한 요약 팩토리 메서드를 제공한다.
- summingInt 는 객체를 int로 매핑하는 함수를 인수로 받음
- summingIn의 인수로 전달된 함수는 객체를 int 로 매핑하는 함수를 인수로 받음
- summingInt가 collect 메서드로 전달되면 요약 작업을 수행
다음은 차 리스트의 총 가격을 합하는 코드이다.
int totalCarPrice = carList.stream().collect(summingInt(Car::getPrice));
summingLong, summingDouble 메서드 또한 같은 방식으로 동작하며 반환 타입이 long, double 형식으로 데이터를 요약한다는 점만 다르다.
단순 합계 외에 평균값 계산 등 연산도 요약 기능으로 제공된다.
- averagingInt
- averagingLong
- averagingDouble
Double collect = carList.stream().collect(averagingInt(Car::getPrice));
합계, 평균, 최대, 최소 모든 정보를 수집하는 방법도 있다. summarizingInt가 반환하는 컬렉터를 사용하면 된다.
IntSummaryStatistics intSummaryStatistics = carList.stream().collect(summarizingInt(Car::getPrice));
6.2.3 문자열 연결
컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환합니다.
String collect = carList.stream()
.map(Car::getName)
.collect(joining(", "));
//출력
k3, avante, sonata, gv70, benzC, g80, benzE
joining 메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만듭니다.
만약 Car클래스가 차량명을 반환하는 toString 메서드를 포함하고 있다면 map으로 각 차량의 이름을 추출하는 과정을 생략가능
String collect = carList.stream()
.collect(joining(", "));
6.2.4 범용 리듀싱 요약 연산
지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있습니다. 즉, 범용 Collectors.reducing으로도 구현할 수 있습니다. 그럼에도 범용 팩토리 메서드 대신 특화된 컬렉터를 사용한 이유는 편의성과 가독성을 위해 사용했습니다.
reducing 메서드로 만들어진 컬렉터로도 차량의 모든 가격을 계산할 수 있습니다.
Integer totalPrice = carList.stream().
collect(reducing(0, Car::getPrice, (a, b) -> a + b));
세 개의 인수를 받습니다.
- 리듀싱 연산의 시작값 or 스트림에 인수가 없을 때는 반환값
- 차량을 가격 정수로 변환할 때 사용한 변환 함수
- 두 항목을 하나의 값으로 더하는 BinaryOperator
한 개의 인수를 가진 reducing 을 이용해서 가장 비싼 차량을 찾는 방법도 있습니다.
Optional<Car> mostCarPrice = carList.stream().
collect(reducing(
(p1, p2) -> p1.getPrice() > p2.getPrice() ? p1 : p2
));
한 개의 인수를 갖는 reducing 팩토리 메서드는 세 개의 인수를 갖는 reducing 메서드에서 스트림의 첫 번째 요소를 시작 요소, 즉 첫 번째 인수로 받으며, 자신을 그대로 반환하는 항등 함수를 두 번째 인수로 받는 상황에 해당한다. 한 개의 인수를 갖는 reducing 컬렉터는 시작값이 없으므로 빈 스트림이 넘겨졌을 때 시작값이 설정되지 않은 상황이 벌어져 Optional을 반환한다.
람다 표현식 대신 Integer 클래스의 sum 메서드 참조를 이용하면 좀더 단순화 할 수 있다.
Integer totalCarPrice = carList.stream().
collect(reducing(0, Car::getPrice, Integer::sum));
스트림을 사용하여 같은 결과를 얻는데 다양한 해결 방법이 있지만 가장 일반적으로 문제에 특화된 해결책을 고르는 것이 바람직 합니다. 왜냐하면 가독성과 성능이라는 두 가지 장점을 가져갈 수 있기 때문입니다.
6.3 그룹화
자바 8의 함수형을 이용하면 가독성 있는 한줄의 코드로 그룹화를 구현할 수 있습니다.
Map<Car.Type, List<Car>> carByType = carList.stream()
.collect(groupingBy(Car::getType));
//출력
{ELECTRIC=[gv70E, gv60],
GASOLINE=[k3, avante, avanteN, gv70, benzC, g80, benzE],
HYBRID=[sonata], DIESEL=[benzC]}
스트림에서 각 차량에서 Car.Type 과 일치하는 모든 차량을 추출하는 함수를 groupingBy 메서드로 전달했습니다. 이 함수를 기준으로 스트림이 그룹화되므로 이를 분류함수 라고 부릅니다.
좀 더 복잡한 분류 기준이 있을 경우 메서드 참조를 분류 함수로 사용할 수 없고 람다 표현식으로 필요한 로직을 구현할 수 있습니다.
//타입 분류를 위한 enum 추가
public enum CarLevel { LOW, MIDDLE, HIGH }
Map<CarLevel, List<Car>> carByPriceLevel = carList.stream()
.collect(groupingBy(car -> {
if (car.getPrice() <= 3000) return CarLevel.LOW;
else if (car.getPrice() <= 6500) return CarLevel.MIDDLE;
else return CarLevel.HIGH;
}));
//출력
{MIDDLE=[avanteN, gv70, gv70E, benzC],
LOW=[k3, avante, sonata],
HIGH=[gv60, benzC, g80, benzE]}
6.3.1 그룹화된 요소 조작
요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요합니다. 차량 가격이 6000이상만 되는 차량만 필터링 한다고 가정해보자 그룹화 하기전에 프레디케이트로 필터를 적용해 문제를 해결할 수 있다고 생각할 수 있습니다.
Map<Car.Type, List<Car>> carListByType = carList.stream()
.filter(car -> car.getPrice() > 6000)
.collect(groupingBy(Car::getType));
//출력
{DIESEL=[benzC], ELECTRIC=[gv60], GASOLINE=[benzC, g80, benzE]}
하지만 위와 같은 코드의 문제점은 HYBRID 의 타입은 필터에서 걸러졌기 때문에 맵에서 해당 키 자체가 사라집니다. Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해 이 문제를 해결합니다.
Collector 안으로 필터 프레디케이트를 이동함으로 이 문제를 해결 할 수 있습니다.
Map<Car.Type, List<Car>> carListByType = carList.stream()
.collect(groupingBy(Car::getType,
filtering(car -> car.getPrice() > 6000, toList())));
//출력
{ELECTRIC=[gv60], DIESEL=[benzC], GASOLINE=[benzC, g80, benzE], HYBRID=[]}
그룹화된 항목을 조작하는 다른 유용한 기능 중 하나로 맵핑 함수를 이용하여 요소를 변환하는 작업이 있습니다.
Map<Car.Type, List<Integer>> carListByType = carList.stream()
.collect(groupingBy(Car::getType,
mapping(Car::getPrice, toList())));
//출력
{HYBRID=[2700], ELECTRIC=[6000, 7000],
DIESEL=[6400], GASOLINE=[1800, 2300, 3100, 6000, 6700, 8000, 8700]}
6.3.2 다수준 그룹화
두 인수를 받는 팩토리 메서드 groupingBy를 이용해서 항목을 다수준으로 그룹화 할 수 있다.
Map<Car.Type, Map<CarLevel, List<Car>>> carByTypePriceLevel = carList.stream()
.collect(groupingBy(Car::getType,
groupingBy(car -> {
if (car.getPrice() <= 3000) {
return CarLevel.LOW;
} else if (car.getPrice() >= 6500) {
return CarLevel.HIGH;
} else {
return CarLevel.MIDDLE;
}
})));
//출력
{HYBRID={LOW=[sonata]}, GASOLINE={MIDDLE=[avanteN, gv70],
HIGH=[benzC, g80, benzE], LOW=[k3, avante]},
ELECTRIC={MIDDLE=[gv70E], HIGH=[gv60]}, DIESEL={MIDDLE=[benzC]}}
그룹화의 결과로 두 수준의 맵이 만들어 진다.
보통 groupingBy 연산을 ‘버킷(물건을 담는 양동이)’ 개념으로 생각하면 쉽다.
6.3.3 서브그룹으로 데이터 수집
6.3.2 절에서 두 번째 groupingBy 컬렉터를 외부 컬렉터로 전달해서 다수준 그룹화 연산을 구현 했다.
사실 첫 번째 groupingBy로 넘겨주는 컬렉터의 형식은 제한이 없다.
Map<Car.Type, Long> typeCount = carList.stream()
.collect(groupingBy(Car::getType, counting()));
//출력
{ELECTRIC=2, GASOLINE=7, HYBRID=1, DIESEL=1}
위와 같은 코드처럼 컬렉터에 두 번째 인수로 counting 컬렉터를 전달해서 메뉴에서 차량수를 종류별로 계산이 가능하다.
분류 함수 한 개의 인수를 갖는 groupingBy(f) 는 groupingBy(f , toList())의 축약형이다.
따라서 아래와 같은 코드도 구현이 가능하다.
Map<Car.Type, Optional<Car>> mostPriceByType = carList.stream()
.collect(groupingBy(Car::getType,
maxBy(Comparator.comparing(Car::getPrice))));
//출력
{DIESEL=Optional[benzC], HYBRID=Optional[sonata],
ELECTRIC=Optional[gv60], GASOLINE=Optional[benzE]}
마지막 그룹화 연산에서 맵의 모든 값을 Optional 로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다.
Collectors.collectingAndThen 으로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.
Map<Car.Type, Car> mostPriceByType = carList.stream()
.collect(groupingBy(Car::getType,
collectingAndThen(
maxBy(Comparator.comparing(Car::getPrice)),
Optional::get)));
//출력
{ELECTRIC=gv60, GASOLINE=benzE, DIESEL=benzC, HYBRID=sonata}
리듀싱 컬렉터는 절대 Optional.empty를 반환하지 않으므로 안전한 코드이기 때문에 maxBy로 만들어진 컬렉터가 감싸지는 컬렉터며 반환 함수 Optional::get 으로 반환된 Optional에 포함된 값을 추출합니다.
6.4 분할
분할은 분할 함수 라고 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능입니다. 분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean 입니다. 결과적으로 맵은 true , flase 두개의 그룹으로 분류가 됩니다.
이를 이용해 차량을 국산차인지 아닌지 분류를 해보겠습니다.
Map<Boolean, List<Car>> partitionedCar = carList.stream()
.collect(partitioningBy(Car::isMadeKorea));
//출력
{false=[benzC, benzC, benzE],
true=[k3, avante, avanteN, sonata, gv70, gv70E, gv60, g80]}
이제 참 값을 이용해 맵에서 모든 국산차 리스트를 얻을 수 있습니다.
List<Car> koreanCarList = partitionedCar.get(true);
//출력
[k3, avante, avanteN, sonata, gv70, gv70E, gv60, g80]
물론 차량 리스트로 생성한 스트림을 프레디케이트로 필터링한 다음에 리스트에 결과를 수집해도 같은 결과를 얻을 수 있습니다.
carList.stream()
.filter(Car::isMadeKorea)
.collect(toList());
6.4.1 분할의 장점
참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할의 장점입니다.
- 참, 거짓 두 가지 키만 포함하므로 간결하고 효과적
컬렉터를 두 번째 인수로 전달할 수 있는 오버로드하여 사용된 partitioningBy 메서드도 있습니다.
Map<Boolean, Map<Car.Type, List<Car>>> koreanCarByType = carList.stream()
.collect(partitioningBy(Car::isMadeKorea,
groupingBy(Car::getType)));
//출력
{false={DIESEL=[benzC], GASOLINE=[benzC, benzE]},
true={ELECTRIC=[gv70E, gv60], GASOLINE=[k3, avante, avanteN, gv70, g80],
HYBRID=[sonata]}}
6.5 Collector 인터페이스
Collector 인터페이스는 리듀싱 연산(컬렉터)을 어떻게 구현할지 제공하는 메서드 집합으로 구성됩니다.
다음 코드는 Collector 인터페이스의 시그니처와 다섯 개의 메서드 정의를 보여줍니다.
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
BinaryOperator<A> combiner();
Function<A, R> finisher();
Set<Characteristics> characteristics();
}
위 코드를 다음처럼 설명할 수 있습니다.
- T는 수집될 스트림 항목의 제네릭 형식
- A는 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식
- R은 수집 연산 결과 객체의 형식(항상 그런 것은 아니지만 대게 컬렉션 형식)
6.5.1 Collector 인터페이스의 메서드 살펴보기
supplier 메서드 : 새로운 결과 컨테이너 만들기
- supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야 한다. 즉 supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다. ToListCollector 처럼 누적자를 반환하는 컬렉터에서는 빈 누적자가 비어있는 스트림의 수집 과정의 결과가 될 수 있다.
ToListCollector에서 supplier는 다음처럼 빈 리스트를 반환한다.
public Supplier<List<T>> supplier(){
return () -> new ArrayList<T>();
}
public Supplier<List<T>> supplier(){
return ArrayList::new;
}
accumlator 메서드 : 결과 컨테이너에 요소 추가하기
- accumlator 메서드는 리듀싱 연산을 수행하는 함수를 반환합니다. 스트림에서 n 번째 요소를 탐색할 때 두 인수, 즉 누적자와 n번째 요소를 함수에 적용한다. 함수의 반환값은 void, 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다. ToListCollector 에서 accumlator가 반환하는 함수는 이미 탐색한 항목을 포함하는 리스트에 현재 항목을 추가하는 연산을 수행한다.
public BiConsumer<List<T>, T> accumlator(){
return (list, item) -> list.add(item);
}
public BiConsumer<List<T>, T> accumlator(){
return List::add;
}
finisher 메서드 : 최종 변환값을 결과 컨테이너로 적용하기
- finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 반환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다. 때로는 ToListCollector에서 볼 수 있는 것처럼 누적자 객체가 이미 최종 결과인 상황도 있다.
이런 때는 변환 과정이 필요하지 않으므로 finisher 메서드는 항등 함수를 반환한다.
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
combiner 메서드 : 두 결과 컨테이너 병합
- combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다. toList의 combiner는 비교적 쉽게 구현할 수 있다. 즉, 스트림의 두 번째 서브파트에서 수집한 항목 리스트를 첫 번째 서브파트 결과 리스트의 뒤에 추가하면 된다.
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> {
list1.addAll(list2);
return list1;
}
}
이 메서드를 이용하면 스트림의 리듀싱을 병렬로 수행할 수 있다.
Characteristics 메서드
- Characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환합니다. Characteristics는 스트림을 병렬로 리듀스할 것인지 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공합니다. Characteristics는 다음 세 항목을 포함하는 열거형입니다.
- UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
- CONCURRENT : 다중 스레드에서 accumlator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
- IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐 이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환 할 수 있다.
마무리
- collect 는 스트림의 요소를 요약 결과로 누적하는 다양한 방법(컬렉터라 불리는)을 인수로 갖는 연산
- 스트림의 요소를 하나의 값으로 리듀스하고 요약하는 컬렉터뿐 아니라 최소, 최대, 평균값을 계산하는 컬렉터 등이 미리 정의되어 있다.
- 미리 정의된 컬렉터인 groupingBy로 스트림의 요소를 그룹화하거나, partitioningBy로 스트림의 요소를 분할 할 수 있다.
- 컬렉터는 다수준의 그룹화 , 분할, 리듀싱 연산에 적합하게 설계됨
- Collector 인터페이스에 정의된 메서드를 구현해서 커스텀 컬렉터 개발이 가능
추가 팁
reduce() 메서드는 항상 새로운 값을 생성하는 반면, collect() 메서드는 기존 값을 업데이트하거나 변경
reduce()는 감소를 수행하는 반면 collect()는 가변 감소를 수행
collect() can only work with mutable result objects. (가변)
reduce() is designed to work with immutable result objects. (불변)
'모던 자바 인 액션 스터디' 카테고리의 다른 글
모던 자바 인 액션 - 5장 스트림 활용 (0) | 2022.05.16 |
---|---|
모던 자바 인 액션 - 4장 스트림 소개 (0) | 2022.05.09 |
모던 자바 인 액션 - 3장 람다 표현식 (0) | 2022.05.02 |
모던 자바 인 액션 - 2장 동작 파라미터화 코드 전달하기 (0) | 2022.04.25 |