빨간색코딩

[이펙티브 자바3판] 7장 람다와 스트림 본문

Java

[이펙티브 자바3판] 7장 람다와 스트림

빨간색소년 2019. 1. 18. 15:04

해당 내용은 이펙티브 자바 3판 (조슈아 블로크 지음, 이복연 옮김)를 읽고 나같은 초심자의 눈으로 이해한 내용을 정리해보았다.

책에 있는 내용을 기반으로 썼지만 책에 없는 내용도 조금 적었다. (익명클래스와 람다 비교, 성능적 관점, 함수형 인터페이스와 default메소드, 코드블록-람다블록 비교, Collectors API의 구체적 설명 등)

7장의 아이템 목록

  1. 익명 클래스보다는 람다를 사용하라
  2. 람다보다는 메소드 참조를 사용하라
  3. 표준 함수형 인터페이스를 사용하라
  4. 스트림은 주의해서 사용하라
  5. 스트림에서는 부작용없는 함수를 사용하라
  6. 반환 타입으로는 스트림보다 컬렉션이 낫다
  7. 스트림 병렬화는 주의해서 적용하라

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

람다는 이름이 없고 문서화도 못한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수(3줄 초과)가 많아지면 람다를 쓰지 말아야 한다.

42-1. 익명클래스 vs 람다

람다 이전에는 추상메소드를 하나만 담은 인터페이스를 사용하여 함수 타입을 표현했다. 함수 객체는 익명클래스로 만들어서 사용했다. 코드길이가 장황하다는 단점이 있다.

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

jdk 1.8 부터 추상메소드를 하나만 담은 인터페이스는 함수형 인터페이스(@FunctionalInterface)로 인정받았고, 인스턴스는 람다식으로 만들어서 사용할 수 있게 되었다.

Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

위 코드에서 반환 타입, 매개변수 타입이 없는데, 컴파일러가 알아서 추론하여 넣어준다. 컴파일러가 추론못하겠다고 오류를 뱉을 때만 직접 명시해주면 된다. (ex. 위 코드에서 words가 List<String> 아닌 List 일 경우 오류 발생)

이외에도 아래와 같은 차이점들이 있다.

  1. 익명클래스는 컴파일하면 클래스명$숫자.class 와 같은 형태로 파일이 생성된다.
  2. 람다는 Lambda Factory 라고 불리는 call site 에 각종 정보(외부인자값 등)와 함께 저장된다. invokedynamic 명령어를 통해 메소드가 동적으로 바인딩되고 MethodHandle로 실행된다.
    • cf. 바이트코드 까보면 private static java.lang.Object lambda$main$1(int); 이런식으로 클래스에 메소드가 바인딩된다.
  3. 익명내부클래스는 class load, new 로 객체를 생성할 때 메모리 할당, 초기화 등의 비용이 들어가지만, 람다는 컴파일 타임의 활동(jvm bytecode 레벨에서 invokedynamic 추가)이므로 런타임에 추가비용이 크지 않다.
  4. this 의 사용법이 다르다. 익명 클래스에서는 자신을 지칭하지만, 람다에서는 선언된 클래스를 가리킨다.
  5. 익명클래스는 이제 함수형 인터페이스가 아닌 타입의 인스턴스를 만들때만 사용하자.
  6. 익명클래스와 마찬가지로 람다를 직렬화하는 일은 최대한 없어야 한다.

42-2. 열거타입에서의 람다

열거타입에서도 람다를 이용하여 효과적으로 구성할 수 있다.

public enum Operation {
    PLUS  ("+", (x, y) -> x + y), // = public double apply(double x, double y) { return 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;
    }
    ...
}

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

  • 함수 객체에서 메소드참조까지 사용하면 람다보다도 간결한 코드를 얻을 수 있다.
    • map.merge(key, 1, (count, incr) -> count + incr); => map.merge(key, 1, Integer::sum);
  • 다만 클래스명이 매우 길거나 명확하지 않은 경우에는 메소드참조를 쓰지 않는 것이 도움될 수도 있다. (Function.identity(); 보단 x -> x)
  • 람다로 할 수 없는 일은 메소드 참조로도 할 수 없다. 람다로는 불가능 하나 메소드 참조로는 가능한 유일한 예는 제네릭 함수타입이다.
  • 람다로 작성했을 때 너무 코드가 길거나 복잡하다면 일반 메소드로 작성하고 메소드 참조를 이용하는 것이 좋은 대안이다. 이러면 문서화에도 문제가 없으며, 명확한 이름을 지어줄 수도 있다.

43-1. 메소드 참조의 유형

  1. 인스턴스::인스턴스메소드 : 인스턴스 메소드에 인자를 넣어 호출한다.
    • number -> System.out.println(number) = System.out::println
  2. 클래스::정적메소드 : 정적메소드에 인자를 넣어 호출한다.
    • number -> Math.sqrt(number) = Math::sqrt
  3. 클래스::인스턴스메소드 : 첫번째 인자는 인스턴스가 되고, 두번째 인자부터는 인스턴스 메소드의 인자로 넣어 호출한다.
    • (a, b) -> a.compareTo(b) = Integer::compareTo
  4. 생성자 레퍼런스
    • () -> new TreeMap<K,V>() = TreeMap<K,V>::new
    • len -> new int[len] = int[]::new

43-2. call stack 의 관점

userList를 다음 코드와 같이 콘솔에 모두 찍어낼 수 있다. userList.forEach(user -> System.out.println(user)); 이 코드의 콜스택을 보면 ArrayList.forEach -> Consumer.accept -> System.out.println 이다. 그런데, Consumer.accept 는 사실 필요없고, depth만 깊어지게 한다.

메소드 레퍼런스를 사용하면 이런 부분을 해결할 수 있다. userList.forEach(System.out::println);

자세한 내용은 Is there a performance benefit to using the method reference syntax instead of lambda syntax in Java 8? 를 참조하자

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

  • 람다를 지원하면서 코드 모범사례도 변화가 있었다.
    • ex. 템플릿 메소드 패턴의 매력이 감소하는 대신 함수객체를 받아 제공하는 것이 떠오르고 있다.
  • 필요한 용도에 맞는게 있다면 직접 구현하지 말고, 표준 함수형 인터페이스(java.util.function)를 사용하라. API를 익혀야하는 부담감이 낮아지고 쉬워진다. 아래는 몇가지 표준 인터페이스들이다.
인터페이스명메소드명설명
Runnablevoid run()실행할 수 있는 인터페이스
SupplierT get()제공할 수 있는 인터페이스
Consumervoid accept(T t)소비할 수 있는 인터페이스
Function<T, R>R apply (T t)입력을 받아서 출력할 수 있는 인터페이스
PredicateBoolean test(T t)입력을 받아 참, 거짓을 판단할 수 있는 인터페이스
UnaryOperatorT apply(T t)단항 연산할 수 있는 인터페이스
  • 위 6개만 알면 총 43개는 충분히 유추할 수 있다. (단항 Unary, 이항 Binary 등) (기본타입별 LongToIntFunction, LongBinaryOperator, BooleanSupplier 등)
  • 표준 함수형 인터페이스 대부분은 기본타입만 지원한다. (LongToIntFunction.applyAsInt는 long 인수를 받고 int를 반환)

44-1. 직접 함수형 인터페이스를 작성해도 되는 경우

Comparator<T>와 ToIntBiFunction<T, U>는 구조가 똑같지만 따로있다. 이와같이 아래 4가지 중 1가지 이상을 만족한다면 고려해볼만 하다.

  1. 자주 쓰이며, 이름 자체가 용도를 명확히 설명할 때
  2. 반드시 따라야하는 규약이 있을 때
  3. 유용한 디폴트 메소드를 제공
    • cf. 이 부분에서 혼동이 올 수 있는데, default 메소드, static 메소드는 함수형 인터페이스 제약조건에 상관없다. 아래는 컴파일되며 동작한다.
    @FunctionalInterface
    public interface Test {
        default boolean test1(String devljh) { return true; }
        static boolean test2(String redboy) { return true; }
        boolean test3(int o);
    }
    
  4. 표준 함수형 인터페이스가 없을 때 : 매개변수를 3개받는 Predicate는 없으니 만들어도 된다

44-2. @FunctionalInterface

직접 만든 함수형 인터페이스에는 @FunctionalInterface 어노테이션을 붙이자

  1. 해당 인터페이스는 람다용으로 설계된 것임을 명시
  2. 해당 인터페이스는 단 하나의 추상메소드를 가져야만 컴파일된다

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

  • 스트림은 다량의 데이터 처리 작업을 위해 jdk 1.8 부터 도입되었다. 스트림은 데이터 원소의 유무한 시퀀스를 뜻하며, 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현한다.
  • 스트림은 어디로부터 만들 수 있는데, 가장 많이 쓰이는 곳은 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기 등이 있다.
  • 스트림은 메소드 연쇄를 지원하는 fluent API 이다.

45-1. 스트림 파이프라인

  • 스트림 파이프라인은 시작에서 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간연산이 있을 수 있다. 중간연산은 스트림을 변환한다.
  • 스트림 파이프라인은 지연 평가되며, 평가는 종단 연산이 호출될 때 이루어지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 이것은 무한 스트림을 다룰 수 있게 해주는 열쇠이다.

45-2. 스트림을 과하게 쓰면 오히려 읽기 힘들다

먼저 스트림을 쓰지 않은 예제 코드이다.

public class Anagrams {
    public static void main(String[] args) throws IOException {
        File dictionary = new File(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);

        Map<String, Set<String>> groups = new HashMap<>();
        try (Scanner s = new Scanner(dictionary)) {
            while (s.hasNext()) {
                String word = s.next();
                groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>())
                    .add(word);
            }
        }

        for (Set<String> group : groups.values())
            if (group.size() >= minGroupSize)
                System.out.println(group.size() + ": " + group);
    }

    private static String alphabetize(String s) {
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

스트림을 사용하여 라인수는 조금 짧아졌지만, 읽기 힘들정도로 과하게 사용한 예제이다.

public class Anagrams {
    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);
        }
    }
}

뭐든 stream으로 푸는 것보다 적절히 분리하여(도우미 메소드. ex. alphabetize) 짧으면서도 명확하게 스트림을 사용하는 것이 좋다. 특히 람다에서는 타입이름이 생략되므로 매개변수 이름을 잘 지어서 가독성을 유지해야한다.

try (Stream<String> words = Files.lines(dictionary)) {
    words.collect(groupingBy(word -> alphabetize(word)))
            .values().stream()
            .filter(group -> group.size() >= minGroupSize)
            .forEach(group -> System.out.println(group.size() + ": " + group));
}

또한 스트림은 기본 타입 중에 int, long, double 만 지원한다. (char용 스트림은 없으니 쓰지말자)

45-3. 코드블록 vs 람다블록

  • 코드블록에서는 지역변수를 읽고 수정할 수 있으나, 람다 블록에서는 final 변수이거나 사실상 final 변수인 것만 읽을 수 있고(클로저, Variable capture), 지역변수를 수정하는 것은 불가능하다.
    • ex. stream + lambda 에서 순차증가하는 방법
      // 배열 이용. 멀티스레드에선 안전X
      int[] count = {0};
      IntStream.range(0, 1000000).forEach((i) -> {
      	count[0]++;
      	System.out.println(count[0]);
      });
      
      // Atomic Reference 이용. 권장
      AtomicInteger count = new AtomicInteger();
      IntStream.range(0, 1000000).forEach((i) -> {
      	System.out.println(count.incrementAndGet());
      });
      
  • 코드블록에서는 return, break, continue 문으로 바깥을 종료시키거나 건너뛰거나 하는 행위를 할 수 있지만, 람다에서는 아무것도 할 수 없다.

45-4. 스트림을 사용하면 좋은 상황과 나쁜 상황

45-4-1. 좋은 상황

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

45-4-2. 나쁜 상황

  1. 스트림 파이프라인이 여러 연산단계로 구성될 때, 각 단계의 값들을 동시에 접근하고자 할때
    • 스트림 파이프라인은 연산이 지나가면 원래값을 잃는 구조이기 때문이다.
    • cf. 매핑객체를 이용하면 방법이 있으나 지저분해짐

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

stream은 단순한 API가 아니라 함수형 프로그래밍에 기초한 패러다임이다. 따라서 API만 익히는 것이 아니라 패러다임도 받아들여야 한다. 각 변환 단계에서는 재구성을 하는데, 이전 단계의 결과를 받아 처리하는 순수 함수이어야 한다. 순수함수란 입력만이 결과에 영향을 주는 함수를 말하며, 다른 가변 상태를 참조하지 않고, 함수도 외부의 상태를 변경하지 않는다.

아래 코드는 스트림 패러다임을 이해하지 못한채 API만 사용한 경우이다.

Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) { // tokens은 java9부터 지원
        words.forEach(word -> {
            freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}

이 코드는 스트림을 가장한 반복적 코드이며, 스트림의 이점을 살리지 못했다. 종단연산인 forEach는 스트림 계산 결과를 보고할 때만 사용하고, 계산할 때는 사용치 말자. 아래는 스트림 패러다임을 이해하고 제대로 활용한 코드이다.

Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

46-1. Collectors

스트림을 잘 사용하려면 알아야하는 개념이다. 종단연산으로 foreach같은 계산보단 Collectors를 권장한다. Collector 를 사용하면 스트림의 원소를 컬렉션으로 쉽게 모을 수 있다. 간단한 API로는 toList, toSet, toCollection 등이 있다. 이외에는 아래에서 살펴보자.

46-1-1. toMap

  • Collector<T,?,Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T,? extends U> valueMapper)
    • keyMapper : 스트림의 요소 중 Key를 생성하는 함수
    • keyMapper : 스트림의 요소 중 Value를 생성하는 함수
    • 스트림 요소들이 key를 중복해서 사용하면 IllegalStateException 을 던진다.
  • Collector<T,?,Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T,? extends U> valueMapper, BinaryOperator<U> mergeFunction)
    • mergeFunction : 위처럼 충돌이 날 때 해결해주는 병합 함수이다. key값이 중복되는 값들은 이 함수을 이용해 결과를 낸다.
    Stream<String> s = Stream.of("apple", "banana", "apricot", "orange", "apple");
    Map<Character, String> m = s.collect(Collectors.toMap(s1 -> s1.charAt(0), s1 -> s1, (oldVal, newVal) -> oldVal + "|" + newVal));
    // {a=apple|apricot|apple, b=banana, o=orange}
    
  • Collector<T,?,M> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T,? extends U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier)
    • mapSupplier : Map Factory로서 Map 구현체를 직접 정할 수 있다. (기본적으론 HashMap 인 듯)

46-1-2. groupingBy

SQL의 group by 와 유사한 기능을 제공한다.

  • Collector<T,?,Map<K,List<T>>> groupingBy(Function<? super T, ? extends K> classifier)
    • classifier : 분류 함수로 각 스트림의 요소를 입력받아 Map의 Key로 사용한다. 값은 List이다.
  • Collector<T,?,Map<K,D>> groupingBy(Function<? super T, ? extends K> classifier, Collector<? super T,A,D> downstream)
    • downstream : 위에서 값이 List 이외의 타입을 갖게하고 싶을 때 사용한다.
    Stream<String> s = Stream.of("apple", "banana", "orange");
    Map<Integer, Long> map = s.collect(Collectors.groupingBy(String::length, Collectors.counting()));
    // {5=1, 6=2} . 만약 위의 경우였다면 {5=[apple], 6=[banana, orange]}
    
  • Collector<T,?,M> groupingBy(Function<? super T, ? extends K> classifier, Supplier<M> mapFactory, Collector<? super T,A,D> downstream)
    • mapFactory : Map Factory로서 Map 구현체를 직접 정할 수 있다.

46-1-3. 기타

  • groupingByConcurrent : HashMap 이 아닌 ConcurrentHashMap 으로 만들어준다.
  • partitioningBy : groupingBy 와 비슷하지만, 분류함수(classifier) 자리에 Predicate를 받으며 Map의 Key 타입이 Boolean 이다.
  • minBy, maxBy : Comparator를 이용해 스트림에서 가장 값이 작거나 큰 원소를 찾아 반환한다.
  • joining : 문자열(CharSequence)에만 사용된다. 인자없는 joining은 단순히 연결을 하며, 1개 인자를 넣으면 구분자를 찍는다.

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

자바7까지는 반환하는 원소 시퀀스의 타입으로 Collection 인터페이스, Iterable 인터페이스, 배열 등을 사용했다. 자바8부터는 여기에 스트림 인터페이스도 추가되었다. 스트림은 반복을 지원하지 않는다. 우회적인 방법을 통해 Iterable을 이용하여 반복할 순 있으나 코드가 난잡해지고 직관성이 떨어진다.

마찬가지로 Iterable 역시 stream을 지원하지 않는다. 우회적으로 구현은 할 수 있으나 코드가 난잡해지고 직관성이 떨어진다.

이런 것들을 모두 고려했을때, 반환타입은 Collection 인터페이스로 하는 것이 가장 좋다. Iterable, Stream 을 모두 지원하기 때문이다.

원소 시퀀스가 매우 많은 컬렉션을 반환하는 것은 위험하다. 이런 경우에는 전용 컬렉션을 구현하는 편이 낫다. (ex. 멱집합, 부분리스트)

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

  • 병렬스트림은 .parallel() 로 쉽게 제공된다.
  • 동시성 프로그래밍을 할 때는 안전성과 응답 가능 상태를 유지하기 위해 힘써야한다.
  • 데이터 소스가 Stream.iterate 거나 중간 연산으로 limit을 쓰면 파이프라인 병렬화로는 성능개선을 기대할 수 없다.
    • limit를 다룰 때, CPU코어가 남는다면 우너소 몇개를 더 처리하고 버려도 상관없다고 가정함
  • 스트림 파이프라인을 막 병렬화하면 성능이 오히려 나빠질 수가 있다. 병렬화는 오직 최후 성능 최적화의 수단임을 명심해야한다.
  • 병렬 스트림 파이프라인도 공통의 포크-조인풀(ForkJoinPool.commonPool)에서 수행되므로, 잘못된 파이프라인 하나가 다른 부분에도 영향을 미칠 수 있다.
  • 아래는 병렬화에 적합한 코드상황이다.
    static long pi(long n) {
        return LongStream.rangeClosed(2, n)
                .parallel() // 이 한줄의 추가로 31초 -> 9초 단축
                .mapToObj(BigInteger::valueOf)
                .filter(i -> i.isProbablePrime(50))
                .count();
    }
    

48-1. 병렬스트림에 좋은 데이터소스들

  • 대체로 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap, 배열, int, long 일 때가 병렬화의 효과가 가장 좋다.
  • 이들은 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 다수의 스레드에 분배하기 좋기 때문이다.
  • 또한 이들은 순차실행할 때 참조 지역성이 뛰어나다. 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있기 때문이다. 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 대부분의 시간을 IO wait 로 소비할 것이다.

48-2. 병렬스트림에 효과있는 종단 연산들

  • 순차연산은 효과가 한계가 있으며, 축소(reduction) 연산이 가장 효과가 좋다.
    • 축소는 모든 원소를 하나로 합치는 작업으로, reduce, min, max, count, sum, average 등의 메소드가 있다.
    • (예외) 가변 축소(mutable reduction)에는 collect, toArray가 있는데, 병렬화에 효과적이지 않는다.
  • anyMatch, allMatch, noneMatch 등(조건에 맞으면 바로 반환되는 메소드들)에도 효과가 좋다.
  • 병렬화의 이점을 제대로 누리고 싶으면 spliterator 메소드를 재정의하라


Comments