[교재 EffectiveJava] 아이템 45. 스트림은 주의해서 사용하라
- Book/Effective Java
- 2021. 11. 11.
들어가기전
스트림의 기본 개념은 숙지해야한다.
스트림 기본개념 포스팅 바로가기
https://devfunny.tistory.com/341
스트림
스트림 API는 다량의 데이터 처리 작업을 위해 추가되었다. 이 API가 제공하는 추상 개념 중 핵심은 두가지다.
1) 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
2) 스트림 파이프라인(stream pipeline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
스트림의 파이프 라인은 소스 스트림에서 시작하여 중간 연산이 있을 수 있고, 종단 연산으로 끝난다. 각 중간연산은 스트림을 어떠한 방식으로 변환한다.
중간연산
모두 한 스트림을 다른 스트림으로 변환한다. 이때, 변환 전 타입과 후의 타입은 다를수도 있다.
종단연산
스트림에 최후의 연산을 가한다. 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력하는 식이다.
스트림 파이프라인
스트림 파이프라인은 지연평가(lazy evaluation)된다. 평가는 종단 연산이 호출될때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다. 종단 연산이 없는 스트림 파이프라인은 아무것도 하지 않는 명령어인 no-op와 같으니, 절대 빼먹으면 안된다.
스트림 API는 메서드 연쇄를 지원하는 플루언트(fluent) API 이다. 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다. 파이프라인 여러개를 연결해 표현식 하나로 만들 수도 있다. 기본적으로 스트림 파이프라인은 순차적으로 실행된다. 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 되나, 효과를 볼수 있는 상황은 많지 않다.
스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.
스트림 변환 예제
사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력한다. 아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다. 이 프로그램은 사용자가 명시한 사전 파일에서 각 단어를 읽어 맵에 저장한다. 맵의 키는 그 단어를 구성하는 철자들을 알파벳 순으로 정렬한 값이다. 아나그램끼리는 같은 키를 공유하게된다.
Anagrams.java
package com.java.effective.item45;
import java.io.File;
import java.io.IOException;
import java.util.*;
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
/** Java8 에 추가된 computeIfAbsent 메서드 사용 */
groups.computeIfAbsent(alphabetize(word),
(unused) -> new TreeSet<>()).add(word);
}
}
for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
아래 코드를 보자.
/** Java8 에 추가된 computeIfAbsent 메서드 사용 */
groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
Java8에 등장한 computeIfAbsend 메서드를 사용했다.
Map.java
...
default V computeIfAbsent(K key,
Function<? super K, ? extends V> mappingFunction) {
Objects.requireNonNull(mappingFunction);
V v;
if ((v = get(key)) == null) {
V newValue;
if ((newValue = mappingFunction.apply(key)) != null) {
put(key, newValue);
return newValue;
}
}
return v;
}
...
이 메서드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환한다. 키가 없으면 건네진 함수 객체를 키에 적용하여 값을 계산해낸 다음 그 키와 값을 매핑해놓고, 계산된 값을 반환한다. computeIfAbsend 메서드를 사용하여 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현하였다.
스트림 변환과정 1. 과한 스트림 사용
package com.java.effective.item45;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;
import static java.util.stream.Collectors.groupingBy;
public class AnagramsStream {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(
groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,
(sb, c) -> sb.append((char) c),
StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}
사전 파일을 여는 부분을 제외하면, 프로그램 전체가 단 하나의 표현식으로 처리된다. 너무 복잡해보인다. 사전을 여는 작업을 분리한 이유는 try~with~resources 문을 사용하여 사전 파일을 제대로 닫기 위해서다.
스트림 변환과정 2. 짧고 명확하게 스트림 사용
package com.java.effective.item45;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.stream.Stream;
import static java.util.stream.Collectors.groupingBy;
public class AnagramsStream {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
// 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻고, 스트림 변수를 words 로 한다.
try (Stream<String> words = Files.lines(dictionary)) {
// 스트림 파이프라인
words.collect(groupingBy(word -> alphabetize(word))) // map 으로 모은다.
.values()
// values()가 반환한 값 : Stream<List<String>>
.stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(group -> System.out.println(group.size() + ": " + group));
}
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
코드의 주석을 보며 코드를 이해하자. 이 스트림의 원소는 아나그램 리스트이고, 원소가 minGroupSize 보다 적은 것은 필터링돼 무시된다. 마지막으로 forEach(종단연사)이 수행된다.
alphabetize 메서드에서도 스트림을 이용하려고 한다면, 아쉽게도 char용 스트림은 지원되지 않는다. char 값들을 스트림으로 처리하는 코드로 문제를 파악해보자.
"Hello Words".chars().forEach(x -> System.out.println((char) x));
char 값을 처리하기 위해서는 위 코드처럼 명시적 형변환을 해줘야한다.
String.java 의 chars() 메서드
...
@Override
public IntStream chars() {
return StreamSupport.intStream(
isLatin1() ? new StringLatin1.CharsSpliterator(value, Spliterator.IMMUTABLE)
: new StringUTF16.CharsSpliterator(value, Spliterator.IMMUTABLE),
false);
}
...
명시적 형변환을 해주지 않으면, chars() 메서드가 반환하는 스트림의 원소는 char 가 아닌 int 값이기 때문에 원하지 않는 결과가 나온다.
스트림 사용 경우
기존 코드는 스트림을 사용하도록 리팩터링 하되, 새 코드가 더 나아보일때만 반영하자.
스트림 파이프라인은 되풀이되는 계산을 함수 객체(람다 또는 메서드 참조 등)로 표현한다. 반면 반복 코드에서는 코드 블록을 사용하여 표현한다. 함수 객체로는 할수 없지만 코드 블록으로는 할 수 있는 일들이 있다.
- 코드 블록에서는 범위 안의 지역 변수를 읽고 수정할 수 있다.
하지만 람다에서는 final 이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는건 불가능하다.
- 코드 블록에서는 return 문을 사용하여 메서드를 빠져나가거나, break, continue 문으로 바깥의 반복문을 종료하거나 한번 건 너 뛸 수 있다. 또한 메서드 선언에 명시된 검사 예외를 던질 수 있다. 하지만 람다로는 불가능하다.
반대로, 아래의 경우는 스트림과 안성맞춤인 경우다.
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다. (더하기, 연결하기, 최솟값 구하기 등)
- 원소들의 시퀀스를 컬렉션에 모은다. (아마도 공통된 속성을 기준으로 묶어가며)
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
스트림으로 처리하기 어려운 일을 예로 들어보자. 한 데이터가 파이프라인의 여러 단계를 통과할때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우다. 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조기 때문이다. 원래 값과 새로운 값의 쌍을 저장하는 객체를 사용하여 해결할 순 있지만, 만족스러운 해법은 아니다. 이 방식은 코드 양도 많고 지저분하여 스트림을 쓰는 주 목적에서 완전히 벗어난다.
스트림 vs 반복문
둘 중 어느 것을 사용해야할지 바로 알기 어려운 작업이 많다. 카드 덱을 초기화하는 작업을 생각해보자. 카드는 숫자/무늬를 묶은 불변 값 클래스이고, 숫자와 무늬는 모두 열거타입이라 하자. 이 작업은 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제다. 수학자들은 이를 두 집합의 데카르트 곱이라고 한다.
private static List<Card> newDeck() {
List<Card> result = new ArrayList<>();
for (Suit suit : Suit.values())
for (Rank rank : Rank.values())
result.add(new Card(suit, rank));
return result;
}
위 코드를 스트림으로 구현해보자.
private static List<Card> newDeck() {
return Stream.of(Suit.values())
.flatMap(suit -> Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}
중간 연산을 사용한 flatMap 은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음 그 스트림들을 다시 하나의 스트림으로 합친다.
flatMap 관련 포스팅
https://devfunny.tistory.com/458
반복문 사용 코드와 스트림 사용 코드 중 어떤게 더 좋아보일지는 개인 취향과 프로그래밍 환경의 문제다. 반복문 방식은 더 단순하고 더 자연스러워보인다. 따라서 반복문과 스트림 중의 선택은 개인의 선택이고, 확신이 드는 코드를 선택하면 된다. 주변 동료들이 스트림 코드를 이해할 수 있고 선호한다면 스트림 방식을 선택할 수도 있는 것이다.
'Book > Effective Java' 카테고리의 다른 글
[교재 EffectiveJava] 아이템 47. 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) | 2021.11.13 |
---|---|
[교재 EffectiveJava] 아이템 46. 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2021.11.12 |
[교재 EffectiveJava] 아이템 44. 표준 함수형 인터페이스를 사용하라 (0) | 2021.11.10 |
[교재 EffectiveJava] 아이템 43. 람다보다는 메서드 참조를 사용하라 (0) | 2021.11.09 |
[교재 EffectiveJava] 아이템 42. 익명 클래스보다는 람다를 사용하라 (0) | 2021.11.05 |