[이펙티브 자바] 06. 람다와 스트림
아이템 42. 익명 클래스보다는 람다를 사용하라
- 예전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스(드물게는 추상 클래스)를 사용했다.
- 이런 인터페이스를 함수 객체(function object)라고 하여, 특정 함수나 동작을 나타내는 데 썼다.
익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법이다!
Collections.sort(words, new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
- 전략 패턴처럼, 함수 객체를 사용하는 과거 객체 지향 디자인 패턴에는 익명 클래스면 충분했다.
- 하지만 익명 클래스 방식은 코드가 너무 길기 때문에 자바는 함수형 프로그래밍에 적합하지 않았다.
- 자바 8에 와서 추상 메서드 하나짜리 인터페이스는 특별한 의미를 인정받았고, 지금은 함수형 인터페이스라 부르는 이 인터페이스들의 인스턴스를 람다식(lambda expression)을 사용해 만들 수 있게 되었다.
- 람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다.
람다식을 함수 객체로 사용 - 익명 클래스 대체
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));
- 여기서 람다, 매개변수(s1, s2), 반환값의 타입은 각각 (Comparator<String>), String, int지만 코드에서는 언급이 없다. 이는 컴파일러가 문맥을 살펴 타입을 추론해준 것이다.
- 상황에 따라 컴파일러가 타입을 결정하지 못할 수도 있는데, 그럴 때는 프로그래머가 직접 명시해야 한다.
- 타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하도록 한다.
- 컴파일러는 타입을 추론하는 데 필요한 타입 정보 대부분을 제네릭에서 얻는다.
- 따라서 우리가 이 정보를 제공하지 않으면 컴파일러는 람다의 타입을 추론할 수 없게 되어, 결국 일일이 타입 정보를 명시해야 한다.
람다 자리에 비교자 생성 메서드를 사용하면 더 간결하다!
Collections.sort(words, comparingInt(String::length));
함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입
public enum Operation {
PLUS("+", (x, y) -> x + y),
MINUS("-", (x, y) -> x - y),
TIMES("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator operator;
Operation(String symbol, DoubleBinaryOperator operator) {
this.symbol = symbol;
this.operator = operator;
}
@Override
public String toString() {
return symbol;
}
public double apply(double x, double y) {
return operator.applyAsDouble(x, y);
}
}
- 람다를 이용하면 열거 타입의 인스턴스 필드를 이용하는 방식으로 상수별로 다르게 동작하는 코드를 쉽게 구현할 수 있다.
- 단순히 각 열거 타입 상수의 동작을 람다로 구현해 생성자에 넘기고, 생성자는 이 람다를 인스턴스 필드로 저장해둔다.
- 그런 다음 apply 메서드에서 필드에 저장된 람다를 호출하기만 하면 된다.
람다를 사용할 때의 고려할 점
1. 메서드나 클래스와 달리, 람다는 이름이 없고 문서화도 못 한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.
- 람다는 한 줄일 때 가장 좋고 길어야 세 줄안에 끝내는 게 좋다. 세 줄을 넘어가면 가독성이 심하게 나빠진다.
- 만약 람다가 길거나 읽기 어렵다면 더 간단히 줄여보거나 람다를 쓰지 않는 쪽으로 리팩터링 하도록 한다.
2. 열거 타입 생성자에 넘겨지는 인수들의 타입도 컴파일타임에 추론된다. 따라서 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다(인스턴스는 런타임에 만들어지기 때문이다).
- 상수별 동작을 단 몇 줄로 구현하기 어렵거나, 인스턴스 필드나 메서드를 사용해야만 하는 상황이라면 상수별 클래스 몸체를 사용해야 한다.
3. 람다는 함수형 인터페이스에서만 쓰인다.
- 추상 클래스의 인스턴스를 만들 때 람다를 쓸 수 없으니, 익명 클래스를 써야 한다.
- 추상 메서드가 여러 개인 인터페이스의 인스턴스를 만들 때도 익명 클래스를 쓸 수 있다.
4.람다는 자신을 참조할 수 없다.
- 람다에서의 this 키워드는 바깥 인스턴스를 가리킨다.
- 반면 익명 클래스에서의 this는 익명 클래스의 인스턴스 자신을 가리킨다.
- 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 써야 한다.
5. 람다도 익명 클래스처럼 직렬화 형태가 구현별로(가령 가상머신별로) 다를 수 있기 때문에, 람다를 직렬화하는 일은 극히 삼가야 한다(익명 클래스의 인스턴스로 마찬가지다).
- 만약 직렬화해야만 하는 함수 객체가 있다면(가령 Comparator처럼) private 정적 중첩 클래스의 인스턴스를 사용하도록 한다.
아이템 43. 람다보다는 메서드 참조를 사용하라
- 람다가 익명 클래스보다 나은 점 중에서 가장 큰 특징은 간결함이다.
- 자바에는 함수 객체를 람다보다도 더 간결하게 만드는 방법은 메서드 참조(method reference)다.
메서드 참조
- 자바 8이 되면서 Interger 클래스와 모든 기본 타입의 박싱 타입은 람다와 기능이 같은 정적 메서드들을 제공하기 시작했다.
- 람다 대신 이 메서드의 참조를 전달하면 똑같은 결과를 더 보기 좋게 얻을 수 있다.
- 람다로 할 수 없는 일이라면 메서드 참조로도 할 수 없다.
- 그래도 메서드 참조를 사용하는 편이 보통은 더 짧고 간결하므로, 람다로 구현했을 때 너무 길거나 복잡하다면 메서드 참조가 좋은 대안이 될 수 있다.
- 람다로 작성할 코드를 새로운 메서드에 담은 다음, 람다 대신 그 메서드 참조를 사용하는 식이다.
- 메서드 참조에는 기능을 잘 드러내는 이름을 지어줄 수 있고 설명을 문서로 남길 수도 있다.
메서드와 람다가 같은 클래스에 있을 때는 람다가 메서드 참조보다 간결하다.
service.execute(GoshThisClassNameIsHumongous::action);
위 코드를 메서드로 대체하면 아래와 같다.
service.execute(() -> action());
메서드 참조 쪽은 더 짧지도, 더 명확하지도 않다. 따라서 람다 쪽이 낫다.
메서드 참조의 유형
- 메서드 참조의 유형은 다섯 가지이다.
- 정적 메서드를 가리키는 메서드 참조하는 유형
- 수신 객체(receiving object; 참조 대상 인스턴스)를 특정하는 한정적(bound) 인스턴스 메서드 참조.
- 한정적 참조는 근본적으로 정적 참조와 비슷하다. 함수 객체가 받는 인수와 참조되는 메서드가 받는 인수가 똑같다.
- 수신 객체를 특정하지 않는 비한정적(unbound) 인스턴스 메서드 참조.
- 비한정적 참조에서는 함수 객체를 적용하는 시점에 수신 객체를 알려준다.
- 이를 위해 수신 객체 전달용 매개변수가 매개변수 목록의 첫 번째로 추가되며, 그 뒤로는 참조되는 메서드 선언에 정의된 매개변수들이 뒤따른다.
- 비한정적 참조는 주로 스트림 파이프라인에서의 매핑과 필터 함수에 쓰인다.
- 클래스 생성자를 가리키는 메서드 참조. 생성자 참조는 팩터리 객체로 사용된다.
- 배열 생성자를 가리키는 메서드 참조.
메서드 참조 유형 | 예 | 같은 기능을 하는 람다 |
정적 | Integer::parseInt | str -> Integer.parseInt(str) |
한정적(인스턴스) | Instant.now()::isAfter | Instant then Instant.now(); t -> then.isAfter(t) |
비한정적(인스턴스) | String::toLowerCase | str -> str.toLowerCase() |
클래스 생성자 | TreeMap<K,V>::new | () -> new TreeMap<K,V>() |
배열 생성자 | int[]::new | len -> new int[len] |
아이템 44. 표준 함수형 인터페이스를 사용하라
- 자바가 람다를 지원하면서 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴의 매력이 크게 줄었다.
- 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 것으로 대체할 수 있기 때문이다.
- 이때, 함수형 매개변수 타입을 올바르게 선택해야 한다.
불필요한 함수형 인터페이스 - 대신 표준 함수형 인터페이스를 사용하라.
@FunctionalInterface
public interface EldestEntryRemovalFunction<K, V> {
boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
}
- 위 인터페이스는 잘 동작하지만, 굳이 사용할 이유는 없다. 자바 표준 라이브러리에 이미 같은 모양의 인터페이스가 준비되어 있기 때문이다.
- java.util.function 패키지를 보면 다양한 용도의 표준 함수형 인터페이스가 담겨 있다.
- 필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하도록 한다.
- 표준 함수형 인터페이스들은 유용한 디폴트 메서드를 많이 제공하므로 다른 코드와의 상호운용성도 좋아질 것이다.
표준 함수형 인터페이스
인터페이스 | 함수 시그니처 | 예 |
UnaryOperator<T> | T apply(T t) | String::toLowerCase |
Binaryoperator<T> | T apply(T t1, T t2) | BigInteger::add |
Predicate<T> | boolean test(T t) | Collection::isEmpty |
Function<T,R> | R apply(T t) | Arrays::asList |
Supplier<T> | T get() | Instant::now |
Consumer<T> | void accept(T t) | System.out::println |
- Operator 인터페이스는 인수가 1개인 UnaryOperator와 2개인 BinaryOperator로 나뉘며, 반환값과 인수의 타입이 같은 함수를 뜻한다.
- Predicate 인터페이스는 인수 하나를 받아 boolean을 반환하는 함수를 뜻한다.
- Function 인터페이스는 인수와 반환 타입이 다른 함수를 뜻한다.
- Supplier 인터페이스는 인수를 받지 않고 값을 반환(혹은 제공)하는 함수를 뜻한다.
- Consumer 인터페이스는 인수를 하나 받고 반환값은 없는, 인수를 소비하는 함수를 뜻한다.
표준 함수형 인터페이스 사용시 주의사항
- 표준 함수형 인터페이스 대부분은 기본 타입만 지원한다. 그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 않도록 한다.
- 계산량이 많을 때는 성능이 처참히 느려질 수 있다.
- 서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안 된다. 클라이언트에게 불필요한 모호함만 줄 뿐이며, 이 때문에 실제로 문제가 일어나기도 한다.
- 표준 인터페이스 중 필요한 용도에 맞는 게 없다면 직접 작성해야 하며, 구조적으로 똑같은 표준 함수형 인터페이스가 있더라도 직접 작성해야만 할 때가 있다.
언제 전용 함수형 인터페이스를 구현해야 할까?
아래 세 특성중 한가지 이상을 만족한다면 전용 함수형 인터페이스 구현에 대해 진지하게 고민해보자.
- 자주 쓰이며, 이름 자체가 용도를 명확히 설명해 준다.
- 반드시 따라야 하는 규약이 있다.
- 유용한 디폴트 메서드를 제공할 수 있다.
만약 전용 함수형 인터페이스를 작성하기로 했다면, '인터페이스'임을 명심해야 한다. 아주 주의해서 설계해야 한다.
@FunctionalInterface 애너테이션
이 애너테이션을 사용하는 이뉴는 @Override를 사용하는 이유와 비슷하다. 프로그래머의 의도를 명시하는 것으로, 크게 세 가지 목적이 있다.
- 첫 번째. 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려준다.
- 두 번째. 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해준다.
- 세 번째. 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.
따라서, 직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하도록 한다.
아이템 45. 스트림은 주의해서 사용하라
- 스트림 API는 다량의 데이터 처리 작업(순차적이든 병렬적이든)을 위해 자바 8에 추가되었다.
- 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스(sequence)를 뜻한다.
- 스트림 파이프라인(stream pipeline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
- 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다.
- 기본 타입 값으로는 int, long, double 이렇게 세 가지를 지원한다.
스트림 파이프라인
- 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산(terminal operation)으로 끝난다.
- 스트림의 시작과 종단 연산의 사이에 하나 이상의 중간 연산(intermediate operation)이 있을 수 있다.
- 각 중간 연산은 스트림을 어떠한 방식으로 변환(transform)한다.
- 스트림 파이프라인은 지연 평가(lazy evaluation)된다.
- 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
- 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으므로, 종단 연산은 필수다.
- 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있으며, 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.
- 기본적으로 스트림 파이프라인은 순차적으로 수행된다.
- 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 된다. 다만, 효과를 볼 수 있는 상황은 많지 않다.
사전 하나를 훑어 원소 수가 많은 아나그램 그룹들을 출력한다 - 스트림 사용 x
public class Anagrams {
public static void main(String[] args) {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner scanner = new Scanner(dictionary)) {
while (scanner.hasNext()) {
String word = scanner.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 word) {
char[] a = word.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
스트림을 과하게 사용한 코드
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);
}
}
}
- 짧지만 읽기가 어렵다.
- 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.
스트림을 적절히 활용하면 깔끔하고 명료해진다
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(g -> System.out.println(g.size() + ": " + g));
} catch (IOException e) {
e.printStackTrace();
}
}
private static String alphabetize(String word) {
char[] a = word.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
- 람다 매개변수의 이름은 주의해서 정해야 한다.
- 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
- 도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다 스트림 파이프라인에서 훨씬 크다. 파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문이다.
- 자바는 기본 타입인 char용 스트림을 지원하지 않는다. 따라서 char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.
- 스트림이 언제나 가독성과 유지보수 측면으로 뛰어난 것은 아니다.
- 스트림과 반복문을 적절히 조합하는 게 최선이다.
- 따라서 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하도록 한다.
함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일
- 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다.
- 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다.
- 코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다.
- 또한, 메서드 선언에 명시된 검사 예외를 던질 수 있다.
- 람다로는 이 중 어떤 것도 할 수 없다.
계산 로직에서 이상의 일들을 수행해야 한다면 스트림과는 맞지 않는 것이다.
스트림이 안성맞춤인 일
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다(더하기, 연결하기, 최솟값 구하기 등).
- 원소들의 시퀀스를 컬렉션에 모은다(아마도 공통된 속성을 기준으로 묶어가며).
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
스트림으로 처리하기 어려운 일
- 한 데이터가 파이프라인의 여러 단계(stage)를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하는 것은 처리하기 어렵다.
- 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다.
아이템 46. 스트림에서는 부작용 없는 함수를 사용하라
- 스트림은 함수형 프로그래밍에 기초한 패러다임이다.
스트림 패러다임
- 스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다.
- 이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
- 순수 함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다.
- 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
- 이렇게 하려면 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다.
스트림 패러다임을 이해하지 못한 채 API만 사용했다 - 따라하지 말 것!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner("file").tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum)
});
}
- 스트림 코드를 가장한 반복적 코드다.
- 위 코드의 모든 작업은 종단 연산인 forEach에서 일어나는데, 이때 외부 상태(빈도표)를 수정하는 람다를 실행하면서 문제가 생긴다.
스트림을 제대로 활용해 빈도표를 초기화한다
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner("file").tokens()) {
words.collect(groupingBy(String::toLowerCase, counting()));
}
- for-each 반복문은 forEach 종단 연산과 비슷하게 생겼다.
- 하지만 forEach 연산은 종단 연산 중 기능이 가장 적고 대놓고 반복적이라서 병렬화할 수도 없다.
- forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말도록 한다.
수집기(Collector)
- java.util.stream.Collectors 클래스는 스트림의 원소들을 객체 하나에 취합하는 여러 메서드를 제공해준다.
- 이를 이용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있으며, toList(), toSet(), toCollection(collectionFactory) 등이 있다.
- Collectors의 멤버를 정적 임포트하여 쓰면 스트림 파이프라인의 가독성을 향상시킬 수 있다.
아이템 47. 반환 타입으로는 스트림보다 컬렉션이 낫다
- 자바 7까지는 일련의 원소들을 반환하는 메서드의 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스, 혹은 Iterable이나 배열을 썼다.
- 기본은 컬렉션 인터페이스이며, for-each 문에서만 쓰이거나 반환된 원소 시퀀스가 일부 Collection 메서드를 구현할 수 없을 때(주로 contains(Object) 같은)는 Iterable 인터페이스를 썼다.
- 반환 원소들이 기본 타입이거나 성능에 민감한 상황이라면 배열을 썼다.
반복
- 스트림은 반복(iteration)을 지원하지 않는다. 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다.
- Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함하며, Iterable 인터페이스가 정의한 방식대로 동작한다.
- 다만, for-each로 반복할 수 없는 이유는 Stream이 Iterable을 extends하지 않았기 때문이다.
자바 타입 추론의 한계로 컴파일되지 않는다
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// 프로세스를 처리한다.
스트림을 반복하기 위한 '끔찍한' 우회 방법
for (ProcessHandle ph : (Iterable<ProcessHandle>)
ProcessHandle.allProcesses()::iterator) {
// 프로세스를 처리한다.
}
작동은 하지만 난잡하고 직관성이 떨어진다.
Stream<E>를 Iterable<E>로 중개해주는 어댑터
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
// 프로세스를 처리한다.
}
- 어댑터 메서드를 사용하면 자바의 타입 추론이 문맥을 잘 파악하여 어댑터 메서드 안에서 따로 형변환하지 않아도 된다.
- 어댑터를 사용하면 어떤 스트림도 for-each 문으로 반복할 수 있다.
Iterable<E>를 Stream<E>로 중개해주는 어댑터
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
- 만약 API가 Iterable만 반환하면 스트림 파이프라인에서 이를 처리할 수 없게 된다. 이 경우, 위와 같이 어댑터를 구현해서 사용할 수 있다.
- 만약 이 메서드가 오직 스트림 파이프라인에서만 쓰일 걸 안다면 마음 놓고 스트림을 반환해도 된다.
- 반대로 반환된 객체들이 반복문에서만 쓰일 걸 안다면 Iterable을 반환하도록 한다.
- 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두를 고려해야 한다.
- Collection 인터페이스는 Iterable의 하위 타입이고 stream 메서드도 제공하기 때문에 반복과 스트림을 동시에 지원한다.
- 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.
- 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는 게 최선일 수도 있다. 하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.
아이템 48. 스트림 병렬화는 주의해서 적용하라
동시성 프로그래밍을 할 때는 안전성(safety)과 응답 가능(liveness) 상태를 유지하도록 해야 한다. 이는 병렬 스트림 파이프라인 프로그래밍에서도 마찬가지다.
스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램
public static void main(String[] args) {
primes().map(prime -> TWO.pow(prime.intValue()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
- 위 코드의 속도를 높이고 싶어서 parallel을 호출하면 프로그램은 병렬화하는 방법을 찾지 못해 마비된다.
- 환경이 아무리 좋더라도 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.
- 파이프라인 병렬화는 limit를 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다.
- 스트림 파이프라인을 마구잡이로 병렬화하면 안 된다. 성능이 오히려 나빠질 수도 있다.
- 대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다.
- 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손 쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기 좋다.
- 이 자료구조들은 원소들을 순차적으로 실행할 때의 참조 지역성(locality of reference)이 뛰어나다. 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻이다.
- 하지만 참조들이 가리키는 실제 객체가 메모리에서 서로 떨어져 있을 수 있는데, 그러면 참조 지역성이 떨어진다.
참조 지역성
- 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 멍하게 있는다.
- 따라서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다.
- 참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다. 기본 타입 배열에서는 참조가 아닌 데이터 자체가 메모리에 연속해서 저장되기 때문이다.
스트림 파이프라인의 종단 연산
- 스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다.
- 종단 연산에서 수행하는 작업량이 파이프라인 전체 작업에서 상당 비중을 차지하면서 순차적인 연산이라면 파이프라인 병렬 수행의 효과는 제한될 수밖에 업다.
- 종단 연산 중에서 병렬화에 가장 적합한 것은 축소(reduction)다. 축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업이다.
- Stream의 reduce 메서드 중 하나, 혹은 min, max, count, sum 같이 완성된 형태로 제공되는 메서드 중 하나를 선택해 수행한다.
- anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합하다.
- 가변 축소(mutable reduction)를 수행하는 Stream의 collect 메서드는 컬렉션들을 합치는 부담이 크기 때문에 병렬화에 적합하지 않다.
성능 저하 및 안전 실패
- 스트림을 잘못 병렬화하면 응답 불가 상태에 빠지거나 성능이 나빠질 수 있고, 결과 자체가 잘못 되거나 예상치 못한 동작이 발생할 수 있다.
- 결과가 잘못되거나 오동작하는 것은 안전 실패(safety failure)라고 한다.
- 안전 실패는 병렬화한 파이프라인이 사용하는 mappers, filters, 혹은 프로그래머가 제공한 다른 함수 객체가 명세대로 동작하지 않을 때 벌어질 수 있다.
- Stream 명세는 이때 사용되는 함수 객체에 관해 엄격한 규약을 정의해놨다.
- ex. Stream의 reduce 연산에 건네지는 accumulator(누적기)와 combiner(결합기) 함수는 반드시 결합법칙을 만족(associative)하고, 간섭받지 않고(non-interfering), 상태를 갖지 않아야(stateless) 한다.
- 데이터 소스 스트림이 효율적으로 나눠지고, 병렬화하거나 빨리 끝나는 종단 연산을 사용하고, 함수 객체들도 간섭하지 않더라도, 파이프라인이 수행하는 진짜 작업이 병렬화에 드는 추가 비용을 상쇄하지 못한다면 성능 향상은 미미할 수 있다.
병렬화 한 forEach의 순서는 순서가 올바르지 않을 수 있어, 순차 버전처럼 정렬하고 싶다면 종단 연산 forEach를 forEachOrdered로 바꿔주면 된다.
최적화
- 스트림 병렬화는 오직 성능 최적화의 수단일 뿐이다. 다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.
- 보통은 병렬 스트림 파이프라인도 공통의 포크 조인 풀에서 수행되므로(같은 스레드 풀을 사용하므로), 잘못된 파이프라인 하나가 시스템의 다른 부분의 성능에까지 악영향을 줄 수 있다.
- 스트림 병렬화가 효과를 보는 경우는 많지 않으나, 조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 얻을 수 있다. -> 머신러닝과 같은 데이터 처리
'스터디 > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] 08. 일반적인 프로그래밍 원칙 (0) | 2022.09.27 |
---|---|
[이펙티브 자바] 07. 메서드 (1) | 2022.09.21 |
[이펙티브 자바] 05. 열거 타입과 애너테이션 (0) | 2022.09.01 |
[이펙티브 자바] 04. 제네릭 (1) | 2022.08.24 |
[이펙티브 자바] 03. 클래스와 인터페이스 (0) | 2022.08.20 |
댓글