목차
728x90
아이템45. 스트림을 주의해서 사용하라
스트림(stream)이란?
- 데이터 처리 작업(순차 또는 병렬)을 지원하고자 Java8부터 추가되었다.
- 핵심은 두가지 이다.
- 데이터 원소의 유한 혹은 무한 시퀀스를 뜻하는 스트림
- 원소들로 수행하는 연산 단계를 표현하는 스트림 파이프 라임
- 스트림의 원소는 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기 등 어디로부터든 원소들이 올 수 있다.
- 기본 타입으로는 int, long, double을 지원하며, 객체 참조도 원소가 될 수 있다.
- 스트림은 한번만 소비할 수 있다. 즉, 스트림의 연산 후 또 다른 연산을 하려고 하면 에러를 발생시킨다.
IntStream stream = IntStream.of(1, 2);
stream.forEach(x -> System.out::println(x));
// 다시 사용~!!
stream.forEach(x -> System.out::println(x));
java.lang.IllegalStateException: stream has already been operated upon or closed
- 성능 문제
Stream foreach 성능 테스트 (stream을 사용하지 말아야할 때는 언제일까로 시작된 간단한 테스트)
- 그러나... 우리 컴퓨터는 좋다. 따라서 stream에 대한 속도는 크게 신경쓸 필요는 없다고 생각한다.
스트림 파이프라인(Stream Pipeline)
- 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 도 있습니다.
- 각 중간 연산은 스트림을 어떠한 방식으로 변환합니다.
coffees.stream()
.map(Code::fromCoffee)
.collect(collectingAndThen(toList(), Commits::new));
- 예를 들면 위의 코드와 같이 map()을 이용해 변환이 가능하기도 하며 ,filter()를 이용해 원소를 거를수도 있다.
- 스트림 파이프라인의 또 다른 특징은 지연 평가(lazy evaluation)된다.
- 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않고 종단 연산이 호출될때 평가가 된다는 의미이다.
- 즉, 종단 연산이 없으면 아무일도 하지 않는다는 것이다.
fluent API
- 메서드 연쇄를 지원한다.
- 즉, 파이프라인 하나를 구성해 모든 호출을 연결해 처리한다.
- 기본적으로 순차적으로 진행이 된다.
- 병렬로 실행하기 위해서는
parallel()
을 호출하면 된다.
예제
- 사전 하나를 훑어가며 원소 수가 많은 아나그램 그룹들을 출력하는 로직이다ㅑ.
- 여기서 아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 언어를 말한다.
import java.io.File;
import java.io.IOException;
import java.util.*;
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);
}
}
- 위와 같이 stream을 상둉하면 가독성이 높은 코드를 사용할수 있다.
- 그러나 아래와 같이 사용한다고 해보자. (행동을 똑같다 그러나 stream을 과도하게 사용한다.)
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 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);
}
}
}
- 위 두개의 코드를 비교해본 결과 스트림을 과용하면 프로그램이 읽거나 유지보수가하기 어려워진다는걸 느낄 수 있다.
- 개인적으로 객체지향 생활체조에 나오는
1depth를 유지하라
라는 걸 지킨다면 좀 더 좋은 스트림 코드를 만들수 있다고 생각한다. - 좀더 스트림을 깔끔하게 사용해보자.
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 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(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 메소드에서는 스트림을 사용하지 않았다. 그 이유는 char의 스트림은 자바는 지원하지 않고 있기 때문이다. 여기서 알 수 있는 것은 char는 스트림을 삼가는 것이 좋다는 것이다.
리팩토링 입장의 스트림
- 원래 반복문을 사용하고 있는 코드에 대해서는 최대한 스트림으로 리팩토링을 하지만 새로운 스트림 코드가 더 나을때만 진행하는 것이 좋다.
함수 블록에서는 할수 없지만 코드 블록으로 할 수 있는 일
- 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있지만, 람다에서는 final이거나 사실상 final 변수만 가능하며 지역 변수를 수정할 수 없다.
- 코드 블록에서는 return문이나 break, continue로 반복문을 제어할 수 있지만, 람다는 할수가 없다.
스트림이 알맞는 상황
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.
- 원소들의 시퀀스를 컬렉션에 모은다
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
스트림으로 처리하기 어려운 일
- 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시 접근하기 어렵다.
- 이유는 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다.
- 원래 값과 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하는 우회 방법도 있지만, 복잡하기 떄문에 사용하지 않는 경우가 좋다,
스트림 vs 반복문
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import static java.util.stream.Collectors.*;
public class Card {
public enum Suit { SPADE, HEART, DIAMOND, CLUB }
public enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN,
EIGHT, NINE, TEN, JACK, QUEEN, KING }
private final Suit suit;
private final Rank rank;
@Override
public String toString() {
return rank + " of " + suit + "S";
}
public Card(Suit suit, Rank rank) {
this.suit = suit;
this.rank = rank;
}
private static final List<Card> NEW_DECK = newDeck();
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;
}
public static void main(String[] args) {
System.out.println(NEW_DECK);
}
}
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문과 스트림 방식의 큰 차이를 느끼지는 못할 것이다.
- 이럴땐 어떻게 해야 하는가? 사실 취향 차이이기 떄문에 둘 중에 뭘 선택해도 상관은 없다.
결론
- 스트림을 사용하면 깔끔해지지만, 잘못 사용하면 유지보수와 가독성을 잃을 수도 있다.
- 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.
- 기존 코드는 스트림을 사용하도록 리팩토링 하되, 새 코드가 더 나아 보일때만 반영하자
- 스트림 vs 반복문 정답은 없다. 둘 다 해보고 더 나은쪽을 개발자입장에서 결정하면 된다.
728x90
728x90
'Book > 이펙티브 자바 3판' 카테고리의 다른 글
아이템54. null이 아닌, 빈 컬렉션이나 배열을 반환하라 (0) | 2021.03.19 |
---|---|
아이템 53. 가변인수는 신중히 사용하라 (0) | 2021.03.19 |
아이템35. ordinal 메서드 대신 인스턴스 필드를 사용하라 (0) | 2021.03.19 |
아이템14. Comparable을 구현할지 고려하라 (0) | 2021.03.19 |
아이템 7. 다 쓴 객체 참조를 해제하라. (0) | 2021.03.19 |
728x90
아이템45. 스트림을 주의해서 사용하라
스트림(stream)이란?
- 데이터 처리 작업(순차 또는 병렬)을 지원하고자 Java8부터 추가되었다.
- 핵심은 두가지 이다.
- 데이터 원소의 유한 혹은 무한 시퀀스를 뜻하는 스트림
- 원소들로 수행하는 연산 단계를 표현하는 스트림 파이프 라임
- 스트림의 원소는 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기 등 어디로부터든 원소들이 올 수 있다.
- 기본 타입으로는 int, long, double을 지원하며, 객체 참조도 원소가 될 수 있다.
- 스트림은 한번만 소비할 수 있다. 즉, 스트림의 연산 후 또 다른 연산을 하려고 하면 에러를 발생시킨다.
IntStream stream = IntStream.of(1, 2);
stream.forEach(x -> System.out::println(x));
// 다시 사용~!!
stream.forEach(x -> System.out::println(x));
java.lang.IllegalStateException: stream has already been operated upon or closed
- 성능 문제
Stream foreach 성능 테스트 (stream을 사용하지 말아야할 때는 언제일까로 시작된 간단한 테스트)
- 그러나... 우리 컴퓨터는 좋다. 따라서 stream에 대한 속도는 크게 신경쓸 필요는 없다고 생각한다.
스트림 파이프라인(Stream Pipeline)
- 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 도 있습니다.
- 각 중간 연산은 스트림을 어떠한 방식으로 변환합니다.
coffees.stream()
.map(Code::fromCoffee)
.collect(collectingAndThen(toList(), Commits::new));
- 예를 들면 위의 코드와 같이 map()을 이용해 변환이 가능하기도 하며 ,filter()를 이용해 원소를 거를수도 있다.
- 스트림 파이프라인의 또 다른 특징은 지연 평가(lazy evaluation)된다.
- 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않고 종단 연산이 호출될때 평가가 된다는 의미이다.
- 즉, 종단 연산이 없으면 아무일도 하지 않는다는 것이다.
fluent API
- 메서드 연쇄를 지원한다.
- 즉, 파이프라인 하나를 구성해 모든 호출을 연결해 처리한다.
- 기본적으로 순차적으로 진행이 된다.
- 병렬로 실행하기 위해서는
parallel()
을 호출하면 된다.
예제
- 사전 하나를 훑어가며 원소 수가 많은 아나그램 그룹들을 출력하는 로직이다ㅑ.
- 여기서 아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 언어를 말한다.
import java.io.File;
import java.io.IOException;
import java.util.*;
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);
}
}
- 위와 같이 stream을 상둉하면 가독성이 높은 코드를 사용할수 있다.
- 그러나 아래와 같이 사용한다고 해보자. (행동을 똑같다 그러나 stream을 과도하게 사용한다.)
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 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);
}
}
}
- 위 두개의 코드를 비교해본 결과 스트림을 과용하면 프로그램이 읽거나 유지보수가하기 어려워진다는걸 느낄 수 있다.
- 개인적으로 객체지향 생활체조에 나오는
1depth를 유지하라
라는 걸 지킨다면 좀 더 좋은 스트림 코드를 만들수 있다고 생각한다. - 좀더 스트림을 깔끔하게 사용해보자.
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 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(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 메소드에서는 스트림을 사용하지 않았다. 그 이유는 char의 스트림은 자바는 지원하지 않고 있기 때문이다. 여기서 알 수 있는 것은 char는 스트림을 삼가는 것이 좋다는 것이다.
리팩토링 입장의 스트림
- 원래 반복문을 사용하고 있는 코드에 대해서는 최대한 스트림으로 리팩토링을 하지만 새로운 스트림 코드가 더 나을때만 진행하는 것이 좋다.
함수 블록에서는 할수 없지만 코드 블록으로 할 수 있는 일
- 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있지만, 람다에서는 final이거나 사실상 final 변수만 가능하며 지역 변수를 수정할 수 없다.
- 코드 블록에서는 return문이나 break, continue로 반복문을 제어할 수 있지만, 람다는 할수가 없다.
스트림이 알맞는 상황
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.
- 원소들의 시퀀스를 컬렉션에 모은다
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
스트림으로 처리하기 어려운 일
- 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시 접근하기 어렵다.
- 이유는 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다.
- 원래 값과 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하는 우회 방법도 있지만, 복잡하기 떄문에 사용하지 않는 경우가 좋다,
스트림 vs 반복문
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import static java.util.stream.Collectors.*;
public class Card {
public enum Suit { SPADE, HEART, DIAMOND, CLUB }
public enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN,
EIGHT, NINE, TEN, JACK, QUEEN, KING }
private final Suit suit;
private final Rank rank;
@Override
public String toString() {
return rank + " of " + suit + "S";
}
public Card(Suit suit, Rank rank) {
this.suit = suit;
this.rank = rank;
}
private static final List<Card> NEW_DECK = newDeck();
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;
}
public static void main(String[] args) {
System.out.println(NEW_DECK);
}
}
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문과 스트림 방식의 큰 차이를 느끼지는 못할 것이다.
- 이럴땐 어떻게 해야 하는가? 사실 취향 차이이기 떄문에 둘 중에 뭘 선택해도 상관은 없다.
결론
- 스트림을 사용하면 깔끔해지지만, 잘못 사용하면 유지보수와 가독성을 잃을 수도 있다.
- 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.
- 기존 코드는 스트림을 사용하도록 리팩토링 하되, 새 코드가 더 나아 보일때만 반영하자
- 스트림 vs 반복문 정답은 없다. 둘 다 해보고 더 나은쪽을 개발자입장에서 결정하면 된다.
728x90
728x90
'Book > 이펙티브 자바 3판' 카테고리의 다른 글
아이템54. null이 아닌, 빈 컬렉션이나 배열을 반환하라 (0) | 2021.03.19 |
---|---|
아이템 53. 가변인수는 신중히 사용하라 (0) | 2021.03.19 |
아이템35. ordinal 메서드 대신 인스턴스 필드를 사용하라 (0) | 2021.03.19 |
아이템14. Comparable을 구현할지 고려하라 (0) | 2021.03.19 |
아이템 7. 다 쓴 객체 참조를 해제하라. (0) | 2021.03.19 |