Skip to content

Item 46. 스트림에서는 부작용 없는 함수를 사용하라 #46

@heon118

Description

@heon118

Chapter : 7. 람다와 스트림

Item : 46. 스트림에서는 부작용 없는 함수를 사용하라

Assignee : heon118


🍑 서론

스트림 패러다임

  • 스트림은 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임
  • 스트림이 제공하는 표현력, 속도, (상황에 따라) 병렬성을 얻으려면 API와 이 패러다임까지 함께 받아들여야 한다.
  • 계산을 일련의 변환으로 재구성하는 부분이 핵심
  • 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
  • 즉, 스트림 연산에 건네는 함수 객체는 모두 부작용이 없어야 한다.

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

🍑 본론

스트림 패러다임의 사용

스트림 패러다임을 이해하지 못한 채 API만 사용한 경우

  • 텍스트 파일에서 단어별 수를 세는 메서드
// Key : 단어, Value : 해당 단어가 나타난 횟수(빈도)
Map<String, Long> freq = new HashMap<>();
// 단어를 읽어 스트림 생성. tokens() : 단어 단위로 토큰을 생성하는 스트림 반환.
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        // freq.merge : 주어진 Key(단어)에 대해 Value(빈도수)를 맵에 추가 및 업데이트
        // merge(맵에 추가할 키, 키가 맵에 존재하지 않을 때 사용할 초기값, 키가 이미 맵에 존재할 경우 기존 값과 1L의 합)
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}
  • 올바른 결과를 도출하지만 스트림 코드라고 할 수 없다.
  • 스트림을 가장한 반복문
  • forEach는 스트림이 수행한 연산 결과를 보여주는 일 이상을 한기에 나쁜 코드(람다가 상태를 수정함)

스트림을 제대로 사용한 경우

  • 텍스트 파일에서 단어별 수를 세는 메서드
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    // Collectors 메서드 사용
    // 스트림의 각 요소(단어)를 소문자로 변환 후, 그것들을 그룹화하고 각 그룹의 요소 수(단어의 빈도) 계산
    freq = words
            .collect(groupingBy(String::toLowerCase, counting()));
}
  • forEach 연산은 대놓고 반복적이라 병렬화할 수 없다.
  • forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 사용하지 말자.

Scanner의 스트림 메서드인 tokens를 사용해 스트림을 얻었다. tokens는 자바9부터 지원하므로, 그 이전 버전을 쓰는 사람은 어댑터를 이용하여(Iterator를 구현한) Scanner를 스트림으로 변환할 수 있다. 코드47-3에서 사용하는 streamof(Iterable)가좋은예다.

collector

  • 자주 사용하는 작업은 java.util.stream.Collectors 클래스에서 제공
  • 39개의 메서드
  • 복잡한 세부 내용을 몰라도 해당 API 장점 대부분 활용 가능
  • 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 생각하자
  • 축소 : 스트림의 원소들을 객체 하나에 취합
  • 일반적으로 컬렉션 객체를 생성하며 collector라고 한다
  • 종단 연산으로 forEach보단 Collectors 권장
toList()                        : 리스트 수집
toSet()                         : 집합 수집
toCollection(collectionFactory) : 지정한 컬렉션 타입 수집

1. toList

  • freq 맵을 사용해 가장 빈도수 높은 단어 10개 선택 및 리스트로 수집
List<String> topTen = freq.keySet().stream()
        // sorted : 스트림의 요소 정렬
        // comparing(freq::get) : freq 맵에서 각 Key(단어)에 대응하는 Value(빈도수) 기준으로 정렬한 Comparator 생성
        // reversed() : 빈도수 높은 단어부터 낮은 단어 순 정렬
        .sorted(comparing(freq::get).reversed())
        .limit(10)
        .collect(toList());

toList는 Collectors의 메서드로 Collectors의 멤버를 정적 임포트하여 사용하면 스트림 파이프라인 가독성이 좋아진다.

  • Collectors의 나머지 36개 메서드들 중 대부분은 스트림을 맵으로 취합하는 기능으로 진짜 컬렉션에 취합하는 것보다 훨씬 복잡하다.
  • 스트림의 각 원소는 키 하나와 값 하나에 연관되어 있다. 또한 다수의 스트림 원소가 같은 키에 연관될 수 있다.

이제부터 수십 개의 메서드를 요약해 설명할 것이다. 따라서 본문의 글만으로는 내용이 헷갈리고 머리에 잘 남지 않을 수 있다. 흐름을 좇기 어렵다면 java.util.stream.Collectors의 API문서(http://bit.ly/2MvTOAR)를 펼쳐놓고 하나씩 짚어가며 읽어보길 추천한다.

2. toMap

인수가 2개인 toMap

  • toMap(keyMapper, valueMapper)
  • toMap(스트림 원소를 키에 매핑하는 함수, 값에 매핑하는 함수)
문자열을 열거 타입 상수에 매핑
private static final Map<String, item34.Operation> stringToEnum =
        // Object::toString : enum 상수를 문자열로 변환
        // e -> e : 값 매핑 함수로, 스트림의 각 요소를 그대로 값으로 사용
        Stream.of(values()).collect(
                toMap(Object::toString, e -> e));
  • 이 형태는 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합

  • 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료

  • 더 복잡한 형태의 toMap이나 groupingBy는 이런 충돌을 다루는 다양한 전략 제공

  • toMap에 키 매퍼와 값 매퍼는 물론 병합(merge) 함수까지 제공

  • 병합 함수의 형태는 BinaryOperator<U>, U는 맵의 값 타입

  • ex) 병합 함수가 곱셈이라면 키가 같은 모든 값을 곱한 결과

인수가 3개인 toMap

  • 어떤 Key와 그에 연관된 Value들 중 하나를 골라 연관 짓는 맵을 만들 때 유용
  • 세 번째 인수 : 병합함수(merge function)
  • 두 Value가 같은 Key에 맵핑될 경우, 어떻게 병합할지 결정
각 키와 해당 키의 특정 원소를 연관짓는 맵 생성
  • 다양한 음악가와 그 음악가의 베스트 앨범을 연관 짓기
Map<Artist, Album> topHits = albums.collect(
        // Album::artist : 앨범의 아티스트가 Key
        // a -> a : 앨범이 Value
        // maxBy(comparing(Album::sales)) : 두 값이 같은 Key에 맵핑될 경우, 두 앨범 중 판매량이 더 높은 앨범을 선택
        toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
마지막에 쓴 값을 취하는 맵 생성
  • 맵핑 함수가 Key 하나에 연결해준 Value들이 모두 같을 때, 혹은 다르더라도 모두 허용되는 값일 때 필요
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)

인수가 4개인 toMap

  • 네 번째 인수 : 맵 팩터리
  • EnumMap이나 TreeMap처럼 원하는 특정 맵 구현체를 직접 지정 가능

맵 팩터리 : Map 인스턴스를 쉽고 간편하게 생성할 수 있도록 도와주는 메서드 제공(자바9 이상에서 java.util.Map 인터페이스에 추가된 정적 메소드들)

toConcurrentMap

  • toMap의 변종
  • 병렬 실행된 결과로 ConcurrentHashMap 인스턴스 생성
  • 동시성을 지원하는 Map
  • 주로 병렬 스트림에서 안전하게 요소를 수집할 때 유용
  • 여러 스레드에서 동시에 맵을 수정해야 할 때

3. groupingBy

  • 입력 : 분류 함수(classifier) -> 입력받은 원소가 속하는 카테고리 반환하며 Key로 사용
  • 출력 : 원소들을 카테고리별로 모아 놓은 맵을 담은 Collector

인수가 1개인 groupingBy

  • 인수 : 분류 함수
  • item45의 아나그램 프로그램
words.collect(groupingBy(word -> alphabetize(word)));

인수가 2개인 groupingBy

  • 두 번째 인수 : 다운스트림 컬렉터
  • groupingBy가 반환하는 Collector가 리스트 외의 값을 갖는 맵을 생성하게 하려면 분류 함수와 함께 다운스트림 컬렉터도 명시해야한다.

다운스트림 컬렉터 : 스트림의 요소를 그룹화(grouping), 분할(partitioning), 또는 다른 복잡한 집계(aggregation) 연산을 수행할 때, 하나의 컬렉터가 결과를 수집하는 과정에서 추가적으로 다른 컬렉터를 사용할 때 내부적으로 사용하는 컬렉터

  • toSet() : groupingBy 원소들의 리스트가 아닌 Set을 값으로 갖는 맵 생성
  • toCollection(collectionFactory) : 리스트나 집합 대신 컬렉션을 값으로 갖는 맵 생성
  • counting() : 각 카테고리(Key)를 해당 카테고리에 속하는 원소의 개수(Value)와 맵핑한 맵 생성
Map<String, Long> freq = words
            .collect(groupingBy(String::toLowerCase, counting()));

인수가 3개인 groupingBy

  • 두 번째 인수 : 맵 팩터리
  • 세 번째 인수 : 다운스트림 컬렉터
  • 점층적 인수 목록 패턴에 어긋난다.(맵 팩터리 매개변수가 다운스트림 매개변수보다 앞에 위치)
  • 값이 TreeSet인 TreeMap을 반환하는 collector 생성 가능
// gpt
public class TreeMapTreeSetCollector {
    public static void main(String[] args) {
        List<String> items = List.of("banana", "apple", "cherry", "apple", "banana", "cherry", "orange");

        // 분류 함수로 첫 글자를 사용하고, 결과를 TreeMap에 저장하며, 각 그룹의 값들을 TreeSet으로 수집하는 Collector 생성
        Map<Character, TreeSet<String>> groupedItems = items.stream()
                .collect(Collectors.groupingBy(
                        item -> item.charAt(0), // 분류 함수: 첫 글자로 그룹화
                        TreeMap::new, // 결과를 TreeMap으로 저장
                        Collectors.toCollection(TreeSet::new) // 각 그룹의 값들을 TreeSet으로 수집
                ));

        System.out.println(groupedItems);
    }
}

groupingByConcurrent

  • 메서드의 동시 수행 버전으로 ConcurrentHashMap 인스턴스 생성
  • 병렬 스트림에서 thread safe하게 요소를 그룹화할 때 사용

4. 기타 메서드

partitioningBy

  • 많이 사용 X
  • groupingBy의 사촌격
  • 분류 함수 자리에 프레디키트를 받고 Key가 Boolean인 맵 반환

Predicate : 자바에서 조건을 표현하는데 사용되는 함수형 인터페이스

사용할 일 없는 메서드

  • counting 메서드가 반환하는 collector는 다운스트림 컬렉터 전용

  • Stream의 count 메서드를 직접 사용하여 같은 기능을 수행할 수 있으니 collect(counting()) 형태로 사용할 일은 전혀 없다.

  • Collections에 이런 속성의 메서드가 16개 더 있다.

  • 그 중 9개는 summing, averaging, summarizing으로 시작하며, 각각 int, long, double 스트림용으로 하나씩 존재

  • 설계 관점에서 스트림 기능의 일부를 복제하여 다운스트림 컬렉터를 작은 스트림처럼 동작하게 하는 메서드로 몰라도 된다.

    • 다중정의된 reducing, filtering, mapping, flatMapping, collectingAndThen

Collectors에 정의되어 있지만 '수집'과는 관련 없는 메서드

minBy / maxBy
  • 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작은 / 큰 원소를 찾아 반환
joining
  • CharSequence 인스턴스의 스트림에만 적용 가능
  • 매개변수가 없는 joining : 단순 원소들을 연결하는 collector 반환
  • 인수 한 개인 joining : CharSequence 타입의 구분문자를 매개변수로 받는다.
  • 인수 세 개인 joining : 접두(prefix), 구분, 접미문자(suffix)
    • '[', ',', ']' -> [came, saw, conquered]처럼 컬렉션을 출력하는 듯한 문자열 생성

🍑 결론

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다.

스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다.

Referenced by

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions