Skip to content

Latest commit

 

History

History
167 lines (124 loc) · 11.7 KB

File metadata and controls

167 lines (124 loc) · 11.7 KB

7장 람다와 스트림

item42. 익명 클래스보다는 람다를 사용하라

  • 예전의 자바에서는 함수 타입 function object을 표현하기 위해 추상 메소드를 하나만 담은 인터페이스를 사용.
    • JDK1.1 부터는 함수 객체를 만들기 위해 익명 클래스를 사용한다. => 하지만 코드가 길기 때문에 함수형 프로그래밍에 적합하지 않음.
  • Java8부터는 드디어 람다식이 생겼다.
    • 컴파일러가 문맥을 살펴 타입을 추론해준다. (상황에 따라 추론이 불가능한 경우, 개발자가 직접 명시.)
    • 타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하자.
  • 그렇다고 무조건 람다를 쓰는 것이 좋은가? 그렇진 않다.
    • 람다는 이름이 없고 문서화도 못 한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 않아야 한다.
    • 보통 3줄 안에 끝나는 코드에서만 람다를 사용하는 게 좋다.
  • 람다를 직렬화하는 것은 삼가자.

item43. 람다보다는 메소드 참조를 사용하라

  • 함수 객체를 람다보다도 더 간결하게 만드는 방법이 있다. 바로 메소드 참조 method reference다.
map.merge(key, 1, Integer::sum);
  • 하지만 람다가 더 짦은 경우도 있다.
    • 클래스 내부에서 함수를 호출하는 경우.
  • 다섯 가지 메소드 참조
메소드 참조 유형 같은 기능을 하는 람다
정적 Integer::parseInt str -> Integer.parseInt(str)
한정적(인스턴스) Instant.now()::isAfter Instant tehn = 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]

item44. 표준 함수형 인터페이스를 사용하라

  • 람다를 지원하면서 API를 작성하는 모범 사례가 바뀜
    • ex) 템플릿 메소드 패턴의 매력이 크게 줄었다.
    • 현대적인 해법: 함수 객체를 받는 정적 팩토리나 생성자를 제공하는 것.
  • 만약 아래와 같은 함수형 인터페이스를 만들었다고 해보자.
@FunctionalInterface interface EldestEntryRemovalFunction<K, V> { 
  boolean remove(Map<K, V> map, Map.Entry<K, V> eldest); 
}
  • 하지만 이런 인터페이스를 굳이 만들어서 사용할 필요가 없는게, 자바 표준 라이브러리에 이미 같은 모양의 인터페이스가 준비되어 있기 때문이다.
  • 필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하라.
    • API가 다루는 개념의 수가 줄어든다는 장점!
    • 또한 표준 함수형 인터페이스는 유용한 default 함수도 많이 제공한다.
  • 위의 예시 대신 Bipredicate<Map<K,V>>, Map.Entry<K,V>>를 사용할 수 있다.
  • 표준 함수형 인터페이스는 대부분 기본 타입만 지원한다. 그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자.
    • 동작은 하지만 "박싱된 기본 타입 대신 기본 타입을 사용하라"라는 아이템 61의 조언을 위배한다.
    • 특히 계산량이 많을 때는 성능이 처참히 느려질 수 있다.

Comparator<T>

  • 그런데 Comparator<T> 인터페이스는 구조적으로 ToIntBiFunction<T,U>와 동일한데 왜 Comparator는 독자적으로 살아남았을까?
  1. API에서 굉장히 자주 사용되는데, 지금의 이름이 그 용도를 아주 훌륭하게 설명해준다.
  2. 구현하는 쪽에서 반드시 지켜야 할 규약을 담고 있다.
  3. 비교자들을 변환하고 조합해주는 융용한 디폴트 메소드들을 듬뿍 담고 있다.
  • 따라서 위의 3가지 중 하나 이상을 만족한다면 전용 함수형 인터페이스를 구현해야 하는 건 아닌지 진중히 고민해야 한다.

@FunctionalInterface

  • @Override와 용도와 비슷하다. 직접 만든 함수형 인터페이스라면 해당 어노테이션을 붙여주자.
  1. 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려준다.
  2. 해당 인터페이스가 추상 메소드를 오직 하나만 가지고 있어야 컴파일되게 해준다.
  3. 그 결과 유지보수 과정에서 누군가 실수로 메소드를 추가하지 못하게 막아준다.

사용 시 주의점

  • 서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메소드들을 다중 정의해서는 안 된다.
    • 클라이언트에게 불필요한 모호함만 안겨줄 뿐.

item45. 스트림은 주의해서 사용하라

  • 스트림은 다량의 데이터 처리 작업을 돕고자 추가된 기능이다.
  • Stream의 핵심 개념 두 가지
  1. 데이터 원소의 유한 혹은 무한 시퀀스
  2. 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념
  • 스트림 파이프라인은 지연 평가된다.
    • 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니, 종단 연산을 빼먹는 일이 절대 없도록 해야 한다.
  • parallel 메소드를 통해 병렬로 실행할 수 있으나, 효과를 볼 수 있는 상황은 많지 않다.(item48)

람다 매개변수의 이름은 주의해서 정하라.

  • 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
  • 도우미 메소드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다는 스트림 파이프라인에서 훨씬 크다.
  • char 값을 처리할 때는 스트림을 삼가는 편이 낫다.
    • 명시적으로 타입 형변환을 해줘야 하기 때문에 불편.

스트림을 써야 하는 경우

  • 다음 일들은 스트림이 아주 안성맞춤이다.
  1. 원소들의 시퀀스를 일관되게 변환한다.
  2. 원소들의 시퀀스를 필터링한다.
  3. 원소들의 시퀀스를 하나의 연산을 사용해 결합한다. (더하기, 연결하기, 최솟값 구하기 등)
  4. 원소들의 시퀀스를 컬렉션에 모은다. (공통된 속성을 기준으로 묶어가며)
  5. 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

스트림으로 처리하기 어려운 일

  • 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우
    • 스트림에서 한 값을 다른 값에 매핑하면 기존의 값을 잃는 구조이기 때문이다.

item46. 스트림에서는 부작용 없는 함수를 사용하라

  • 스트림의 패러다임? 함수형 프로그래밍이다.
  • 스트림이 제공하는 표현력, 속도, 병렬성을 얻으려면 이 패러다음까지 함께 받아들여야 한다.
  • 스트림의 각 변환 단계는 순수 함수여야 한다. (side effect가 없어야 한다.)
    • foreach로 순수하지 않은 스트림을 만드는 것은 스트림 API의 이점을 살리지 못한다.
    • map, collect, groupingBy 등 다양한 함수를 사용해 스트림 API의 이점을 극대화하자.

내가 몰랐던 것

  • groupingBy()에서 counting()을 활용하면 키에 매칭되는 카운트 값을 얻을 수 있다. (Map<Key, Long>)
  • groupingByConcurrent()를 사용하면 ConcurrentHashMap 인스턴스를 만들어 준다.
  • partitioningBy()는 분류 함수에 Predicate를 받고 키가 boolean인 맵을 반환

item47. 반환 타입으로는 스트림보다 컬렉션이 낫다.

  • (item45에서 언급) 스트림은 반복 iteration을 지원하지 않는다. 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다.
    • Iterable을 지원하지 않아 for문에서 사용이 불가능하다는 말인 걸로 이해
    • 그리고 스트림은 1회성 소비만 가능하다는 점을 기억하자.
  • 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문을 사용하려는 사람 모두를 배려해야 한다.
    • Collection 인터페이스는 Iterable의 하위 타입이고 stream 메소드도 제공하니 반복과 스트림을 동시에 지원한다.
    • 따라서 Collection의 하위 타입을 사용하는 것을 추천

item48. 스트림 병렬화는 주의해서 적용하라

  • stream에는 parallel 메소드가 있는데, 파이프라인을 병렬로 실행할 수 있다.
    • 동시성 프로그래밍을 할 때는 안정성 safety응답 가능 liveness 상태를 유지하기 위해 애써야 한다.
  • 스트림 라이브러리가 파이프라인을 병렬화하는 방법을 찾아내지 못하면 오히려 성능이 엄청나게 안 좋아질 수 있다.
    • 환경이 아무리 좋더라도 데이터 소스가 Stream.iterate거나 중간 연산으로 limit을 사용하면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.
    • 특히, limit을 사용할 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다. => 만약 이전 결과를 만들어내는 비용이 높다면, 엄청난 손해다.
  • 대체로 스트림의 소스사 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다.
    • 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다는 특징이 있다.
    • 나누는 작업은 Spliterator가 담당하며, Spliterator 객체는 Stream이나 Iterablespliterator 메소드로 얻어올 수 있다.
    • 이 자료구조들의 또 다른 중요한 공통점. 원소들을 순차적으로 실행할 때 참조 지역성 locality of reference가 뛰어나다는 것이다. 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻이다.
    • 참조 지역성은 병렬 처리를 할 때 아주 중요한 요소로 작용한다.
  • 스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다.
    • 종단 연산에서 수행하는 작업량이 파이프라인 전체 작업에서 상당 비중을 차지하면서 순차적인 연산이라면 파이프라인 병렬 수행의 효과는 제한될 수밖에 없다.
    • 종단 연산 중 병렬화에 가장 적합한 것은 축소 reduction다. 축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업으로 reduce, min, max, count, sum 같이 완성된 형태로 제공되는 메소드 중 하나를 선택해 수행
    • anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메소드도 병렬화에 적합하다.
    • 반면, 가변 축소 mutable reduction를 수행하는 Streamcollect 메소드는 병렬화에 적합하지 않다. 컬렉션들을 합치는 부담이 크기 때문이다.
  • 조건만 잘 갖춰지면 parallel 메소드를 호출해 거의 프로세서 코어 수에 비례하는 성능 향상을 할 수 있다.

참고자료

  • Effective Java 3/E, Joshua Bloch