본문 바로가기
Java

[Effective Java] Stream에서는 부작용 없는 함수를 사용하라. (item 46)

by 곰민 2023. 1. 12.
728x90

Effective Java item 46 스트림(Stream)에서는 부작용 없는 함수를 사용하라에서의 부작용 없는 함수와 어떤 함수를 사용하기를 권장하는지 확인해보도록 하겠습니다.

 

stream과 부작용 없는 함수


stream은 새로 추가된 또 하나의 API가 아닌 함수형 프로그래밍에 기초한 패러다임이기 때문에
장점이 무엇인지 쉽게 와닿지 않을 수도 있습니다.
Stream 패러다임에 핵심은 계산 로직을 일련의 변환(transformation)으로 재구성하는 부분입니다.
각 변환 단계는 가변 상태를 참조하지 않고 오로지 입력값에만 영향을 받는 순수함수로 이루어져
이전 단계의 결과를 받아 처리해야 합니다.
이 핵심을 지키려면 stream 연산 내에 건네는 함수 객체는 모두 함수가 결과값 이외에
다른 상태를 변경시켜 순수함수가 아니게 되어 예상하는 값을 반환할 수 없는 side effect가 없어야 합니다.

 

외부 값을 변경하는 예시


Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
	words.forEach(word -> {
		freq.merge(word.toLowerCase(), 1L, Long::sum);
	}
);

스트림을 사용해 나오는 결과 값은 같지만, 스트림 코드를 가장한 반복적 코드

foreach에서 외부 상태를 수정하는 lambda를 실행하면서 문제가 생긴다.

 

stream 답게 사용한 예시


Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
	  freq = words.collect(groupingBy(String::toLowerCase, counting()));
};

짧고 명확하다.

 

ForEach() 연산은 stream 계산하는 데는 쓰지 말자.


forEach 연산은 종단 연산 중 기능이 적고 가장 덜 stream 답다.
대놓고 반복이라 병렬화할 수도 없다.

forEach 연산은 stream 계산 결과를 보고할 때만 사용하고 계산하는 데는 쓰지 말자.
가끔 stream 계산 결과를 기존 collection에 추가하는 등 다른 용도로 쓸 수는 있다.

 

stream을 올바르게 사용하려면 수집기(collector)를 잘 알아 두자.


수집기를 사용하면 stream의 원소를 손쉽게 컬렉션으로 모을 수 있습니다.

  • 수집기는 총 세 가지로, toList(), toSet(), toCollection(collectionFactory) 가 있습니다.
  • http://bit.ly/2MvTOAR (API 문서)

 

toList() 예시와 Java8, Java10, Java16에서의 사용법


List로 모아서 반환하는 간단한 예시를 보도록 하겠습니다.

Java 버전별로 toList()의 경우 사용 방식이 조금 차이가 나는데 이유가 존재합니다.

// Java8
List<String> collectorsToListCase = Stream.of("one", "two")
            .collect(Collectors.toList());
  collectorsToListCase.add("new");

// Java10
List<String> unmodifiableCase = Stream.of("one", "two")
				    .collect(Collectors.toUnmodifiableList());
unmodifiableCase.add("new");

// Java16
List<String> toListCase = Stream.of("one", "two").toList();
toListCase.add("new");

 

Collectors.toList();

  • 변경 가능한 List를 반환하며 null값을 허용합니다.

Collectors.toUnmodifiableList();

  • 변경 가능하지 않은 List를 반환하며 null값을 허용하지 않습니다.

to.List();

  • 변경 가능하지 않은 List를 반환하며 null값을 허용합니다.

위와 같은 이유로 java version 8 이상을 사용 중인 경우에 intellij 나 sonarlint에서 immutable 한 list를 반환하도록 toList()를 사용하도록 권장하고 있습니다.

 

Map 수집기


Map을 수집하여 반환해 주는 Map수집기를 확인해 보자

 

toMap

  • Collectors의 메서드는 대부분 Stream을 Map으로 취합하는 기능으로, Stream의 각 원소는 키 하나와 값 하나에 연관되어 있다. 또한, 다수의 Stream 원소가 같은 키에 연관될 수 있습니다.

 

toMap(keyMapper, valueMapper)

 

// 2개의 인수를 받는 toMap
private static final Map<String, Operation> stringToEnum =
  Stream.of(values()).collect(
    toMap(Object::toString, e -> e)
  )

// 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료
// 3개의 인수를 받는 toMap
Map<Artist, Album> toHits = albums.collect(
  toMap(Album::artist, a->a, maxBy(comparing(Album::sales)))
;

//albums stream을 map으로 변경하는데 각 artist와 그 artist의 best album을 짝지은 것
// 비교자로 BinaryOperator에서 정적 임포트한 maxBy라는 정적 팩터리 메서드를 사용
// 충돌이 나면 마지막 값을 취하는 수집기도 만들 수 있다.
toMap(KeyMapper, valueMapper, (oldVal, newVal) -> newVal)

 

네 번째 인수로는 Map 팩토리를 받아 EnumMap이나 TreeMap처럼 원하는 특정 Map 구현체를 직접 지정할 수 있습니다.

 

GroupingBy

 

특정 속성값에 의해서 그룹핑을 짓는 것입니다.

매개변수로 3가지를 받을 수 있습니다.

 

  1. classifier (Function<? super T,? extends K> ): 분류 기준을 나타낸다.
  2. mapFactory (Supplier) : 결과 Map 구성 방식을 변경할 수 있다.
  3. downStream (Collector<? super T,A,D>): 집계 방식을 변경할 수 있다.

classifier만 받는 경우 (예시 A)

static <T,K> Collector<T,?,Map<K,List<T>>> 
  groupingBy(Function<? super T,? extends K> classifier)

 

classifier와 downStream(Collector)를 받는 경우 (예시 B)

static <T,K,A,D> Collector<T,?,Map<K,D>>
  groupingBy(Function<? super T,? extends K> classifier, 
    Collector<? super T,A,D> downstream)



classifier와 mapFactory, downStream(Collector)를 받는 경우(mapFactory)가 앞에 온다 (예시 C)

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

 

😀 아래에서 각각에 매칭한 예시들을 확인해 봅시다.

 


 

입력으로 classifier를 받고 출력으로는 원소들을 카테고리별로 모은 Map을 담은 수집기를 반환합니다. (예시 A)

// 알파벳화한 단어를 알파벳화 결과가 같은 단어들의 리스트로 매핑하는 맵을 생성한다.
words.collect(groupingBy(word -> alpahabetize(word)))

 

리스트 외의 값을 갖는 Map을 생성하게 하려면 classifier와 함께 downStream도 명시해야 합니다. (예시 B)

Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));

 

매개 변수로 toSet()을 넘기면 List 대신 set으로 매핑합니다.

toSet() 대신 toCollection(collectionFactory)를 건네는 방법도 있습니다.

 

다운스트림 수집기에 더해 Map 팩토리도 지정할 수 있습니다. (예시 C)

EnumMap<OrderType, List<Order>> collect9 = orders.stream().collect(groupingBy(Order::getOrderType, () -> new EnumMap<>(OrderType.class), toList()));

 

mapFactory 매개변수가 downStream 매개변수보다 앞에 놓입니다.

mapFactory를 지정하면 맵과 그 안에 담긴 컬렉션의 타입을 모두 지정할 수 있습니다.

그 외 동시 수행 버전인 groupingByConcurrent 메서드, 키가 Boolean인 맵을 반환하는 partitioningBy도 있습니다. → 많이 쓰이지 않습니다.

 

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


maxBy, minBy → 인수로 받은 비교자를 이용해 Stream에서 값이 가장 작은 혹인 큰 원소를 찾아 반환합니다.

joining → CharSequence 타입의 구분문자를 매개 변수로 받아 연결 부위에 구분문자를 삽입해서 반환합니다.

  • ex) 구분문자를 ", "를 받아 CSV형태의 문자열을 만들 수 있습니다.

 

참조


baeldung

EffectiveJava

728x90

댓글