java 8, stream collect

JAVA 8에서 추가된 stream API를 사용할 때, stream으로 넘어가기 이전의 collection에서 stream 내의 작업이 완료된 새 collection을 얻기를 원해서 하는 경우가 있다. reduce() 계열에 비해 오히려 이쪽이 더 자주 쓰이지 않을까? 나는 더 자주 쓴다.

이럴 때는 collect() 메서드가 유용하다.

기본 용법

용례는 다음과 같다.

1
ArrayList<Object> result = objectList.stream().collect(ArrayList::new, ArrayList::add, ArrayList::addAll);

이 예제의 인자들을 타입으로 보면 이렇다.

1
objectList.stream().collect(Supplier<R> supplier, BiConsumer<R, ? super Object> accumulator, BiConsumer<R, R> combiner);

여기에서 supplier란 어떤 collection의 생성자 레퍼런스이며, accumulator란 대상에 어떤 요소를 추가하는 function이고, combiner는 어떤 collection 2개를 병합하는 function임을 알 수 있다.

당연하지만 supplier, accumulator, combiner를 일일이 입력하는 것은 몹시 귀찮은 일이다. 이때 쓸 수 있는 Collectors.class가 있다.

1
2
3
List<Object> resultList = objectList.stream().collect(Collectors.toList());

Set<Object> resultSet = objectList.stream().collect(Collectors.toSet());

그런데 이 List와 Set은 구체적으로 어떤 구현체로 되어있을까? 명시적으로 핸들링하고 싶다면 Collectors.toCollection(supplier)를 사용할 수 있다.

1
ArrayList<Object> resultArrayList = objectList.stream().collect(Collectors.toCollection(ArrayList::new));

joining

collect()는 그 외에도 용도가 다양한 편인데, stream이 String 객체만을 대상으로 하고 있다면 Collectors.joining()을 사용해 String을 결합할 수도 있다. 결합되는 String 사이에 무언가 다른 String을 끼워넣고 싶다면 .joining("String")을 사용한다.

summary

그 외에도 int, double, long 객체를 가진 collection을 대상으로 단번에 sum, avg, max, min 값을 원할 때도 collect가 유용할 수 있다.

1
2
3
4
5
6
//IntSummaryStatistics, Collectors.summarizingInt 외에 DoubleSummaryStatistics 등이 있다.
ToIntFunction<Integer> mapper = i -> i * 2;
IntSummaryStatistics intSumAvgMaxMin = Arrays.asList(1, 2, 3, 4, 5).stream().collect(Collectors.summarizingInt(mapper));
double avg = intSumAvgMaxMin.getAverage();
double sum = intSumAvgMaxMin.getSum();
//.... 기타 등

toMap

collect(Collectors.toMap())의 경우는 조금 더 복잡하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// key&value
groupRepository.findAll().stream().collect(Collectors.toMap(Group::getId, Group::getAdmin));

// value가 요소 자신인 경우 i -> i, 즉 identity()이므로 다음 둘은 동일하다.
groupRepository.findAll().stream().collect(Collectors.toMap(Group::getId, i -> i));
groupRepository.findAll().stream().collect(Collectors.toMap(Group::getId, Function.identity()));

// key가 같은 케이스가 복수일 때는 .toMap()에 3번째 parameter를 던져서 해결한다.
// 3번째 인자는 BinaryOperator<U> mergeFunction이다.
// 즉 타입이 같은 둘을 받아서 합치던 둘 중 하나를 골라내던 같은 타입 하나를 return한다.
// 이외에 exception을 날릴 수도 있다.
groupRepository.findAll().stream().collect(Collectors.toMap(Group::getAdminId
, i -> i
, (oldGroup, newGroup) -> newGroup ));

// Map의 구현체를 선택하고 싶다면 4번 째 인자로 생성자 레퍼런스를 던진다.
groupRepository.findAll().stream().collect(Collectors.toMap(Group::getAdminId
, i -> i
, (oldGroup, newGroup) -> newGroup
, HashMap::new ));

groupingBy와 partitioningBy

Collectors.toMap의 3번 째 parameter로 동일한 key를 가진 복수개의 요소를 처리할 수 있지만, collection에서 중복된 요소들을 모아 group을 만들기 위해서 map을 만드려고 하는 요구를 달성하기에는 좀 귀찮아진다. 때문에 이 작업을 위한 메서드가 별도로 있다. groupingBypartitioningBy가 그것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Data
public class Person {
private String name;
private int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

public boolean isAdult() {
return this.age > 18;
}
}

//...

List<Person> people = new ArrayList<Person>();
people.add(new Person("Hyva", 19));
people.add(new Person("Javajigi", 43));
people.add(new Person("Javajigi", 16));
people.add(new Person("Ybin", 16));
people.add(new Person("Das", 18));
people.add(new Person("Das", 27));

이제 Person의 name으로 grouping 해보자. nameGroup은 Person::getName으로 분류된 그룹이므로 이름이 같인 Person을 묶어 List를 만들고, 그 이름을 Key로 하여 Map에 넣는다.

1
2
3
4
Map<String, List<Person>> nameGroup = people.stream().collect(Collectors.groupingBy(Person::getName));

// grouping을 하고 싶다면 groupingBy 대신 groupingByConcurrent를 쓸 수 있다.
Map<String, List<Person>> nameGroup2 = people.parallelStream().collect(Collectors.groupingByConcurrent(Person::getName));

위의 Person::getName처럼 분류 목적으로 사용한 function을 classfier function이라고 한다. classfier function이 T t -> boolean라면, 즉 Predicate<T>라면 groupingBy 대신 partitioningBy를 쓸 수 있다.

1
2
3
Map<Boolean, List<Person>> generationGroup = people.stream().collect(Collectors.partitioningBy(Person::isAdult));
List<Person> adultGroup = generationGroup.get(true);
List<Person> kidsGroup = generationGroup.get(false);

Downstream collector

예제처럼 groupingBy나 partitioningBy를 그냥 쓰면 Map의 value는 List다. 개발을 하다보면 단순히 count만 필요하거나, Set인 것이 편할 때가 있다. downstream을 던져넣어 List가 아닌 다른 value를 가진 Map을 얻을 수 있다.

1
2
3
4
5
6
7
8
//타입으로 보면 이렇다.
Collectors.groupingBy(Function<? super T,? extends K> classifier, Collector<? super T,A,D> downstream);

// key가 같은 value가 몇 개인지 count만 얻기
Map<String, Set<Person>> nameGroupCount = people.stream().collect(Collectors.groupingBy(Person::getName, Collectors.counting()));

// 집합으로 얻기
Map<String, Set<Person>> nameGroupSet = people.stream().collect(Collectors.groupingBy(Person::getName, Collectors.toSet()));

summingInt, summarizingInt, mapping, maxBy, minBy, reducing 등 여러가지를 쓸 수 있으므로 적절히 사용하면 편리해지지만, depth가 너무 깊어지지 않게 주의하자. 하려고 하면 아주 복잡해질 수 있는 부분이다.

이외에도

1
Collectors groupingBy(Function<? super T,? extends K> classifier, Supplier<M> mapFactory, Collector<? super T,A,D> downstream)

의 형태로 mapFactory를 넣을 수도 있는데.. 충분히 길어졌으므로 이 포스팅은 여기서 끊자.