Effective Java - Chapter 7

9 분 소요

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가지 이유 중 하나를 만족하기 때문
  1. 자주 쓰이며, 이름 자체가 용도를 명확히 설명한다.
  2. 반드시 따라야하는 규약이 있다
  3. 유용한 디폴트 메소드를 제공한다.
  4. 표준 함수형 인터페이스가 없다.

@FunctionalInterface

  • 직접 만든 함수형 인터페이스에는 @FunctionalInterface 어노테이션을 붙이자
  1. 그 인터페이스가 람다용으로 설계된 것임을 알려준다,
  2. 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일 되게 해준다.
  3. 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.

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

스트림

  • 람다를 활용할 수 있는 기술. 예전에는 배열이나 컬렉션을 반복문을 순회하면서 요소를 하나씩 꺼내 여러가지 코드를 섞어서 작성했다면 스트림과 람다를 이용하여 코드의 양을 대폭 줄이고 조금 더 간결하게 코드를 작성할 수 있다.
  • 스트림을 이용하면 스레드를 사용하여 많은 데이터들을 빠르게 처리할 수 있다.

스트림 파이프라인

  • 소스 스트림 시작 - 중간 연산(스트림을 변환) - 종단 연산
  • 스트림 파이프라인은 지연 평가 된다. 평가는 종단 연산이 호출될 때 이루어지고, 이것은 무한 스트림을 다룰 수 있게 해주는 열쇠이다.

스트림의 사용

  • 스트림을 과하게 사용하면 라인수는 짧아지지만, 읽기 힘들어진다
  • 스트림을 적절히 활용하여 짧으면서도 명확하게 사용하는 것이 좋다.
  • 람다에서는 타입이름이 생략 되므로 매개변수 이름을 잘 지어서 가독성을 유지해야 한다.
  • 스트림은 char용 스트림은 지원하지 않는다 (int, double, long만)

코드블록 vs 람다블록

  • 코드블록에서는 지역변수를 읽고 수정할 수 있으나, 람다 블록에서는 final 변수이거나 사실상 final 변수인 것만 읽을 수 있고(클로저, Variable capture), 지역변수를 수정하는 것은 불가능하다.
  • 코드블록에서는 return, break, continue 문으로 바깥을 종료시키거나 건너뛰거나 하는 행위를 할 수 있지만, 람다블록에서는 아무것도 할 수 없다.

언제 쓸까?

  1. 원소들의 시퀀스를 일관되게 변환한다.
  2. 원소들의 시퀀스를 필터링한다.
  3. 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.
  4. 원소들의 시퀀스를 컬렉션에 모은다.
  5. 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
  • 다음과 같은 경우 스트림을 사용하면 좋다. 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 메소드를 재정의하라

댓글남기기