BOOK 6 - 이펙티브 자바(7)
7장 람다와 스트림
익명 클래스보다는 람다를 사용하라
- 예전에 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스를 사용했다.
- 이런 인터페이스의 인스턴스를 함수 객체라고 하고, 특정 함수나 동작을 나타내는데 썼다.
- JDK 1.1부터 함수 객체를 만드는 주요 수단은 익명 클래스가 되었다.
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
Comparator인터페이스가 정렬을 담당하는 추상 전략이다.- 문자열을 정렬하는 구체적인 전략을 익명 클래스로 구현했다.
람다식 (lambda expression)
- 자바 8부터 추상 메서드 하나짜리 인터페이스를 함수형 인터페이스라고 부르고, 이 인터페이스의 인스턴스를 람다식을 이용해 만들 수 있게 되었다.
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
- 여기서 람다, 매개변수
(s1, s2), 반환값의 타입을 직접 명시하지 않았다.- 컴파일러가 문맥을 살펴 타입을 추론해준 것이다.
- 컴파일러가 상황에 따라 타입을 결정하지 못할때는 프로그래머가 직접 명시해야 한다.
- 따라서 타입을 명시해야 코드가 더 명확할 때를 제외하고, 람다의 모든 매개변수 타입은 생략하는 것이 좋다.
비교자 생성 메서드 사용
- 람다 자리에 비교자 생성 메서드를 사용하면 코드를 더 간결하게 만들 수 있다.
Collections.sort(words, comparingInt(String::length));
Operation 예제
- 이전 챕터
Operation예제에서apply메서드 동작이 상수마다 달라질 때 상수별 클래스 몸체를 사용해 각 상수에서apply를 재정의하도록 했다.- 상수별 클래스 몸체를 구현하는 방식보다 열거 타입에 인스턴스 필드를 두는 편이 더 낫다고 했다.
- 람다를 이용하면 열거 타입의 인스턴스 필드를 이용하는 방식으로 쉽게 구현할 수 있다.
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 op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
@Override
public String toString() {
return symbol;
}
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
DoubleBinaryOperator는java.util.function이 제공하는 다양한 함수 인터페이스 중 하나이다.
람다보다 상수별 클래스 몸체를 사용해야하는 경우
- 람다는 이름이 없고 문서화도 하지 못한다.
- 코드 자체로 동작이 명확히 설명되지 않거나 코드가 길어지면 람다를 쓰지 말아야 한다.
- 열거 타입 생성자에 넘겨지는 인수들의 타입은 컴파일타임에 추론된다.
- 따라서 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다.
- 인스턴스는 런타임에 만들어지기 때문이다.
- 상수별 동작을 세 줄 이내로 구현하기 어렵거나, 인스턴스 필드나 메서드를 사용해야 하는 상황이라면 상수별 클래스 몸체를 사용해야 한다.
람다의 한계
- 람다는 함수형 인터페이스에서만 쓰이기 때문에, 추상 클래스의 인스턴스를 만들 때는 사용할 수 없다.
- 추상 메서드가 여러 개인 인터페이스의 인스턴스를 만들 때도 익명 클래스를 사용해야 한다.
- 람다는 자신을 참조할 수 없다.
- 람다에서의
this키워드는 바깥 인스턴스를 가리킨다. - 반면 익명 클래스에서
this는 익명 클래스의 인스턴스 자신을 가리킨다. - 함수 객체가 자신을 참조해야 한다면 익명 클래스를 사용해야 한다.
- 람다에서의
람다보다는 메서드 참조를 사용하라
-
메서드 참조는 함수 객체를 람다보다도 간결하게 만들 수 있는 방법이다.
-
다음 코드는 임의의 키와 Integer 값의 매핑을 관리하는 프로그램의 일부다.
map.merge(key, 1, (count, incr) -> count + incr);
-
merge메서드는 키, 값, 함수를 인수로 받아 주어진 키가 맵 안에 아직 없다면 (키, 값) 쌍을 그대로 저장하고, 키가 이미 있다면 함수를 현재 값과 주어진 값에 적용한 다음 현재 값에 덮어 쓴다. - 위 코드에서 매개변수
count,incr는 크게 하는 일 없이 공간을 꽤 차지한다. - 람다 대신
Integer클래스의 정적 메서드sum메서드 참조를 전달하면 똑같은 결과를 더 보기 좋게 얻을 수 있다.
map.merge(key, 1, Integer::sum);
메서드 참조의 장점
- 람다로 구현했을 때 너무 길거나 복잡하다면 메서드 참조가 좋은 대안이 되어준다.
- 즉, 람다로 작성할 코드를 새로운 메서드에 담은 다음, 람다 대신 그 메서드 참조를 사용하는 것이다.
- 메서드 참조에는 기능을 잘 드러내는 이름을 지어줄 수 있고, 설명을 문서화할 수도 있다.
메서드 참조보다 람다를 사용하는 것이 나은 경우
- 때로는 람다가 메서드 참조보다 간결할 때가 있다.
- 메서드와 람다가 같은 클래스 안에 있을 때 그렇다.
service.execute(GoshThisClassNameIsHumongous::action);
- 만약 해당 코드가
GoshThisClassNameIsHumongous클래스 안에 있다면 다음과 같이 람다를 사용하는 것이 더 간단할 수 있다.
service.execute(() -> action());
java.util.function패키지가 제공하는 정적 팩터리 메서드Function.identity()를 사용할 때도 똑같은 기능의 람다(x -> x)를 사용하는 것이 코드가 짧고 명확하다.
메서드 참조 유형
- 메서드 참조의 유형은 다섯가지이다.
| 메서드 참조 유형 | 예 | 같은 기능을 하는 람다 |
|---|---|---|
| 정적 | 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] |
- 한정적 인스턴스 메서드 참조 : 수신 객체(참조 대상 인스턴스)를 특정한다.
- 근본적으로 정적 참조와 비슷하다.
- 즉, 함수 객체가 받는 인수와 참조되는 메서드가 받는 인수가 똑같다.
- 비한정적 인스턴스 메서드 참조 : 수신 객체를 특정하지 않는다.
- 함수 객체를 적용하는 시점에 수신 객체를 알려준다.
- 수신 객체 전달용 매개변수가 매개변수 목록의 첫번째로 추가되며, 그 뒤로는 참조되는 메서드 선언에 정의된 매개변수들이 뒤따른다.
- 주로 스트림 파이프라인에서의 매핑과 필터 함수에 쓰인다.
- 참고 : https://countryxide.tistory.com/127
표준 함수형 인터페이스를 사용하라
- 자바가 람다를 지원하면서 API를 작성하는 모범 사례도 크게 바뀌었다.
- 예전에는 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴을 사용했다.
- 이제는 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공한다.
LinkedHashMap 예제
LinkedHashMap클래스의 메서드removeEldestEntry를 재정의하면 캐시로 사용할 수 있다.- 다음처럼 재정의하면 맵에 원소가 100개가 될 때까지 커지다가 그 이상이 되면 새로운 키가 더해질 때마다 가장 오래된 원소를 하나씩 제거한다.
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}
람다 사용
- 람다를 사용하도록 함수 객체를 받는 정적 팩터리나 생성자를 제공할 수 있다.
removeEldestEntry는size()를 호출해 맵 안의 원소 수를 알아내는데, 이는removeEldestEntry가 인스턴스 메서드라서 가능한 방식이다.- 생성자에 넘기는 함수 객체는 이 맵의 인스턴스 메서드가 아니다.
- 팩터리나 생성자를 호출할 때는 맵의 인스턴스가 존재하지 않기 때문이다.
- 따라서 맵 자기 자신도 함수 객체에 건네줘야 한다.
- 이를 반영하여 함수형 인터페이스를 다음과 같이 선언할 수 있다.
@FunctionalInterface
interface EldestEntryRemovalFunction<K,V> {
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
- 이 인터페이스도 잘 동작하긴 하지만, 굳이 사용할 이유는 없다.
- 자바 표준 라이브러리에 이미 같은 모양의 인터페이스가 준비되어 있기 때문이다.
BiPredicate<Map<K,V>, Map.Entry<K,V>>를 대신 사용할 수 있다.
- 필요한 용도에 맞는게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하는 것이 좋다.
표준 함수형 인터페이스
java.util.function패키지에 다양한 용도의 표준 함수형 인터페이스가 담겨있다. (총 43개)- 표준 함수형 인터페이스들은 유용한 디폴트 메서드를 많이 제공하므로 다른 코드와의 상호운용성도 크게 좋아진다.
기본 인터페이스
- 기본 인터페이스 6개만 기억하면 나머지를 충분히 유추해낼 수 있다.
| 인터페이스 | 함수 시그니처 | 예 |
|---|---|---|
| UnaryOperator |
T apply(T t) | String::toLowerCase |
| BinaryOperator |
T apply(T t1, T t2) | BigInteger::add |
| Predicate |
boolean test(T t) | Collection::isEmpty |
| Function<T, R> | R apply(T t) | Arrays::asList |
| Supplier |
T get() | Instant::now |
| Consumer |
void accept(T t) | System.out::println |
Operator: 반환값과 인수의 타입이 같은 함수UnaryOperator: 인수가 1개BinaryOperator: 인수가 2개
Predicate: 인수를 받아boolean을 반환하는 함수Function: 인수와 반환 타입이 다른 함수Supplier: 인수를 받지 않고 값을 반환하는 함수Consumer: 인수를 받고 반환값은 없는 함수 (인수를 소비하는 함수)
기본 인터페이스 변형
- 기본 인터페이스는 기본 타입인
int,long,double용으로 각 3개씩 변형이 있다.IntPredicate,LongBinaryOperator등등Function의 경우 반환타입이 매개변수화 됐다.LongFunction<int[]>:long인수를 받아int[]를 반환한다.
Function 인터페이스 변형
- 기본 타입을 반환하는 변형이 총 9개 더 있다.
- 입력과 결과 타입이 모두 기본 타입이면 접두어로
srcToResult를 사용한다.LongToIntFunction:long인수를 받아int를 반환한다.
- 입력을 매개변수화 하고 접두어로
ToResult를 사용한다.ToLongFunction<int[]>:int[]인수를 받아long을 반환한다.
인수를 2개씩 받는 변형
BiPredicate<T,U>BiFunction<T,U,R>ToIntBiFunction<T,U>,ToLongBiFunction<T,U>,ToDoubleBiFunction<T,U>
-
BiConsumer<T,U> ObjDoubleConsumer<T>,ObjIntConsumer<T>,ObjLongConsumer<T>: 객체 참조 하나, 기본 타입 하나 총 인수 2개를 받는다.
Supplier 인터페이스 변형
BooleanSupplier:boolean을 반환한다.
주의사항
- 표준 함수형 인터페이스 대부분은 기본 타입만 지원한다.
- 그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자.
- 동작은 하지만 계산량이 많을 때는 성능이 느려질 수 있다.
전용 함수형 인터페이스
- 대부분 상황에서는 직접 작성하는 것보다 표준 함수형 인터페이스를 사용하는 것이 낫다.
- 물론 표준 인터페이스 중 필요한 용도에 맞는 게 없다면 직접 작성해야 한다.
- 하지만 구조적으로 똑같은 표준 함수형 인터페이스가 있더라도 직접 작성해야 할 때가 있다.
Comparator 인터페이스
Comparator<T>인터페이스는 구조적으로ToIntBiFuction<T,U>와 동일하다.- 그러나
Comparator가 독자적인 인터페이스로 살아남아야 하는 이유가 몇 개 있다.
- API에서 굉장히 자주 사용되고, 지금의 이름이 그 용도를 아주 잘 설명해준다.
- 구현하는 쪽에서 반드시 지켜야할 규약을 담고 있다.
- 비교자들을 변환하고 조합해주는 유용한 디폴트 메서드들을 담고 있다.
전용 함수형 인터페이스를 작성해야 하는 조건 세가지
- 자주 쓰이며, 이름 자체가 용도를 명확히 설명해준다.
- 반드시 따라야 하는 규약이 있다.
- 유용한 디폴트 메서드를 제공할 수 있다.
위 세가지 중 하나 이상을 만족한다면, 전용 함수형 인터페이스를 구현해야 하는건 아닌지 고민해봐야 한다.
@FunctionalInterface
- 함수형 인터페이스에 이 애너테이션을 사용하는 이유는
@Override를 사용하는 이유와 비슷하다.- 프로그래머의 의도를 명시하는 것이다.
- 해당 인터페이스가 람다용으로 설계된 것임을 알려준다.
- 해당 인터페이스가 오직 하나의 추상 메서드만 가지고 있어야 컴파일되게 해준다.
- 유지보수 과정에서 누군가 새로운 메서드를 추가하지 못하게 막아준다.
- 따라서 직접 만든 함수형 인터페이스에는 항상
@FunctionalInterface애너테이션을 사용하는 것이 좋다.
스트림은 주의해서 사용하라
- 스트림 API는 다량의 데이터 처리 작업을 돕는다.
스트림 API의 핵심 추상 개념 두가지
- Stream : 데이터 원소의 유한 혹은 무한 시퀀스
- Stream pipeline : 원소들로 수행하는 연산 단계
- 스트림의 원소들은 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기, 다른 스트림 등 다양한 곳에서 올 수 있다.
- 원소들은 객체 참조나 기본 타입(
int,long,double) 값이다.
스트림 파이프 라인
- 스트림 파이프라인은 소스 스트림에서 시작해 terminal operation으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다.
- 각 중간 연산은 스트림을 어떤 방식으로 변환한다.
- 변한된 스트림의 원소 타입은 이전과 같을 수도 있고 다를 수도 있다.
- 기본적으로 스트림 파이프라인은 순차적으로 수행된다.
- 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서
parallel메서드를 호출해주면 된다.- 그러나 효과를 볼 수 있는 상황은 많지 않다.
lazy evaluation
- 스트림 파이프라인은 지연 평가된다.
- 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 평가에 쓰이지 않는다.
- 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠이다.
메서드 연쇄
- 스트림 API는 메서드 연쇄를 지원하는 fluent API이다.
- 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 하나의 표현식으로 완성할 수 있다.
스트림을 써야하는 경우 - anagram 예제
- 사전 파일에서 단어를 읽어 지정한 limit 값보다 원소 수가 많은 아나그램 그룹을 출력하는 예제를 보자.
- 아나그램은 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다.
public class Anagrams {
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);
}
}
- 사전 파일에서 각 단어를 읽어 맵에 저장한다.
- 맵의 키는 그 단어를 구성하는 철자들을 알파벳 순으로 정렬한 값이다.
- 맵의 값은 같은 키를 공유한 단어들을 담은 집합이다.
computeIfAbsent- 맵 안에 키가 있는지 찾고, 있으면 그 키에 매핑된 값을 반환하고,
- 없으면 건네진 함수 객체를 키에 적용하여 값을 계산한 다음 키와 값을 매핑해놓고, 계산된 값을 반환한다.
스트림을 사용한 코드1 - 스트림을 과하게 활용
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);
}
}
}
- 이 코드는 짧지만 이해하기 어렵다.
- 이처럼 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.
스트림을 사용한 코드2 - 스트림을 적절히 활용
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));
}
}
}
try-with-resources블록에서 사전 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻는다.- 스트림 파이프라인의 종단 연산으로 모든 단어를 수집해 맵으로 모은다.
- 이 맵은 단어들을 아나그램끼리 묶어놓은 것으로, 앞에서 생성한 맵과 실질적으로 같다.
- 맵의
values()가 반환한 값으로부터 새로운Stream<List<String>>스트림을 연다. -
minGroupSize보다 원소 개수가 적은 것을 필터링하고, 종단 연산인forEach로 살아남은 리스트를 출력한다. char용 스트림을 지원하지 않기 때문에alphabetize메서드의 동작을 스트림을 사용해 구현하기 보다는 위처럼 구현하는 것이 간단하고 명확하다.- 즉, char 값들을 처리할 때는 스트림 사용을 삼가는 편이 낫다.
스트림 VS 반복 코드
- 스트림 파이프라인은 되풀이되는 계산을 함수 객체(주로 람다나 메서드 참조)로 표현한다.
- 반면 반복 코드에서는 코드 블록을 사용해 표현한다.
함수 객체로는 할 수 없고 코드 블록으로는 할 수 있는 일
- 범위 안의 지역변수를 읽고 수정할 수 있다.
- 람다에서는
final이거나 사실상final인 변수만 읽을 수 있고, 지역변수를 수정하는 것은 불가능하다.
- 람다에서는
return문을 사용해 메서드에서 빠져나가거나,break나continue로 블록 바깥의 반복문을 종료하거나 한번 건너뛸 수 있다.- 메서드 선언에 명시된 checked exception을 던질 수 있다.
스트림을 사용하기 좋은 수행 조건
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.
- 더하기, 연결하기, 최솟값 구하기 등
- 원소들의 시퀀스를 컬렉션에 모은다.
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
스트림으로 처리하기 어려운 경우
- 하나의 예로, 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기 어려운 경우가 있다.
- 스트림 파이프라인은 한 값을 다른 값에 매핑하고 나면, 원래의 값은 잃는 구조이기 때문이다.
메르센 소수 예제
- 처음 20개의 메르센 소수를 출력하는 예제를 보자.
- 메르센 수는 2^p-1 형태의 수이고, p가 소수일 때 해당 메르센 수도 소수일 수 있는데, 이 때의 수를 메르센 소수라고 한다.
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
- 2부터 시작하는 소수를 생성하는 무한 스트림을 반환하는 메서드이다.
Stream.iterate라는 정적 팩터리 메서드는 매개변수 2개를 받는다.- 첫번째 매개변수: 스트림의 첫번째 원소
- 두번째 매개변수: 스트림에서 다음 원소를 생성해주는 함수
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).substract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
-
소수들을 사용해 메르센 수를 계산하고, 결과값이 소수인 경우만 필터링한 다음 원소 수를 20개로 제한하고 결과를 출력한다.
- 이제 메르센 소수의 앞에 지수(p)를 출력하길 원한다고 해보자.
- 이 값은 초기 스트림에만 나타나므로, 결과를 출력하는 종단 연산에서는 접근할 수 없다.
- 중간 연산에서 수행한 매핑을 거꾸로 수행해서 다시 계산해내야 한다.
- 이 예제에서는 비교적 단순히 값을 다시 계산해낼 수 있긴 하다.
- 지수는 숫자를 이진수로 표현한 다음 몇 비트인지를 세면 나오기 때문이다.
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
카드덱 초기화(데카르트 곱) 예제
- 카드는 숫자(rank)와 무늬(suit)를 묶은 불변 값 클래스이고, 숫자와 무늬는 열거 타입이다.
- 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 예제를 보자.
반복 방식 구현
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());
}
flatMap: 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음, 그 스트림들을 다시 하나의 스트림으로 합친다.
스트림에서는 부작용 없는 함수를 사용하라
- 스트림은 그저 하나의 API가 아닌 함수형 프로그래밍에 기초한 패러다임이다.
- 스트림이 제공하는 표현력, 속도, 병렬성을 얻으려면 이 패러다임까지 함께 받아들여야 한다.
순수 함수
- 스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.
- 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
- 순수 함수란, 오직 입력만이 결과에 영향을 주는 함수를 뜻한다.
- 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
- 스트림 연산에 건네는 함수 객체는 모두 부작용이 없어야 한다.
단어 빈도표 예제 - 스트림 API 올바르게 사용하기
스트림 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에서 일어난다. forEach는 스트림 계산 결과를 보고할 때만 사용하고, 계산하는데는 쓰지 않는 것이 좋다.
스트림 API를 제대로 활용한 코드
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words.collect(groupingBy(String::toLowerCase, counting()));
}
- 앞의 코드와 같은 동작을 하지만, 스트림 API를 제대로 사용했다.
- collector를 사용해 코드가 짧고 명확하다.
수집기 (collector)
- 수집기를 사용하면 스트림의 원소를 쉽게 컬렉션으로 모을 수 있다.
java.util.stream.Collectors클래스는 다양한 수집 메서드를 제공한다.- 축소(reduction) 전략을 캡슐화한 객체라고 생각하면 쉽다.
- 여기서 축소는 스트림의 원소들을 객체 하나에 취합한다는 의미이다.
- 수집기가 생성하는 객체는 일반적으로 컬렉션이다.
toList
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
- 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인이다.
sorted에Comparator를 넘겨 정렬하도록 했다.comparing메서드 : 키 추출 함수를 받는 비교자 생성 메서드 (Comparator.comparing)- 참고 : https://www.baeldung.com/java-8-comparator-comparing
- 한정적 메서드 참조로
freq::get키 추출 함수를 넘겼다.
toMap
- 가장 간단한 맵 수집기는
toMap(keyMapper, valueMapper)형태이다.- 이 형태는 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다.
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(toMap(Object::toString, e -> e));
- 열거 타입 상수의 문자열 표현을 열거 타입 자체에 매핑하는
fromString을 구현하는데 사용하는 맵이다.
병합 함수
- 스트림 원소 다수가 같은 키를 사용하는 경우, 더 복잡한 형태의
toMap이나groupingBy로 이런 충돌을 다룰 수 있다. - 예를 들어,
toMap에 키 매퍼, 값 매퍼와 함께 병합 함수를 제공할 수 있다. - 병합 함수의 형태는
BinaryOperator<U>이고, 여기서U는 해당 맵의 값 타입이다. - 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다.
maxBy
- 다양한 음악가의 앨범들을 담은 스트림을 가지고 음악가와 그 음악가의 베스트 앨범을 연관짓는 예제를 보자.
Map<Artist, Album> topHits = albums.collect(
toMap(Album::artist, a -> a, maxBy(comparing(Album::sales))));
maxBy:Comparator<T>를 입력받아BinaryOperator<T>를 반환하는 정적 팩터리 메서드comparing이 키 추출 함수로Album::sales를 받아maxBy에 넘겨줄 비교자를 반환한다.
마지막 값 취하는 수집기
toMap은 충돌이 나면 마지막 값을 취하는 수집기를 만들 때도 유용하다.
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
맵 팩터리
toMap은 네번째 인수로 맵 팩터리를 받을 수 있다.- 이 인수로
EnumMap이나TreeMap같이 원하는 특정 맵 구현체를 직접 지정할 수 있다.
groupingBy
- 입력으로 분류 함수(classifier)를 받고, 출력으로 원소들을 카테고리별로 모아놓은 맵을 담은 수집기를 반환한다.
- 분류 함수 : 입력받은 원소가 속하는 카테고리를 반환한다.
- 카테고리는 해당 원소의 맵 키로 쓰인다.
- 가장 간단한 형태의
groupingBy는 분류 함수 하나를 인수로 받아 맵을 반환한다.- 반환된 맵에 담긴 각각의 값은 해당 카테고리에 속하는 원소들을 모두 담은 리스트이다.
words.collect(groupingBy(word -> alphabetize(word)))
다운스트림 수집기
groupingBy가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생성하도록 하려면 다운스트림 수집기를 명시해야 한다.- 즉 다운스트림 수집기의 역할은 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일이다.
toSet()/toCollection(collectionFactory): 집합이나 컬렉션을 값으로 갖는 맵을 생성한다.
counting()
- 각 카테고리에 속하는 원소의 개수를 값으로 갖는 맵을 생성한다.
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));
맵 팩터리
groupingBy의 인수로 맵 팩터리를 넘겨 맵과 그 안에 담긴 컬렉션 타입을 정할 수 있다.
partitioningBy
Predicate를 인수로 받아 키가Boolean인 맵을 반환한다.- 다운스트림 수집기까지 입력 받는 버전도 다중정의 되어있다.
joining
- 이 메서드는
CharSequence인스턴스의 스트림에만 적용할 수 있다. - 매개변수가 없는
joining은 단순히 원소들을 연결하는 수집기를 반환한다. - delimeter를 인수로 받는
joining은 연결 부위에 구분문자를 삽입하여 연결한다. - 인수 3개짜리
joining은 구분문자와 prefix, suffix를 받아 문자열을 생성한다.
반환 타입으로는 스트림보다 컬렉션이 낫다.
- 스트림은 반복(iteration)을 지원하지 않는다.
- 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다.
- 사실 Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함할 뿐만 아니라 Iterable 인터페이스가 정의한 방식대로 동작한다.
- 그럼에도
for-each로 스트림을 반복할 수 없는 이유는Stream이Iterable을 확장하지 않아서이다.
- 그럼에도
스트림을 반복하기위한 우회 방법
Stream.iterator 메서드 활용
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
...
}
-
Stream의iterator메서드 참조를 건네는 방법이다. -
위 코드는 아래와 같은 컴파일 오류가 발생한다.
error: method reference not expected here -
오류를 고치려면 메서드 참조를 매개변수화된
Iterable로 적절히 변환해주어야 한다.
for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator) {
...
}
- 이 코드는 동작은 하지만 너무 복잡하고 직관성이 떨어진다.
어댑터 메서드 활용
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
Stream의iterator메서드를 활용하는 것은 동일하다.- 자바의 타입 추론이 문맥을 파악하기 때문에 안에서 따로 형변환해줄 필요가 없다.
for (ProcessHandle ph : iterableOf(ProcessHandle.allProcesses())) {
...
}
Iterable을 Stream으로 중개해주는 어댑터
- API가 Iterable만 반환한다면 이를 스트림 파이프라인에서 처리하려는 사용자는 Stream으로 변환해야 한다.
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
공개 API에서 컬렉션을 반환하는 이유
객체 시퀀스를 반환하는 메서드 작성 시에,
- 이 메서드가 오직 스트림 파이프라인에서만 쓰인다면 스트림을 반환하도록 한다.
-
반대로 반환된 객체들이 반복문에서만 쓰인다면 Iterable을 반환하도록 한다.
- 하지만, 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문을 사용하는 사람 모두를 고려해야 한다.
Collection인터페이스는Iterable의 하위 타입이고,stream메서드도 제공한다.- 즉, 반복과 스트림을 동시에 지원한다.
- 따라서, 원소 시퀀스를 반환하는 공개 API에서는
Collection이나 그 하위 타입을 반환하는게 일반적으로 최선이다.
전용 컬렉션 구현
- 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 표준 컬렉션 구현체를 반환하는게 일반적이다.
- 그러나 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안된다.
- 반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현할 수 있다.
멱집합 예제
-
주어진 집합의 멱집합을 반환하는 예제를 보자.
-
원소 개수가 n개면 멱집합의 원소 개수는 2^n개가 된다.
-
멱집합을 표준 컬렉션 구현체에 저장하는 것은 위험하다.
-
AbstractList를 활용하여 전용 컬렉션을 구현할 수 있다.
-
public class PowerSet {
public static final <E> Collection<Set<E>> of(Set<E> s) {
List<E> src = new ArrayList<>(s);
if(src.size() > 30) {
throw new IllegalArgumentException("집합에 원소가 너무 많습니다(최대 30개). : " + s);
}
return new AbstractList<Set<E>>() {
@Override
public int size() {
return 1 << src.size();
}
@Override
public boolean contains(Object o) {
return o instanceof Set && src.containsAll((Set)o);
}
@Override
public Set<E> get(int index) {
Set<E> result = new HashSet<>();
for (int i = 0; index != 0; i++, index >>= 1) {
if ((index & 1) == 1) {
result.add(src.get(i));
}
}
return result;
}
};
}
}
- 멱집합을 구성하는 각 원소의 인덱스를 비트 벡터로 사용했다.
- 인덱스의 n번째 비트 값은 멱집합의 해당 원소가 원래 집합의 n번째 원소를 포함하는지 여부를 알려준다.
AbstractCollection을 활용해서Collection구현체를 구현할 때는Iterable용 메서드 외에 2개만 더 구현하면 된다.contains와size이다.- 이 두개 메서드를 구현하기 어려울 때는 컬렉션보다는 스트림이나 Iterable을 반환하는 것이 낫다.
스트림으로 반환하는 경우
- 때로는 단순히 구현하기 쉬운 쪽을 선택하기도 한다.
- 예를 들어 입력 리스트의 부분 리스트를 모두 반환하는 메서드를 만든다고 하자.
- 부분리스트를 만들어 표준 컬렉션에 담는 코드는 매우 간단하지만, 이 컬렉션은 입력 리스트 크기의 거듭제곱만큼 메모리를 차지한다.
- 멱집합 예제처럼 전용 컬렉션을 구현하기도 애매하다.
- 하지만 입력 리스트의 모든 부분 리스트를 스트림으로 구현하기는 어렵지 않다.
부분 리스트 예제 코드
- 첫 번째 원소를 포함하는 부분 리스트를 그 리스트의 prefix라고 하자.
- 마지막 원소를 포함하는 부분 리스트를 그 리스트의 suffix라고 하자.
- 어떤 리스트의 부분 리스트는 그 리스트의 프리픽스의 서픽스 + 빈 리스트 이다.
Stream.concat 활용
public class SubLists {
public static <E> Stream<List<E>> of(List<E> list) {
return Stream.concat(Stream.of(Collcetions.emptyList()),
prefixes(list).flatMap(SubLists::suffixes));
}
private static <E> Stream<List<E>> prefixes(List<E> list) {
return IntStream.rangeClosed(1, list.size())
.mapToObj(end -> list.subList(0, end));
}
private static <E> stream<List<E>> suffixes(List<E> list) {
return Intstream.range(0, list.size())
.mapToObj(start -> list.subList(start, list.size()));
}
}
flatMap메서드로 모든 프리픽스의 모든 서픽스로 구성된 하나의 스트림을 만든다.- 그리고
Stream.concat메서드로 빈 리스트와 합쳐주었다.
반복문 형태의 스트림 변환
for반복문을 중첩해 서브리스트를 만드는 코드는 아래와 같을 것이다.
for (int start = 0; start < src.size(); start++) {
for (int end = start + 1; end <= src.size(); end++) {
System.out.println(src.subList(start, end));
}
}
- 이 코드를 그대로 스트림으로 변환할 수 있다.
public static <E> Stream<List<E>> of(List<E> list) {
return IntStream.range(0, list.size())
.mapToObj(start -> IntStream.rangeClosed(start + 1, list.size())
.mapToObj(end -> list.subList(start, end)))
.flatMap(x -> x);
}
- 이 코드는 위 반복문처럼 빈 리스트는 포함하지 않는다.
- 앞의
concat을 활용한 코드보다 간결하지만 읽기에는 더 좋지 않다.
정리
- 원소 시퀀스를 반환하는 메서드를 작성할 때는 스트림과 반복 모두를 고려하는 것이 좋다.
- 컬렉션을 반환할 수 있다면 그렇게 하자.
- 반환 전부터 이미 원소들을 컬렉션에 담아 관리하고 있거나, 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면
ArrayList같은 표준 컬렉션에 담아 반환한다. - 그렇지 않으면 전용 컬렉션을 구현하는 것도 좋다.
- 컬렉션을 반환하는게 불가능하다면 스트림과
Iterable중에 더 자연스러운 것을 반환한다.
스트림 병렬화는 주의해서 적용하라
- 동시성 프로그래밍을 할 때는 안전성(safety)과 응답 가능(liveness) 상태를 유지하기 위해 애써야 하는데, 병렬 스트림 파이프라인 프로그래밍에서도 마찬가지이다.
- 참고 : https://ko.wikipedia.org/wiki/%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%8B%88%EC%8A%A4
- 라이브니스 : 동시성 컴퓨팅에서, 동시에 실행되는 프로세스가 임계 영역을 교대로 사용하면서 교착 상태나 기아 상태로부터 자유로우면서 시스템이 정상적으로 동작하도록하는 속성
병렬화하기 좋은 스트림 소스
- 스트림의 소스가
ArrayList,HashMap,HashSet,ConcurrentHashMap의 인스턴스거나 배열,int범위,long범위일 때 병렬화의 효과가 가장 좋다.
공통점
- 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있다.
- 일을 다수의 스레드에 분배하기 좋다.
- 나누는 작업은
Spliterator가 담당하며, 이 객체는Stream이나Iterable의spliterator메서드로 얻을 수 있다.
-
원소들을 순차적으로 실행할 때 참조 지역성이 뛰어나다.
- 참조 지역성 : 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다.
-
참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 시간을 허비하게 된다.
- 참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다.
병렬화하기 좋은 종단 연산
- 종단 연산의 작업량이 전체 작업에서 상당한 비중을 차지하면서 순차적인 연산이라면 병렬화의 효과는 제한된다.
- 종단 연산 중 병렬화에 가장 적합한 것은 축소이다.
Stream의reduce메서드 중 하나,min,max,count,sum메서드를 선택해 수행한다.
- 가변 축소를 수행하는
Stream의collect메서드는 병렬화에 적합하지 않다.
정리
- 스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한다.
- 다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.
- 조건이 잘 갖춰지면
parallel메서드 호출 하나로 프로세서 코어 수에 비례하는 성능 향상을 얻을 수 있다.