아이템 42: 익명 클래스보다는 람다를 사용하라

예전에는 함수 타입을 표현할 때 추상 메서드가 하나만 있는 인터페이스를 익명 클래스로 구현하는 방식을 사용했다.

Collections.sort(words, new Comparator<String>() {
        public int compare(String s1, String s2) {
            return Integer.compare(s1.length(), s2.length());
        }
    });

람다가 도입되면서 간결한 방식으로 함수 객체를 표현할 수 있게 되었다.

Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
// 코드 42-4 함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입 (256-257쪽)
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가 자기 자신을 가리켜야 하는 경우

아이템 43: 람다보다는 메소드 참조를 사용하라

메소드 참조를 사용하면 람다보다도 간결한 코드를 작성할 수 있다.

frequencyTable.merge(s, 1, (count, incr) -> count + incr); // 람다
frequencyTable.merge(s, 1, Integer::sum); // 메서드 참조

람다가 메서드 참조보다 더 간결한 경우도 있다. e.g. 람다와 메서드가 같은 클래스 내에 있을 경우

메서드 참조 유형

  • 정적

    • str -> Integer.parseInt(str)Integer::parseInt
  • 한정적 (인스턴스)

    Instant then = Instant.now();
    t -> then.isAfter(t);
    
    Instant.now()::isAfter
    
  • 비한정적 (인스턴스)

    • str -> str.toLowerCase()String::toLowerCase
  • 클래스 생성자

    • () -> new TreeMap<K, V>()TreeMap<K,V>::new
  • 배열 생성자

    • len -> new int[len]int[]::new

아이템 44: 표준 함수형 인터페이스를 사용하라

자바에서는 람다에 사용할 수 있도록 다양한 모양의 표준 함수형 인터페이스를 제공하고 있다.

  • 람다에 사용하기 위해 직접 인터페이스를 구현하기보다는, 표준 함수형 인터페이스를 사용하자.

전용 함수형 인터페이스를 구현해야 하는 경우

e.g. Comparator

  • 자주 쓰이며, 이름 자체가 용도를 명확히 설명한다.
  • 반드시 따라야 하는 규약이 있다.
  • 유용한 디폴트 메서드를 제공할 수 있다.

직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하자.

아이템 45: 스트림은 주의해서 사용하라

자바 8에서 추가된 스트림은 다량의 데이터 처리 작업을 위해 도입되었다.

스트림 파이프라인

  • 스트림 파이프라인은 지연 평가된다.
  • 평가는 종단 연산이 호출될 때 이루어지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.

스트림을 잘못 사용하면 읽기 어렵고 유지보수가 힘든 코드가 만들어진다.

// 코드 45-2 스트림을 과하게 사용했다. - 따라 하지 말 것! (270-271쪽)
public class StreamAnagrams {
    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);
        }
    }
}

→ 도우미 메서드를 적절히 활용하자.

// 코드 45-3 스트림을 적절히 활용하면 깔끔하고 명료해진다. (271쪽)
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);
    }
}

스트림과 람다

  • 람다에서는 타입을 자주 생략하므로 매개변수 이름을 잘 지어야 한다.
  • 도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드보다는 스트림 파이프라인에서 훨씬 크다.

스트림을 쓰면 좋은 것

  • 원소들의 시퀀스를 일관되게 변환한다.
  • 원소들의 시퀀스를 필터링한다.
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.
  • 원소들의 시퀀스를 컬렉션에 모은다.
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

스트림(+람다)이 할 수 없는 것

  • 람다에선 final 이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정할 수 없다.
  • 람다는 흐름 제어가 불가능하지만, 코드 블록에서는 가능하다.

스트림과 반복문 중 어떤 쪽이 나은지 확신하기 어렵다면 직접 구현해 보고 나은 쪽을 선택하자.

아이템 46: 스트림에서는 부작용없는 함수를 사용하라

  • 함수형 패러다임을 지키려면 부작용없는 순수함수를 사용해야 한다.

  • 순수함수는 입력만이 결과에 영향을 주는 함수이며, 함수 외부의 가변 상태를 참조하지 않고 외부의 상태도 변경하지 않는 함수이다.

  • 스트림 파이프라인에 사용할 함수는 부작용이 없는 순수 함수를 사용해야 한다.

    • forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.
    • 잘못된 예
    // 코드 46-1 스트림 패러다임을 이해하지 못한 채 API만 사용했다 - 따라 하지 말 것! (277쪽)
    Map<String, Long> freq = new HashMap<>();
    try (Stream<String> words = new Scanner(file).tokens()) {
        words.forEach(word -> {
            freq.merge(word.toLowerCase(), 1L, Long::sum);
        });
    }
    
    • 좋은 예
    // 코드 46-2 스트림을 제대로 활용해 빈도표를 초기화한다. (278쪽)
    Map<String, Long> freq;
    try (Stream<String> words = new Scanner(file).tokens()) {
        freq = words
                .collect(groupingBy(String::toLowerCase, counting()));
    }
    
  • 수집기를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다.

    • toList(), toSet(), toMap() …

아이템 47: 반환 타입으로는 스트림보다 컬렉션이 낫다

스트림은 반복을 지원하지 않는다. Iterable을 사용해 우회할 수 있으나, 코드가 지저분해지고 읽기 힘들어진다.

  • 어댑터를 사용해서 Iterable ↔ Stream간 상호 변환을 간단히 구현할 수 있다.
// 스트림 <-> 반복자 어댑터 (285-286쪽)
public class Adapters {
    // 코드 47-3 Stream<E>를 Iterable<E>로 중개해주는 어댑터 (285쪽)
    public static <E> Iterable<E> iterableOf(Stream<E> stream) {
        return stream::iterator;
    }

    // 코드 47-4 Iterable<E>를 Stream<E>로 중개해주는 어댑터 (286쪽)
    public static <E> Stream<E> streamOf(Iterable<E> iterable) {
        return StreamSupport.stream(iterable.spliterator(), false);
    }
}
  • 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 하위 타입을 사용하는 것이 일반적으로 최선이다.
  • 원소를 이미 컬렉션에 담아 관리하고 있거나 원소 개수가 적다면 표준 컬렉션에 담아서 반환하고, 그렇지 않다면 전용 컬렉션을 구현하는 것을 검토하자.

→ 반환 타입은 스트림보다는, 반복과 스트림을 모두 지원할 수 있는 컬렉션 타입이 낫다.

아이템 48: 스트림 병렬화는 주의해서 적용하라

스트림은 .parallel() 로 쉽게 병렬화할 수 있지만, 마구잡이로 사용할 경우 오히려 성능을 해치거나, 잘못된 결과를 만들 수 있다.

  • 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.
  • 대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나, 배열, IntRange, LongRange일 때 병렬화의 효과가 가장 좋다.
    • 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있고, 참조 지역성이 뛰어나기 때문
  • 파이프라인이 수행하는 작업이 병렬화에 드는 추가 비용을 상쇄하지 못한다면 성능 향상에는 도움이 되지 않는다.