Effective Java 3rd - Ch07
포스트
취소

Effective Java 3rd - Ch07

Java 기초를 다지기 위해 효과적인 자바란 책을 공부 중입니다.

제7장 람다와 스트림

  • 자바 8에서 함수형 인터페이스, 람다, 메서드 참조라는 개념이 추가되면서 함수 객체를 더 쉽게 만들 수 있게 됨
  • 스트림 API 도 추가되어 데이터 원소의 시퀀스 처리를 라이브러리 차원에서 지원하기 시작

42) 익명 클래스보다는 람다를 사용하라

예전에는 추상 메서드를 하나만 담은 인터페이스를 함수 타입으로 사용했다. (함수 객체)

코드 42-1 익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법이다

1
2
3
4
5
6
7
Collections.sort(words,
  // 익명 클래스 : 코드가 길기 때문에 함수형 프로그래밍에 적합하지 않음
  new Comparator<String>(){       // 정렬을 담당하는 추상 전략
    public int compare(String s1, String s2) {
      return Integer.compare(s1.length(), s2.length());
    }
});

자바 8에 와서 추상 메서드 하나짜리 인터페이스는 특별한 의미를 인정받아 람다식을 이용해 만들게 되었다.

코드 42-2 람다식을 함수 객체로 사용 - 익명 클래스 대체

1
2
3
4
5
6
7
8
// 컴파일러가 대신 문맥을 살펴 타입을 추론
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

// 람다 자리에 비교자 생성 메서드를 사용하면 더 간결해진다
Collections.sort(words, comparingInt(String::length));

// 자바 8에서 List 인터페이스에 추가된 sort 메서드를 이용하면 더 짧아진다
words.sort( comparingInt(String::length));

:hand: Guide to Java 8 - Comparator.comparing()

타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하자.

코드 42-4 함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public enum Operation {
/*
  // >> 코드 42-3
  // 코드 34-6 에서는 상수별 클래스를 이용해 apply 메서드를 재정의 했었다
  PLUS("+") {
      public double apply(double x, double y) { return x + y; }
  },
  MINUS("-") {
      public double apply(double x, double y) { return x - y; }
  },
  TIMES("*") {
      public double apply(double x, double y) { return x * y; }
  },
  DIVIDE("/") {
      public double apply(double x, double y) { return x / y; }
  };

  Operation(String symbol) { this.symbol = symbol; }
*/

  // 람다는 한줄일 때 가장 좋고 길어야 세줄 안에 끝내는 것이 좋다
  // 계산식이 여러줄이라면 상수별 익명 클래스를 사용해야 한다

  // DoubleBinaryOperator 함수 인터페이스로 계산식을 정의
  // **NOTE: 두개의 double 입력을 받아 double 출력하는 함수 형식
  PLUS  ("+", (x, y) -> x + y),   // 계산식을 람다로 구성해 생성자에 넘긴다
  MINUS ("-", (x, y) -> x - y),
  TIMES ("*", (x, y) -> x * y),
  DIVIDE("/", (x, y) -> x / y);

  // DoubleBinaryOperator : Double 타입 인수 2개를 받아 Double 타입 결과를 돌려준다
  private final DoubleBinaryOperator op;    // 계산식의 함수 객체 (멤버)
  private final String symbol;

  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);
  }
}

//////////////////////////////////////////

// 아이템 34의 메인 메서드 (215쪽)
public static void main(String[] args) {
  double x = Double.parseDouble(args[0]);
  double y = Double.parseDouble(args[1]);
  for (Operation op : Operation.values())
    System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}

메서드나 클래스와 달리, 람다는 이름이 없고 문서화도 못한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 함수 객체로 정렬하기 (254-255쪽)
public class SortFourWays {
    public static void main(String[] args) {
        List<String> words = Arrays.asList(args);

        // 코드 42-1 익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법이다! (254쪽)
        Collections.sort(words, new Comparator<String>() {
            public int compare(String s1, String s2) {
                return Integer.compare(s1.length(), s2.length());
            }
        });
        System.out.println(words);
        Collections.shuffle(words);

        // 코드 42-2 람다식을 함수 객체로 사용 - 익명 클래스 대체 (255쪽)
        Collections.sort(words,
                (s1, s2) -> Integer.compare(s1.length(), s2.length()));
        System.out.println(words);
        Collections.shuffle(words);

        // 람다 자리에 비교자 생성 메서드(메서드 참조와 함께)를 사용 (255쪽)
        Collections.sort(words, comparingInt(String::length));
        System.out.println(words);
        Collections.shuffle(words);

        // 비교자 생성 메서드와 List.sort를 사용 (255쪽)
        words.sort(comparingInt(String::length));
        System.out.println(words);
    }
}

:bangbang:   핵심 정리

익명 클래스는 함수형 인터페이스가 아닌 타입의 인스턴스를 만들 때만 사용하라. 람다는 작은 함수 객체를 아주 쉽게 표현할 수 있어 (이전 자바에서는 실용적이지 않던) 함수형 프로그래밍의 지평을 열었다.

43) 람다보다는 메서드 참조를 사용하라

람다가 익명 클래스보다 간결하고, 메서드 참조는 람다보다 더 간결하다.

1
2
3
4
5
6
7
// merge 메서드는 '키/값/함수'를 인수로 받으며
// 주어진 키가 맵 안에 아직 없다면 주어진 {키,값} 쌍을 그대로 저장한다.
// 반대로 키가 이미 있다면 함수를 현재 값과 주어진 값에 적용한 다음, 결과로 현재 값을 덮어쓴다
map.merge(key, 1, (count, incr) -> count + incr);

// 자바 8에서 Integer 클래스는 이 람다와 기능이 같은 정적 메서드 'sum'을 제공하기 시작했다
map.merge(key, 1, Integer::sum);

:point_right: 사용 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// map.merge를 이용해 구현한 빈도표 - 람다 방식과 메서드 참조 방식을 비교해보자. (259쪽)
public class Freq {
  public static void main(String[] args) {
    Map<String, Integer> frequencyTable = new TreeMap<>();
    for (String s : args)
        frequencyTable.merge(s, 1, (count, incr) -> count + incr); // 람다
    System.out.println(frequencyTable);

    frequencyTable.clear();
    for (String s : args)
        frequencyTable.merge(s, 1, Integer::sum); // 메서드 참조
    System.out.println(frequencyTable);
  }
}

람다로 할 수 없는 일이라면 메서드 참조로도 할 수 없다. 즉, 람다로 작성할 코드를 새로운 메서드에 담은 다음, 람다 대신 그 메서드 참조를 사용하는 식이다.

메서드 참조의 유형은 다섯 가지로, 가장 흔한 유형은 정적 메서드를 가리키는 메서드 참조이다.

  • 한정적 인스턴스 메서드
    • 수신 객체(참조 대상 인스턴스)를 특정. 근본적으로 정적 참조와 비슷
    • 함수 객체가 받는 인수와 참조되는 인수가 받는 인수가 똑같다
  • 비한정적 인스턴스 메서드
    • 수신 객체를 특정하지 않는 참조
    • 함수 객체를 적용하는 시점에 수신 객체를 알려준다
      • 이를 위해 수신 개게 전달용 매개변수가 매개변수 목록의 첫번째로 추가되며 그 뒤로 참조되는 매개변수들이 뒤따른다
    • 주로 스트림 파이프라인에서의 매핑과 필터 함수에 쓰인다

:bangbang:   다섯가지 메서드 참조

메서드 참조유형 같은 기능을 하는 람다
정적 Integer::parseInt str -> Integer.parseInt(str)
한정적(인스턴스) Instant.now()::isAfter Instant then = 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]

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

자바가 람다를 지원하면서 API를 작성하는 모범 사례도 크게 바뀌었다. 같은 효과의 함수 객체를 받는 정적 팩토리나 생성자를 제공하는 것이다. 즉, 함수 객체를 매개변수로 받는 생성자와 메서드를 더 많이 만들어야 한다.

LinkedHashMap 을 생각해보면, removeEldestEntry 를 다음처럼 재정의하면 맵에 원소가 100개가 될 때까지 커지다가 그 이상이 되면 가장 오래된 원소를 하나씩 제거한다. (가장 최근 원소 100개를 유지한다)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
  return size() > 100;
}

//////////////////////////////

// ** 참고 https://www.geeksforgeeks.org/linkedhashmap-removeeldestentry-method-in-java/

// Creating the linked hashmap and implementing
// removeEldestEntry() to MAX size
LinkedHashMap<Integer, String> li_hash_map =
    new LinkedHashMap<Integer, String>() {
        protected boolean removeEldestEntry(Map.Entry<Integer, String> eldest) {
            return size() > MAX;    // MAX = 6
        }
    };

코드 44-1 불필요한 함수형 인터페이스 - 대신 표준 함수형 인터페이스를 사용하라

1
2
3
4
5
@FunctionalInterface interface EldestEntryRemovalFunction<K,V> {
    boolean remove( Map<K,V> map, Map.Entry<K,V> eldest );
}

// 표준 인터페이스인 BiPredicate<Map<K,V>, Map.Entry<K,V>> 를 사용할 수도 있다

:hand: @FunctionalInterface 애너테이션을 사용하는 이유

  • 그 인터페이스가 람다용으로 설계된 것임을 알려준다
  • 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해준다
  • 그 결과 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다

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

java.util.function 패키지를 보면 다양한 용도의 표준 함수형 인터페이스가 담겨 있다.

:arrow_right: 필요한 용도에 맞는게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하라.

  • API 가 다루는 개념의 수가 줄어들어 익히기 더 쉬워진다
  • 다른 코드와의 상호운용성도 크게 좋아질 것이다

java.util.function 패키지에는 총 43개의 인터페이스가 담겨 있다. 기본 인터페이스 6개만 기억하면 나머지를 유추해 낼 수 있다.

  • 기본 인터페이스는 기본 타입인 int, long, double 용으로 각 3개씩 변형이 생겨난다
    • ex: IntPredicate, DoubleBinaryOperator, LongFuntion<int[]>
  • Function 인터페이스의 입력과 출력은 ‘To’로 연결
    • ex: LongToIntFunction, ToLongFunction<int[]>
  • 인수를 두개씩 받는 변형 3가지
    • ex: BiPredicate<T,U>, BiFunction<T,U,R>, BiConsumer<T,U>
  • boolean 명시한 유일한 인터페이스 : BooleanSupplier
인터페이스 함수 시그니처
BinaryOperator<T> T apply(T t1, T t2) BigInteger::add
Predicate<T> boolean test(T t) Collection::isEmpty
Function<T,R> R apply(T t) Arrays::asList
Supplier<T> T get() Instant::now
Consumer<T> void accept(T t) System.out::println

:bangbang:   기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자 (처참히 느려질 수 있다)

Comparator<T> 인터페이스는 구조적으로 ToIntBiFunction<T,U>와 동일하다.

그러나 Comparator 가 독자적인 인터페이스로 살아남아야 하는 이유

  • API 에서 자주 사용되는데, 이름이 용도를 훌륭히 설명해준다
  • 구현하는 쪽에서 반드시 지켜야 할 규약을 담고 있다
  • 비교자들을 변환하고 조합해주는 유용한 디폴트 메서드들을 듬뿍 담고 있다

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

스트림 API 는 다량의 데이터 처리 작업을 돕고자 자바 8에 추가 되었다.

  • 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다
    • 대표적으로 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기, 혹은 다른 스트림
    • 데이터 원소들은 객체 참조나 기본 타입값 (int, long, double 지원)
  • 스트림 파이프라인(pipeline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념
    • 소스 스트림에서 시작해 종단 연산으로 끝나며, 하나 이상의 중간 연산이 존재
    • 중간 연산은 스트림을 어떠한 방식으로 변환(transform) 한다
    • 지연 수행(lazy evaluation) [ ** ‘평가’ 라는 해석은 적절치 않음 ]
      • evaluation 은 종단 연산이 호출될 때 이루어진다. (종단 연산에 쓰이지 않는 데이터 원소는 계산되지 않음)

스트림 API는 메서드 연쇄를 지원하는 플루언트 API(fluent API)이다.

  • 즉 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다.
  • 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.
  • 파이프라인은 기본적으로 순차적으로 수행된다.
    • 병렬처리를 위해 parallel 메서드는 사용할 수 있으나 효과를 볼 수 있는 상황은 많지 않다.

스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.

코드 45-1 사전 하나를 훑어 원소 수가 많은 아나그램 그룹들을 출력한다. (269-270쪽)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 아나그램 : 철자를 구성하는 알파벳이 같고 순서만 다른 단어

public class IterativeAnagrams {
    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);
                // "unused" == "__" : 람다에서 파라미터 사용하지 않음을 의미
            }
        }

        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);
    }
}

:hand: JAVA) TreeSet의 기본 사용법

  • HashSet : 중복을 허용하지 않음, 순서를 보장하지 못함
  • TreeSet : RB 트리를 이용해 값에 따라 정렬, 약간 느림
  • LinkedHashSet : 입력순서를 보장, 더 느리다

:hand: Java 8의 람다 함수 살펴보기 - computeIfAbsent

1
2
3
public V
       computeIfAbsent(K key,
             Function<? super K, ? extends V> remappingFunction)
1
2
3
4
5
Parameters: This method accepts two parameters:
- key : key for which we want to compute value using mapping.
- remappingFunction : function to do the operation on value.

Returns: This method returns current (existing or computed) value associated with the specified key, or null if mapping returns null.

코드 45-2 스트림을 과하게 사용했다. - 따라 하지 말 것! (270-271쪽)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 스트림을 과용하면 프로그램을 읽거나 유지보수하기 어려워진다
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쪽)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class HybridAnagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);

        // try-with-resouces 블록
        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));
        }
    }

    // 분리함으로써 가독성을 높여준다
    // **NOTE: 자바는 char용 스트림을 지원하지 않는다 (int 값의 형변환 문제)
    private static String alphabetize(String s) {
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

:hand: 람다 매개변수의 이름은 주의해서 정해야 한다. 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.

:hand: 별도 메서드인 alpabetize 경우, 연산에 적절한 이름을 지어주고 세부 구현을 주 프로그램 로직 밖으로 빼내 전체적인 가독성을 높였다. 도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다는 스트림 파이프라인에서 훨씬 크다.

1
2
3
4
5
6
7
8
9
10
11
12
// char 데이터를 처리할 때는 스트림 사용을 자제하자. (272쪽)
public class CharStream {
    public static void main(String[] args) {
        // 예상한 결과와 다르다.
        "Hello world!".chars().forEach(System.out::print);
        System.out.println();

        // 문제를 고치려면 형변환을 명시적으로 해줘야 한다.
        "Hello world!".chars().forEach(x -> System.out.print((char) x));
        System.out.println();
    }
}

기존 코드는 스트림을 사용하여 리팩토링 하되, 새 코드가 더 나아 보일 때만 반영하자.

다음 일들에는 스트림이 아주 안성맞춤이다.

  • 원소들의 시퀀스를 일관되게 변환한다
  • 원소들의 시퀀스를 필터링한다
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합한다 (더하기, 연결하기, 최소값 구하기 등)
  • 원소들의 시퀀스를 컬렉션에 모은다 (아마도 공통된 속성을 기준으로)
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다

스트림으로 처리하기 어려운 일도 있다.

  • 대표적으로 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기 어려운 경우다.
    • 스트림 파이프라인은 일단 한 값을 다른 값에 맵핑하고 나면 원래의 값은 잃는 구조이기 때문
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 스트림을 사용해 처음 20개의 메르센 소수를 생성한다. (274쪽)
public class MersennePrimes {
    // 스트림 출력은 복수 명사로 쓸 것을 강력히 추천 (가독성)
    static Stream<BigInteger> primes() {
        // Stream.iterate(initial value, next value)
        // 첫번째 param: 스트림의 첫번째 원소
        // 두번째 param: 스트림에서 다음 원소를 생성해주는 함수
        return Stream.iterate(TWO, BigInteger::nextProbablePrime);
    }

    public static void main(String[] args) {
        // 원래 값과 새로운 값이 모두 필요한 상황
        // ==> 가능하다면 일단 진행하고 나중에 매핑을 거꾸로 해서라도 원래 값을 구하는 방식이 낫다
        primes()
                // 메르센 수 (p는 소수) : '2^p-1' 도 소수일 확률이 높다
                .map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
                // 소수성 검사가 50% true 일 확률로 필터링
                .filter(mersenne -> mersenne.isProbablePrime(50))
                .limit(20)
                // 거꾸로 (원래 값) p 값을 구하기 위해 bitLength() 수행
                .forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
    }
}

스트림과 반복 중 어느 쪽을 써야할지 바로 알기 어려운 작업도 많다.

데카르트 곱 : 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제

코드 45-4 데카르트 곱 계산을 반복 방식으로 구현 (275쪽)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Card {
    public enum Suit { SPADE, HEART, DIAMOND, CLUB }
    public enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN,
                       EIGHT, NINE, TEN, JACK, QUEEN, KING }

    private final Suit suit;
    private final Rank rank;

    @Override public String toString() {
        return rank + " of " + suit + "S";
    }

    public Card(Suit suit, Rank rank) {
        this.suit = suit;
        this.rank = rank;

    }
    private static final List<Card> NEW_DECK = newDeck();

    // 반복 방식으로 두 리스트의 데카르트 곱을 생성한다.
    private static List<Card> newDeck() {
        List<Card> result = new ArrayList<>();
        for (Suit suit : Suit.values())
            for (Rank rank : Rank.values())
                result.add(new Card(suit, rank));
        return result;
    }

    public static void main(String[] args) {
        System.out.println(NEW_DECK);
    }
}

코드 45-5 데카르트 곱 계산을 스트림 방식으로 구현 (276쪽)

1
2
3
4
5
6
7
8
9
10
    // 스트림 방식으로 두 리스트의 데카르트 곱을 생성한다.
    private static List<Card> newDeck() {
        return Stream.of(Suit.values())
                // 평탄화 : 원소 각각을 하나의 스트림으로 매핑한 다음, 다시 하나의 스트림으로 합친다
                // [A[a,b,c]], [B[a,b,c]], [C[a,b,c]] ==> [Aa][Ab][Ac][Ba]...
                .flatMap(suit ->
                        Stream.of(Rank.values())
                                .map(rank -> new Card(suit, rank)))
                .collect(toList());
    }

:bangbang:   스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 선택하라

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

스트림은 처음 봐서는 이해하기 어려울 수 있다. 스트림은 그저 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이기 때문이다.

스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.

이 때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다. (부작용이 없어야 한다)

1
- 가장 중요한 수집기 팩토리는 toList, toSet, toMap, groupingBy, joining 이다

코드 46-1 스트림 패러다임을 이해하지 못한 채 API만 사용했다 - 따라 하지 말 것!

1
2
3
4
5
6
7
8
9
10
11
// 스트림 코드를 가장한 반복적 코드다
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {

    // **NOTE: forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고
    //         계산하는데는 쓰지 말자
    // 반복문
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}

코드 46-2 스트림을 제대로 활용해 빈도표를 초기화한다.

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

수집기(collector)는 스트림을 사용하려면 꼭 배워야 하는 새로운 개념이다. (java.util.stream.Collectors 클래스)

  • 메서드가 무려 39개, 그중엔 매개변수가 5개나 되는 것도 있음
  • 일단 축소 전략을 캡슐화한 블랙박스 객체라고 생각하기 바란다
  • 수집기가 생성하는 객체는 일반적으로 컬렉션이며, 그래서 ‘collector’라는 이름을 쓴다
    • 총 세가지 : toList(), toSet(), toCollection(collectionFactory)

코드 46-3 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인 (279쪽)

1
2
3
4
5
6
7
8
List<String> topTen = freq.keySet().stream()
        // Stream.sorted(Comparator<? super T> comparator)
        // Comparator.comparing() : 비교자 생성 메서드
        .sorted( comparing(freq::get).reversed() )
        .limit(10)
        // Collectors.toList : Collectors 의 멤버를 정적 임포트하여 사용.
        //                     (가독성이 높아져 흔히들 이렇게 함)
        .collect(toList());

:hand: Guide to Java 8 Comparator.comparing()
:hand: Java 8 Stream sorted() Example

Collectors.toMap 메서드

코드 46-4 toMap 수집기를 사용하여 문자열을 열거 타입 상수에 매핑한다

1
2
3
4
5
6
private static final Map<String, Operation> stringToEnum =
    Stream.of( values() ).collect(
      // 같은 키가 출현하면 충돌하므로 groupingBy 또는
      // 병합함수가 제공되는 toMap 함수를 사용해야 한다
      toMap( Object::toString, e -> e)
    );

코드 46-5 각 키와 해당 키의 특정 원소를 연관 짓는 맵을 생성하는 수집기

1
2
3
4
Map<Artist, Album> topHits = albums.collect(
    // Collectors.maxBy 는 Comparator<T> 를 받아 BinaryOperator<T> 를 돌려준다
    toMap( Album::artist, a->a, maxBy(comparing(Album::sales)))
  );

:hand: Collectors minBy() and maxBy() method in Java - Techie Delight

코드 46-6 마지막에 쓴 값을 취하는 수집기

1
toMap( keyMapper, valueMapper, (oldVal, newVal) -> newVal )

Collectors.groupingBy 메서드

1
2
3
4
5
words.collect( groupingBy( word -> alphabetize(word)) )

//////////////////

Map<String, Long> freq = words.collect( groupingBy(String::toLowerCase, counting()));

Collectors.counting 메서드

다운스트림 수집기 전용이다. collect(counting()) 형태로 사용할 일은 전혀 없다.

그 외에도

  • summing, averaging, summarizing 으로 시작하며 int, long, double 용으로 각각 3개씩 존재
  • 다중정의 된 reducing 메서드들
  • filtering, mapping, flatMapping, collectingAndThen 메서드 등
  • joinging : 문자열 등의 CharSequence 인스턴스의 스트림에만 적용 가능

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

항목45에서 언급했듯이 스트림은 반복을 지원하지 않는다.

API 를 스트림만 반환하도록 짜 놓으면 반환된 스트림을 for-each로 반복하길 원하는 사용자는 당연히 불만을 토로할 것이다.

코드 47-1 자바 타입 추론의 한계로 컴파일 되지 않는다

1
2
3
4
// Error : not expected for
for( ProcessHandle ph : ProcessHandle.allProcesses()::iterator ){
  // 프로세스를 처리한다
}

코드 47-2 스트림을 반복하기 위한 ‘끔찍한’ 우회 방법

1
2
3
4
// Iterable 로 형변환
for( ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator ){
  // 프로세스를 처리한다
}

다행히 어댑터 메서드를 사용하면 상황이 나아진다.
자바는 이런 메서드를 제공하지 않지만 쉽게 만들어 낼 수 있다.

코드 47-3 Stream<E>를 Iterable<E>로 중개해주는 어댑터 (285쪽)

1
2
3
4
5
6
7
8
9
10
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}

/////////////////////

// 어댑터를 사용하면 어떤 스트림도 for-each 문으로 반복할 수 있다.
for( ProcessHandle ph : iterableOf( ProcessHandle.allProcesses()) ){
  // 프로세스를 처리한다
}

반대로, API 가 Iterable 만 반환하면 이를 스트림 파이프라인에서 처리하려 프로그래머가 성을 낼 것이다.
마찬가지로 stream 어댑터도 쉽게 구현할 수 있다.

코드 47-4 Iterable<E>를 Stream<E>로 중개해주는 어댑터 (286쪽)

1
2
3
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false);
}

원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection 이나 그 하위 타입을 쓰는게 일반적으로 최선이다.

  • Collection 인터페이스는 Iterable 의 하위 타입이고 stream 메서드도 제공
  • Arrays 역시 Arrays.asList 와 Stream.of 메서드로 손쉽게 반복과 스트림을 제공

하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안된다

코드 47-5 입력 집합의 멱집합을 전용 컬렉션에 담아 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class PowerSet {
    // 원소 개수 n개의 멱집합 원소 개수는 2^n 개가 된다
    // {a,b,c} => { {},{a},{b},{c},{a,b},{a,c},{b,c},{a,b,c} }
    public static final <E> Collection<Set<E>> of(Set<E> s) {
        List<E> src = new ArrayList<>(s);
        // **NOTE: Stream 이나 Iterable 은 무한하지만,
        //         size()는 int를 반환하므로 Collection 길이에 한계가 있다
        if (src.size() > 30)
            throw new IllegalArgumentException(
                "집합에 원소가 너무 많습니다(최대 30개).: " + s);

        // 멱집합은 매우 크므로 반환할 때 생성한다
        return new AbstractList<Set<E>>() {
            // Collection 구현체 작성시 꼭 필요한 메서드1 : size
            @Override public int size() {
                // 멱집합의 크기는 2를 원래 집합의 원소 수만큼 거듭제곱 것과 같다.
                return 1 << src.size();
            }

            // Collection 구현체 작성시 꼭 필요한 메서드2 : contains
            @Override public boolean contains(Object o) {
                return o instanceof Set && src.containsAll((Set)o);
            }

            @Override public Set<E> get(int index) {
                Set<E> result = new HashSet<>();
                for (int i = 0; index != 0; i++, index >>= 1)
                    if ((index & 1) == 1)
                        result.add(src.get(i));
                return result;
            }
        };
    }

    public static void main(String[] args) {
        Set s = new HashSet(Arrays.asList(args));
        System.out.println(PowerSet.of(s));
    }
}

contains 와 size 를 구현하는게 불가능할 때는 컬렉션보다는 스트림이나 Iterable 을 반환하는 편이 낫다.

코드 47-6 입력 리스트의 모든 부분리스트를 스트림으로 반환한다. (288-289쪽)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 리스트의 모든 부분리스트를 원소를 갖는 스트림을 생성하는 첫번째 방법
public class SubLists {
    public static <E> Stream<List<E>> of(List<E> list) {
        return Stream.concat(Stream.of(Collections.emptyList()),
                prefixes(list).flatMap(SubLists::suffixes));
    }

    // prefix => 1:{a}, 2:{a,b}, 3:{a,b,c}
    private static <E> Stream<List<E>> prefixes(List<E> list) {
        return IntStream.rangeClosed(1, list.size())
                .mapToObj(end -> list.subList(0, end));
    }

    // suffix => 1:{a,b,c}, 2:{b,c}, 3:{c}
    private static <E> Stream<List<E>> suffixes(List<E> list) {
        return IntStream.range(0, list.size())
                .mapToObj(start -> list.subList(start, list.size()));
    }

    ///////////////////////
    public static void main(String[] args) {
        List<String> list = Arrays.asList(args);
        SubLists.of(list).forEach(System.out::println);
    }
}
1
2
3
4
// for 문 중첩해 만든 것과 비슷
for( int start=0; start<src.size(); start++ )
  for( int end=start+1; end<=src.size(); end++ )
    System.out.println( src.subList(start, end));

코드 47-7 입력 리스트의 모든 부분리스트를 스트림으로 반환한다(빈 리스트는 제외)

1
2
3
4
5
6
7
8
9
// 두번째 구현 방법
// for 문 중첩한 형태를 스트림으로 변환한 것이라 읽기에는 안좋다.
public static <E> Stream<List<E>> of(List<E> list) {
    return IntStream.range(0, list.size())
            .mapToObj(start ->
                    IntStream.rangeClosed(start + 1, list.size())
                            .mapToObj(end -> list.subList(start, end)))
            .flatMap(x -> x);
}

48) 스트림 병렬화는 주의해서 적용하라

병렬 스트림 파이프라인 프로그래밍

자바 8부터는 parallel 메서드만 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다.

코드 48-1 스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램

1
2
3
4
5
6
7
8
9
10
11
// 12.5 초 걸렸다. 더 빠르게 하고 싶어 parallel 을 넣고자 한다
public static void main(String[] args) {
    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);
}
1
2
3
4
5
6
7
// 주의!!: 병렬화의 영향으로 프로그램이 종료하지 않는다.
//         스트림 라이브러리가 이 파이프라인을 병렬화 하는 방법을 찾아내지 못했기 때문
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
        .parallel()     // 스트림 병렬화
        .filter(mersenne -> mersenne.isProbablePrime(50))
        .limit(20)
        .forEach(System.out::println);

데이터 소스가 Stream.iterate 거나 중간 연산으로 limit 를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.

스트림 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap 의 인스턴스이거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다.

  • 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기 좋다는 특징이 있다
    • 나누는 작업은 Spliterator 가 담당하며, Stream 이나 Iterable 의 spliterator 메서드로 얻어올 수 있다
  • 원소들을 순차적으로 실행할 때 참조 지역성이 뛰어나다

스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다.

  • 병렬화에 가장 적합한 것은 축소(reduction) 이다 : min, max, count, sum 등
  • 반면, 가변 축소는 적합하지 않다 (컬렉션들을 합치는 부담이 크기 때문)

직접 구현한 Stream, Iterable, Collection 이 병렬화의 이점을 제대로 누리게 하고 싶다면 spliterator 메서드를 반드시 재정의하고 결과 스트림의 병렬화 성능을 강도 높게 테스트 하라.

파이프라인이 수행하는 진짜 작업이 병렬화에 드는 추가 비용을 상쇄하지 못한다면 성능 향상은 미미할 수 있다.

  • 스트림 안의 원소 수와 원소당 수행되는 코드 줄 수를 곱해, 이 값이 최소 수십만은 되어야 성능 향상을 맛볼 수 있다.
  • 조건이 잘 갖춰지면 거의 프로세서 코어수에 비례하는 성능 향상도 가능 (머신러닝, 빅데이터 처리 같은 경우)

코드 48-2 소수 계산 스트림 파이프라인 - 병렬화에 적합하다

1
2
3
4
5
6
7
// 10^8 계산시 31초 소요
static long pi(long n) {
    return LongStream.rangeClosed(2, n)
            .mapToObj(BigInteger::valueOf)
            .filter(i -> i.isProbablePrime(50))
            .count();
}

코드 48-3 소수 계산 스트림 파이프라인 - 병렬화 버전

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// parallel() 호출 하나로 시간이 9.2초로 단축되었다
public class ParallelPrimeCounting {
    static long pi(long n) {
        return LongStream.rangeClosed(2, n)
                .parallel()
                .mapToObj(BigInteger::valueOf)
                .filter(i -> i.isProbablePrime(50))
                .count();
    }

    public static void main(String[] args) {
        System.out.println(pi(10_000_000));
    }
}

:hand: 무작위 수들로 이뤄진 스트림을 병렬화 하려거든, SplittableRandom 인스턴스를 이용하자

  • ThreadLocalRandom 에 비해 선형으로 성능이 증가한다 (구식인 Random 은 극악)

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.