본문 바로가기

Effective Java

[Effective Java] 아이템 46 - 스트림에서는 부작용 없는 함수를 사용하라

* 스트림에 대한 개념

스트림: 데이터 원소의 유한 또는 무한 시퀀스

스트림 파이프라인: 원소들을 수행하는 연산 단계를 표현하는 개념

 

스트림은 그저 API가 아니라 함수형 프로그래밍에 기초한 패러다임이다.
그래서 이 패러다임까지 함께 받아들여야 한다.

스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성 하는 부분이다.

이때 각 변화 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.

 

순수 함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다.
다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다. 

 

이렇게 하려면 (중간 단계든 종단 단계든) 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다.

 

 

다양한 스트림 연산

 

for Each

스트림을 잘못 사용한 경우(스트림 api만 사용)
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}​

 

위의 코드는 텍스트 파일에서 단어별 수를 세어 빈도표를 만드는 코드이다.
스트림 API의 이점을 살리지 못하여 같은 기능의 반복적 코드보다 길고, 읽기 어렵고, 유지보수에도 좋지 않다. 
더 큰 문제는 forEach에서 일어나는데, 이때 빈도표를 수정하는 람다를 실행하면서 문제가 생긴다. 
forEach가 그저 스트림의 연산 결과 보여주기 이상을 하면 나쁜 코드일 가능성이 크다.
 
스트림을 잘 활용한 경우
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
   freq = words
         .collect(groupingBy(String::toLowerCase, counting()));
}​
위의 코드는 이전의 코드에 비해 짧고 명확하다.
forEach 연산은 종단 연산 중 기능이 가장 적고 가장 '덜' 스트림답다. 대놓고 반복적이라서 병렬화할 수도 없다.


-> forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는데는 쓰지말자

 

Collector

Collector를 잘 활용하자

 

* Collector란?

  • 축소(reduction) 전략을 캡슐화한 블랙박스 객체
  •  reduction: 원소들을 객체 하나에 취합한다는 뜻

 

맵 수집기(toMap)

ㄱ. 각각 KeyMapper, ValueMapper 를 인수로하는 가장 간단한 맵 수집기. Key 가 중복되면 Exception.

@DisplayName("맵수집기 - 각 원소당 하나의 키")
    @Test
    void toMap_Test() {
        Map<String, Operation> expectedMap = new HashMap<>();
        expectedMap.put("PLUS", Operation.PLUS);
        expectedMap.put("MINUS", Operation.MINUS);
        expectedMap.put("DIVIDE", Operation.DIVIDE);

        Map<String, Operation> collect = Stream.of(Operation.values())
                .collect(toMap(Objects::toString, e -> e));

//잘못된 예
// Map<String, Operation> failCollect = Stream.of(Operation.values())
// .collect(toMap(e -> "SameKey", e -> e)); // java.lang.IllegalStateException: Duplicate key SameKey (attempted merging values PLUS and MINUS)

        assertThat(collect).isEqualTo(expectedMap);

    }

ㄴ. KeyMapper, ValueMapper, 두 원소가 충돌했을 때의 병합함수

@DisplayName("맵수집기 - 인수가 세개")
    @Test
    void toMap_Test_Merge() {
        Map<String, Operation> expectedMap = new HashMap<>();
        expectedMap.put("SameKey", Operation.PLUS);

        Map<String, Operation> collect = Stream.of(Operation.values())
                .collect(toMap(e -> "SameKey", e -> e, (a, b) -> a));

        assertThat(collect).isEqualTo(expectedMap);

    }

ㄷ.map의 구현체를 정할 수 있다.

@DisplayName("맵수집기 - 인수가 네개")
    @Test
    void toMap_Test_Four() {
        Map<Operation, String> expectedMap = new EnumMap<>(Operation.class);
        expectedMap.put(Operation.PLUS, "PLUS");
        expectedMap.put(Operation.MINUS, "MINUS");
        expectedMap.put(Operation.DIVIDE, "DIVIDE");

        EnumMap<Operation, String> collect = Stream.of(Operation.values())
                .collect(toMap(e -> e, Object::toString, (a, b) -> a, () -> new EnumMap<>(Operation.class)));

        assertThat(collect).isEqualTo(expectedMap);

    }

 

2. groupingBy

ㄱ. classfier(분류함수)를 받고 카테고리로 묶은 Map을 담은 수집기 반환. 값은 List

 @Test
    void groupByBasic() {
//given
        List<String> dictionary = Arrays.asList("apple", "apartment", "banana", "bigbang", "count", "cleancode");

//when
        Map<Character, List<String>> collect = dictionary.stream()
                .collect(groupingBy(word -> word.toLowerCase().charAt(0)));
    }

ㄴ. 값을 리스트 외 다른 타입을 반환하기 위해서는 downstream명시. 다운스트림 수집기의 역할은 해당카테고리의 원소들을 담은 스트림으로부터 값을 생성하는 일

  @DisplayName("groupby + downstream")
    @Test
    public void groupByDownStream() {
//given
        List<String> dictionary = Arrays.asList("apple", "apartment", "banana", "bigbang", "count", "cleancode");

//when
        Map<Character, Long> collect = dictionary.stream()
                .collect(groupingBy(word -> word.toLowerCase().charAt(0), counting()));

ㄷ. 다운스트림, 맵 팩터리 지정

ㄴ에 의해 맵팩터리 인수는 세번째에 와야하짐나 두번째에 온다. 점층적 인수 목록 패턴에 어긋난다.

    @DisplayName("groupby + 맵팩터리")
    @Test
    void groupByMapFactory() {
//given
        List<String> dictionary = Arrays.asList("apple", "apartment", "banana", "bigbang", "count", "cleancode");

//when
        Map<Character, Long> collect = dictionary.stream()
                .collect(groupingBy(word -> word.toLowerCase().charAt(0), TreeMap::new, counting()));
    }

 

3. 그 외 다양한 메서드

joining, reducing, filtering, mapping, flatMapping, collectingAndThen

elements.stream().collect(Collectors.summingInt(a -> a * a));
// mapToInt().sum() 과 동일 

IntSummaryStatistics collect = elements.stream().collect(Collectors.summarizingInt(a -> a + a));
//IntSummaryStatistics{count=6, sum=42, min=2, average=7.000000, max=12}

Integer collect = elements.stream()
.collect(Collectors.collectingAndThen(Collectors.toList(), Collection::size));
// collect한 뒤 변환까지

 

결론:
스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다. 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다. 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다. 스트림을 올바로 사용하려면 수집기를 잘 알아둬야 한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

참고: https://javabom.tistory.com/58