-
Notifications
You must be signed in to change notification settings - Fork 3
Open
Labels
7장 람다와 스트림이펙티브 자바 7장 (람다와 스트림)이펙티브 자바 7장 (람다와 스트림)
Description
Discussed in https://github.com/orgs/Study-2-Effective-Java/discussions/116
Originally posted by JoisFe February 12, 2023
아이템 45. 스트림은 주의해서 사용하라
스트림 API
- 다량의 데이터 처리 작업 (순차적이든 병렬적이든)을 돕고자 자바 8에서 추가 된 API
스트림 API가 제공하는 추상 개념
- 스트림 (Stream) 은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻함
- 스트림 파이프라인 (Stream Pipeline) 은 이 원소들로 수행하는 연산 단계를 표현하는 시스템
- 스트림의 원소들은 어디로부터든 올 수 있음
- 대표적으로 컬렉션, 배열, 파일, 정규표현식 패턴 매처(matcher), 난수 생성기, 혹은 다른 스트림이 있음
- 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값
- 기본 타입 값으로는 int, long, double 이렇게 3가지를 지원
스트림 파이프라인 (Stream Pipeline)
- 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산 (terminal operation)으로 끝남
- 그 사이에 하나 이상의 중간 연산 (intermediate operation)이 있을 수 있음
중간 연산 (intermediate operation)
- 각 중간 연산은 스트림을 어떠한 방식으로 변환 (transform) 함
- 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러낼 수 있음
- 중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있음
종단 연산 (terminal operation)
- 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가함
- 원소를 정렬해 컬렉션에 담거나 특정 원소 하나를 선택하거나 모든 원소를 출력하는 식
지연 평가 (lazy evaluation)
- 스트림 파이프라인은 지연 평가된다.
- 평가는 종단 연산이 호출될 때 이뤄지며 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다
- 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다.
- 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니 종단 연산을 빼먹는 일이 절대 없도록 주의해야 한다!
플루언트 API (Fluent API)
- 스트림 API는 메서드 연쇄를 지원하는 플루언트 API 이다.
- 즉 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있음
- 기본적으로 스트림 파이프라인은 순차적으로 수행됨
- 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 되나 효과를 볼 수 있는 상황은 적다 (아이템 48 참고)
다재다능한 스트림 API 하지만 주의해서 사용해야
- 스트림 API는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있음
- 하지만 할 수 있다는 뜻이지 해야하는 것은 아님
- 스트림을 제대로 사용하면 프로그램이 짧고 깔끔해짐
- 잘 못 사용하면 읽이 어렵고 유지보수도 힘들어 짐
스트림 API 사용 노하우
- 스트림을 언제 써야하는지를 규정하는 명확한 규칙은 존재하지 않지만 참고할 만한 노하우는 존재함
public class Anagrams {
public static void main(String[] args) throws FileNotFoundException {
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);
}
}
위 코드의 설명
- 위 코드는 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 (anagram) 그룹을 출력함
- 아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말함
- 위 코드는 사용자가 명시한 사전 파일에서 각 단어를 읽어 맵에 저장
- 맵의 키는 그 단어를 구성하는 철자들을 알파벳순으로 정렬한 값
- 즉 "staple"의 키는 "aelpst"가 되고 "petals"의 키도 "aelpst"가 됨
- 따라서 두 단어는 아나그램이고 아나그램끼리는 같은 키를 공유함
- 맵의 값은 같은 키를 공유한 단어들을 담은 집합
- 사전 하나를 모두 처리하고 나면 각 집합은 사전에 등재된 아나그램들을 모두 담은 상태가 됨
- 마지막으로 이 프로그램은 맵의 values() 메서드로 아나그램 집합들을 얻어 원소 수가 문턱값보다 많은 집합들을 출력
위 코드의 스트림 사용
- 맵에 각 단어를 삽입할 때 자바 8에서 추가된 computeIfAbsent 메서드를 사용
- 이 메서드는 맵 안에 키가 있는지 찾은 다음 있으면 단순히 그 키에 매핑된 값을 반환
- 키가 없으면 건네진 함수 객체를 키에 적용하여 값을 계산해낸 다음 그 키와 값을 매핑해놓고 계산된 값을 반환
- computeIfAbsent를 사용하면 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있음
public class Anagrams {
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 문을 사용해 사전 파일을 제대로 닫기 위해서임
스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워짐
절충 지점
public class Anagrams {
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(group -> System.out.println(group.size() + ": " + group));
}
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
- 위 코드 또한 이전의 코드들과 같은 기능을 함
- 스트림을 적당히 사용하여 원래 코드보다 짧을 뿐만 아니라 명확하기 까지 한 코드가 되었음
- 스트림을 전에 본 적이 없더라도 위 코드는 이해하기 쉬울 것임
- 파일의 모든 라인으로 구성된 스트림을 얻고 스트림 변수의 이름을 words로 지어서 스트림 안의 각 원소가 단어(word) 임을 명확히 명시하였음
- 이 스트림의 파이프라인에는 중간 연산은 없으며 종단 연산에서는 모든 단어를 수집해 맵으로 모음
- 이 맵은 단어들을 아나그램끼리 묶어놓은 것으로 앞선 두 코드가 생성한 맵과 실질적으로 같음
- 그 다음 이 맵의 values()가 반환한 값으로부터 새로운 Stream<List> 스트림을 열게됨
- 이 스트림의 원소는 물론 아나그램 리스트
- 그 리스트들 중 원소가 minGroupSize 보다 적은 것은 필터링돼 무시됨
- 마지막으로 종단 연산인 forEach는 살아남은 리스트를 출력
alphabetize 메서드를 스트림을 사용해 구현하기
- 해당 메서드를 스트림을 사용하여 다르게 구현시 명확성이 떨어지고 잘 못 구현할 가능성이 커짐
- 심지어 느려질 수도 있음
why?
- 자바가 기본 타입인 char용 스트림을 지원하지 않기 때문 (그렇다고 자바가 char 스트림을 지원했어야 한다는 뜻 아님!!, 그렇게 하는게 불가능함)
"Hello world!".chars().forEach(System.out::print);

- 위 코드는 Hello world!를 출력하리라 기대했지만 72~~~~ 이상한 값을 출력함
- "Hello world!".chars()가 반환하는 스트림의 원소는 char가 아닌 int 값이기 때문
- 따라서 정숫값을 출력하는 print 메서드가 호출된 것
이름이 chars 인데 int 스트림을 반환하면 헷갈리는 문제
- 올바른 print 메서드를 호출하게 하려면 형변환을 명시적으로 해줘야 함
"Hello world!".chars().forEach(x -> System.out.println((char) x));

하지만 char 값들을 처리할 때는 스트림을 삼가하는 편이 나음
1. 스트림으로 바꾸는게 가능할지라도 코드 가독성과 유지보수 측면에는 손해를 볼 수 있기에 무조건 스트림으로 바꾸는 것을 서두르지 말자
2. 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자
- 스트림과 반복문을 적절히 조합하는게 최선
함수 객체(람다 or 메서드 참조 등) 보단 코드 블록의 경우가 나은 경우
- 스트림 파이프라인은 되풀이되는 계산을 함수 객체로 표현
- 반복 코드에서는 코드 블록을 주로 사용
함수 객체(람다 or 메서드 참조 등) 으로는 할 수 없지만 코드 블록(반복) 으로 할 수 있는 일
- 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있음 하지만 람다에서는 final 이거나 사실상 final 변수만 읽을 수 있고 지역변수를 수정하는 건 불가능
- 코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나 break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있음, 또한 메서드 선언에 명시된 검사 예외를 던질 수 있음 하지만 람다로는 이 중 어떠한 것도 할 수 없음
코드 블록 (반복) 보단 함수 객체(람다 or 메서드 참조 등)의 경우가 나은 경우
- 원소들의 시퀀스를 일관되게 변환함
- 원소들의 시퀀스를 필터링함
- 원소들의 시퀀스를 하나의 연산을 사용해 결합 (더하기, 연결하기, 최솟값 구하기 등)
- 원소들의 시퀀스를 컬렉션에 모음 (아마도 공통된 속성을 기준으로 묶어가면서)
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾음
스트림으로 처리하기 어려운 경우
EX)
- 한 데이터가 파이프라인의 여러 단계 (stage)를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우임
- 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문
- 원래 값과 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하는 우회 방법이 존재하지만 그리 만족스러운 해결은 아님
- 매핑 객체가 필요한 단계가 여러 곳이라면 특히 더 그럼
- 이런 방식은 코드 양도 많고 지저분하여 스트림을 쓰는 주목적에서 완전히 벗어남
- 가능한 경우라면 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하는 방법이 나을 것
코드 예시
- 처음 20개의 메르센 소수(Mersenne prime)를 출력하는 코드
- 메르센 수 : 2^p - 1 형태의 수
- 메르센 소수 : 여기서 p가 소수이면 해당 메르센 수도 소수일 수 있는데 이때의 수를 메르센 소수
- 파이프라인의 첫 스트림으로는 모든 소수를 사용할 것
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
- 위 코드는 (무한) 스트림을 반환하는 메서드
- BigInteger의 정적 멤버들은 정적 임포트하여 사용한다고 가정
- Stream.iterate라는 정적 팩터리는 매개변수 2개를 받음
- 첫 번째 매개변수는 스트림의 첫 번째 원소
- 두 번째 매개변수는 스트림에서 다음 원소를 생성해주는 함수
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)를 출력하길 원한다고 가정
- 이 값은 초기 스트림에만 나타나므로 결과를 출력하는 종단 연산에서는 접근할 수 없음
- 하지만 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수를 쉽게 계산해낼 수 있음
- 지수는 단순히 숫자를 이진수로 표현한 다음 몇 비트인지를 세면 나옴 따라서 종단 연산을 이 아이디어를 이용해 작성하면 원하는 결과를 얻음
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
}

스트림과 반복 중 어느 것을 써야 할지 알기 어려운 작업
EX) 카드 덱을 초기화 하는 작업
- 카드는 숫자 (rank)와 무늬(suit)를 묶은 불변 값 클래스, 숫자와 무늬는 모두 열거 타입 이라 가정
- 이 작업은 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제 (수학자들은 이를 두 집합의 데카르트 곱이라 함)
for-each 반복문을 중첩해 구현한 코드
private static List<Card> newDeck() {
List<Card> result = new ArrayList<>();
for (Suit suit : Suit.values()) {
for (Rankd 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은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음 그 스트림들을 다시 하나의 스트림으로 합침 -> 평탄화 (flattening)
- 위 코드는 중첩된 람다를 사용
위 두 코드에서 누가 좋은가...
- 위의 두 코드는 결국 개인 취향과 프로그래밍 환경의 문제
- 처음 방법이 더 단순하고 자연스러워 보임
- 하지만 스트림과 함수형 프로그래밍에 익숙한 프로그래머라면 두번째 방식이 조금 더 명확하고 어렵지 않을 것임
정리
- 스트림이 나을수도 반복 방식이 더 나을수도 (상황마다 달라)
- 어찌 됬든 스트림에 대해서는 알아야 하지 않겠는가..
- 스트림과 반복 중 어느 쪽이 나은지 모르겠다면 둘 다 해보고 더 나은 쪽을 택하라
Metadata
Metadata
Assignees
Labels
7장 람다와 스트림이펙티브 자바 7장 (람다와 스트림)이펙티브 자바 7장 (람다와 스트림)