Reading/Effective Java

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

케이tae 2021. 11. 1. 01:51

스트림 패러다임


스트림은 또 하나의 API가 아니라 단지 함수형 프로그래밍에 기초한 패러다임이고, 핵심은 계산을 일련의 변환으로 재구성 하는 부분이다.

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

이렇게 하려면 중간 단계든 종단 단계든 스트림 연산에 건네는 함수 객체는 모두 부작용(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);
    });
}

 

스트림 코드를 가장한 반복코드일 뿐이다. 뭐가 문제일까?

 

우선 일반적인 for-each(enhance for)로 작성했을 때보다 길고 읽기 어렵고 유지보수에도 좋지 않다.

모든 작업이 종단 연산인 forEach(stream)에서 일어나는데 외부 상태를 수정하는 람다를 실행하며 문제가 생간다.

 

위 스트림 패러다임에서 가능한 입력만이 결과에 영향을 주는 순수함수여야만 한다고했다.

하지만 forEach(stream)내부는 순수함수가 아니다. 외부의 freq Map의 상태를 변경시키는 freq.merge 메소드가 있기 때문이다.

merge 메소드를 통해 words에 있는 단어들이 소문자로 바뀌어 키 값으로 저장되고 그 키의 개수를 value로 넣는다. 

freq가 위 코드처럼 빈 해시맵으로 시작하는게 아닌, 다른 값을 가지고 있다면 words.forEach의 결과는 달라질 수 있다.

 

위에서 설명했던 코드를 for-each(enhance for)로도 처리할 수 있지만 스트림을 올바르게 사용한다면 아래와 같이 작성되야한다.

 

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

 

짧고 명확해졌다.

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

물론 가끔은 스트림 패러다임 챕터에서 사용했던 코드처럼 사용하는, 기존 컬렉션에 추가하는 등의 용도로도 쓸 수 있긴 하지만

forEach(stream) 연산은 스트림 계산 결과를 보고할 때만 사용하고 계산하는 데는 쓰지 말자.

--> 견해: 내부는 순수함수를 의도했을텐데 의도에 벗어나게 사용하지않는 편이 확실히 좋을 것 같다.

 

Collectors


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

 

마지막으로 봤던 코드를 다시보자. 해당 코드는 위 코드는 수집기(collector)를 사용한다.

java.util.stream.Collectors 클래스는 39개의 메소드와 5개의 타입 매개변수가 있다. 많으니 활용해야할 때 찾아서 보면 좋을 것 같다.

 

Collector: 축소(reduction) 전략을 캡슐화한 블랙박스 객체(인터페이스를 통해서 가져다 쓸 수 있게!)

축소(rduction): 스트림의 원소들을 객체 하나에 취합한다는 뜻

 

이제 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있는 수집기를 살펴보자. 수집기가 생성하는 객체는 일반적으로 컬렉션이다.

 

1. 수집기

(1) toList()

 

List<String> topTen = freq.keySet().stream()
        .sorted(Comparator.comparing(freq::get).reversed())
        .limit(10)
        .collect(Collectors.toList());
System.out.println(topTen);

 

sorted에 넘긴 비교자, comparing 메소드는 키 추출 함수를 받는 비교자 생성 메소드다.

한정적 메소드 참조이고 키 추출 함수로 쓰이는 freq::get은 입력받은 단어(키)를 빈도표에서 찾아(추출) 그 빈도를 반환한다.

그리고 reversed() 메소드를 이용해 가장 흔한 단어가 위로 올 수 있게 역순으로 정렬해 List로 수집한다.

 

내부를 보면 List의 구현체는 ArrayList로 CollectorImpl에 담아 반환해주는 것을 알 수 있다.

 

 

(2) toSet()

toList() 메소드와와 사용 방법의 차이는 없다.

 

내부를 보면 Set 구현체는 HashSet으로 CollectorImpl에 담아 반환해주는 것을 알 수 있다.

 

 

 

(3) toMap()

앞의 두 메소드와 달리 매개변수가 필요하고, 점층적 생성자 패턴으로 보인다. 하나씩 보자

 

 

[1] keyMapper, valueMapper

 

 

키에 패밍파는 함수와 값에 매핑하는 함수를 인수로 받는다. 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다.

Map의 구현체로 HashMap을 가져온다. 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던진다. 

아이템 34에서 짰던 Operation의 fromString에서 호출하는, 모든 Operation과 심볼을 가지는 Map으로 만들었던걸 보자.

 

private static final Map<String, Operation> stringToEnum =
        Stream.of(values()).collect(toMap(Object::toString, e -> e));

 

Operation에 존재하는 모든 값들을 values()를 통해 가져오고, 이 객체의 Object.toString 결과를 키 값으로, 값으로 자기 자신을 갖게 Function을 세팅해서 Map을 수집해준다. 더 복잡한 형태의 toMap이나 groupingBy는 이런 충돌을 다루는 다양한 전략을 제공한다.

 

 

[2] keyMapper, valueMapper, mergeFunction

[1]에서 병합함수를 점진적 생성자 패턴을 통해서 추가로 전달할 수 있다.

 

 

BinaryOperator로 받고, U는 해당 맵의 타입이다. 같은 키를 공유하는 값들은 이렇게 합쳐지게 된다.

같은 키를 공유하는 값들을 처리할 수 있고, 만약 병합 함수가 곱셈이면 키가 같은 모든 값(키/값 매퍼가 정함)을 곱한 결과를 얻는다.

 

List<Integer> tmp = new ArrayList<>();
tmp.add(5);tmp.add(5);tmp.add(5);
Map<Integer, Integer> result = tmp.stream()
    .collect(Collectors.toMap(ii -> ii, jj -> jj, (oldVal,newVal) -> oldVal * newVal));
System.out.println(result); // {5=125}, 키가 같으니 모든 값을 곱한다.

 

어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들때도 유용하다. 다음 코드들을 보자.

 

// 키는 앨범의 아티스트, 값은 앨범, 아티스트가 같다면 앨범 판매량이 가장 많은 앨범이 값으로 들어감.
Map<Artist,Album> topHits = albums
    .collect(Collectors.toMap(Album::artist,a->a,maxBy(Comparator.comparing(Album::sales))));

 

maxBy 메소드는 BinaryOperator<T>를 반환해준다. 내부함수로는 앨범의 판매량을 쓰고 있음을 알 수 있다.

 

그리고 충돌이 났을 때 마지막 값을 취하는 방식도 적용할 수 있다.

 

toMap(keyMapper,valueMapper,(oldVal,newVal)->newVal);

 

이 방법을 통해 키가 같을때 가장 최신의 값으로 적용할 수 있다.

 

[3] keyMapper, valueMapper, mergeFunction, mapFactory

[2]에서 mapFactory가 추가된 점진적 생성자 패턴이다. mapFactory에 특정 맵 구현체를 설정할 수 있다.

 

 

아래는 TreeMap을 구현체로 설정한 예다. Supplier로 제공하면 된다. 메소드 참조를 이용했다.

 

Map<Integer, Integer> result = tmp.stream().collect(Collectors.toMap(ii -> ii, jj -> jj, (kk, ll) -> kk * ll, TreeMap::new));

 

(4) toConcurrentMap()

toMap() 메소드와 같이 파라미터가 점진적 생성자 패턴이고, 병렬 실행이 된 후 결과로 ConcurrentHashMap 구현체를 생성한다.

 

 

 

(5) toUnmodifiableMap(), toUnmodifiableList(), toUnmodifiableSet()

자바 10에서 추가된 4개의 메소드다. 이름의 뜻대로 수정할 수 없는, 즉 읽기 전용(Read-Only)으로 컬렉션을 반환하는 것이다.

unModifiableMap이 keyMapper, valueMapper와 함께 점진적 생성자 패턴으로 mergeFunction까지 받아서 2+1+1 = 4다.

구현체는 기존의 toMap(), toList(), toSet()과 같으나, 리턴되는 컬랙션은 수정과 관련된 메소드를 사용할 수 없다.

 

 

억지로 사용하려하면, Immutable 객체는 수정할 수 없다고 나온다. 

 

 

하지만 컴파일 타임에 알려주지 않고 런타임에 알 수 있다. UnsupportedOperationException을 던진다. 

 

(5) toCollection()

파라미터로 Supplier를 넘기면 된다.

 

 

그러면 그 구현체로 만들어서 넘겨준다. 하지만 대부분은 생성자로 해결이 가능하다.

 

// 첫 번째 코드는 두 번째 코드로 쓰면 된다!
tmp.stream().collect(Collectors.toCollection(TreeSet::new));
new TreeSet<>(tmp);

 

 

2. groupingBy

입력으로 분류 함수(classifier)를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.

분류 함수는 입력받은 원소가 속하는 카테고리를 반환하고, 이 카테고리가 해당 원소의 맵 키로 쓰인다.

아래 사진을 보면, 점층적 인수 목록 패턴에 어긋난다. mapFactory 매개변수가 downStream 매개변수 앞에 놓인다.

사진에 3번째 다중정의된 맵팩토리를 생성하지 않으면 기본적으로 HashMap을 사용한다.

 

 

2번째 다중정의지만 1번째 다중정의도결국  이 메소드를 호출한다.

 

(1) classifier

반환된 맵에 담긴 각각의 값은 해당 카테고리에 속하는 원소들을 모두 담은 리스트고, 아이템 45의 아나그램에서 사용했다.

알파벳화한 단어를 알파벳화 결과가 같은 단어들의 리스트로 매핑하는 맵을 생성하는 것이다.

 

Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);

try (Stream<String> words = Files.lines(dictionary)) {
    words.collect(groupingBy(word -> alphabetize(word)))
    ...

 

value로는 word가 그대로 들어가고, 키 값으로는 alphabetize의 결과(String)가 된다.

그러면 키가 같다면 어떻게 될까? 만들어지는 map의 value는 컬렉션이니까, 같다면 컬렉션에 쌓이게 된다.

 

매개변수 classifier 하나일 경우 Collectors.toList() 메소드로 지정된다.

 

다른 예를 살펴보자.

 

List<Integer> tmp = new ArrayList<>();
tmp.add(5);tmp.add(5);tmp.add(5);
Map<Integer, List<Integer>> collect = tmp.stream().collect(Collectors.groupingBy(val -> val * 2));
System.out.println(collect); // {10=[5, 5, 5]}

 

이 코드의 실행 결과는 키 값을 val*2로 Function을 넣었기 때문에 List의 모든 원소가 5기 때문에 키는 전부 10이된다.

리스트기 때문에 value 5들은 리스트에 전부 담기게되서 10=[5,5,5] 형태로 출력이 된 것이다. 

리스트 외의 값을 갖는 맵을 생성하게 하려면 분류함수와 함께 다운스트림(downstream) 수집기도 명시해야한다.

 

(2) classifier, downstream

두번째 인자로는 Collectors를 넘길 수 있다. set을 쓰려면 Collectors.toSet() 메소드를 넘기면 된다.

아니면 그냥 그냥 본인이 하고싶은 Collection을 만들면 된다. 그만큼 유연성이 있다는 것이다.

 

다운 스트림 수집기로 counting() 메소드를 건네는 방법도 있다.

 

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

 

이렇게 하면 각 카테고리(키)를 원소를 담은 컬렉션이 아닌 해당 카테고리에 속하는 원소의 개수(값)와 매핑한 맵을 얻는다.

 

단순히 Long 타입으로 개수를 넘겨줌 

counting 메소드가 반환하는 수집기는 다운스트림 수집기 전용이다. stream의 count 메소드를 직접 사용하여 같은 기능을

수행할 수 있으니 collect(counting()) 형태로 사용할 일은 전혀 없다.

 

 

(3) classifier, mapFactory, downstream

이렇게 사용하면 맵과 그 안에 담긴 컬렉션의 타입을 모두 지정할 수 있다. 예컨대 TreeSet인 TreeMap을 반환할 수 있다는 얘기다.

 

 

 

3. groupingByConcurrent

3개를 다중정의한 groupingBy 각각에 대응하는 메소드다. 메소드의 동시 수행 버전으로 ConcurrentHashMap 인스턴스를 만든다.

 

 

4. partitioningBy

분류 함수 자리에 predicate를 받고 키가 Boolean인 맵을 반환한다.

 

 

Boolean key를 생성할 predicate를 넣고, value에 어떤 타입을 쓸지 downstream을 정의할 수 있게 2개를 다중정의했다.

 

5. 기타

이외에도 16개의 메소드가 남아있다.

 

(1) summing, averaging, summarizing + int, long, double

summing, averaging, summarizing으로 시작하며 뒤에 int, long, double 스트림용으로 하나씩 존재한다. 총 9개다.

매개변수로는 To(int,long,double)Function을 가진다.

 

 

아래와 같이 사용할 수 있으나, sum은 2번째 줄로 치환해서 쓰는게 더 나아보인다.

 

Integer sum = tmp.stream().collect(Collectors.summingInt(value->value*2));
Integer sum2 = tmp.stream().mapToInt(value -> value * 2).sum(); // mapToInt의 sum으로 대체 가능
Double avg = tmp.stream().collect(Collectors.averagingInt(value->value*2));
IntSummaryStatistics innSumma = tmp.stream().collect(Collectors.summarizingInt(value -> value * 2));

 

(2) reducing, mapping, flatMapping, collecting, AndThen

 

reducing: 

초기값(identity)을 설정하고, 어떤 값을 처리할지(mapper)로 지정하고 연산을 전부 수행(op)해서 결과를 반환한다.(Op의 클래스로)

 

 

filtering: 

조건을 설정하고(predicate) 어떤 타입으로 모을건지(downstream) 지정

 

 

mapping: 

어떤 타입으로 모을건지(downstream) 지정하고 매핑함수를 작성(mapper)

 

 

flatMapping: 

어떤 타입으로 모을건지(downstream) 지정하고 매핑함수를 작성(mapper)하는데 매핑함수가 스트림 혹은 optional을 반환한다.

 

 

collectingAndThen: 

다른 컬렉터로 감싸고(downstream) 그 결과를 나타낼 변환함수(finisher)를 지정

ex) 리스트로 감쌀건데, 이 리스트에 들어갈 변환함수가 size면 size로 들어간다.

 

 

(3) minBy, maxBy

minBy와 maxB는 수집과 관련이 없다. 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작거나 큰 원소를 찾아 반환한다.

Stream 인터페이스의 min과 max메소드를 일반화한 것이자, Java.util.function.BinaryOperator의 minBy와 maxBy 메소드가 반환하는 이진 연산자의 수집기 버전이다.

 

 

 

(4) joining

문자열 등의 CharSequence 인스턴스의 스트림에만 적용할 수 있다.

 

 

파라미터가 없는 joining은 단순히 원소들을 연결(concatenate)하는 수집기다.

파라미터 1개 joining은 CharSequence 타입의 구분문자(delimiter)를 매개변수로 받아 연결 부위에 이 문자를 삽입한다.

파라미터 3개 joining은 접두문자(prefix), 접미문자(suffix)를 받아 마치 컬렉션을 출력하는 듯한 문자열을 생성한다.

 

List<String> tmp2 = new ArrayList<>();
tmp2.add("z");tmp2.add("x");tmp2.add("c");
tmp2.stream().collect(Collectors.joining()); // zxc
tmp2.stream().collect(Collectors.joining(", ")); // z, x, c
tmp2.stream().collect(Collectors.joining("[",", ","]")); // [z, x, c]

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.