모던 자바 인 액션 - CH06. 스트림으로 데이터 수집(2)
6.4 분할
분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다.
분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이다. -> 그룹화 맵은 최대 (참 or 거짓) 두 개의 그룹으로 분류됨
// 채익 요리와 채식이 아닌 요리로 분류
Map<Boolean, List<Dish>> partitionedMenu =
menu.stream().collect(partitioningBy(Dish::isVegetarian)); // 분할 함수
// 채식 요리를 얻기
List<Dish> vegitarianDishes = partitionedMenu.get(true);
// 프레디케이트로 필터링한 다음에 별도의 리스트에 결과를 수집해도 같은 결과를 얻을 수 있다.
List<Dish> vegitarianDishes =
menu.stream().filter(Dish::isVegetarian).collect(toList());
6.4.1 분할의 장점
분할 함수가 반환하는 참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할의 장점이다.
컬렉터를 두 번째 인수로 전달할 수 있는 오버로드된 버전의 partitioningBy 메서드가 있다. 채식 요리의 스트림과 채식이 아닌 요리의 스트림을 각각 요리 종류로 그룹화해서 두 수준의 맵이 반환되었다.
Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(
partitioningBy(Dish::isVegetarian, groupingBy(Dish::getType)));
// {false=FISH=[prawns, salmon], MEAT=[pork, beef, chicken]}, true={OTHER=[french fires, rice, season fruit, pizza]}}
이전 코드를 활용하면 채식 요리와 채식이 아닌 요리 각각의 그룹에서 가장 칼로리가 높은 요리도 찾을 수 있다.
Map<Boolean, List<Dish>> mostCaloricPartitionedByVegetarian =
menu.stream().collect(
partitioningBy(Dish::isVegetarian,
collectingAndThen(maxBy(comparingInt(Dish::getCalories)), Optional::get)));
// {false=pork, true=pizza}
partitioningBy가 반환한 맵 구현은 참과 거짓 두 가지 키만 포함하므로 더 간결하고 효과적이다.
6.4.2 숫자를 소수와 비소수로 분할하기
정수 n을 인수로 받아서 2에서 n까지의 자연수를 소수와 비소수로 나누는 프로그램을 구현하자.
먼저 주어진 수가 소수인지 아닌지 판단하는 프레디케이트를 구현하자
public boolean isPrime(int candidate) {
return IntStream.range(2, candidate).noneMatch(i -> candidate % i == 0);
//스트림의 모든 정수로 candidate를 나눌 수 없으면 참을 반환
}
소수의 대상을 주어진 수의 제곱근 이하의 수로 제한한다.
public boolean isPrime(int candidate) {
int candidateRoot = (int) Math.sqrt((double)candidate);
return IntStream.rangeClosed(2, candidateRoot).noneMatch(i -> candidate % i == 0);
}
n개의 숫자를 포함하는 스트림을 만든 다음에 우리가 구현한 isPrime 메서드를 프레디케이트로 이용하고 partitioningBy 컬렉터로 리듀스해서 숫자를 소수와 비소수로 분류할 수 있다.
public Map<Boolean, List<Integer>> partitionPrimes(int n) {
return IntStream.rangeClosed(2, n).boxed().collect(partitioningBy(candidate -> isPrime(candidate)));
}
6.5 Collector 인터페이스
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>>
6.5.1 Collector 인터페이스의 메서드 살펴보기
이제 Collector 인터페이스에 정의된 다섯 개의 메서드를 하나씩 살펴보자.
네 개의 메서드는 collect 메서드에 실행하는 함수를 반환하는 반면,
characteristics는 collect 메서드가 어떤 최적화(예를 들면 병렬화)를 이용해서 리듀싱 연산을 수행할 것인지를 결정하도록 돕는 힌트 특성 집합을 제공한다.
supplier 메서드 : 새로운 결과 컨테이너 만들기
supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야한다.
즉, supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다.
ToListCollector처럼 누적자를 반환하는 컬렉터에서는 빈 누적자가 비어있는 스트림의 수집 과정의 결과가 될 수 있다.
public Supplier<List<T>> supplier() {
return() -> new ArrayList<T>();
}
//생성자 참조 방식으로 전달도 가능
public Supplier<List<T>> supplier() {
return ArrayList::new;
}
accumulator 메서드 : 결과 컨테이너에 요소 추가하기
accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다.
스트림에서 n번째 요소를 탐색할 때 두 인수, 즉 누적자(스트림의 첫 n-1개 항목을 수집한 상태)와 n번째 요소를 함수에 적용한다.
함수의 반환값은 void, 즉 요소를 탐색하면서 적용하는 함수에 의해서 누적자 내부상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다.
public BiConsumer<List<T>, T> acuumulator() {
return (list, item) -> list.add(item);
}
//다음처럼 메서드 참조를 이용하면 코드가 더 간결해진다.
public BiConsumer<List<T>, T> accumulator() {
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) -> {
liat.addAll(list2);
return list1;
}
}
Charactoeristics 메서드
characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다.
Characteristics는 스트림을 병렬로 리듀스 할지와 병렬로 리듀스한다면 어떤 최적화를 선택해야할지 힌트를 제공한다.
Characteristics는 다음 세 항목을 포함하는 열거형이다.
- UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
- CONCURRENT : 다중 스레드에서 accumulator 함수를 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
- IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있으며, 누적자 A를 결과 R로 안전하게 형변환할 수 있다.
6.5.2 응용하기
지금까지 살펴본 다섯 가지 메서드를 이용해서 자신만의 커스텀 ToListCollector를 구현할 수 있다.
public class ToListCollector<T> implements Collect<T, List<T>, List<T>> {
@Override
public Supplier<List<T>> supplier() {
return ArrayList::new; // 수집 연산의 시발점
}
@Override
public BiConsumer<List<T>, T> accumulator() {
return List::add; // 탐색한 항목을 누적하고 바로 누적자를 고친다.
}
@Override
public Function<List<T> List<T>> finisher() {
return Function.identity(); // 항등 함수
}
@Override
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> { // 두 번째 콘텐츠와 합쳐서 첫 번째 누적자를 고친다.
liat.addAll(list2); // 변경된 첫 번째 누적자를 반환한다.
return list1;
}
}
@Override
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH, CONCURRENT)); // 컬렉터의 플래그 설정
}
}
이제 자바에서 제공하는 API 대신 우리가 만든 컬렉터를 메뉴 스트림의 모든 요리를 수집하는 예제에 사용할 수 있다.
List<Dish> dishies = menuStream.collect(new ToListCollector<Dish>());
컬렉터 구현을 만들지 않고도 커스텀 수집 수행하기
IDENTITY_FINISH 수집 연산에서는 Collector 인터페이스를 완전히 새로 구현하지 않고도 같은 결과를 얻을 수 있다.
Stream은 세 함수(발행, 누적, 합침)를 인수로 받는 collect 메서드를 오버로드하여 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수와 같은 기능을 수행한다.
List<Dish> dishes = menuStream.collect(
ArrayList::new, //발행
List::add, //누적
List:addAll); //합침
위 코드는 가독성이 떨어지고, Characteristics를 전달할 수 없다.
6.6 커스텀 컬렉터를 구현해서 성능 개선하기
6.4절에서 커스텀 컬렉터로 n까지의 자연수를 소수와 비소수로 분할하였다. 그리고 제곱근 이하로 대상의 숫자 범위를 제한해서 isPrime 메서드를 개선했다.
public boolean isPrime(List<Integer> primes, int candidate) {
int candidateRoot = (int) Math.sqrt((double)candidate);
return primes.stream()
.talkWhile(i -> i <= candidateRoot);
.noneMatch(i -> candidate % i == 0);
}
6.6.1 소수로만 나누기
제수(devisor)가 소수가 아니면 소용없으므로 제수를 현재 숫자 이하에서 발견한 소수로 제한할 수 있다.
주어진 숫자가 소수인지 아닌지 판단해야 한다 -> 지금까지 발견한 소수 리스트에 접근해야 한다.
컬렉터로는 컬렉터 수집 과정에서 부분 결과에 접근할 수 없다. -> 커스텀 컬렉터 클래스로 이 문제를 해결할 수 있다.
중간 결과 리스트가 있다면 isPrime 메서드로 중간 결과 리스트를 전달하도록 다음과 같이 코드를 구현할 수 있다.