중간연산과정은 한 스트림을 다른 스트림으로 변환하는 연산으로서, 여러 연산을 연결할 수 있었다. 즉, 스트림의 요소를 소비하지 않는다. 하지만 최종연산의 경우 스트림을 소비하여 결과를 도출했다. 하지만 우리가 사용한 최종연산은 뭔가 부족하다. 이 장에서는 최종연산의 과정으로 데이터를 수집하는 방법을 알아 볼 것이다. 그 예로 앞서 사용한 collector의 toList를 이용했던 것처럼
컬렉터란 무엇인가?
Collector 인터페이스는 우리가 무엇을 원하는지 직접 명시하면 그걸 만들어 주었다. (앞선 toList처럼) 그럼 도대체 컬렉터의 동작과 컬렉터는 무엇일까?
고급 리듀싱 기능을 수행하는 컬렉터
Collect로 결과를 수집하는 과정은 간단하면서도 유연한 방식으로 정의할 수 있다. 구체적으로 스트림에 collect를 호출하면 스트림의 요소에 리듀싱 연산(상태 있는 연산)이 수행된다.
간단한 동작 방식
Dish의 Type 별로 메뉴를 그룹핑하고 싶다면?
1.
[ dish1, dish2, dish3, dish4, dish5 ... ] → Dish Stream
2.
변환함수(dish1) : dish1 의 타입을 가져온다.
3.
Map<Type, List<Dish>> ← add(dish1) 타입별로 추가시켜준다.
미리 정의된 컬렉터
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.
•
스트림 요소를 하나의 값으로 리듀스하고 요약
•
요소 그룹화
•
요소 분할
리듀싱과 요약
public class ReducingAndSummary {
public static void main(String[] args) {
// Collector.counting()
Dish.DISHES.stream().collect(counting());
Dish.DISHES.stream().count(); // 위의 결과를 생략한 방법
// Collector.maxBy()
Dish.DISHES.stream()
.collect(maxBy(Comparator.comparingInt(Dish::getCalories)));
Optional<Dish> maxCalories =
Dish.DISHES.stream()
.max(Comparator.comparingInt(Dish::getCalories));
// 위의 결과를 생력하는 방법
}
}
/*
스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이
자주 사용된다. 이러한 연산을 요약연산이라 부른다.
*/
Java
복사
요약 연산
Collectors 클래스는 Collectors.summingInt() 라는 특별한 요약 팩토리 메소드를 제공한다.
// summing 사용 전
int reduce = Dish.DISHES.stream()
.mapToInt(Dish :: getCalories)
.reduce(Integer :: sum)
.getAsInt();
// summing 사용 후
int summingInt = Dish.DISHES.stream()
.collect(summingInt(Dish::getCalories));
/*
같은 연산이지만 차이가 생긴다.
이러한 연산말고도 averaging[Type], summing[type] 등 다양한 요약연산이 가능하다.
또한 이러한 결과연산들이 모두 필요할 시점이 있다. 아니라면 일부 연산이 필요하다.
그럼 모두 summing, averaging 을 써야한다. 하지만 이럴때를 대비해서 summarizing
메소드를 제공해준다.
*/
// summarizing
IntSummaryStatistics collect =
Dish.DISHES.stream()
.collect(summarizingInt(Dish :: getCalories));
System.out.println(collect.toString());
/* result
IntSummaryStatistics{count=8, sum=3670, min=120, average=458.750000, max=800}
*/
// IntSummaryStatistics class ..
public class IntSummaryStatistics implements IntConsumer {
private long count;
private long sum;
private int min = Integer.MAX_VALUE;
private int max = Integer.MIN_VALUE;
Java
복사
문자열 연결
// joining
String collect = Dish.DISHES.stream().map(Dish :: getName).collect(joining(", "));
System.out.println(collect);
/*
joining은 내부적으로 StringBuilder를 이용하여 문자열을 하나로만든다.
StringBuilder의 append를 활용하는것으로 추측
이 이유는 String이 불변성을 가지기 때문에.. 라는 추측
https://dololak.tistory.com/699 String 불변성에 관한 참고
*/
// ... joining 메서드 실제 확인 결과
public static Collector<CharSequence, ?, String> joining() {
return new CollectorImpl<CharSequence, StringBuilder, String>(
StringBuilder::new, StringBuilder::append, // 맞았다.
(r1, r2) -> { r1.append(r2); return r1; },
StringBuilder::toString, CH_NOID);
}
Java
복사
Collectors.reducing 을 이용한 방법
지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의가 가능하다. 즉, 범용 Collectors.reducing으로도 충분히 구현할 수 있다는 거다. 그럼 왜 범용대신 특화된 Collectors.summing, Collectors.summarizing 등등을 사용하는 것일까?바로 편의성 때문이다.
// reducing 을 이용한 방법
Optional<Dish> collect = Dish.DISHES.stream()
.collect(reducing((x, y) -> x.getCalories() > y.getCalories() ? x : y));
// Collectors.maxBy 를 이용한 방법
Optional<Dish> collect1 = Dish.DISHES.stream()
.collect(maxBy(Comparator.comparingInt(Dish :: getCalories)));
// 사실 필자의 경우에는 읽기는 maxBy가 쉽지만 편의는 reducing이 편하다. 나만 그런가
// 라고 몇분전까지 생각했다. collect가 더 편하다. 불변성, 가변성에 대해서 생각해보니 너무 어렵다.. 하지만 깊이 있게는 아니더라도 알고는 있어야한다.
Java
복사
collect 와 reduce의 차이?
collect의 경우 누적 연산이 일어나는 컨테이너를 바꾸는 방식 [1] ← 얘를 그대로 계속 사용 ... 결과 [1] 이말인즉 [1].add 형식
reduce의 경우 누적자로 사용된 객체를 그대로 바꿔 버린다. [1] ← 얘가 갑자기 ㅁ ←얘가 된다. ... 결과 뭐가 나올지 모른다. 얘는 [1] = values 형식
정확한 이해 reduce와 collect의 차이 꼭 보도록!!!
요소 그룹화
데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터 베이스에서 많이 수행되는 작업이다. 이러한 특성의 작업 또한 자바 8의 함수형을 이용하면 가독성 있는 한줄로 표현할 수 있다.
// stream을 이용
Map<Dish.Type, List<Dish>> collect =
Dish.DISHES.stream().collect(groupingBy(Dish :: getType));
/*
이렇듯 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 메서드로 전달 했다. 이 함수를 기준으로 스트림이 그룹화되므로 이를 분류함수라고 부른다.
*/
// 외부 for을 이용
Map<Dish.Type,List<Dish>> dishMap = new HashMap<>();
for (Dish dish : Dish.DISHES){
dishMap.put(dish.getType(),new ArrayList<>());
}
for (Dish dish : Dish.DISHES){
dishMap.get(dish.getType()).add(dish);
}
Java
복사
단순한 속성 접근자(Dish::getType) 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 할 수 없다. 예를 들어 400 칼로리 이하를 "diet"로, 400~700 칼로리를 'normal'로, 700칼로리 초과하면 'fat'으로 분류한다고 가정하자.
Map<caloricLevel, List<Dish>> collect =
Dish.DISHES.stream().collect(groupingBy(dish -> {
int caloric = dish.getCalories();
return caloric >= 700 ? caloricLevel.FAT :
(caloric < 700) && (caloric >= 400) ?
caloricLevel.NORMAL : caloricLevel.DIET;
}));
/* result
{
DIET=[
Dish{name='rice', vegetarian=true, calories=350, type=OTHER},
Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER},
Dish{name='prawns', vegetarian=false, calories=300, type=FISH}
],
NORMAL=[
Dish{name='chicken', vegetarian=false, calories=400, type=MEAT},
Dish{name='pizza', vegetarian=true, calories=550, type=MY_DISH},
Dish{name='salmon', vegetarian=false, calories=450, type=FISH}
],
FAT=[
Dish{name='pork', vegetarian=false, calories=800, type=MEAT},
Dish{name='beef', vegetarian=false, calories=700, type=MEAT}
]
}
*/
Java
복사
그룹화된 요소 조작
요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다. 예를 들어 500칼로리가 넘는 요리만 필터링 한다고 가정해보자
// 하지만 문제가 있다.
Map<Dish.Type, List<Dish>> collect =
Dish.DISHES.stream()
.filter(dish -> dish.getCalories() > 500)
.collect(groupingBy(Dish::getType));
/* 문제점 나는 모든 타입을 원하지만 filter를 통하여 타입이 걸러지는 문제
{
MY_DISH=[
Dish{name='pizza', vegetarian=true, calories=550, type=MY_DISH}
],
MEAT=[
Dish{name='pork', vegetarian=false, calories=800, type=MEAT},
Dish{name='beef', vegetarian=false, calories=700, type=MEAT}
]
}
이러한 문제점을 해결하기 위해서 Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 두번째 인수를 갖도록 groupingBy 팩터리 메서드를 오버로드 이문제를 해결한다.
*/
// filtering 지원
Map<Dish.Type, List<Dish>> collect =
Dish.DISHES.stream()
.collect(groupingBy(Dish::getType,
filtering(dish -> dish.getCalories() > 500,
toList())));
/*
public static <T, K, A, D>
Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier,
Collector<? super T, A, D> downstream) {
return groupingBy(classifier, HashMap::new, downstream);
}
-> 이러한 메소드로 타고 들어가서 다시한번 다른 groupingBy를 실행시킴
이까지는 이해하겠는데 한번 더 들어가는 과정에서 솔직히 이해하기가 힘들다. Collector 타입의 메소드를 accumulator() 해주는 과정을 통해서
추출하는 것 같음
*/
// mapping 지원
Map<Dish.Type, List<String>> collect =
Dish.DISHES.stream()
.collect(groupingBy(Dish::getType,
mapping(Dish::getName,
toList())));
/* result
{
MEAT=[pork, beef, chicken], FISH=[prawns, salmon],
OTHER=[rice, season fruit],
MY_DISH=[pizza]
}
*/
// flatMapping 지원
// List<String> -> Set<String>
public static Map<String, List<String>> dishTags = new HashMap<>();
public static void main(String[] args) {
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", "natural"));
dishTags.put("pizza",asList("tasty", "salty"));
dishTags.put("prawns",asList("tasty", "roasted"));
dishTags.put("salmon",asList("delicious", "fresh"));
Map<Dish.Type,Set<String>> dishNameByTags =
Dish.DISHES.stream().collect(
groupingBy(Dish::getType,
flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())
));
System.out.println(dishNameByTags.toString());
}
// 다수준 그룹화
// 요리의 타입별로 칼로리레벨과 이름들을 나열하고 싶다.
Map<Dish.Type,Map<CaloricLevel,List<String>>> dishesByTypeCaloricLevel =
Dish.DISHES.stream().collect(
groupingBy(Dish::getType, groupingBy(dish -> {
int caloric = dish.getCalories();
return caloric >= 700 ? CaloricLevel.FAT :
(caloric < 700) && (caloric >= 400) ?
CaloricLevel.NORMAL : CaloricLevel.DIET;
},mapping(Dish::getName,toList()))));
System.out.println(dishesByTypeCaloricLevel.toString());
Java
복사
Show All
Search
서브그룹 수준 그룹
실제 groupingBy(f) 는 groupingBy(f, toList()) 의 축약형이다. 또한 groupingBy로 넘겨주는 컬렉터의 형식, 즉 toList의 자리에 들어가는 Collector의 형식은 제한이 없다. 그래서 counting(), maxBy() 등 다양하게 활용할 수 있다.
Map<Dish.Type, Optional<Dish>> collect =
Dish.DISHES.stream().collect(
groupingBy(Dish :: getType,
maxBy(Comparator.comparingInt(Dish :: getCalories))));
/*
그런데 Optional<Dish>의 형으로 들어간다. 근데 실제로 생각해보면 groupingBy의 경우 없는 값은 Map의 키로 담지 않는다. 그렇기에 Optional 자체가 필요없다.
*/
Map<Dish.Type, Dish> collect =
Dish.DISHES.stream().collect(
groupingBy(Dish :: getType,
collectingAndThen( // collecting이 끝난후 작업명시
maxBy(Comparator.comparingInt(Dish :: getCalories)),
Optional::get))); // get 호출
Java
복사
분할
List<Dish> useFilterCollect = Dish.DISHES.stream()
.filter(Dish :: isVegetarian)
.collect(toList());
Map<Boolean, List<Dish>> usePartitioningCollect = Dish.DISHES.stream()
.collect(partitioningBy(Dish :: isVegetarian));
Map<Boolean, List<String>> usePartitioningMappingcollect =
Dish.DISHES.stream()
.collect(partitioningBy(Dish :: isVegetarian,
mapping(Dish :: getName, toList())));
/*
분할의 장점은 무엇일까?
기본적으로 filter를 이용하면 참, 거짓 요소의 리스트를 유지하는
힘들다. 하지만 partitioning을 이용하면 true={}, false={} 요소
로 나타낼 수 있다.
*/
// 채식주의 메뉴 구별, 젤 높은 칼로리 값 찾기, 메뉴 이름으로 가져오기
Map<Boolean, String> collect = Dish.DISHES.stream()
.collect(
partitioningBy(Dish :: isVegetarian,
collectingAndThen(maxBy(Comparator.comparingInt(Dish :: getCalories)), dish -> dish.get().getName())
));
System.out.println(collect.toString());
Java
복사
Collectors 클래스의 정적 팩토리 메서드 정리
// Method Name : toList()
// @return : List<T>
// 스트림의 모든 항목을 리스트로 수집
List<Dish> collect = Dish.DISHES.stream().collect(Collectors.toList());
// Method Name : toSet()
// @return : Set<T>
// 스트림의 모든 항목을 집합으로 수집
Set<Dish> collect = Dish.DISHES.stream().collect(Collectors.toSet());
// Method Name : toCollection()
// @return : Collection<T>
// 스트림의 모든 항목을 발행자가 제공하는 컬렉션으로 수집
Collection<Dish> collect = Dish.DISHES.stream().collect(toCollection(HashSet ::new));
// Method Name : counting()
// @return : Long
// 스트림의 항목 수 계산
Long collect = Dish.DISHES.stream().collect(counting());
// Method Name : summing()
// @return : Integer
// 스트림의 항목에서 정수 프로퍼티 값을 더함
Integer collect = Dish.DISHES.stream().collect(summingInt(Dish :: getCalories));
// Method Name : averaging()
// @return : Double
// 스트림의 항목에서 정수 프로퍼티 값의 평균 계산
Double collect = Dish.DISHES.stream().collect(averagingInt(Dish :: getCalories));
// Method Name : summarizing()
// @return : IntSummaryStatistics
// 스트림의 항목에서 최대, 최소, 합계, 평균, 등 통계수집
IntSummaryStatistics collect = Dish.DISHES.stream().collect(summarizingInt(Dish :: getCalories));
System.out.println(collect);
// Method Name : joining()
// @return : String
// 스트림의 항목에서 문자열 항목에서 toString()을 호출한 결과 결합
String collect = Dish.DISHES.stream().map(Dish :: getName).collect(joining(", "));
System.out.println(collect);
// Method Name : maxBy(), minBy()
// @return : Optional<T>
// 스트림의 항목에서 주어진 비교자를 이용하여 스트림 요소의 최대값, 최소값을 Optional<T> 형태로 반환, 없을땐 Optional.empty() 반환
Optional<Dish> collect = Dish.DISHES.stream().collect(maxBy(Comparator.comparingInt(Dish :: getCalories)));
Optional<Dish> collect = Dish.DISHES.stream().collect(minBy(Comparator.comparingInt(Dish :: getCalories)));
// Method Name : reducing()
// @return : T
// 누적자를 초깃값으로 설정, 다음에 BinaryOperator로 스트림의 각 요소를 반복적으로 누적자와 합쳐 스트림을 하나의 값으로 리듀싱
Integer collect = Dish.DISHES.stream().collect(reducing(0, Dish :: getCalories, Integer :: sum));
// Method Name : collectingAndThen()
// @return : ?
// 다른 컬렉터를 감싸고 그 결과에 변환 함수 적용
Long collect = Dish.DISHES.stream().collect(
collectingAndThen(
summarizingInt(Dish :: getCalories), IntSummaryStatistics :: getSum));
// Method Name : groupingBy()
// @return : Map<K, List<T>>
// 하나의 프로퍼티값을 기준으로 스트림의 항목을 그룹화하며 기준 프로퍼티값을 결과 맵의 키로 사용
Map<Dish.Type, List<Dish>> collect = Dish.DISHES.stream()
.collect(
groupingBy(Dish :: getType)
);
// Method Name : partitioningBy()
// @return : Map<Boolean, List<T>>
// 프레디케이트를 스트림의 각 항목에 적용한 결과로 항목 분할
Map<Dish.Type, Map<Boolean, List<Dish>>> collect = Dish.DISHES.stream()
.collect(
groupingBy(Dish :: getType, partitioningBy(Dish :: isVegetarian))
);
System.out.println(collect);
Java
복사
지금까지의 내용 정리 : https://velog.io/@kskim/Java-Stream-API
특성관한 정리
Collector 인터페이스
Collector 인터페이스는 리듀싱 연산(컬렉터)을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다. toList, groupingBy 등 Collector인터페이스를 구현하는 많은 컬렉터 연산이 존재했다. 이중 Collector 인터페이스를 구현하는 리듀싱 연산을 만들 수 있다.
// Collector 인터페이스
/*
T 수집될 스트림 항목의 형식
A 누적자, 수집과정에서 중간결과를 누적하는 객체의 형식
R 수집 연산의 결과 객체 형식
*/
public interface Collector<T, A, R> {
/*
빈 누적자 인스턴스를 만드는 함수 즉, A를 만드는 과정
*/
Supplier<A> supplier();
/*
reducing 연산을 수행하는 함수를 반환 A.add(T) 라고 보면 된다. 여기를 함수 반화으로 생각해보면 A.add(T)를 해주는 연산자를 반환
*/
BiConsumer<A, T> accumulator();
/*
조합자로서 두 누적자가 이결과를 어떻게 처리할지 결정한다. 예를 들어 서브파트를 병렬로 처리할 경우
*/
BinaryOperator<A> combiner();
/*
스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환, 같은 타입이면 객체 변환이 필요없기 때문에 Fuction.identity() 항등함수를 반환
*/
Function<A, R> finisher();
/*
collect 메서드가 어떤최적화(병렬화 같은)를 이용해서 리듀싱 연산을 수행할지 결정하도록 돕는 힌트
*/
Set<Characteristics> characteristics();
/*
UNODERED : 리듀싱 결과는 순서의 영향을 받지않는다.
CONCURRENT : 다중 스레드에서 accumulator 함수를 동시 호출가능 병렬 리듀싱 가능 UNODERED와 같이 사용하거나 사용하지 않는다면 순서와 상관없어야함
IDENTITY_FINISH : identity를 적용할 뿐이므로 이를 생략가능.
*/
Java
복사
나만의 Colletor 만들기
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));
}
}
// main ...
ArrayList<Dish> collect =
Dish.DISHES.stream().collect(
new ToListCollector());
// other ...
ArrayList<Dish> collect =
Dish.DISHES.stream().collect(
ArrayList :: new,
ArrayList :: add,
ArrayList :: addAll);
System.out.println(collect);
Java
복사