[Effective Java] Chapter 7. 람다와 스트림
Item 42. 익명 클래스보다는 람다를 사용하라
왜 람다 표현식을 권장할까?
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class AnonymousClassExample {
public static void main(String[] args) {
List<String> words = new ArrayList<>();
words.add("Banana");
words.add("Apple");
words.add("Cherry");
// 익명 클래스 사용
Collections.sort(words, new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return s1.compareTo(s2);
}
}
// 람다
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));
}
}
- 불필요한 클래스 선언 및 구문을 줄여주어 코드가 간결해진다.
- 무엇을 하고자 하는지 의도를 명확하게 전달한다.
특히 함수형 인터페이스를 구현할 때, 익명 클래스는 오히려 코드 가독성을 떨어뜨릴 수 있다.
( 익명클래스에서의 @Override 어노테이션은 선택 사항이지만, 사용하면 메서드가 부모 클래스나 인터페이스의 메서드를 정확히 재정의하고 있음을 명확하게 표시하는 장점이 있다 )
추가 설명) 익명 클래스란 무엇인가?
특정 클래스의 서브 클래스나 인터페이스의 구현체를 일회성으로 정의하여 사용하는 클래스
이름이 없기 때문에 정의와 동시에 인스턴스를 생성하며, 새로운 클래스를 정의하는 것보다 간편하게 구현할 수 있다
타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하자
람다 표현식에서 매개변수 타입을 명시하지 않아도 컴파일러가 타입을 추론할 수 있기 때문이다. 타입을 생략하면 코드가 더 간결해지고 ,읽기 쉬워진다.
람다는 한 줄일 때 가장 좋고 길어야 세 줄 안에 끝내는 게 좋다
람다 표현식은 간단한 동작을 짧고 명확하게 표현할 때 가장 효과적이고 여러 줄로 길어지면 코드가 복잡해지고, 가독성이 떨어진다.
추상 클래스의 인스턴스를 만들 때 람다를 쓸 수 없으니, 익명 클래스를 써야한다.
람다 표현식은 함수형 인터페이스(메서드가 하나만 있는 인터페이스)를 구현할 때에만 사용할 수 있다. 추상 클래스는 여러 개의 메서드를 가질 수 있어, Java에서는 람다 표현식으로 추상 클래스의 인스턴스를 생성하는 것을 허용하지 않는다.
람다는 자신을 참조할 수 없다.
람다는 내부적으로 익명 클래스처럼 인스턴스를 생성하는 방식이 아니고, 함수형 인터페이스를 구현하는 방법으로 사용되기 때문에 자기 자신을 참조하는 this를 사용할 수 없다.
람다에서의 this 키워드는 바깥 인스턴스를 가리킨다. 그래서 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 사용해야 한다.
익명 클래스에서는 this가 익명 클래스 자체를 가리킨다.
Item 43. 람다보다는 메서드 참조를 사용하라
람다 표현식이 단순히 기존 메서드를 호출하는 경우 메서드 참조가 더 간결하고 가독성이 높다.
메서드 참조는 코드를 읽는 사람이 쉽게 이해할 수 있도록 의도를 명확하게 전달한다.
메서드 참조 유형 | 예 | 같은 기능을 하는 람다 |
정적 | 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) |
메서드 참조 쪽이 짧고 명확하다면 메서드 참조를 쓰고, 그렇지 않을 때만 람다를 사용하자.
Item 44. 표준 함수형 인터페이스를 사용하라
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 |
표준 함수형 인터페이스 대부분은 기본 타입(int, long, double)만 지원하기 때문에 박싱된 기본 타입(Integer, Long, Double)을 사용하면 성능 저하가 발생할 수 있다.
직접 함수형 인터페이스를 만들 때는 항상 @FunctionalInterface 어노테이션을 달아주자
함수형 인터페이스는 하나의 추상 메서드만 포함하는 인터페이스로, 위 어노테이션을 달면 컴파일러가 검증해준다
Item 45. 스트림은 주의해서 사용하라
char 값들을 처리할 때는 스트림 사용을 피하는 것이 좋다
Java의 스트림 API는 char 타입에 대한 직접적인 스트림을 제공하지 않기 때문에, char 값들을 스트림으로 처리하려면 불필요한 변환 과정이 필요해 성능 저하와 코드 복잡성을 유발할 수 있다.
스트림을 무조건 적용하기 보다는, 코드가 더 간결하고 읽기 쉽게 개선되는 경우에만 적용하자
람다에서는 final 이거나 사실상 final (한 번 초기화된 이후 값이 변경되지 않는 변수를 의미) 인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다
왜 final 또는 사실상 final 변수만 사용할까?
람다 표현식이나 익명 클래스에서는 외부 변수를 참조할 때, 해당 변수가 변경되지 않음을 보장해야 한다. 스트림 연산은 병렬 처리될 수 있기 때문에 람다 표현식에서 외부 변수를 읽어올 때 그 값이 안정적으로 유지되어야 한다. 그리고 스트림 파이프라인에서는 중간 연산이 지연 평가되므로, 코드가 실행될 때 해당 변수의 값이 예측할 수 없이 변할 수 있어 final 과 사실상 final 인 변수로 제한함으로써 변수 값의 일관성을 유지해야 한다.
사실 스트림 내에서 외부 지역 변수를 수정하는 것도 가능은 하지만 권장되지 않는다. 특히, 병렬 스트림에서는 여러 스레드가 동시에 외부 변수에 접근하고 수정할 수 있어 예상치 못한 결과가 발생할 수 있기 때문이다. (스레드 간 충돌 발생) 또한 외부 상태를 변경하는 것은 함수형 프로그래밍 원칙에 위배된다.
외부 변수를 수정해야 하는 경우에는, 스트림 연산 자체를 통해 결과를 반환하도록 만드는 것이 좋다.
int sum = numbers.stream().reduce(0, Iteger::sum);
return 사용 불가
스트림 내부에서는 중간 연산에서 명시적으로 return을 사용해 메서드를 종료할 수 없고, 대신 filter, map, findAny 등 필터링이나 종료 조건을 나타내는 스트림 연산을 활용해야 한다.
break, continue 사용 불가
스트림은 명령형 루프에서처럼 break 와 continue 를 직접 사용할 수 없다. 이를 대신해 filter, limit 등으로 필터링 하거나 스트림을 제한하는 방식으로 처리한다.
Item 46. 스트림에서는 부작용 없는 함수를 사용하라
스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.
각 변환 단계는 오직 입력만이 결과에 영향을 주는 순수 함수여야 한다.
여기서 말하는 부작용이 없는 함수는 입력값이 같으면 항상 같은 결과를 반환하는 함수라고 보면 된다.
forEach는 대놓고 반복적이라서 스트림다운 연산이 아니고 병렬 처리와 잘 맞지 않는다
최종 연산으로 결과를 보고하거나 출력할 때 적합하고, 연산이나 변환에는 사용을 자제하자 !
연산이나 계산이 필요한 경우에는 collect나 reduce같은 최종 연산을 사용에 한 번에 처리하는 게 더 스트림다운 방식이다
수집기(Collector)를 잘 활용해보자
수집기는 스트림의 데이터를 특정 방식으로 모으거나 집계해서 최종 결과를 생성하는 도구다. (Java.util.stream.Collectors)
1. Collectors.toList(): 스트림의 요소를 List로 수집
2. Collectors.toSet(): 스트림의 요소를 Set으로 수집
3. Collectors.toMap(): 키와 값으로 이루어진 Map으로 수집
4. Collectors.joining(): 문자열 스트림을 연결하여 하나의 문자열로 변환
5. Collectors.groupingBy(): 특정 기준으로 그룹화하여 Map으로 변환
6. Collectors.partitioningBy(): 조건에 따라 데이터를 true 와 false 로 나누어 Map 으로 반환
7. Collectors.summingInt() 등: 숫자를 합계, 평균, 카운팅 등의 연산 결과로 반환
Item 47. 반환 타입으로는 스트림보다 컬렉션이 낫다
Stream 인터페이스가 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함하고, Iterable 인터페이스가 정의한 방식대로 동작한다.
Iterable 인터페이스에는 iterator 라는 딱 하나의 추상 메서드가 있는데, Stream 인터페이스가 상속받는 BaseStream 에 iterator 메서드가 있다.
Iterable 인터페이스가 정의한 방식이라는 것은 컬렉션 요소를 순차적으로 접근할 수 있는 표준적인 방법을 의미한다. (Java 컬렉션을 처리하는 기본적인 접근 방식) 컬렉션의 요소를 하나씩 순회(iterate)할 수 있도록 도입된 인터페이스고, 이는 Java의 for-each 문법과 호환되도록 설계된 아주 중요한 인터페이스다.
스트림도 iterator() 메서드를 제공하니 순회는 할 수 있지만, Iterable 인터페이스를 extends 하지 않아서 for-each로 스트림을 반복할 수는 없다.
그러니 Stream으로 반환을 하면 for-each로 반복하고 싶을 때 번거롭고, Iterable로만 반환을 하면 스트림 파이프라인에서 처리할 때 번거롭다.
Collection 인터페이스는 Iterable의 하위 타입이고 stream 메서드도 제공하니 반복과 스트림을 동시에 지원하기 때문에 Collection이나 그 하위 타입을 반환 타입으로 하는게 좋다.
주의할 점은, 컬렉션을 반환한다고 덩치가 큰 시퀀스를 메모리에 올리지 말라는 점이다.
만약 수백만 개의 데이터를 리스트로 만들어서 반환한다면, 모든 데이터를 메모리에 한꺼번에 저장을 해야한다. 메모리 사용량도 커지고 성능도 떨어질 수 있으니 그 대신, 필요한 만큼만 순서대로 처리를 하는게 더 효율적이다.
public List<String> getAllLines(String filePath) throws IOException {
return Files.readAllLines(Path.of(filePath)); // 파일의 모든 줄을 한 번에 메모리에 올림
}
public Stream<String> getLinesStream(String filePath) throws IOException {
return Files.lines(Path.of(filePath)); // 파일의 줄을 하나씩 스트림으로 반환
}
무조건 컬렉션으로 반환할 것이 아니라 Stream, Iterable 중 더 자연스러운 것으로 반환해도 된다.
Item 48. 스트림 병렬화는 주의해서 적용하라
병렬 스트림(parallelStream)은 데이터를 여러 스레드에서 동시에 처리하기 때문에 성능을 높일 수 있지만, 모든 경우에 적합하지 않고 주의할 점이 많다.
왜 데이터 소스가 Stream.iterate거나 중간 연산으로 limit을 쓰면 파이프라인 병렬화로는 성능 개선이 어려울까?
Stream.iterate 는 무한 스트림을 생성한다. 시작값과 함수만으로 끝없이 값을 생성하기 때문에, 스트림을 처음부터 끝까지 하나씩 순차적으로 계산해야한다. 병렬 처리를 하려면 데이터를 빠르게 나누어 각 스레드가 독립적으로 작업해야 하는데, Stream.iterate는 이런 분할이 어렵다.
limit은 스트림의 요소 개수를 고정된 개수로 제한하는 중간 연산이다. 병렬 스트림에서는 각 스레드가 데이터를 일정한 기준으로 분할해 작업하는데, limit은 이 과정에서 모든 스레드가 자신이 어디까지 데이터를 가져가야할지 계산해야 한다. 모든 요소를 확인하며 제한된 요소까지만 가져가는 연산을 해야해서 오히려 순차적인 계산이 되어버리거나 불필요한 계산이 추가 된다.
ArrayList, HashMap, HashSet 이 병렬 스트림에서 효과적인 이유
내부 구조상 병렬 스트림에서 효율적이다. 병렬 스트림이 데이터를 여러 스레드에 나누어 처리할 때, 데이터 분할이 쉬운 자료구조가 유리한데, ArrayList 와 HashMap, HashSet 은 데이터 분할이 간단하기 때문이다.
ArrayList: 배열 기반이므로 연속적인 인덱스를 쉽게 나눌 수 있어 병렬 처리 시 효율적
HashMap, HashSet: 해시 기반으로 저장된 데이터를 병렬로 나누어 처리하기에 효율적
ConcurrentHashMap, ConcurrentLinkedQueue 가 병렬 처리가 최적화된 이유
동시성을 고려한 자료구조는 여러 스레드가 동시에 데이터를 안전하게 수정하거나 읽을 수 있도록 설계되어 있다.
int, long 범위일 때 병렬화 효과가 좋은 이유
IntStream.range() 와 LongStream().range() 는 연속된 숫자의 범위를 생성한다. 어디서 시작하고 끝날지 명확하기 때문에, 데이터를 여러 스레드에 균등하게 나누는 것이 쉽고 병렬화 처리 과정에서 모든 요소를 예측 가능하게 처리할 수 있다.
IntStream 과 LongStream 은 숫자의 생성과 순회가 가볍고 간단해서 IntStream.range(), LongStream.range() 는 단순 숫자 나열 방식이라 데이터를 나누는 데 있어 추가 비용이 거의 발생하지 않는다.
종단 연산 중 병렬화에 가장 적합한 것은 축소(reduce) 연산이다.
축소 연산은 스트림의 모든 요소를 하나의 값으로 집계하거나 결합하는 작업을 수행하는데, 이 과정이 여러 스레드로 나눠 병렬 처리하기에 특히 효율적이기 때문이다.
축소 연산은 스트림의 요소들을 하나의 값으로 축약하는 작업이고, 대표적인 축소 연산으로는 reduce, collect 가 있다.
reduce, collect(Collectors.toList(), Collectors.toSet()등과 함께 사용), sum, min, max, count
가변 축소(mutable reduction)는 일반적인 축소(reduction)와는 다른 방식으로 수행된다. 가변 축소는 최종 결과를 만드는 과정에서 가변 상태(즉, 변경 가능한 객체)를 사용하는 연산이다.
병렬 스트림에서 가변 객체를 수집하는 과정에서 비효율이 발생할수 있는데, 특히 병렬 스트림에서의 collect 연산은 결합 비용이 크기 때문에, 병렬화의 성능 이점을 살리지 못할 가능성이 크다.
collect 메서드는 일반적으로 하나의 변경 가능한 객체(컬렉션 등)에 데이터를 계속 추가하는 방식으로 동작한다. 병렬 스트림에서는 각 스레드가 별도의 청크를 처리하게 되는데, 이 경우 각 스레드의 부분 결과를 하나로 합치는 과정(결합)이 필요해 부하가 발생할 수 있다.
또한 여러 스레드가 동시에 접근할 경우 스레드 안전성 문제가 발생할 수 있다.