728x90
반응형
7장 - 람다와 스트림
💡 익명 클래스보다는 람다를 사용하라
“익명 클래스는 (함수형 인터페이스가 아닌) 타입의 인스턴스를 만들 때만 사용하라”
1. 익명 클래스
- 자바8 이전에는 함수 객체를 만드는 주요 수단으로 익명 클래스를 사용했었음
- 익명 클래스 방식은 코드가 너무 길다..
// 익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법이다!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
System.out.println(words);
Collections.shuffle(words);
2. 람다
- 자바8에 와서 함수형 인터페이스의 인스턴스를 람다식을 이용해 짧게 만들 수 있게 됨.
- 아래 코드의 람다식을 살펴보면 매개변수, 반환값의 타입이 명시되어 있지 않음.
- 컴파일러가 타입을 알아서 추론해 줌.
- 컴파일러의 타입 추론 규칙은 매우 복잡하므로 잘 알지 못해도 상관 없음
- 타입을 명시해야 코드가 더 명확할 때를 제외하고는, 아래 코드처럼 모든 매개변수 타입은 생략하자.
- 아래 코드의 람다식을 살펴보면 매개변수, 반환값의 타입이 명시되어 있지 않음.
// 코드 42-2 람다식을 함수 객체로 사용 - 익명 클래스 대체
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));
System.out.println(words);
Collections.shuffle(words);
- 람다를 사용해 상수별 동작을 구현한 열거 타입 예시
- 열거 타입 상수의 동작을 표현한 람다를
DoubleBinaryOperator
인터페이스 변수에 할당함. DoubleBinaryOperator
인터페이스는double
타입 인수 2개를 받아double
타입 결과를 반환함.
- 열거 타입 상수의 동작을 표현한 람다를
// 함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입
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);
}
}
- 람다를 사용하지 말아야 할 경우
- 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아질 때.
- 람다는 이름이 없고 문서화도 못 하기 때문.
- 람다는 한 줄일 때 가장 좋고, 길어야 세 줄 안에 끝내는 게 좋음
- 자신을 참조해야할 경우
- 람다는 자신을 참조할 수 없음.
- 람다에서
this
키워드는 바깥 인스턴스, 익명 클래스에서의this
는 자신
- 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아질 때.
💡 람다보다는 메서드 참조를 사용하라
“메서드 참조 쪽이 짧고 명확하다면 메서드 참조를 쓰고, 그렇지 않을 때만 람다를 사용하라”
1. 람다를 메서드 참조로
- 메소드 참조(method reference) : 람다 표현식이 단 하나의 메소드만을 호출하는 경우에 해당 람다 표현식에서 불필요한 매개변수를 제거하고 사용할 수 있도록 해줌
- 함수 객체를 람다보다 더 간결하게 만드는 예시
// map.merge를 이용해 구현한 빈도표 - 람다 방식과 메서드 참조 방식을 비교해보자.
public class Freq {
public static void main(String[] args) {
Map<String, Integer> frequencyTable = new TreeMap<>();
for (String s : args)
frequencyTable.merge(s, 1, (count, incr) -> count + incr); // 람다
System.out.println(frequencyTable);
frequencyTable.clear();
for (String s : args)
frequencyTable.merge(s, 1, Integer::sum); // 메서드 참조
System.out.println(frequencyTable);
}
}
- 메서드 참조를 사용하는 편이 보통은 더 짧고 간결함
- 때로는 람다에서 매개변수의 이름 자체가 프로그래머에게 좋은 가이드가 되기도 함.
2. 5가지 메서드 참조 유형
메서드 참조 유형 | 예시 | 람다 표현 |
---|---|---|
정적 | 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] |
💡 표준 함수형 인터페이스를 사용하라
“보통은
java.util.function
패키지의 표준 함수형 인터페이스를 사용하는 것이 가장 좋은 선택”
1. 표준 함수형 인터페이스
java.util.function
패키지를 보면 다양한 용도의 표준 함수형 인터페이스가 담겨 있음.- 필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하자
- 기본 인터페이스 6개
인터페이스 | 함수 시그니처 | 예 | 설명 |
---|---|---|---|
UnaryOperator | T apply(T t) | String::toLowerCase | 반환값과 인수의 타입이 같은 함수(인수 1개) |
BinaryOperator | T apply(T t1, T t2) | BigInteger::add | 반환값과 인수의 타입이 같은 함수(인수 2개) |
Predicate | boolean test(T t) | Collection::isEmpty | 인수 하나를 받아 boolean을 반환하는 함수 |
Function<T, R> | R apply(T t) | Arrays::asList | 인수와 반환 타입이 다른 함수 |
Supplier | T get() | Instance::now | 인수를 받지 않고 값을 반환(혹은 제공)하는 함수 |
Consumer | void accept(T t) | System.out::println | 인수를 하나 받고 반환값은 없는(특히 인수를 소비하는 ) 함수 |
2. 전용 함수형 인터페이스
- 표준 함수형 인터페이스를 사용하지 않고, 전용 함수형 인터페이스를 구현해야하는 때는 언제일까?
- 아래 세 가지 조건 중 하나이상을 만족할 때.
- 자주 쓰이며, 이름 자체가 용도를 명확히 설명해준다.
- 반드시 따라야 하는 규약이 있다.
- 유용한 디폴트 메서드를 제공할 수 있다.
- 아래 세 가지 조건 중 하나이상을 만족할 때.
- ex)
Compartor<T>
인터페이스- 이 인터페이스는 구조적으로
ToIntBiFunction<T, U>
와 동일함. - 이 인터페이스가 독자적으로 살아남은 이유
- 세가지 조건을 모두 만족함.
- API에서 굉장히 자주 쓰이며 이름이 용도를 명확히 설명함
- 구현하는 쪽에서 반드시 지켜야 할 규약을 담고 있음.
- 비교자들을 변환하고 조합해주는 유용한 디폴트 메서드들을 담고 있음.
- 이 인터페이스는 구조적으로
- 전용 함수형 인터페이스를 작성할 때
- 자신이 작성하는 게 ‘인터페이스’임을 명심해야 함.(주의해서 설계해야 함)
- 항상
@FunctionalInterface
어노테이션을 사용하라.
@FunctionalInterface
어노테이션의 기능- 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려줌
- 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해줌
- 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아줌.
💡 스트림은 주의해서 사용하라
“스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라”
1. 스트림과 스트림 파이프라인
- 스트림(stream)이란?
- 데이터 원소의 유한 혹은 무한 시퀀스(sequence)
- 스트림 파이프라인(stream pipeline)이란?
- 스트림의 원소들로 수행하는 연산 단계를 표현
- 소스 스트림에서 시작해 종단 연산(terminal operation)으로 끝나며, 그 사이에 하나 이상의 중간 연산(intermediate operation)이 있을 수 있음.
- 각 중간 연산은 스트림을 어떠한 방식으로 변환(transform)함
- 스트림 파이프라인은 지연 평가됨. 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않음.
- 1개 이상의 중간연산들은 계속합쳐진 후 종단연산 시 수행된다는 뜻.
- 무한 스트림을 다룰 수 있게 해주는 열쇠
- 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니, 종단 연산을 빼먹는 일이 절대 없도록 하자.
- 스트림 파이프라인은 순차적으로 수행 됨.
- 스트림 API의 특징
- 스트림 API는 메서드 체이닝을 지원하는 플루언트 API(fluent API)임
- 스트림 API는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있음.
2. 스트림의 사용
- 스트림을 적절히 활용해 아나그램 그룹을 출력하는 예시
// 스트림을 적절히 활용하면 깔끔하고 명료해진다.
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(g -> System.out.println(g.size() + ": " + g));
}
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
- 스트림을 사용하기 적절한 경우
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다(더하기, 연결하기, 최솟값 구하기 등)
- 원소들의 시퀀스를 컬렉션에 모은다(아마도 공통된 속성을 기준으로 묶어가며)
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
- 스트림 사용 시 주의점
- 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.
- char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.
- 자바가 기본 타입인 char용 스트림을 지원하지 않음.
CharSequence
인터페이스의chars()
메서드는 반환 값이IntStream
임.
- 반복문을 스트림으로 바꾸는 게 가능할지라도 코드 가독성과 유지보수 측면에서는 손해를 볼 수도 있음
- 권장
- 스트림과 반복문을 적절히 조합하자
- 함수 객체로는 할 수 없지만 반복문(코드 블록)으로만 할 수 있는 일들도 있음
- 범위 안의 지역변수 읽기 / 수정. (람다에서는
final
이거나 사실상final
인 변수만 읽을 수 있음) return
,break
,continue
문 사용. (람다에서는 불가능)- 메서드 선언에 명시된 검사 예외 던지기. (람다에서는 불가능)
- 범위 안의 지역변수 읽기 / 수정. (람다에서는
- 함수 객체로는 할 수 없지만 반복문(코드 블록)으로만 할 수 있는 일들도 있음
- 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자
- 스트림을 반환하는 메서드 이름은 원소의 정체를 알려주는 복수명사로 쓰기를 강력히 추천
- ex)
static Stream<BigInteger> primes() { ... }
- ex)
- 스트림과 반복문을 적절히 조합하자
💡 스트림에서는 부작용 없는 함수를 사용하라
“스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다”
1. 스트림 패러다임
- 스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분
- 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 함.
- 순수 함수 : 다른 가변 상태를 참조하거나 함수 스스로 다른 상태를 변경하지 않으며 오직 입력만이 결과에 영향을 주는 함수
- 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 함.
2. forEach 종단 연산
forEach
연산은 종단 연산 중 기능이 가장 적고 가장 ‘덜’ 스트림다움.- 대놓고 반복적이라서 병렬화할 수도 없음.
→ forEach
연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.
3. 수집기(collector)
- 스트림을 올바로 사용하려면 수집기를 잘 알아둬야 함.
- 가독성을 위해 일반적으로
java.util.stream.Collectors
의 멤버를 정적 임포트하여 사용함. - 수집기가 생성하는 객체는 일반적으로 컬렉션임.
- 중요한 5가지 수집기 팩터리를 알아보자
toList()
: 리스트 반환
// 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
toMap(keyMapper, valueMapper)
: 키 매퍼와 값 매퍼를 받아 맵을 반환
Map<String, Operation> stringToEnum =
Stream.of(values()).collect(
toMap(Object::toString, e -> e));
toSet()
: 집합 반환
Set<String> result = givenList.stream()
.collect(toSet());
joining()
: 원소들을 연결하여 문자열 반환
// 매개변수가 없을 경우 : "abbcccdd" 출력
String result = givenList.stream()
.collect(joining());
// 매개변수가 있을 경우(구분문자를 연결부위에 삽입해 줌) : "a bb ccc dd" 출력
String result = givenList.stream()
.collect(joining(" "));
groupingBy(classifier)
: 분류 함수를 매개변수로 받아 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기 반환.
// 간단한 예시 : 알파벳화한 단어를 알파벳화 결과가 같은 단어들의 리스트로 매핑하는 맵 생성.
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
words.collect(groupingBy(word -> alphabetize(word)));
// 분류 함수와 다운 스트림을 받는 예시
/** 다운스트림 수집기로 counting()을 건네서 각 카테고리(키)를 (원소를 담은 컬렉션이 아닌)
* 해당 카테고리에 속하는 원소의 개수(값)와 매핑한 맵을 얻음
*/
// 스트림을 제대로 활용해 빈도표를 초기화한다.
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words
.collect(groupingBy(String::toLowerCase, counting()));
}
- [참고]
groupingBy
명세
static <T,K> Collector<T,?,Map<K,List<T>>>
groupingBy(Function<? super T,? extends K> classifier)
static <T,K,A,D> Collector<T,?,Map<K,D>>
groupingBy(Function<? super T,? extends K> classifier,
Collector<? super T,A,D> downstream)
static <T,K,D,A,M extends Map<K,D>> Collector<T,?,M>
groupingBy(Function<? super T,? extends K> classifier,
Supplier<M> mapFactory, Collector<? super T,A,D> downstream)
💡 반환 타입으로는 스트림보다 컬렉션이 낫다
“원소 시퀀스를 반환하는 공개 API의 반환 타입에는
Collection
이나 그 하위 타입을 쓰는 게 일반적으로 최선”
1. 스트림 반환의 문제점
- 스트림은 반복(iteration)을 지원하지 않음
- API를 스트림만 반환하도록 짜놓으면 반환된 스트림을
for-each
로 반복하길 원하는 사용자는 불만을 토로할 것임. Stream<E>
를Iterable<E>
로 중개해주는 어댑터를 사용하면 스트림을for-each
문으로 반복할 수 있음- 그러나 어댑터는 클라이언트 코드를 어수선하게 만들고 느림.
// 스트림 <-> 반복자 어댑터
public class Adapters {
// Stream<E>를 Iterable<E>로 중개해주는 어댑터
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
// Iterable<E>를 Stream<E>로 중개해주는 어댑터
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
}
2. Collection 반환
Collection
인터페이스는Iterable
의 하위 타입이고stream
메서드도 제공하니 반복과 스트림을 동시에 지원함.
→ 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection
이나 그 하위 타입을 쓰는 게 일반적으로 최선임.
- 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작은 경우
→ ArrayList
나 HashSet
같은 표준 컬렉션 구현체를 반환하자
- 반환하는 시퀀스의 크기가 덩치가 큰 경우
→ 전용 컬렉션을 구현하는 방안을 검토해보자.
- 전용 컬렉션 구현 예시
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() {
// 멱집합의 크기는 2를 원래 집합의 원소 수만큼 거듭제곱 것과 같다.
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;
}
};
}
public static void main(String[] args) {
Set s = new HashSet(Arrays.asList(args));
System.out.println(PowerSet.of(s));
}
}
- (반복이 시작되기 전에는 시퀀스의 내용을 확정할 수 없는 등의 사유로)
contains
와size
를 구현하는 게 불가능할 때(즉, 컬렉션을 반환하는 게 불가능할 때)는 컬렉션보다는 스트림이나Iterable
을 반환하는 편이 낫다.
💡 스트림 병렬화는 주의해서 적용하라
“계산도 올바로 수행하고 성능도 빨라질 거라는 확신 없이는 스트림 파이프라인 병렬화는 시도조차 하지 말라”
1. 스트림 병렬화의 문제점
// 병렬 스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램
// 주의: 병렬화의 영향으로 프로그램이 종료하지 않는다.
public class ParallelMersennePrimes {
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.parallel() // 스트림 병렬화
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
}
- 이 프로그램은 아무것도 출력하지 못하면서 CPU는 90%나 잡아먹는 상태가 지속됨(응답 불가; liveness failure)
- 무슨 일이 벌어진 걸까?
→ 스트림 라이브러리가 이 파이프라인을 병렬화하는 방법을 찾아내지 못했기 때문.
- 데이터 소스가
Stream.iterate
거나 중간 연산으로limit
를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없음.
→ 위 코드는 두 문제를 모두 지니고 있음..
- 교훈 : 스트림 파이프라인을 마구잡이로 병렬화하면 안 됨. 오히려 끔찍한 성능저하를 가져올 수 있음.
- 스트림을 잘못 병렬화하면 (응답 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있음.
→ 스트림 병렬화는 오직 성능 최적화 수단임을 기억하자. 반드시 변경 전후로 성능 테스트를 진행하여 병렬화를 사용할 가치가 있는지 따져보자.
2. 스트림 병렬화가 적합한 경우
- 스트림의 소스가
ArrayList
,HashMap
,HashSet
,ConcurrentHashMap
의 인스턴스거나 배열, int 범위, long 범위일 때 적합. - 위 자료구조들의 특징
- 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋음
- 참조 지역성(locality of reference)이 뛰어남
- 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻
- 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소
- 종단 연산 중에는 축소(reduction)가 병렬화에 가장 적합.
- 축소 : 파이프라인에서 만들어진 원소를 하나로 합치는 작업
- ex)
Stream
의reduce
메서드 중 하나, 혹은min
,max
,count
,sum
등
- 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합
- ex)
anyMatch
,allMatch
,noneMatch
등
- ex)
- 스트림 병렬화의 적합한 예시
public class ParallelPrimeCounting {
// 소수 계산 스트림 파이프라인 - 병렬화 버전
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
public static void main(String[] args) {
System.out.println(pi(10_000_000));
}
}
728x90
반응형
'📚 Reading > Tech' 카테고리의 다른 글
이펙티브 자바 - 9장. 일반적인 프로그래밍 원칙 (0) | 2023.01.24 |
---|---|
이펙티브 자바 - 8장. 메서드 (0) | 2023.01.24 |
이펙티브 자바 - 6장. 열거 타입과 애너테이션 (0) | 2023.01.24 |
이펙티브 자바 - 5장. 제네릭 (0) | 2023.01.24 |
이펙티브 자바 - 4장. 클래스와 인터페이스 (0) | 2023.01.24 |