📌 Stream, 함수형 프로그래밍 그리고 순수 함수
- Stream을 이해하고 싶다면 함수형 프로그래밍 패러다임까지 받아들여라
- 스트림 패러다임 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다.
- 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
- 오직 입력만이 결과에 영향을 주는 함수
- 다른 가변 상태를 참조하지 않고, 함수 스스로도 외부 상태를 변경하지 않는다.
- 즉, 함수는 입력값에 의존하여 항상 동일한 결과만을 내놓아야 한다.
- 이를 보장하기 위해서는 스트림 연산에 건네는 함수 객체는 모두 side-effect가 없어야 한다.
✒️ Stream API
1. Java Stream API
• 컬렉션에 저장되어 있는 element들을 추상화시키고, 간단하게 처리할 수 있다.
• Java 8 이전에는 Iterator 클래스를 이용했다.
2. 특징
(1) 원본 데이터를 변경하지 않는다.
• 원본 데이터를 조회하여 별도의 요소들로 stream을 생성한다.
• 정렬이나 필터링 등의 작업은 별도의 stream data들에서 처리된다.
• List<String> sortedList = nameStream.sorted().collect(toList());
(2) 스트림은 일회용이다.
• Stream API는 한 번 사용이 끝나면 재사용이 불가능하다.
• 또 필요한 경우 stream을 재생성해야 하고, 닫힌 stream을 이용하면 IllegalStateException 발생
• userSteam.sorted().forEach(System.out::print);
int count = userStream.count(); // IllegalStateException
(3) 내부 반복으로 작업을 처리한다.
• Stream을 사용하면 코드가 간결해지는 이유 중 하나는 '내부 반복' 때문이다.
• for이나 while 등을 쓰지 않고, stream 내부에 숨겨진 반복 문법을 사용한다.
• nameStream.forEach(System.out::println); // 반복문이 forEach라는 함수 내부에 숨겨져 있다.
더보기
✒️ Scanner().tokens()
책에 나온 예제에서 Scanner().tokens() 라는 메서드를 사용하는데, 그런 메서드가 정의되어 있지 않다.
찾아보니 자바 버전이 낮아서 그런 거였다. (뒷부분에서나 Java 9부터 지원한다고 언급해놨다. ㅡㅡ)
그래서 이 참에 내가 직접 구현해보았다.
class ScannerStream implements Iterable<String> {
private final Scanner scanner;
public ScannerStream(File file) throws FileNotFoundException {
scanner = new Scanner(file);
}
@Override public Iterator<String> iterator() {
return new ScannerIterator();
}
@Override public void forEach(Consumer<? super String> action) {
while (scanner.hasNext())
action.accept(scanner.next());
}
@Override public Spliterator<String> spliterator() {
return new ScannerSpliterator(scanner);
}
public Stream<String> tokens() {
return StreamSupport.stream(spliterator(), false);
}
private class ScannerIterator implements Iterator<String> {
@Override public boolean hasNext() {
return scanner.hasNext();
}
@Override public String next() {
return scanner.next();
}
@Override public void remove() {
throw new UnsupportedOperationException();
}
}
private class ScannerSpliterator implements Spliterator<String> {
private final Scanner scanner;
public ScannerSpliterator(Scanner scanner) {
this.scanner = scanner;
}
@Override public boolean tryAdvance(Consumer<? super String> action) {
if (scanner.hasNext()) {
action.accept(scanner.next());
return true;
} else {
return false;
}
}
@Override public Spliterator<String> trySplit() {
return null;
}
@Override public long estimateSize() {
return Long.MAX_VALUE;
}
@Override public int characteristics() {
return ORDERED | SIZED | NONNULL | IMMUTABLE;
}
}
}
- ScannerStream은 Iterator<String> : 인터페이스를 구현하여 반복 가능 문자열 스트림을 생성한다.
- forEach(Consumer<? super String> action) : Consumer 함수형 인터페이스를 통해 각 문자열에 대한 action을 수행한다. 내부적으로 Scanner의 hasNext()와 next()를 이용하여 문자열을 반복한다.
- Spliterator을 구현하기 위해 내부적으로 ScannerSpliterator을 구현해서 객체를 반환하도록 처리했다.
- tokens()는 파일에서 읽은 문자열을 스트림으로 반환한다.
사실 ChatGPT 도움을 어느 정도 받아가면서 했는데, Spliterator<String>을 반환하기 위해서 Java9 버전에서나 나오는 이상한 메서드를 호출하길래, Spliterator을 내부 클래스에서 구현해버렸다. (난 Java 8 따리라고..)
저렇게 구현하는 게 맞는지는 잘 모르겠다. 대충 작동만 하게끔 여기저기 참고해서 쓰긴 했는데, 여튼 챕터에 나오는 테스트용 정도로는 충분할 것 같다.
1️⃣ Stream 패러다임을 제대로 이해하지 못한 경우
public class Foo {
public static void main(String[] args) {
File file = new File("src\\Chat7\\Item46\\input.txt");
Map<String, Long> freq = new HashMap<>();
// 1. 스트림 패러다임을 이해하지 못한 경우
try (Stream<String> words = new ScannerStream(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
System.out.println(freq);
}
}
- 스트림, 람자, 메서드 참조를 사용했고 결과도 올바르지만, 스트림 코드를 가장한 반복 코드에 불과하다. (오히려 반복 코드보다 모든 면에서 나쁘다.)
- 모든 연산이 종단 forEach에서 발생하는데, 외부 변수(freq)를 수정하는 람다를 실행하면서 순수 함수로서의 기능을 상실했다.
2️⃣ Stream API를 제대로 사용한 경우
💡 forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자
public class Foo {
public static void main(String[] args) {
File file = new File("src\\Chat7\\Item46\\input.txt");
Map<String, Long> freq;
// 2. 올바른 방법
try (Stream<String> words = new ScannerStream(file).tokens()) {
freq = words.collect(groupingBy(String::toLowerCase, counting()));
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
System.out.println(freq);
}
}
- 짧고 명확하며, 스트림 API를 제대로 사용했다.
- for-each반복문이 forEach와 비슷하게 생겨 많이 사용하지만, forEach연산은 종단 연산 중 기능이 가장 적고 가장 '덜' 스트림답다. (대놓고 반복적이라 병렬화할 수도 없다.)
- 가끔 스트림 계산 결과를 기존 컬렉션에 추가하는 등의 다른 용도로 사용할 수는 있다.
📌 수집기(collector)
Object collect(Collector collector)
- 스트림 사용을 위해 꼭 배워야 한다.
- collect() : 스트림 종단연산, 매개변수로 Collector를 필요로 한다.
- Collector : 인터페이스, collect의 파라미터는 해당 인터페이스를 구현해야 한다.
- Collectors : 클래스, static 메서드로 미리 작성된 컬렉트를 제공한다.
- java.util.stream.Collectors 클래스는 Java 10 기준 43개나 되고, 그 중 타입 매개변수가 5개나 되는 것도 있다.
- 익숙해지기 전까지는 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 생각하라
- collector가 생성하는 객체는 일반적으로 컬렉션이다.
- 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다.
📌 Stream 파이프라인 작성해보기
💡 Collectors의 멤버를 정적 임포트하면 스트림 파이프라인 가독성이 좋아진다.
- toList(), toSet(), toCollection(collectionFactory) 세 가지가 대표적이다.
List<String> topTem = freq.keySet().stream()
.sorted(comparing(freq::get).reversed()) // Comparator method
.limit(10)
.collect(toList()); // Collectors.toList()
- comparing 메서드는 키 추출 함수를 받는 비교자 생성 메서드 (Item 14)
- 키 추출 함수로 freq 인스턴스의 get 메서드 호출
- key(단어)로 value(빈도수)를 추출한 값을 반환한다.
- comparing(비교자)을 reversed(역순)으로 sorted(정렬)한다.
- 스트림에서 단어 10개를 뽑아 리스트에 담는다.
📌 Collectors 나머지 36개 메서드
💡 수십 개 메서드를 요약해놓았기 때문에 본문의 글만으로 헷갈릴 수 있으니 java.util.stream.Collectors를 참고하라. (API Doc)
• toMap & 복잡한 형태의 toMap
• 인수 3개를 받는 toMap
• 인수 4개를 받는 toMap
• toMap 변종 (ex. toConcurrentMap)
• groupingBy
• groupingBy 다른 자료형으로 반환
• groupingBy 세 번째 버전
• groupingByConcurrent & partitioningBy
• joining & minBy/maxBy
• 그 외
1️⃣ toMap & 복잡한 형태의 toMap
public static <T,K,U> Collector<T,?,Map<K,U>> toMap(Function<? super T,? extends K> keyMapper, Function<? super T,? extends U> valueMapper)
- 가장 간단한 형태는 toMap(keyMapper, valueMapper)
- keyMapper : 스트림 원소를 키에 매핑하는 함수
- valueMapper : 스트림 원소를 값에 매핑하는 함수
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(toMap(Object::toString, e -> e));
- Stream의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다.
- 다수가 같은 키를 사용하면 파이프라인이 IllegalStateException을 던진다.
2️⃣ 인수 3개를 받는 toMap
private static final Map<String, Operation> stringToEnum =
Stream.of(values())
.collect(toMap(Object::toString, e -> e, (existing, replacement) -> existing));
- 병합(merge) 함수를 통해 충돌을 제어할 수 있다.
- BinaryOpearation<U> 형태를 가지며, U는 해당 맵의 value 타입
- 같은 키를 공유하는 값들은 병합 함수를 사용해 기존 값에 합쳐진다.
- toMap이나 groupingBy는 이러한 방식으로 충돌을 다룬다.
Map<Artist, Album> topHist = albums.collect(
toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
- "어떤 키"와 "그 키에 연관된 원소들 중 하나"를 골라 연관 짓는 맵을 만들 때도 유용하다.
- 코드를 직역하면 "앨범 스트림을 맵으로 바꾸는데, 이 맵은 각 음악가와 그 음악가의 베스트 앨범을 짝지은 것이다."
3️⃣ 인수 4개를 받는 toMap
private static final Map<Operation, String> operationToString =
Stream.of(Operation.values())
.collect(toMap(Function.identity(), Object::toString, (a, b) -> a, EnumMap::new));
- toMap은 일반적으로 HashMap을 사용하는데, 4번째 인수로 EnumMap이나 TreeMap처럼 특정 맵 구현체를 직접 지정할 수도 있다.
4️⃣ toMap 변종 (ex. toConcurrentMap)
- 위 세 가지 toMap에는 변종이 있다.
- 그 중 toConcurrentMap은 병렬 실행된 후 결과로 ConcurrentHashMap 인스턴스를 생성한다.
public static <T,K,U> Collector<T,?,ConcurrentMap<K,U>> toConcurrentMap(Function<? super T,? extends K> keyMapper, Function<? super T,? extends U> valueMapper)
5️⃣ groupingBy
word.collect(groupingBy(word -> alphabetize(word));
Map<Integer, List<Product>> collectorMapOfLists = productList.stream()
.collect(Collectors.groupingBy(Product::getAmount));
/*
{23=[Product{amount=23, name='potatoes'}, Product{amount=23, name='bread'}],
13=[Product{amount=13, name='lemon'}, Product{amount=13, name='sugar'}],
14=[Product{amount=14, name='orange'}]}
*/
- Stream 작업 결과를 특정 그룹으로 묶고, 결과를 Map으로 반환한다.
- 매개변수로 함수형 인터페이스 Function을 필요로 한다. (분류 함수(classifier) 입력)
- 반환 받은 Map의 key에 매핑된 value는 리스트로 묶여있다.
6️⃣ groupingBy 다른 자료형으로 반환
Map<Integer, Set<String>> resultGroupingBy = list.stream()
.collect(groupingBy(String::length, toSet()));
- groupingBy가 반환하는 Map의 value가 리스트 외의 자료형을 생성하게 하려면, 다운스트림(downstream) 수집기도 명시해야한다.
- 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성한다.
- toCollection(collectionFactory)를 넘기는 방법도 있다.
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));
- counting() → 각 카테고리(key)를 해당 카테고리에 속하는 원소의 개수(value)와 매핑한 맵을 얻는다.
💡 counting 메서드가 반환하는 수집기는 다운스트림 수집기 전용이다. collect(counting()) 형태로 사용할 일은 전혀 없다.
7️⃣ groupingBy 세 번째 버전
public static <T, K> Collector<T, ?, Map<K, List<T>>> groupingBy(Function<? super T, ? extends K> classifier)
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Alex", "Amy");
Map<Character, List<String>> groupedByNameLength = names.stream()
.collect(groupingBy(name -> name.charAt(0)));
System.out.println(groupedByNameLength);
- toMap처럼 mapFactory를 지정할 수 있다.
- 해당 메서드는 점층적 인수 목록 패턴(telescoping argument list pattern, Item 2)에 어긋난다.
- mapFactory 매개변수가 downStream 매개변수보다 앞에 놓인다.
- 값이 TreeSet인 TreeMap을 반환하는 수집기도 만들 수 있다.
8️⃣ groupingByConcurrent & partitioningBy
- 각각의 groupingBy에 대응하는 groupingByConcurrent 메서드는 동시 수행 버전으로 ConcurrentHashMap을 반환한다.
Map<Boolean, List<Product>> mapPartitioned = productList.stream()
.collect(Collectors.partitioningBy(p -> p.getAmount() > 15));
/*
{false=[Product{amount=14, name='orange'}, Product{amount=13, name='lemon'}, Product{amount=13, name='sugar'}],
true=[Product{amount=23, name='potatoes'}, Product{amount=23, name='bread'}]}
*/
- 많이 쓰진 않는다.
- groupingBy의 사촌격
- 분류 함수 자리에 predicate를 받고, key가 Boolean인 Map을 반환한다.
9️⃣ joining & minBy/maxBy
- minBy & maxBy
- '수집'과는 관련이 없다.
- 인수로 받은 비교자를 이용해 스트림에서 가장 값이 작은/큰 원소를 반환한다.
String listToString = productList.stream()
.map(Product::getName)
.collect(Collectors.joining());
// potatoesorangelemonbreadsugar
String listToString = productList.stream()
.map(Product::getName)
.collect(Collectors.joining(" "));
// potatoes orange lemon bread sugar
String listToString = productList.stream()
.map(Product::getName)
.collect(Collectors.joining(", ", "<", ">"));
// <potatoes, orange, lemon, bread, sugar>
- joining
- '수집'과는 관련이 없다.
- 문자열 등의 CharSequence 인스턴스 스트림에만 적용 가능하다.
- 매개변수가 없을 경우, 단순히 원소들을 연결(concatenate)하는 수집기 반환
- 인수
- delimiter(구분자) : 각 요소 구분자로 사용
- prefix(접두사) : 접두사로 붙임
- suffix(접미사) : 접미사로 붙임
🔟 그 외
- counting()처럼 다운스트림 수집기 전용인 Collections 메서드가 16개나 더 있다.
- 9개는 summing, averaging, summarizing으로 시작하며 in, long, double 스트림용으로 하나씩 존재한다.
- 다중 정의된 reducing 메서드들과 filtering, mapping, flatMapping, collectingAndThen 메서드가 있다.
- 대부분 프로그래머는 이들의 존재를 몰라도 된다.
Double averageAmount = productList.stream()
.collect(Collectors.averagingInt(Product::getAmount));
// 86
Integer summingAmount = productList.stream()
.collect(Collectors.summingInt(Product::getAmount));
// 86
Integer summingAmount = productList.stream()
.mapToInt(Product::getAmount)
.sum();