java, stream의 collect 사용하기 - 특히 Map 위주로
stream의 collect과 다양한 최종 연산 타입 - toList, toSet, joining, summarizing 등
- 자바의 stream api을 최종 연산할 때 대표적으로 collect 메서드를 사용한다.
- List로 반환하는 Collector.toList()와 Set을 반환하는 Collectors.toSet()이 대표적이다.
List<Integer> list = IntStream.range(0, 5).boxed().collect(Collectors.toList());
// List<Integer> list = IntStream.range(0, 5).boxed().toList(); // 자바 16 이후로 축약 가능하다.
assertThat(list).containsExactly(0,1,2,3,4);
Set<Integer> set = IntStream.of(1, 2, 3, 3).boxed().collect(Collectors.toSet());
assertThat(set).size().isEqualTo(3);
assertThat(set).containsExactly(1,2,3);
- Collectors가 지원하는 여러 팩터리 메서드가 존재한다. 필요로한 메서드를 찾아서 연산을 수행해보자.
// String 배열을 합성하는 joining
String joining = Stream.of("kim", "lee", "choi")
.collect(Collectors.joining(", "));
assertThat(joining).isEqualTo("kim, lee, choi");
// 산수에 대한 다양한 결과값을 제공하는 summarizing
IntSummaryStatistics statistics = IntStream.of(1, 2, 3, 4).boxed()
.collect(Collectors.summarizingInt(i -> i));
assertThat(statistics.getAverage()).isEqualTo(2.5);
assertThat(statistics.getSum()).isEqualTo(10);
assertThat(statistics.getMax()).isEqualTo(4);
Map과 groupingBy
map으로 최종연산 과정에서 key와 NPE 문제
- list와 set으로 최종연산을 수행할 때, 직관적이며 고려해야 할 부분이 적다.
- 한편, map을 구현하는 방식은 다른 컬렉션과 달리 다소 복잡하다.
- 아래는 collect에 직접 인자를 삽입하여 map을 최종연산하였다.
// 이하 예제에 사용할 객체와 이넘
class Person{
private String name;
private int age;
private City city;
// constructor
Person(String name, int age, City city) {...}
// getter 생략
}
enum City{
SEOUL, PUSAN, DAEJEON
}
Map<City, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
.collect(
HashMap::new
, (map, person) -> {
if(!map.containsKey(person.getCity())){
map.put(person.getCity(), new ArrayList<>());
}
map.get(person.getCity()).add(person);
}
, HashMap::putAll
);
assertThat(collect.get(SEOUL)).size().isEqualTo(2);
assertThat(collect.get(DAEJEON)).size().isEqualTo(1);
assertThat(collect.get(PUSAN)).isNull();
- 위의 코드는 여러 가지의 문제를 가지고 있다.
- (collect의) 두 번째 인자가 함수형 프로그래밍 답지 않게 작성되었다. 구현에 초점이 맞춰져 있다.
- 두 번째 인자는 두 가지 기능을 가진다. 1) map에 key와 value를 put하고 2) 인자가 가진 key에 해당하는 value에 자기 자신을 삽입한다.
-
NPE가 발생할 수 있다. enum을 사용했기 때문에 모든 enum에 대응되는 키가 존재할 것이라 기대할 수 있다. 하지만 PUSAN을 조회한 결과 null을 반환한다.
- toMap을 사용하면 collect를 사용하는 것보다 더 좋은 코드를 작성할 수 있다. 아래 예제를 확인하자.
Map<City, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
.collect(Collectors.groupingBy(Person::getCity));
assertThat(collect.get(SEOUL)).size().isEqualTo(2);
assertThat(collect.get(DAEJEON)).size().isEqualTo(1);
assertThat(collect.get(PUSAN)).isNull();
Collectors.groupingBy(Person::getCity)
코드 한 줄로 장황한 collect 코드가 사라진다. 앞의 예제와 달리Person::getCity
라는 단 하나의 역할만을 수행한다.- 하지만 NPE 문제는 해결되지 않았다.
- 다음 예제는 groupingBy에 추가 인자를 삽입하며 NPE문제를 해결한다.
Supplier<EnumMap<City, List<Person>>> fulfilled = () -> {
EnumMap<City, List<Person>> map = new EnumMap<>(City.class);
for (City c : values()) {
map.put(c, new ArrayList<>());
}
return map;
};
Map<City, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
.collect(Collectors.groupingBy(Person::getCity, fulfilled, Collectors.toList()));
assertThat(collect.get(SEOUL)).size().isEqualTo(2);
assertThat(collect.get(DAEJEON)).size().isEqualTo(1);
assertThat(collect.get(PUSAN)).size().isEqualTo(0);
- map 인스턴스에 필요로 한 key와 value을 먼저 초기화한다(fulfilled, Collectors.toList()). 그 후 인자를 평가한다.
- 필요로한 key를 미리 map에 삽입한다. 이를 통해 NPE 문제를 최소화한다.
- 다양한 역할을 수행했던 코드를 잘게 분리할 수 있다.
groupingBy 내부의 추가 연산 - mapping, filtering, groupingBy
- groupingBy의 value에 대한 추가적인 조작을 할 수 있다. Collector.mapping, Collector.filtering, Collector.groupingBy가 사용된다.
- mapping은 value의 타입을 조작한다. 중간 연산인 stream.map에 대응한다.
- filtering은 value를 필터링한다. 중간 연산인 stream.filter에 대응한다.
- 아래는 mapping에 대한 예제이며 Person을 String(getName)으로 변환한다.
Map<City, List<String>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
.collect(Collectors.groupingBy(Person::getCity, Collectors.mapping(Person::getName, Collectors.toList())));
assertThat(collect.get(SEOUL)).containsExactlyInAnyOrder("kim", "lee");
assertThat(collect.get(DAEJEON)).containsExactlyInAnyOrder("choi");
- groupingBy를 중복하여 사용할 수도 있다.
- City - Age로 두 단계를 구분한다.
enum Age {
KID, ADULT
}
Function<Person, Age> classifyWithAge = p -> {
if (p.getAge() > 19) return ADULT;
return KID;
};
Map<City, Map<Age, List<Person>>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
.collect(Collectors.groupingBy(Person::getCity, Collectors.groupingBy(classifyWithAge)));
assertThat(collect.get(SEOUL).get(ADULT)).size().isEqualTo(1);
assertThat(collect.get(SEOUL).get(KID)).size().isEqualTo(1);
assertThat(collect.get(DAEJEON).get(KID)).size().isEqualTo(1);
map과 partitioningBy
- groupingBy는 값을 기준으로 나눈다. partitioningBy는 boolean을 기준으로 나눈다.
Map<Boolean, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
.collect(Collectors.partitioningBy(p -> p.getAge() > 16));
assertThat(collect.get(true)).size().isEqualTo(1);
assertThat(collect.get(false)).size().isEqualTo(2);
- partitioningBy의 분류 결과 모든 값이 true라 하더라도 false 값에 대한 NPE가 발생하지 않는다.
Map<Boolean, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
.collect(Collectors.partitioningBy(p -> p.getAge() > 30));
assertThat(collect.get(true)).size().isEqualTo(0);
assertThat(collect.get(false)).size().isEqualTo(3);