티스토리 뷰
스트림 API
자바8부터는 스트림 API가 다량의 데이터 처리작업을 돕고자 추가되었다.
- 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻함
- 스트림 파이프라인은 이 원소들로 수행하는 연산단계를 뜻함
- 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값(int, long, double 지원)
스트림 파이프라인
- 소스 스트림에서 시작하여 종단 연산으로 끝남. 그 사이에 중간 연산이 있을 수 있음
- 중간 연산은 스트림을 어떤 방식으로 변환
- 한 스트림을 다른 스트림으로 변환
- 변환된 스트림의 원소 타입은 변환 전과 같을 수도 있고 다를 수도 있음
- 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 함
- 스트림은 지연 평가(lazy evaluation) 된다.
- 종단연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
- 즉, 종단 연산이 없으면 아무런 일도 하지 않음 → 빼먹지 말자
- 무한 스트림을 다룰 수 있게 해주는 열쇠
fluent API
- 메서드 연쇄를 지원 → 파이프라인 하나를 구성하는 모든 호출을 연결하여 하나의 표현식으로 완성 가능
- 순차적으로 수행
- 병렬 실행을 하려면 parallel 메서드 호출
스트림을 과하게 사용하지 말기
// 과하게 사용한 스트림 - 따라하지 말 것
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 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);
}
람다에서는 타입 이름을 자주 생략하기때문에 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
또, 위 코드에서는 알파벳순으로 정렬하는 일을 alphabetize 메서드로 로직 밖으로 빼주었다.
이렇게 도우미 메서드를 적절히 활용하는 일은 가독성을 높여줘 스트림 파이프라인에서 중요하다.
그리고 alphabetize에서 사용하지 않은 것처럼 char 값들을 처리할때는 스트림을 삼가는 편이 낫다.
리팩토링
기존 코드는 스트림을 사용하도록 리팩토링하되, 새 코드가 더 나아보일 때만 반영하자.
1) 함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일
스트림 파이프라인에서는 되풀이되는 계산을 함수 객체로 표현(람다나 메서드 참조)
반면 반복 코드에서는 코드 블록을 사용해 표현한다.
함수 객체로는 할 수 없지만 코드블록으로는 할 수 있는 일들이 있다.
- 코드 블록에서는 범위 안의 지역 변수를 읽고 수정 가능. but 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있으며 지역변수 수정은 불가능
왜 람다에서는 final이거나 effectively final 변수만 읽을 수 있을까?
외부에 있는 지역변수(=자유 변수)는 람다 캡쳐링(capturing lambda) 에 의해 복사본을 만들어 접근하도록 합니다. 그러나 이 변수는 다른 스레드에서 참조할 수 있고, 복사된 참조 값을 변경하는 코드는 람다 실행 시점에 따라 복사된 참조값이 어떤 값인지 예측할 수 없기 때문에 final 또는 effectively final 로 정한 것입니다. 즉, final로 처리되지 않으면 자유 변수 참조 값의 동기(sync)를 맞출 수가 없습니다.
→ 자바의 ‘스레드 한정(Thread Comfinement) 원칙'을 위배하지 않기 위해서 final 또는 effectively final로 정한 것입니다.
- 코드 블록에서는 return문이나 break, continue 문으로 반복문을 제어할 수 있지만 람다는 할 수 없다.
- 코드 블록에서는 메서드 선언에 명시된 검사 예외를 던질 수 있지만 람다는 할 수 없다.
2) 스트림과 어울리는 상황
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링
- 원소들의 시퀀스를 하나의 연산을 사용해 결합(더하기, 연결하기, 최솟값 구하기 등)
- 원소들의 시퀀스를 하나의 컬렉션에 모은다.
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소 찾기
3) 스트림으로 처리하기 어려운 일
한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하는 것.
한 값을 다른 값에 매핑하고 나면 원래 값을 잃는 구조여서.
스트림 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());
}
위 두 방식 중 개인 취향과 프로그래밍의 환경에 맞게 원하는 방식을 사용하자.
결론
스트림을 사용하든 반복을 사용하든 더 알맞은 일을 선택해 사용하면 된다.
무엇이 나은지 확연히 드러날때도 있지만, 확신하기 어렵다면 둘다 해보고 더 나은쪽을 선택해라.
'Java > Effective Java' 카테고리의 다른 글
[Effective Java] 47. 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) | 2022.04.01 |
---|---|
[Effective Java] 46. 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2022.04.01 |
[Effective Java] 44.표준 함수형 인터페이스를 사용하라 (0) | 2022.04.01 |
[Effective Java] 43.람다보다는 메서드 참조를 사용하라 (0) | 2022.04.01 |
[Effective Java] 42.익명 클래스보다는 람다를 사용하라 (0) | 2022.03.31 |