스트림이 제공하는 표현력, 속도, (상황에따라) 병령성을 얻으려면 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()));
}
위의 수정된 코드는 올바르다. 처음 예제와 같은 일을 하지만, 이번엔 스트림 API를 제대로 사용했다고 말할 수 있다. 그 뿐만 아니라 짧고 더 명확하다.
하지만 가장 처음에 짯던 방식으로 짜는 사람도 많을 것이다. 익숙하기 때문이다.
자바 프로그래머라면 for-each 반복문을 사용할 줄 알텐데, 종단 연산과 비슷하게 생겼다.
하지만 forEach 연산은 종단 연산 중 기능이 가장 적고 가장 “덜" 스트림답다. 대놓고 반복적이라서 병렬화할 수도 없다.
forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자 물론 가끔은 스트림 계산 결과를 기존 컬렉션에 추가하는 등의 다른 용도로는 쓸 수 없다.
수집기..?
위의 코드는 Collector 라는 수집기를 사용하는데 스트림을 사용하려면 꼭 ! 꼭꼭 ! 배워야 한다.
java.util.stream.Collectors 클래스는 메서드를 무려 39개나 가지고 있고 그 중에는 타입 매개변수가 5개나 되는 것도 있다. 다행히 복잡한 세부 내용을 잘 몰라도, 이 API의 장점을 대부분 활용할 수 있다.
익숙해지기 전까지는 Collector 인터페이스를 잠시 잊고 그저 축소(redution) 전략을 캡슐화한 블랙박스 객체라고 생각하기 바란다.
여기서 축소는 스트림의 원소들 객체 하나에 취합한다는 뜻이다. 수집기가 생성하는 객체는 일반적으로 컬렉션이며 그래서 Collector라는 이름을 쓴다.
수집기를 이용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다. 수집기는 총 세가지로 toList(), toSet(), toCollection(collectionFactory)가 그 주인공이다. 이들은 차례로 리스트, 집합, 프로그래머가 지정한 컬렉션 타입을 반환한다.
지금까지 배운 지식을 활용해 빈도표에서 가장 흔한 단어 10개를 뽑아내는 스트림 파이프라인을 생각해보자.
List<String> toTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
// 마지막 toList는 Collectors의 메서드다 이처럼 collectors의 멤버를 정적 임포트하여 쓰면 스트림 파이프라인 가독성이 좋아져 흔히 많이 이렇게 사용한다.
이 코드에서 어려운 부분운 sorted에 넘긴 비교자, 즉 comparing(freq::get).reversed() 뿐이다
comparing 메서드는 키 추출 함수를 받는 비교자 생성 메서드다. 그리고 한정적 메서드 참조이자 여기서 키 추출 함수로 쓰인 freq::get은 입력받은 단어 키를 빈도표에서 찾아 그 빈도를 반환한다.
그런 다음 가장 흔한 단어가 위로 오도록 비교자를 역순으로 정렬하고 10개를뽑아 리스트에 담는 행위이다.