-
Stream Collector 커스터마이징 - 모던 자바 인 액션BackEnd/자바 2022. 3. 10. 18:03
collect 메서드는 interface Stream 안에 아래와 같은 시그니처를 갖는다.
인자로 인터페이스 Collector 를 받는다.
menu.stream().collect(Collectors.toList());
//interface Stream <R, A> R collect(Collector<? super T, A, R> collector);
Collectors.toList()와 Characteristics 설명
Collectors.toList() 메서드는 스트림의 요소를 List로 수집하는 간단한 방법을 제공합니다. 이 메서드는 Collector 인터페이스를 구현하는 CollectorImpl 클래스를 반환하며, List를 만드는 작업을 생성자로 구현합니다.
여기서 중요한 부분은 Characteristics입니다. Characteristics는 컬렉터가 어떤 특성을 갖는지 나타내며, 병렬 처리에 필요한 최적화 힌트를 제공합니다. 또한, Collector가 병렬 리듀싱을 할 수 있는지, 그리고 어떤 최적화를 사용할 수 있는지 결정하는 데 도움을 줍니다.
1. Collectors.toList() 메서드
Collectors.toList()는 내부적으로 CollectorImpl 클래스를 사용하여 리스트를 만듭니다. 이 클래스는 Collector 인터페이스를 구현하며, 다음과 같은 방식으로 동작합니다.
//class Collectors static final Set<Collector.Characteristics> CH_ID = Collections.unmodifiableSet( EnumSet.of(Collector.Characteristics.IDENTITY_FINISH)); public static <T> Collector<T, ?, List<T>> toList() { return new CollectorImpl<>( (Supplier<List<T>>) ArrayList::new, // Supplier: 새로운 List 인스턴스를 생성 List::add, // BiConsumer: 요소를 List에 추가 (left, right) -> { left.addAll(right); return left; }, // BinaryOperator: 두 List를 병합 CH_ID // Characteristics ); }
2. CollectorImpl 클래스
CollectorImpl 클래스는 Collector 인터페이스를 구현하며, 스트림을 수집할 때 사용하는 누적자(supplier), 누적 연산(accumulator), 병합 연산(combiner), 최종 연산(finisher), 그리고 특성(characteristics)을 정의합니다.
static class CollectorImpl<T, A, R> implements Collector<T, A, R> { private final Supplier<A> supplier; private final BiConsumer<A, T> accumulator; private final BinaryOperator<A> combiner; private final Function<A, R> finisher; private final Set<Characteristics> characteristics; CollectorImpl(Supplier<A> supplier, BiConsumer<A, T> accumulator, BinaryOperator<A> combiner, Function<A,R> finisher, Set<Characteristics> characteristics) { this.supplier = supplier; this.accumulator = accumulator; this.combiner = combiner; this.finisher = finisher; this.characteristics = characteristics; } CollectorImpl(Supplier<A> supplier, BiConsumer<A, T> accumulator, BinaryOperator<A> combiner, Set<Characteristics> characteristics) { this(supplier, accumulator, combiner, castingIdentity(), characteristics); }
3. Characteristics 설명
Characteristics는 컬렉터가 특정 조건을 병렬 처리에 어떻게 적용할 수 있는지에 대한 힌트를 제공합니다. 컬렉터의 특성은 컬렉터가 병렬화 가능한지, 그리고 데이터 순서가 중요한지 등을 제어할 수 있습니다.
주요 Characteristics:
- UNORDERED:
- 스트림 요소의 방문 순서나 누적 순서가 결과에 영향을 미치지 않는 경우 사용됩니다.
- 예를 들어, HashSet과 같이 순서에 의존하지 않는 자료구조에서 유용합니다.
- 이 플래그가 설정되면, 병렬 스트림이 사용될 때 성능 최적화를 할 수 있습니다.
- CONCURRENT:
- 다중 스레드에서 accumulator 함수가 동시에 호출될 수 있다는 의미입니다.
- 병렬 리듀싱을 안전하게 수행할 수 있음을 나타냅니다.
- UNORDERED 플래그가 설정되지 않았다면, 병렬 리듀싱을 수행할 수 있습니다.
- IDENTITY_FINISH:
- finisher() 메서드가 단순히 **identity()**를 반환하는 경우를 의미합니다.
- 즉, 누적자 객체를 그대로 최종 결과로 사용할 수 있게 됩니다. 이 경우 형변환이 가능하고, 누적자 객체를 결과 객체로 안전하게 변환할 수 있습니다.
4. 특징 플래그 설정과 병렬화 힌트
Characteristics를 설정하면 스트림이 병렬 리듀싱을 최적화할 수 있는 힌트를 제공합니다. 예를 들어, toList() 메서드는 IDENTITY_FINISH를 특징으로 설정하여 최종 결과로 누적자 객체를 바로 반환할 수 있도록 합니다.
static final Set<Collector.Characteristics> CH_ID = Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
두 가지 방식의 차이:
// 첫 번째 방법 (Characteristics 사용) List<Dish> list1 = menu.stream().collect(Collectors.toList()); // 두 번째 방법 (수동으로 Characteristics 전달) List<Dish> list2 = menu.stream().collect(ArrayList::new, List::add, List::addAll);
- 첫 번째 방법은 Collectors.toList()를 사용하여 내부적으로 CollectorImpl 클래스를 반환하며, IDENTITY_FINISH와 같은 특성을 설정할 수 있습니다.
- 두 번째 방법은 ArrayList::new, List::add, List::addAll을 사용하여 직접 Collector를 구현하는 방식입니다. 이 방법에서는 특성 설정을 명시적으로 제공할 수 없습니다.
5. 커스텀 Collector 예시
위에서 설명한 toList()는 기본적으로 **ArrayList**를 생성하여 스트림의 요소를 리스트로 수집하는 데 사용됩니다. 그러나 커스텀 컬렉터를 만들어서 특정 조건에 맞는 수집 작업을 수행할 수 있습니다.
직접 Collector 를 상속해 사용할 수도 있다.
컬렉터 제네릭 인자의 의미 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 BinaryOperator<List<T>> combiner() { return (list1,list2)->{ list1.addAll(list2); return list1; }; } @Override public Function<List<T>, List<T>> finisher() { return Function.identity(); } @Override public Set<Characteristics> characteristics() { return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH,Characteristics.CONCURRENT)); } }
List<Dish> list3 = menu.stream().collect(new ToListCollector<Dish>());
6. 소수 구분 예시
소수와 비소수를 구분하는 메서드를 아래와 같이 만들 때, 소수 확인 대상을 제곱근 이하로 제한하면 N 개의 정수를 모두 확인하는 것보다 효율적이다.
근데 여기서 더 성능을 개선하기 위해 소수로 나눠떨어지는지 확인해 대상의 범위를 더 좁힐 수 있다.
제수(나누는 숫자,devisor) 이 소수여야 하므로, 지금까지 발견한 소수 리스트에 접근해야한다.
기존 컬렉터에는 그런 기능이 없으므로 이 문제를 해결하는 커스텀 컬렉터를 직접 만들어 본다.
* before 메서드
/** * n의 제곱근까지 숫자를 생성해 소수를 확인 * n은 candidate * n의 제곱근 candidateRoot * n을 n의 제곱근으로 나눠 0으로 떨어지면 소수가 아니다. * */ public static boolean isPrimeV1(int candidate){ int candidateRoot = (int) Math.sqrt((double) candidate); return IntStream .rangeClosed(2,candidateRoot) .noneMatch(i->candidate % i == 0); }
- rangeClosed(2, candidateRoot): 2부터 candidate의 제곱근까지의 숫자들을 생성합니다.
- noneMatch(i -> candidate % i == 0): 이 범위 내의 숫자들로 candidate가 나누어 떨어지지 않으면, candidate는 소수로 판단됩니다.
- 성능: 모든 숫자(2부터 candidate의 제곱근까지)로 나누어보기 때문에, 소수를 판별하는 데 시간이 오래 걸릴 수 있습니다.
* after 메서드
/*** * 소수 리스트를 받아 n의 제곱근보다 작은 소수들을 devisor 로 선택해 * candidate 를 devisor 로 나눠 0으로 떨어지면 소수가 아니다. */ public static boolean isPrimeV2(List<Integer> primes, int candidate){ int candidateRoot = (int) Math.sqrt((double) candidate); return primes.stream() .takeWhile(i -> i <= candidateRoot) .noneMatch(i->candidate % i == 0); } /** * 자바9 이전 버전에서 takeWhile 직접 구현해 적용 * */ public static boolean isPrimeV2WithJava8TakeWhile(List<Integer> primes, int candidate){ int candidateRoot = (int) Math.sqrt((double) candidate); return takeWhile(primes, i -> i <= candidateRoot) .stream() .noneMatch(i->candidate % i == 0); } private static <A> List<A> takeWhile(List<A> list, Predicate<A> p){ int i=0; for (A a : list) { if(!p.test(a)){ return list.subList(0,i); } i++; } return list; }
- primes.stream().takeWhile(i -> i <= candidateRoot): 이미 발견된 소수 리스트(여기서는 primes)에서 제곱근 이하의 소수들만 사용하여 candidate를 나누어 봅니다.
- noneMatch(i -> candidate % i == 0): 소수 리스트에서 candidate를 나누어 떨어지는 소수가 있으면, candidate는 소수가 아닌 것으로 판단합니다.
- 소수 리스트를 이용한 최적화: 이미 구해진 소수들만을 이용하여 나누어보므로, 나누어야 할 숫자의 범위가 줄어듭니다. 특히 큰 숫자의 경우 효율적으로 소수를 판별할 수 있습니다.
- 성능 개선: 소수 리스트를 이용하여 필요한 범위만 확인하므로, 불필요한 비교를 줄여 성능을 개선할 수 있습니다.
PrimeNumbersCollector 클래스 구현
Integer 값을 받아서, Map<Boolean, List<Integer>> 형태로 소수와 비소수를 구분하여 저장하는 커스텀 수집 로직을 구현하고 있습니다.
/*** * Integer 스트림 요소의 형식 * Map<Boolean, List<Integer>> true=[소수 리스트] , false=[비소수 리스트] 중간 결과 누적, 결과 집합의 형식 */ 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>());//비소수 }}; } /** * isPrime 으로 소수인지 확인해 * acc 는 소수 리스트, Integer 는 확인 대상 * 소수 리스트에서 소수를 꺼내 List 에 추가 * */ @Override public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() { return (Map<Boolean,List<Integer>> acc, Integer candidate) ->{ acc.get(Prime.isPrimeV2(acc.get(true),candidate)).add(candidate); }; } /** * 두 개의 map 을 받아 하나의 map 으로 합친다. * */ @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(Characteristics.IDENTITY_FINISH)); } }
//version 1 public Map<Boolean,List<Integer>> partitionPrimes(int n){ return IntStream.rangeClosed(2,n).boxed() .collect(Collectors.partitioningBy(candidate->isPrimeV1(candidate))); } //version 2 with Customized Collector public Map<Boolean,List<Integer>> partitionPrimesWithCustomCollector(int n){ return IntStream.rangeClosed(2,n).boxed().collect(new PrimeNumbersCollector()); }
- partitionPrimes()는 기본적인 partitioningBy()를 사용하여 소수와 비소수를 구분합니다.
- partitionPrimesWithCustomCollector()는 PrimeNumbersCollector를 사용하여 소수와 비소수를 구분합니다. 이 방법은 커스텀 컬렉터를 활용한 방법입니다.
public class Main { public static void main(String[] args) { Prime p = new Prime(); Map<Boolean, List<Integer>> map1 = p.partitionPrimes(10); System.out.println(map1); // 기본 partitioningBy 사용 Map<Boolean, List<Integer>> map2 = p.partitionPrimesWithCustomCollector(10); System.out.println(map2); // 커스텀 컬렉터 사용 } }
반응형'BackEnd > 자바' 카테고리의 다른 글
자바의 컬렉션 - 모던 자바 인 액션 (0) 2022.03.21 스트림의 병렬 처리 - 모던 자바 인 액션 (0) 2022.03.20 스트림의 사용 - 모던 자바 인 액션 (0) 2022.03.04 스트림 API의 핵심과 함수형 인터페이스 - 모던 자바 인 액션 (0) 2022.02.13 자바 비동기 (0) 2021.12.31 - UNORDERED: