Modern Java in Action - Ch.6(2)

참고: 책 - Modern Java in Action

책 Modern Java in Action을 읽고 정리합니다. 이번 포스트에서는 Ch 6.4 ~ Ch 6.6의 내용을 읽고 정리합니다.

Ch 6. 스트림으로 데이터 수집
6.4 분할
- 6.4.1 분할의 장점
- 6.4.2 숫자를 소수와 비소수로 분할하기
6.5 Collector 인터페이스
- 6.5.1 Collector 인터페이스의 메서드 살펴보기
- 6.5.2 응용하기
6.6 커스텀 컬렉터를 구현해서 성능 개선하기
- 6.6.1 소수로만 나누기
- 6.6.2 컬렉터 성능 비교

6.4 분할

분할은 분할 함수(partitioning function)라 불리는 predicate를 분류 함수로 사용하는 특수한 그룹화 기능입니다. 분할 함수는 Boolean을 반환하므로 맵의 키 형식이 Boolean입니다.

ex. 채식 요리와 채식이 아닌 요리로 나누는 예제입니다.

Map<Boolean, List<Dish>> partitionedMenu = 
        menu.stream()
            .collect(partitioningBy(Dish::isVegetarian)); // 분할 함수

List<Dish> vegetarianDishes = 
        menu.stream()
            .filter(Dish::isVegetarian).collect(toList());  // 메뉴 리스트로 생성한 스트림을 필터링하고 수집해도 가능


6.4.1 분할의 장점

분할 함수가 반환하는 참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할의 장점입니다. 컬렉터를 두 번째 인수로 전달할 수 있는 오버로드된 버전의 partitioningBy 메서드도 있습니다.

ex. 재료 별로 채식 요리와 채식이 아닌 요리로 나누는 예제입니다.

Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = 
        menu.stream()
            .collect(partitioningBy(Dish::isVegetarian, groupingBy(Dish::getType)));

ex. 채식 요리와 채식이 아닌 요리 각각 그룹에서 가장 칼로리가 높은 요리를 찾는 예제입니다.

Map<Boolean, Dish> mostCaloricPartitionedByVegetarian = 
        menu.stream()
            .collect(partitioningBy(Dish::isVegetarian, collectingAndThen(maxBy(comparingInt(Dish::getCalories)), Optional::get)));


6.4.2 숫자를 소수와 비소수로 분할하기

ex. 정수 n을 인수로 받아서 2에서 n까지의 자연수를 소수와 비소수로 나누는 프로그램을 구현합니다.

public boolean isPrime(int candidate) {
    int candidateRoot = (int) Math.sqrt((double)candidate); //제곱근 이하의 수로 제한
    return IntStream.rangeClosed(2, candidateRoot) //2부터 candidateRoot미만 사이의 자연수 생성
                    .noneMatch(i -> candidate % i == 0); //스트림의 모든 정수로 candidate를 나눌 수 없으면 true 반환
}
public Map<Boolean, List<Integer>> partitionPrimes(int n) {
    return IntStream.rangeClosed(2, n).boxed()
        .collect(partitioningBy(candidate -> isPrime(candidate)));
}


Collectors 클래스의 정적 팩토리 메서드




6.5 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();
}


6.5.1 Collector 인터페이스의 메서드 살펴보기

supplier 메서드: 새로운 결과 컨테이너 만들기

public Supplier<List<T>> supplier() {
    return () -> new ArrayList<T>();
}

//생성자 참조 전달
public Supplier<List<T>> supplier() {
    return ArrayList::new;
}


accumulator 메서드: 결과 컨테이너에 요소 추가하기

public BiConsumer<List<T>, T> accumulator() {
    return(list, item) -> list.add(item);
}

//메서드 참조
public BiConsumer<List<T>, T> accumulator() {
    return List::add;
}


finisher 메서드: 최종 변환값을 결과 컨테이너로 적용하기

public Function<List<T>, List<T>> finisher() {
    return Function.identity();
}


순차 리듀싱 과정의 논리적 순서

image


combiner 메서드: 두 결과 컨테이너 병합

public BinaryOperator<List<T>> combiner() {
    return (list1, list2) -> {
        list1.addAll(list2);
        return list1;
    }
}

Characteristics 메서드


6.5.2 응용하기

ex. 커스텀 ToListCollector를 구현하는 예제입니다.

import java.util.*;
import java.util.function.*;
import java.util.stream.Collector;
import static java.util.stream.Collector.Characteristics.*;

public class ToListCollector<T> implements Collector<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) -> { //두 번째 콘텐츠와 합쳐서 첫 번째 누적자를 고침
            list1.addAll(list2); //변경된 첫 번째 누적자를 반환
            return list1;
        };
    }
    
    @Override
    public Set<Characterisics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH, CONCURRENT));
        return ArrayList::new;
    }
}




6.6 커스텀 컬렉터를 구현해서 성능 개선하기

ex. 커스텀 컬렉터로 n까지의 자연수를 소수와 비소수로 분할하는 예제입니다.

//Before

public Map<Boolean, List<Integer>> partitionPrimes(int n) {
    return IntStream.rangeClosed(2, n).boxed()
                    .collect(partitioningBy(candidate -> isPrime(candidate)));
}
//Before

public boolean isPrime(int candidate) {
    int candidateRoot = (int) Math.sqrt((double)candidate); //제곱근 이하의 수로 제한
    return IntStream.rangeClosed(2, candidateRoot) //2부터 candidateRoot미만 사이의 자연수 생성
                    .noneMatch(i -> candidate % i == 0); //스트림의 모든 정수로 candidate를 나눌 수 없으면 true 반환
}

6.6.1 소수로만 나누기

//After - 중간 결과 리스트를 전달하도록 구현

public boolean isPrime(List<Integer> primes, int candidate) {
    return primes.stream().noneMatch(i -> candidate % i == 0);
}


//After - 중간 결과 리스트를 전달하도록 구현 & 제곱근 이하의 수로 제한

public boolean isPrime(List<Integer> primes, int candidate) {
    int candidateRoot = (int) Math.sqrt((double)candidate); //제곱근 이하의 수로 제한
    return primes.stream()
                 .takeWhile(i -> i <= candidateRoot)
                 .noneMatch(i -> candidate % i == 0);
}

참고 - Java 8에서 takeWhile(Java 9부터 지원) 흉내내기

public static <A> List<A> takeWhile(List<A> list, Predicate<A> p) {
    int i = 0;
    for (A item : list) {
        if (!p.test(item)) { //리스트의 현재 항목이 프레디케이트를 만족하는지 확인
            return list.subList(0, i); //만족하지 않으면 현재 검사한 항목의 이전 항목 하위 리스트를 반환
        }
        i++;
    }
    return list; //리스트의 모든 항목이 프레디케이트를 만족하므로 리스트 자체를 반환
}


1단계: Collector 클래스 시그니처 정의

public interface Collector<T, A, R>
/*
    T: 스트림 요소의 형식
    A: 중간 결과를 누적하는 객체 형식
    R: collect 연산의 최종 결과 형식
 */
public class PrimeNumbersCollector implements Collector<Integer, 
        Map<Boolean, List<Integer>>, //누적자 형식
        Map<Boolean, List<Integer>>> { //수집 연산의 결과 형식
}


2단계: 리듀싱 연산 구현

public class PrimeNumbersCollector implements Collector<Integer, 
        Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> {
    
    //supplier - 누적자를 만드는 함수를 반환
    public Supplier<Map<Boolean, List<Integer>>> supplier() {
        return () -> new HashMap<Boolean, List<Integer>>() { {
            put(true, new ArrayList<Integer>());
            put(false, new ArrayList<Integer>());
        } };
    }
    
    //accumulator - 스트림 요소를 어떻게 수집할지 결정
    public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
        return (Map<Boolean, List<Integer>> acc, Integer candidate) -> {
            acc.get( isPrime(acc.get(true), candidate) )
                    .add(candidate);
        };
    }
}


3단계: 병렬 실행할 수 있는 컬렉터 만들기(가능하다면)

public class PrimeNumbersCollector implements Collector<Integer, 
        Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> {
    
    public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
        return (Map<Boolean, List<Integer>> map1, Map<Boolean, List<Integer>> map2) -> {
            map1.get(true).addAll(map2.get(true));
            map1.get(false).addAll(map2.get(false));
            return map1;
        };
    }
}

4단계: finisher 메서드와 컬렉터의 characteristics 메서드

public class PrimeNumbersCollector implements Collector<Integer, 
        Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> {

    //finisher - 항등 함수 identity를 반환
    public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
        return Function.identity();
    }
    
    //Characteristics - CONCURRENT & UNORDERED(X), IDENTITY_FINISH(O)
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));
    }
}


최종 PrimeNumbersCollector

public class PrimeNumbersCollector implements Collector<Integer, 
        Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> {

    @Override
    public Supplier<Map<Boolean, List<Integer>>> supplier() {
      return () -> new HashMap<Boolean, List<Integer>>() { { //두 개의 빈 리스트를 포함하는 맵으로 수집 동작 시작
        put(true, new ArrayList<Integer>());
        put(false, new ArrayList<Integer>());
      } };
    }
  
    @Override
    public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
      return (Map<Boolean, List<Integer>> acc, Integer candidate) -> {
        acc.get( isPrime(acc.get(true), candidate) ) //지금까지 발견한 소수 리스트를 isPrime()에 전달
           .add(candidate); //isPrime()의 결과에 따라 맵에서 알맞은 리스트를 받아 현재 candidate 추가
      };
    }

    @Override
    public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
      return (Map<Boolean, List<Integer>> map1, Map<Boolean, List<Integer>> map2) -> { //두 번째 맵을 첫 번째 맵에 병합
        map1.get(true).addAll(map2.get(true));
        map1.get(false).addAll(map2.get(false));
        return map1;
      };
    }

    @Override
    public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
        return Function.identity(); //최종 수집 과정에서 데이터 변환이 필요하지 않으므로 항등 함수 반환
    }
    
    @Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH)); //발견한 소수의 순서에 의미가 있으므로 컬렉터는 IDENTITY_FINISH
    }
}


public Map<Boolean, List<Integer>> partitionPrimesWithCustomController(int n) {
    return IntStream.rangeClosed(2, n).boxed().collect(newPrimeNumbersCollector());
}


6.6.2 컬렉터 성능 비교

ex. 팩토리 메서드 partitioningBy로 만든 코드와 커스텀 컬렉터의 성능을 비교하는 예제입니다.

public class CollectorHarness {
    public static void main(String[] args) {
        long fastest = Long.MAX_VALUE;
        for (int i = 0; i < 10; i++) {
            long start = System.nanoTime();
            partitionPrimes(1_000_000);
            long duration = (System.nanoTime() - start) / 1_000_000;
            if (duration < fastest) fastest = duration;
        }
        System.out.println("Fastest execution done in " + fastest + " msecs");
    }
}