Effective Java - Chapter 7
Effective Java
[Effective Java 3/E] 7장 람다와 스트림
Item 42. 익명 클래스보다는 람다를 사용하라
함수 객체
- 예전 자바에서는 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스를 사용, 이런 인터페이스의 인스턴스를 함수 객체 라고 하며, 특정 함수나 동작을 나타내는 데 썼다 but, JDK 1.1 부터는 익명 클래스를 사용했다.
익명 클래스
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
- 익명 클래스의 인스턴스를 함수 객체로 사용하는 방식으로 사용되었다. but, 코드가 너무 길기 때문에 함수형 프로그래밍에 적합하지 않다!
람다
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
- JDK 1.8 버전부터는 추상 메서드 하나 짜리 인터페이스, 즉 함수형 인터페이스를 말하는데 그 인터페이스의 인스턴스를 람다식이라고 사용해서 만들 수 있게 됨
- 위 코드에서 람다의 타입은 Comparator이고, 매개변수들의 타입은 String이며 반환값은 int인 형태
- 코드가 짧으며, 명시되지 않은 타입 들을 컴파일러가 알아서 추론하여 넣어준다.
람다의 단점
- 람다는 이름도 없고 문서화를 할 수 없다.
- 코드 자체로 동작이 명확하게 설명되지 않으면 사용을 고려해봐야 함
- 추상 클래스의 인스턴스를 만들 때는 람다 사용X, 익명 클래스 사용
- 자기 자신 참조가 불가능
- Serialization 형태가 구현별로 다르기 때문에 Serialization하는 일은 삼가야 한다
Item 43. 람다보다는 메서드 참조를 사용하라
- 람다는 익명클래스보다 간결한 게 장점이지만, 메소드 참조까지 사용하면 람다보다도 간결한 코드를 얻을 수 있다.
map.merge(key, 1, (count, incr) -> count + incr);
//람다
map.merge(key, 1, Integer::sum);
//메서드 참조
- but, 클래스명이 매우 길거나 명확하지 않은 경우에는 메소드 참조를 쓰지 않는 것이 좋을 수도 있으니 유의하자
service.execute(GoshThisClassNameIsHumnous::action);
//메소드 참조. 너무 길다.
service.execute(() -> action());
//람다. 이 경우에는 훨씬 짧다
Item 44. 표준 함수형 인터페이스를 사용하라
람다 in JAVA
- 자바가 람다를 지원하면서 템플릿 메서드 패턴의 사용이 줄고 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 스타일로 바뀌었다.
- 따라서, 직접 구현하지 말고 표준 함수형 인터페이스를 사용하는 것을 권장한다.
표준 인터페이스
- 표준 함수형 인터페이스 대부분은 기본타입만 지원한다.(LongToIntFunction.applyAsInt는 long 인수를 받고 int를 반환)
- but, 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자. 아이템 61의 규칙을 위배하고(후술), 계산량이 많을 때는 성능이 느려진다
직접 작성하는 경우
- Comparator와 ToIntBiFunction<T, U>는 구조가 똑같지만 따로있다.
// Comparator
@FunctionInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
// ToIntBiFunction
@FunctionalInterface
public interface ToIntBiFunction<T, U> {
int applyAsInt(T t, U u);
}
- Comparator가 먼저 등장하긴 했지만, 그래도 표준형 함수형 인터페이스인 ToIntBiFunction을 사용하지 않았을 것이다. 다음 4가지 이유 중 하나를 만족하기 때문
- 자주 쓰이며, 이름 자체가 용도를 명확히 설명한다.
- 반드시 따라야하는 규약이 있다
- 유용한 디폴트 메소드를 제공한다.
- 표준 함수형 인터페이스가 없다.
@FunctionalInterface
- 직접 만든 함수형 인터페이스에는 @FunctionalInterface 어노테이션을 붙이자
- 그 인터페이스가 람다용으로 설계된 것임을 알려준다,
- 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일 되게 해준다.
- 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.
Item 45. 스트림은 주의해서 사용하라
스트림
- 람다를 활용할 수 있는 기술. 예전에는 배열이나 컬렉션을 반복문을 순회하면서 요소를 하나씩 꺼내 여러가지 코드를 섞어서 작성했다면 스트림과 람다를 이용하여 코드의 양을 대폭 줄이고 조금 더 간결하게 코드를 작성할 수 있다.
- 스트림을 이용하면 스레드를 사용하여 많은 데이터들을 빠르게 처리할 수 있다.
스트림 파이프라인
- 소스 스트림 시작 - 중간 연산(스트림을 변환) - 종단 연산
- 스트림 파이프라인은 지연 평가 된다. 평가는 종단 연산이 호출될 때 이루어지고, 이것은 무한 스트림을 다룰 수 있게 해주는 열쇠이다.
스트림의 사용
- 스트림을 과하게 사용하면 라인수는 짧아지지만, 읽기 힘들어진다
- 스트림을 적절히 활용하여 짧으면서도 명확하게 사용하는 것이 좋다.
- 람다에서는 타입이름이 생략 되므로 매개변수 이름을 잘 지어서 가독성을 유지해야 한다.
- 스트림은 char용 스트림은 지원하지 않는다 (int, double, long만)
코드블록 vs 람다블록
- 코드블록에서는 지역변수를 읽고 수정할 수 있으나, 람다 블록에서는 final 변수이거나 사실상 final 변수인 것만 읽을 수 있고(클로저, Variable capture), 지역변수를 수정하는 것은 불가능하다.
- 코드블록에서는 return, break, continue 문으로 바깥을 종료시키거나 건너뛰거나 하는 행위를 할 수 있지만, 람다블록에서는 아무것도 할 수 없다.
언제 쓸까?
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.
- 원소들의 시퀀스를 컬렉션에 모은다.
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
- 다음과 같은 경우 스트림을 사용하면 좋다. but, 스트림 파이프라인이 여러 연산단계로 구성될 때, 각 단계의 값들을 동시에 접근하고자 할 때는 사용을 삼가자.
- 스트림 파이프라인은 연산이 지나가면 원래값을 잃는 구조이기 때문
Item 46. 스트림에서는 부작용 없는 함수를 사용하라
스트림 패러다임
- 스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다. 이때 각 변환 단계는 가능한 이전 단계의 결과를 받아서 처리하는 함수여야 한다.
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);;
})
}
- 위 코드는 모든 연산이 forEach에서 일어나는데, 외부 상태를 수정하는 람다를 실행하면서 문제가 생긴다. forEach는 스트림의 계산 결과를 보고할 때만 사용하는 것이 좋다.
Collectors
- 스트림의 종단연산으로 foreach같은 계산보다는 Collectors를 권장한다. 간단한 API로는 toList, toSet, toCollection등이 있다.
- keyMapper : 스트림의 요소 중 Key를 생성하는 함수
- keyMapper : 스트림의 요소 중 Value를 생성하는 함수
- 스트림 요소들이 key를 중복해서 사용하면 IllegalStateException 을 던진다.
- mergeFunction : 위처럼 충돌이 날 때 해결해주는 병합 함수이다. key값이 중복되는 값들은 이 함수을 이용해 결과를 낸다.
- classifier : 분류 함수로 각 스트림의 요소를 입력받아 Map의 Key로 사용한다. 값은 List이다.
- downstream : 위에서 값이 List 이외의 타입을 갖게하고 싶을 때 사용한다.
- mapFactory : Map Factory로서 Map 구현체를 직접 정할 수 있다.
Item 47. 반환 타입으로는 스트림보다 컬렉션이 낫다
- 스트림은 반복을 지원하지 않기 때문에 스트림과 반복을 알맞게 조합하여야 한다. but, 스트림 인터페이스가 Iterable 인터페이스가 정의한 방식대로 동작하지만 for-each로 스트림을 반복할 수 없다. 스트림이 Iterable을 확장하지 않았기 때문에.
스트림보다는 컬렉션을 반환하라
- 동작은 하지만 복잡하고 직관성이 떨어진다.
- Collection은 Iterable 하위 타입이고, Stream 메서드도 지원한다.
- 공개 API의 반환 타입에는 컬렉션이나 그 하위 타입을 쓰는게 보통 최선이다.
- Arrays 역시 asList와 Stream.of 메서드로 쉽게 반복문과 Stream을 지원할 수 있다.
Item 48. 스트림 병렬화는 주의해서 적용하라
- 병렬스트림은 .parallel()로 제공
- 데이터 소스가 Stream.iterate 거나 중간 연산으로 limit을 쓰면 파이프라인 병렬화 parallel로는 성능개선을 기대할 수 없다
public static void main(String[] args) {
// java.math.BigInteger.TWO는 자바 9부터 public 접근이 가능하다.
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
- 위 코드 성능을 높이기 위해 parallel()을 사용하면 응답 불가 상황이 발생한다. 스트림 라이브러리가 병렬화 방법을 찾을 수 없기 때문.
병렬화가 좋은 경우?
- 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap, 배열, int, long 일 때가 병렬화의 효과가 가장 좋다.
- 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 다수의 스레드에 분배하기 좋기 때문
병렬화가 효과 있는 종단 연산
- 순차연산보다는, min, max 와 같이 만들어진 모든 원소를 하나로 합치는 축소(reduction) 연산이 좋다.
- anyMatch, allMatch, noneMatch 등(조건에 맞으면 바로 반환되는 메소드들)에도 효과가 좋다.
- 병렬화의 이점을 제대로 누리고 싶으면 spliterator 메소드를 재정의하라
댓글남기기