-
[이펙티브 자바] item 45 - 스트림은 주의해서 사용하라개발서적읽기/Effective Java - temp 2020. 8. 21. 12:34
■스트림 API의 등장
스트림 API는 다량의 데이터 처리 작업(순차적 혹은 병렬적)을
효율적으로 처리하기 위해 자바 8에 추가되었다.
스트림 API의 핵심은 두가지다.
1. 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
2. 스트림 파이프라인(stream pipeline)은 이 원소들로 수행하는
연산 단계를 표현하는 개념이다.
스트림의 원소들은 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기,
다른 스트림 등 다양한 경로를 통해 존재할 수 있다.
스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다.
기본 타입 값으로는 int, long, double 세가지를 지원한다.
IntStream intStream = IntStream.range(1, 5);
LongStream longStream = LongStream.range(1, 5);
DoubleStream doubleStream = DoubleStream.builder().add(1.2).build();
■스트림 연산스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝난다.
그리고 그 사이에 하나 이상의 중간 연산이 있을 수 있다.
각 중간 연산은 특정 방식으로 스트림을 변환한다.
예컨대 각 원소에 함수를 적용하거나 특정 조건을 만족하지 못하는 원소를 걸러낼 수 있다.
중간 연산들은 모두 한 스트림을 다른 스트림으로 변환한다.
변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고
다를 수도 있다. 종단 연산은 마지막 중간 연산이 만든 스트림에 마지막 연산을 적용한다.
보통 원소를 정렬해서 컬렉션에 담거나, 특정 원소를 선택하거나, 모든 원소를 출력한다.
■스트림 지연평가
스트림 파이프라인은 지연 평가(lazy evaluation)된다.
지연 평가를 '게으른 연산' 이라고 표현하기도 한다.
평가(중간 연산)는 종단 연산이 호출될 때 이뤄지며
종단 연산에 쓰이지 않는 데이터 원소는 사실 중간 연산을 포함한
모든 연산이 적용되지 않는다.
이러한 지연 평가는 무한 스트림을 다룰 수 있게 해주는 중요한 요소이다.
종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인
no-op과 같으니, 종단 연산을 빼먹는 일이 절대로 없도록 해야겠다.
■스트림 API는 메서드 연쇄를 지원하는 Fluent API
스트림 API는 메서드 연쇄를 지원하는 Fluent API다.
즉, 파이프라인 하나를 구성하는 호출들을 연결하여 단 하나의 표현식으로 완성할 수 있다.
파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.
기본적으로 스트림 파이프라인은 순차적으로 수행된다.
파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서
parallel 메서드를 호출해주기만 하면 되나, 효과를 볼 수 있는 상황은 많지 않다. (item 48)
■스트림 API는 잘쓰면 약, 못쓰면 독!
스트림 API는 다양한 계산에 활용될 수 있다.
하지만 '잘' 사용해야 한다. 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.
스트림을 언제 써야 하는지를 규정하는 확실한 규칙은 없지만
참고해볼만한 노하우는 있다.
스트림을 사용하지 않은 코드
다음 코드를 보자.
이 프로그램은 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다
원소 수가 많은 아나그램(anagram) 그룹을 출력한다.
아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 뜻한다.
이 프로그램은 사용자가 명시한 사전 파일에서 각 단어를 읽어 맵에 저장한다.
맵의 키는 그 단어를 구성하는 철자들을 알파벳순으로 정렬한 값이다.
즉 'staple'의 키는 'aelpst'가 되고 'petals'의 키도 'aelpst'가 된다.
따라서 두 단어는 아나그램이고 아나그램끼리는 같은 키를 공유한다.
맵의 값은 같은 키를 공유한 단어들을 담은 집합이다.
사전 하나를 모두 처리하고 나면 각 집합은
사전에 등재된 아나그램들을 모두 담은 상태가 된다.
마지막으로 이 프로그램은 맵의 values()로 아나그램 집합들을 얻어
원소 수가 문턱값보다 많은 집합들을 출력한다.
public class IterativeAnagrams {
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();
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);
}
}이 프로그램의 첫 번째 단계에 주목하자.
맵에 각 단어를 삽입할 때 자바 8에서 추가된 computeIfAbsent()를 사용했다.
이 메서드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환한다.
키가 없으면 건네진 함수 객체를 키에 적용하여 값을 계산해낸 다음
그 키와 값을 매핑해놓고, 계산된 값을 반환한다. 이처럼 computeIfAbsent를 사용하면
각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있다.
스트림을 과하게 사용한 코드
이제 다음 프로그램을 살펴보자.
IterativeAnagrams와 같은 일을 하지만 스트림을 과하게 활용한다.
사전 파일을 여는 부분만 제외하면 프로그램 전체가 단 하나의 표현식으로 처리된다.
사전을 여는 작업을 분리한 이유는
그저 try-with-resources 문을 사용해 사전 파일을 제대로 닫기 위해서다.
public class StreamAnagrams {
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);
}
}
}이해하기 힘든 코드다.... 짧지만 읽기 어렵다.
특히 스트림에 익숙하지 않은 프로그래머라면 더욱 그렇다.
이처럼 스트림을 과용하면 프로그램 유지보수가 힘들어진다.
스트림을 적당히 사용한 코드
다행히 절충 지점이 있다. 아래 프로그램도 위 두 프로그램과 기능은 같다.
하지만 스트림을 적당히 사용했고 그 결과 코드의 양과 가독성 모두를 얻었다.
public class HybridAnagrams {
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 -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}try-with-resources에서 사전 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻는다.
스트림 변수의 이름을 words로 지어 스트림 안의 각 원소가 단어(words)임을 명확히 했다.
이 스트림의 파이프라인에는 중간 연산은 없으며, 종단 연산에서는 모든 단어를 수집해
맵으로 모은다. 이 맵은 단어들을 아나그램끼리 묶어놓은 것이다. (item 46)
(앞선 두 프로그램이 생성한 맵과 실질적으로 같다)
그다음 이 맵의 values가 반환한 값으로부터 새로운 Stream<List<String>> 스트림을 연다.
이 스트림의 원소는 물론 아나그램 리스트다.
그 리스트들 중 원소가 minGroupSize보다 적은 것은 필터링돼 무시된다.
마지막으로 종단 연산인 forEach는 살아남은 리스트를 출력한다.
람다 매개변수의 이름은 주의해서 정해야 한다.
HybridAnagrams에서의 매개변수 g는 사실 group이라고 하는게 좋다.
하지만 책의 지면 폭이 부족해 짧게 줄였다.
람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야
스트림 파이프라인의 가독성이 유지된다.
한편, 단어의 철자를 알파벳순으로 정렬하는 일은 별도 메서드인 alphabetize에서
수행했다. 연산에 적절한 이름을 지어주고 세부 구현을 주 프로그램 로직 밖으로
빼내 전체적인 가독성을 높인 것이다. 이처럼 도우미 메서드를 적절히 활용하는 일의
중요성은 일반 반복 코드에서보다는 스트림 파이프라인에서 훨씬 크다.
왜냐하면 파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를
자주 사용하기 때문이다.
alphabetize()도 스트림을 사용해 다르게 구현할 수 있다.
하지만 명확성이 떨어지고 잘못 구현할 가능성이 커질 수도 있다.
심지어 느려질 수도 있다. 자바가 기본 타입인 char용 스트림을 지원하지 않기 때문이다.
자바가 char 스트림을 지원했어야 한다는 뜻은 아니다. 그렇게 하는 건 불가능했다!
아래는 char 값들을 스트림으로 처리하는 코드다.
"Hello world!".chars().forEach(System.out::print);
"Hello world!" 를 출력하리라 기대했겠지만, 72101108~~~~~~033 을 출력한다.
왜냐하면 "Hello world!".chars()가 반환하는 스트림의 원소는 char가 아닌 int이기 때문이다.
따라서 정수값을 출력하는 print 메서드가 호출된 것이다.
이처럼 이름이 chars인데 int 스트림을 반환하면 헷갈릴 수 있다.
올바른 print 메서드를 호출하게 하려면 다음처럼 형변환을 명시적으로 해야 한다.
"Hello world!".chars().forEach(x -> System.out::print((char) x));
char 값들을 처리할 때는 스트림을 삼가는 편이 낫겠다 ㅎㅎ...
■Stream으로 처리하기 어려운 경우
한편 스트림으로 처리하기 어려운 일도 있다.
예를 들어, 한 데이터가 파이프라인의 여러 단계(stage)를 통과할 때
이 데이터의 각 단계에서의 값들에 동시에 접근이 필요한 경우를 들 수 있다.
스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값을 잃어버린다.
원래 값과 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하여 우회할 수도 있지만
그리 좋은 방법은 아니다. 매핑 객체가 필요한 단계가 여러 곳이라면 특히 더 그렇다
이런 매핑을 통한 우회하는 방식은 코드 양도 많고 지저분하기 때문에
스트림을 쓰는 주목적에서 완전히 벗어난다.
가능한 경우라면, 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하는 방법이 나을 것이다.
'매핑을 거꾸로 수행하는 방법'을 예를 들어 구체화 해보자.
처음 20개의 메르센 소수를 출력하는 프로그램을 작성해보자.
메르센 수는 2^p - 1 형태의 수다.
여기서 p가 소수이면 해당 메르센 수도 소수일 수 있는데,
이때의 수를 메르센 소수라 한다.
이 파이프라인의 첫 스트림으로는 모든 소수를 사용할 것이다.
다음 코드는 (무한) 스트림을 반환하는 메서드다.
(BigInteger의 정적 멤버들은 정적 임포트하여 사용한다고 가정한다.)
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}메서드 이름 primes는 스트림의 원소가 소수임을 말해준다.
스트림을 반환하는 메서드 이름은
이처럼 원소의 정체를 알려주는 복수 명사로 쓰기를 강력히 추천한다.
스트림 파이프라인의 가독성이 크게 좋아진다.
이 메서드가 이용하는 Stream.iterate라는 정적 팩터리는 매개변수를 2개 받는다.
첫 번째 매개변수는 스트림의 첫 번째 원소이고
두 번째 매개변수는 스트림에서 다음 원소를 생성해주는 함수다.
이제 처음 20개의 메르센 소수를 출력하는 프로그램을 보자.
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}이 코드는 앞서의 설명을 정확하게 구현했다.
소수들을 사용해 메르센 수를 계산하고, 결괏값이 소수인 경우만 남긴 다음
(매직넘버 50은 소수성 검사가 true를 반환할 확률을 제어한다)
결과 스트림의 원소 수를 20개로 제한해놓고 작업이 끝나면 결과를 출력한다.
이제 우리가 각 메르센 소수의 앞에 지수(p)를 출력하길 원한다고 해보자.
이 값은 초기 스트림에만 나타나므로 결과를 출력하는 종단 연산에서는 접근할 수 없다.
하지만 다행히 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수를
쉽게 계산할 수 있다. 단순히 숫자를 이진수로 표현한 다음 몇 비트인지를 세면
해당 숫자의 지수를 얻을 수 있으므로, 종단 연산을 다음처럼 작성하면 된다.
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
■Stream과 for-each 모두 적절한 경우
스트림과 반복 중 어느 쪽을 써야 할지 바로 알기 어려운 작업도 많다.
카드 덱을 초기화하는 작업을 생각해보자.
카드는 숫자(rank)와 무늬(suit)를 묶은 불변 값 클래스이고,
숫자와 무늬는 모두 열거 타입이라 하자.
이 작업은 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제다.
수학자들은 이를 두 집합의 데카르트 곱이라고 부른다.
다음은 for-each 반복문을 중첩해서 구현한 코드로, 스트림에 익숙하지 않은 사람에게
친숙한 방식이다.
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;
}다음은 스트림으로 구현한 코드다.
중간 연산으로 사용한 flatMap은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음
그 스트림들을 다시 하나의 스트림으로 합친다. 이를 평탄화(flattening)라고도 한다.
이 구현에서는 중첩된 람다를 사용했음에 주의하자.
private static List<Card> newDeck() {
return Stream.of(Suit.values())
.flatMap(suit ->
Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}어느 방법이 좋을까? 개인 취향과 프로그래밍 환경의 문제다.
for-each를 사용한 방식은 더 단순하고 자연스러워 보인다.
스트림과 함수형 프로그래밍에 익숙한 프로그래머라면
스트림 방식이 조금 더 명확하고 쉬울 것이다.
어떤 방식이 맞을 지 모르겠다면 첫 번째 방식을 쓰는게 더 안전할 것이다!
'개발서적읽기 > Effective Java - temp' 카테고리의 다른 글
[이펙티브 자바] item 59 - 라이브러리를 익히고 사용하라 (0) 2020.09.03 [이펙티브 자바] item 52 - 다중정의는 신중히 사용하라 (0) 2020.08.28 [이펙티브 자바] item 38 - 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 (0) 2020.08.15 [이펙티브 자바] item 32 - 제네릭과 가변인수를 함께 쓸 때는 신중하라 (0) 2020.07.27 [이펙티브 자바] item 26 - 로 타입은 사용하지 말라 (0) 2020.07.19