BOOK 6 - 이펙티브 자바(7)

7장 람다와 스트림

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

  • 예전에 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스를 사용했다.
    • 이런 인터페이스의 인스턴스를 함수 객체라고 하고, 특정 함수나 동작을 나타내는데 썼다.
  • JDK 1.1부터 함수 객체를 만드는 주요 수단은 익명 클래스가 되었다.
Collections.sort(words, new Comparator<String>() {
  public int compare(String s1, String s2) {
    return Integer.compare(s1.length(), s2.length());
  }
});
  • Comparator 인터페이스가 정렬을 담당하는 추상 전략이다.
    • 문자열을 정렬하는 구체적인 전략을 익명 클래스로 구현했다.

람다식 (lambda expression)

  • 자바 8부터 추상 메서드 하나짜리 인터페이스를 함수형 인터페이스라고 부르고, 이 인터페이스의 인스턴스를 람다식을 이용해 만들 수 있게 되었다.
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
  • 여기서 람다, 매개변수 (s1, s2) , 반환값의 타입을 직접 명시하지 않았다.
    • 컴파일러가 문맥을 살펴 타입을 추론해준 것이다.
    • 컴파일러가 상황에 따라 타입을 결정하지 못할때는 프로그래머가 직접 명시해야 한다.
  • 따라서 타입을 명시해야 코드가 더 명확할 때를 제외하고, 람다의 모든 매개변수 타입은 생략하는 것이 좋다.

비교자 생성 메서드 사용

  • 람다 자리에 비교자 생성 메서드를 사용하면 코드를 더 간결하게 만들 수 있다.
Collections.sort(words, comparingInt(String::length));

Operation 예제

  • 이전 챕터 Operation 예제에서 apply 메서드 동작이 상수마다 달라질 때 상수별 클래스 몸체를 사용해 각 상수에서 apply 를 재정의하도록 했다.
    • 상수별 클래스 몸체를 구현하는 방식보다 열거 타입에 인스턴스 필드를 두는 편이 더 낫다고 했다.
  • 람다를 이용하면 열거 타입의 인스턴스 필드를 이용하는 방식으로 쉽게 구현할 수 있다.
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);
  }
}
  • DoubleBinaryOperatorjava.util.function 이 제공하는 다양한 함수 인터페이스 중 하나이다.
람다보다 상수별 클래스 몸체를 사용해야하는 경우
  • 람다는 이름이 없고 문서화도 하지 못한다.
    • 코드 자체로 동작이 명확히 설명되지 않거나 코드가 길어지면 람다를 쓰지 말아야 한다.
  • 열거 타입 생성자에 넘겨지는 인수들의 타입은 컴파일타임에 추론된다.
    • 따라서 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다.
    • 인스턴스는 런타임에 만들어지기 때문이다.
  • 상수별 동작을 세 줄 이내로 구현하기 어렵거나, 인스턴스 필드나 메서드를 사용해야 하는 상황이라면 상수별 클래스 몸체를 사용해야 한다.

람다의 한계

  • 람다는 함수형 인터페이스에서만 쓰이기 때문에, 추상 클래스의 인스턴스를 만들 때는 사용할 수 없다.
  • 추상 메서드가 여러 개인 인터페이스의 인스턴스를 만들 때도 익명 클래스를 사용해야 한다.
  • 람다는 자신을 참조할 수 없다.
    • 람다에서의 this 키워드는 바깥 인스턴스를 가리킨다.
    • 반면 익명 클래스에서 this 는 익명 클래스의 인스턴스 자신을 가리킨다.
    • 함수 객체가 자신을 참조해야 한다면 익명 클래스를 사용해야 한다.

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

  • 메서드 참조는 함수 객체를 람다보다도 간결하게 만들 수 있는 방법이다.

  • 다음 코드는 임의의 키와 Integer 값의 매핑을 관리하는 프로그램의 일부다.

map.merge(key, 1, (count, incr) -> count + incr);
  • merge 메서드는 키, 값, 함수를 인수로 받아 주어진 키가 맵 안에 아직 없다면 (키, 값) 쌍을 그대로 저장하고, 키가 이미 있다면 함수를 현재 값과 주어진 값에 적용한 다음 현재 값에 덮어 쓴다.

  • 위 코드에서 매개변수 count , incr 는 크게 하는 일 없이 공간을 꽤 차지한다.
  • 람다 대신 Integer 클래스의 정적 메서드 sum 메서드 참조를 전달하면 똑같은 결과를 더 보기 좋게 얻을 수 있다.
map.merge(key, 1, Integer::sum);

메서드 참조의 장점

  • 람다로 구현했을 때 너무 길거나 복잡하다면 메서드 참조가 좋은 대안이 되어준다.
  • 즉, 람다로 작성할 코드를 새로운 메서드에 담은 다음, 람다 대신 그 메서드 참조를 사용하는 것이다.
  • 메서드 참조에는 기능을 잘 드러내는 이름을 지어줄 수 있고, 설명을 문서화할 수도 있다.

메서드 참조보다 람다를 사용하는 것이 나은 경우

  • 때로는 람다가 메서드 참조보다 간결할 때가 있다.
  • 메서드와 람다가 같은 클래스 안에 있을 때 그렇다.
service.execute(GoshThisClassNameIsHumongous::action);
  • 만약 해당 코드가 GoshThisClassNameIsHumongous 클래스 안에 있다면 다음과 같이 람다를 사용하는 것이 더 간단할 수 있다.
service.execute(() -> action());
  • java.util.function 패키지가 제공하는 정적 팩터리 메서드 Function.identity() 를 사용할 때도 똑같은 기능의 람다(x -> x)를 사용하는 것이 코드가 짧고 명확하다.

메서드 참조 유형

  • 메서드 참조의 유형은 다섯가지이다.
메서드 참조 유형 같은 기능을 하는 람다
정적 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]
  • 한정적 인스턴스 메서드 참조 : 수신 객체(참조 대상 인스턴스)를 특정한다.
    • 근본적으로 정적 참조와 비슷하다.
    • 즉, 함수 객체가 받는 인수와 참조되는 메서드가 받는 인수가 똑같다.
  • 비한정적 인스턴스 메서드 참조 : 수신 객체를 특정하지 않는다.
    • 함수 객체를 적용하는 시점에 수신 객체를 알려준다.
    • 수신 객체 전달용 매개변수가 매개변수 목록의 첫번째로 추가되며, 그 뒤로는 참조되는 메서드 선언에 정의된 매개변수들이 뒤따른다.
    • 주로 스트림 파이프라인에서의 매핑과 필터 함수에 쓰인다.
  • 참고 : https://countryxide.tistory.com/127

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

  • 자바가 람다를 지원하면서 API를 작성하는 모범 사례도 크게 바뀌었다.
  • 예전에는 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴을 사용했다.
  • 이제는 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공한다.

LinkedHashMap 예제

  • LinkedHashMap 클래스의 메서드 removeEldestEntry 를 재정의하면 캐시로 사용할 수 있다.
    • 다음처럼 재정의하면 맵에 원소가 100개가 될 때까지 커지다가 그 이상이 되면 새로운 키가 더해질 때마다 가장 오래된 원소를 하나씩 제거한다.
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
  return size() > 100;
}

람다 사용

  • 람다를 사용하도록 함수 객체를 받는 정적 팩터리나 생성자를 제공할 수 있다.
  • removeEldestEntrysize() 를 호출해 맵 안의 원소 수를 알아내는데, 이는 removeEldestEntry 가 인스턴스 메서드라서 가능한 방식이다.
  • 생성자에 넘기는 함수 객체는 이 맵의 인스턴스 메서드가 아니다.
    • 팩터리나 생성자를 호출할 때는 맵의 인스턴스가 존재하지 않기 때문이다.
  • 따라서 맵 자기 자신도 함수 객체에 건네줘야 한다.
  • 이를 반영하여 함수형 인터페이스를 다음과 같이 선언할 수 있다.
@FunctionalInterface
interface EldestEntryRemovalFunction<K,V> {
  boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
  • 이 인터페이스도 잘 동작하긴 하지만, 굳이 사용할 이유는 없다.
  • 자바 표준 라이브러리에 이미 같은 모양의 인터페이스가 준비되어 있기 때문이다.
    • BiPredicate<Map<K,V>, Map.Entry<K,V>> 를 대신 사용할 수 있다.
  • 필요한 용도에 맞는게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하는 것이 좋다.

표준 함수형 인터페이스

  • java.util.function 패키지에 다양한 용도의 표준 함수형 인터페이스가 담겨있다. (총 43개)
  • 표준 함수형 인터페이스들은 유용한 디폴트 메서드를 많이 제공하므로 다른 코드와의 상호운용성도 크게 좋아진다.

기본 인터페이스

  • 기본 인터페이스 6개만 기억하면 나머지를 충분히 유추해낼 수 있다.
인터페이스 함수 시그니처
UnaryOperator T apply(T t) String::toLowerCase
BinaryOperator T apply(T t1, T t2) BigInteger::add
Predicate boolean test(T t) Collection::isEmpty
Function<T, R> R apply(T t) Arrays::asList
Supplier T get() Instant::now
Consumer void accept(T t) System.out::println
  • Operator : 반환값과 인수의 타입이 같은 함수
    • UnaryOperator : 인수가 1개
    • BinaryOperator : 인수가 2개
  • Predicate : 인수를 받아 boolean 을 반환하는 함수
  • Function : 인수와 반환 타입이 다른 함수
  • Supplier : 인수를 받지 않고 값을 반환하는 함수
  • Consumer : 인수를 받고 반환값은 없는 함수 (인수를 소비하는 함수)

기본 인터페이스 변형

  • 기본 인터페이스는 기본 타입인 int , long , double 용으로 각 3개씩 변형이 있다.
    • IntPredicate , LongBinaryOperator 등등
    • Function 의 경우 반환타입이 매개변수화 됐다.
      • LongFunction<int[]> : long 인수를 받아 int[] 를 반환한다.
Function 인터페이스 변형
  • 기본 타입을 반환하는 변형이 총 9개 더 있다.
  • 입력과 결과 타입이 모두 기본 타입이면 접두어로 srcToResult 를 사용한다.
    • LongToIntFunction : long 인수를 받아 int 를 반환한다.
  • 입력을 매개변수화 하고 접두어로 ToResult 를 사용한다.
    • ToLongFunction<int[]> : int[] 인수를 받아 long 을 반환한다.
인수를 2개씩 받는 변형
  • BiPredicate<T,U>
  • BiFunction<T,U,R>
    • ToIntBiFunction<T,U> , ToLongBiFunction<T,U> , ToDoubleBiFunction<T,U>
  • BiConsumer<T,U>

  • ObjDoubleConsumer<T> , ObjIntConsumer<T> , ObjLongConsumer<T> : 객체 참조 하나, 기본 타입 하나 총 인수 2개를 받는다.
Supplier 인터페이스 변형
  • BooleanSupplier : boolean 을 반환한다.

주의사항

  • 표준 함수형 인터페이스 대부분은 기본 타입만 지원한다.
    • 그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자.
    • 동작은 하지만 계산량이 많을 때는 성능이 느려질 수 있다.

전용 함수형 인터페이스

  • 대부분 상황에서는 직접 작성하는 것보다 표준 함수형 인터페이스를 사용하는 것이 낫다.
  • 물론 표준 인터페이스 중 필요한 용도에 맞는 게 없다면 직접 작성해야 한다.
  • 하지만 구조적으로 똑같은 표준 함수형 인터페이스가 있더라도 직접 작성해야 할 때가 있다.

Comparator 인터페이스

  • Comparator<T> 인터페이스는 구조적으로 ToIntBiFuction<T,U> 와 동일하다.
  • 그러나 Comparator 가 독자적인 인터페이스로 살아남아야 하는 이유가 몇 개 있다.
  1. API에서 굉장히 자주 사용되고, 지금의 이름이 그 용도를 아주 잘 설명해준다.
  2. 구현하는 쪽에서 반드시 지켜야할 규약을 담고 있다.
  3. 비교자들을 변환하고 조합해주는 유용한 디폴트 메서드들을 담고 있다.

전용 함수형 인터페이스를 작성해야 하는 조건 세가지

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

위 세가지 중 하나 이상을 만족한다면, 전용 함수형 인터페이스를 구현해야 하는건 아닌지 고민해봐야 한다.

@FunctionalInterface

  • 함수형 인터페이스에 이 애너테이션을 사용하는 이유는 @Override 를 사용하는 이유와 비슷하다.
    • 프로그래머의 의도를 명시하는 것이다.
  • 해당 인터페이스가 람다용으로 설계된 것임을 알려준다.
  • 해당 인터페이스가 오직 하나의 추상 메서드만 가지고 있어야 컴파일되게 해준다.
    • 유지보수 과정에서 누군가 새로운 메서드를 추가하지 못하게 막아준다.
  • 따라서 직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하는 것이 좋다.

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

  • 스트림 API는 다량의 데이터 처리 작업을 돕는다.

스트림 API의 핵심 추상 개념 두가지

  1. Stream : 데이터 원소의 유한 혹은 무한 시퀀스
  2. Stream pipeline : 원소들로 수행하는 연산 단계
  • 스트림의 원소들은 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기, 다른 스트림 등 다양한 곳에서 올 수 있다.
  • 원소들은 객체 참조나 기본 타입(int, long , double) 값이다.

스트림 파이프 라인

  • 스트림 파이프라인은 소스 스트림에서 시작해 terminal operation으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다.
  • 각 중간 연산은 스트림을 어떤 방식으로 변환한다.
    • 변한된 스트림의 원소 타입은 이전과 같을 수도 있고 다를 수도 있다.
  • 기본적으로 스트림 파이프라인은 순차적으로 수행된다.
  • 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주면 된다.
    • 그러나 효과를 볼 수 있는 상황은 많지 않다.

lazy evaluation

  • 스트림 파이프라인은 지연 평가된다.
  • 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 평가에 쓰이지 않는다.
  • 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠이다.

메서드 연쇄

  • 스트림 API는 메서드 연쇄를 지원하는 fluent API이다.
  • 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 하나의 표현식으로 완성할 수 있다.

스트림을 써야하는 경우 - anagram 예제

  • 사전 파일에서 단어를 읽어 지정한 limit 값보다 원소 수가 많은 아나그램 그룹을 출력하는 예제를 보자.
    • 아나그램은 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다.
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);
  }
}
  • 사전 파일에서 각 단어를 읽어 맵에 저장한다.
  • 맵의 키는 그 단어를 구성하는 철자들을 알파벳 순으로 정렬한 값이다.
    • 맵의 값은 같은 키를 공유한 단어들을 담은 집합이다.
  • computeIfAbsent
    • 맵 안에 키가 있는지 찾고, 있으면 그 키에 매핑된 값을 반환하고,
    • 없으면 건네진 함수 객체를 키에 적용하여 값을 계산한 다음 키와 값을 매핑해놓고, 계산된 값을 반환한다.

스트림을 사용한 코드1 - 스트림을 과하게 활용

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);
    }
  }
}
  • 이 코드는 짧지만 이해하기 어렵다.
  • 이처럼 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

스트림을 사용한 코드2 - 스트림을 적절히 활용

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 -> alphabetize(word)))
        .values().stream()
        .filter(group -> group.size() >= minGroupSize)
        .forEach(group -> System.out.println(group.size() + ": " + group));
    }
  }
}
  • try-with-resources 블록에서 사전 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻는다.
  • 스트림 파이프라인의 종단 연산으로 모든 단어를 수집해 맵으로 모은다.
    • 이 맵은 단어들을 아나그램끼리 묶어놓은 것으로, 앞에서 생성한 맵과 실질적으로 같다.
  • 맵의 values() 가 반환한 값으로부터 새로운 Stream<List<String>> 스트림을 연다.
  • minGroupSize 보다 원소 개수가 적은 것을 필터링하고, 종단 연산인 forEach 로 살아남은 리스트를 출력한다.

  • char 용 스트림을 지원하지 않기 때문에 alphabetize 메서드의 동작을 스트림을 사용해 구현하기 보다는 위처럼 구현하는 것이 간단하고 명확하다.
    • 즉, char 값들을 처리할 때는 스트림 사용을 삼가는 편이 낫다.

스트림 VS 반복 코드

  • 스트림 파이프라인은 되풀이되는 계산을 함수 객체(주로 람다나 메서드 참조)로 표현한다.
  • 반면 반복 코드에서는 코드 블록을 사용해 표현한다.

함수 객체로는 할 수 없고 코드 블록으로는 할 수 있는 일

  • 범위 안의 지역변수를 읽고 수정할 수 있다.
    • 람다에서는 final 이거나 사실상 final 인 변수만 읽을 수 있고, 지역변수를 수정하는 것은 불가능하다.
  • return 문을 사용해 메서드에서 빠져나가거나, breakcontinue 로 블록 바깥의 반복문을 종료하거나 한번 건너뛸 수 있다.
  • 메서드 선언에 명시된 checked exception을 던질 수 있다.

스트림을 사용하기 좋은 수행 조건

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

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

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

메르센 소수 예제

  • 처음 20개의 메르센 소수를 출력하는 예제를 보자.
  • 메르센 수는 2^p-1 형태의 수이고, p가 소수일 때 해당 메르센 수도 소수일 수 있는데, 이 때의 수를 메르센 소수라고 한다.
static Stream<BigInteger> primes() {
  return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
  • 2부터 시작하는 소수를 생성하는 무한 스트림을 반환하는 메서드이다.
  • Stream.iterate 라는 정적 팩터리 메서드는 매개변수 2개를 받는다.
    • 첫번째 매개변수: 스트림의 첫번째 원소
    • 두번째 매개변수: 스트림에서 다음 원소를 생성해주는 함수
public static void main(String[] args) {
  primes().map(p -> TWO.pow(p.intValueExact()).substract(ONE))
    .filter(mersenne -> mersenne.isProbablePrime(50))
    .limit(20)
    .forEach(System.out::println);
}
  • 소수들을 사용해 메르센 수를 계산하고, 결과값이 소수인 경우만 필터링한 다음 원소 수를 20개로 제한하고 결과를 출력한다.

  • 이제 메르센 소수의 앞에 지수(p)를 출력하길 원한다고 해보자.
  • 이 값은 초기 스트림에만 나타나므로, 결과를 출력하는 종단 연산에서는 접근할 수 없다.
  • 중간 연산에서 수행한 매핑을 거꾸로 수행해서 다시 계산해내야 한다.
  • 이 예제에서는 비교적 단순히 값을 다시 계산해낼 수 있긴 하다.
    • 지수는 숫자를 이진수로 표현한 다음 몇 비트인지를 세면 나오기 때문이다.
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));

카드덱 초기화(데카르트 곱) 예제

  • 카드는 숫자(rank)와 무늬(suit)를 묶은 불변 값 클래스이고, 숫자와 무늬는 열거 타입이다.
  • 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 예제를 보자.

반복 방식 구현

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

스트림 방식 구현

private static List<Card> newDeck() {
  return Stream.of(Suit.values())
    .flatMap(suit -> Stream.of(Rank.values())
            .map(rank -> new Card(suit, rank)))
    .collect(toList());
}
  • flatMap : 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음, 그 스트림들을 다시 하나의 스트림으로 합친다.

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

  • 스트림은 그저 하나의 API가 아닌 함수형 프로그래밍에 기초한 패러다임이다.
  • 스트림이 제공하는 표현력, 속도, 병렬성을 얻으려면 이 패러다임까지 함께 받아들여야 한다.

순수 함수

  • 스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.
  • 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
    • 순수 함수란, 오직 입력만이 결과에 영향을 주는 함수를 뜻한다.
    • 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
  • 스트림 연산에 건네는 함수 객체는 모두 부작용이 없어야 한다.

단어 빈도표 예제 - 스트림 API 올바르게 사용하기

스트림 API를 잘못 사용한 코드

Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
  words.forEach(word -> {
    freq.merge(word.toLowerCase(), 1L, Long::sum);
  });
}
  • 위 코드는 정상적으로 동작하지만, 스트림의 이점을 살렸다고 할 수 없다.
  • 스트림 코드를 가장한 반복적 코드이다.
    • 같은 기능의 반복적 코드보다 길고, 읽기 어렵고, 유지보수에도 좋지 않다.
  • 코드의 모든 작업이 종단 연산인 forEach 에서 일어난다.
  • forEach 는 스트림 계산 결과를 보고할 때만 사용하고, 계산하는데는 쓰지 않는 것이 좋다.

스트림 API를 제대로 활용한 코드

Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
  freq = words.collect(groupingBy(String::toLowerCase, counting()));
}
  • 앞의 코드와 같은 동작을 하지만, 스트림 API를 제대로 사용했다.
  • collector를 사용해 코드가 짧고 명확하다.

수집기 (collector)

  • 수집기를 사용하면 스트림의 원소를 쉽게 컬렉션으로 모을 수 있다.
  • java.util.stream.Collectors 클래스는 다양한 수집 메서드를 제공한다.
  • 축소(reduction) 전략을 캡슐화한 객체라고 생각하면 쉽다.
    • 여기서 축소는 스트림의 원소들을 객체 하나에 취합한다는 의미이다.
  • 수집기가 생성하는 객체는 일반적으로 컬렉션이다.

toList

List<String> topTen = freq.keySet().stream()
  .sorted(comparing(freq::get).reversed())
  .limit(10)
  .collect(toList());
  • 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인이다.
  • sortedComparator 를 넘겨 정렬하도록 했다.
  • comparing 메서드 : 키 추출 함수를 받는 비교자 생성 메서드 (Comparator.comparing)
    • 참고 : https://www.baeldung.com/java-8-comparator-comparing
    • 한정적 메서드 참조로 freq::get 키 추출 함수를 넘겼다.

toMap

  • 가장 간단한 맵 수집기는 toMap(keyMapper, valueMapper) 형태이다.
    • 이 형태는 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다.
private static final Map<String, Operation> stringToEnum = 
  Stream.of(values()).collect(toMap(Object::toString, e -> e));
  • 열거 타입 상수의 문자열 표현을 열거 타입 자체에 매핑하는 fromString 을 구현하는데 사용하는 맵이다.
병합 함수
  • 스트림 원소 다수가 같은 키를 사용하는 경우, 더 복잡한 형태의 toMap 이나 groupingBy 로 이런 충돌을 다룰 수 있다.
  • 예를 들어, toMap 에 키 매퍼, 값 매퍼와 함께 병합 함수를 제공할 수 있다.
  • 병합 함수의 형태는 BinaryOperator<U> 이고, 여기서 U 는 해당 맵의 값 타입이다.
  • 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다.
maxBy
  • 다양한 음악가의 앨범들을 담은 스트림을 가지고 음악가와 그 음악가의 베스트 앨범을 연관짓는 예제를 보자.
Map<Artist, Album> topHits = albums.collect(
  toMap(Album::artist, a -> a, maxBy(comparing(Album::sales))));
  • maxBy : Comparator<T> 를 입력받아 BinaryOperator<T> 를 반환하는 정적 팩터리 메서드
  • comparing 이 키 추출 함수로 Album::sales 를 받아 maxBy 에 넘겨줄 비교자를 반환한다.
마지막 값 취하는 수집기
  • toMap 은 충돌이 나면 마지막 값을 취하는 수집기를 만들 때도 유용하다.
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
맵 팩터리
  • toMap 은 네번째 인수로 맵 팩터리를 받을 수 있다.
  • 이 인수로 EnumMap 이나 TreeMap 같이 원하는 특정 맵 구현체를 직접 지정할 수 있다.

groupingBy

  • 입력으로 분류 함수(classifier)를 받고, 출력으로 원소들을 카테고리별로 모아놓은 맵을 담은 수집기를 반환한다.
  • 분류 함수 : 입력받은 원소가 속하는 카테고리를 반환한다.
    • 카테고리는 해당 원소의 맵 키로 쓰인다.
  • 가장 간단한 형태의 groupingBy 는 분류 함수 하나를 인수로 받아 맵을 반환한다.
    • 반환된 맵에 담긴 각각의 값은 해당 카테고리에 속하는 원소들을 모두 담은 리스트이다.
words.collect(groupingBy(word -> alphabetize(word)))
다운스트림 수집기
  • groupingBy 가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생성하도록 하려면 다운스트림 수집기를 명시해야 한다.
  • 즉 다운스트림 수집기의 역할은 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일이다.
  • toSet() / toCollection(collectionFactory) : 집합이나 컬렉션을 값으로 갖는 맵을 생성한다.
counting()
  • 각 카테고리에 속하는 원소의 개수를 값으로 갖는 맵을 생성한다.
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));
맵 팩터리
  • groupingBy 의 인수로 맵 팩터리를 넘겨 맵과 그 안에 담긴 컬렉션 타입을 정할 수 있다.

partitioningBy

  • Predicate 를 인수로 받아 키가 Boolean 인 맵을 반환한다.
  • 다운스트림 수집기까지 입력 받는 버전도 다중정의 되어있다.

joining

  • 이 메서드는 CharSequence 인스턴스의 스트림에만 적용할 수 있다.
  • 매개변수가 없는 joining 은 단순히 원소들을 연결하는 수집기를 반환한다.
  • delimeter를 인수로 받는 joining 은 연결 부위에 구분문자를 삽입하여 연결한다.
  • 인수 3개짜리 joining 은 구분문자와 prefix, suffix를 받아 문자열을 생성한다.

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

  • 스트림은 반복(iteration)을 지원하지 않는다.
  • 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다.
  • 사실 Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함할 뿐만 아니라 Iterable 인터페이스가 정의한 방식대로 동작한다.
    • 그럼에도 for-each 로 스트림을 반복할 수 없는 이유는 StreamIterable 을 확장하지 않아서이다.

스트림을 반복하기위한 우회 방법

Stream.iterator 메서드 활용

for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
  ...
}
  • Streamiterator 메서드 참조를 건네는 방법이다.

  • 위 코드는 아래와 같은 컴파일 오류가 발생한다.

    error: method reference not expected here
    
  • 오류를 고치려면 메서드 참조를 매개변수화된 Iterable 로 적절히 변환해주어야 한다.

for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator) {
  ...
}
  • 이 코드는 동작은 하지만 너무 복잡하고 직관성이 떨어진다.

어댑터 메서드 활용

public static <E> Iterable<E> iterableOf(Stream<E> stream) {
  return stream::iterator;
}
  • Streamiterator 메서드를 활용하는 것은 동일하다.
  • 자바의 타입 추론이 문맥을 파악하기 때문에 안에서 따로 형변환해줄 필요가 없다.
for (ProcessHandle ph : iterableOf(ProcessHandle.allProcesses())) {
  ...
}
Iterable을 Stream으로 중개해주는 어댑터
  • API가 Iterable만 반환한다면 이를 스트림 파이프라인에서 처리하려는 사용자는 Stream으로 변환해야 한다.
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
  return StreamSupport.stream(iterable.spliterator(), false);
}

공개 API에서 컬렉션을 반환하는 이유

객체 시퀀스를 반환하는 메서드 작성 시에,

  • 이 메서드가 오직 스트림 파이프라인에서만 쓰인다면 스트림을 반환하도록 한다.
  • 반대로 반환된 객체들이 반복문에서만 쓰인다면 Iterable을 반환하도록 한다.

  • 하지만, 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문을 사용하는 사람 모두를 고려해야 한다.
  • Collection 인터페이스는 Iterable 의 하위 타입이고, stream 메서드도 제공한다.
    • 즉, 반복과 스트림을 동시에 지원한다.
  • 따라서, 원소 시퀀스를 반환하는 공개 API에서는 Collection 이나 그 하위 타입을 반환하는게 일반적으로 최선이다.

전용 컬렉션 구현

  • 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 표준 컬렉션 구현체를 반환하는게 일반적이다.
  • 그러나 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안된다.
  • 반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현할 수 있다.

멱집합 예제

  • 주어진 집합의 멱집합을 반환하는 예제를 보자.

  • 원소 개수가 n개면 멱집합의 원소 개수는 2^n개가 된다.

    • 멱집합을 표준 컬렉션 구현체에 저장하는 것은 위험하다.

    • AbstractList 를 활용하여 전용 컬렉션을 구현할 수 있다.

public class PowerSet {
  public static final <E> Collection<Set<E>> of(Set<E> s) {
    List<E> src = new ArrayList<>(s);
    
    if(src.size() > 30) {
      throw new IllegalArgumentException("집합에 원소가 너무 많습니다(최대 30개). : " + s);
    }
    
    return new AbstractList<Set<E>>() {
      @Override
      public int size() {
        return 1 << src.size();
      }
      
      @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;
      }
    };
  }
}
  • 멱집합을 구성하는 각 원소의 인덱스를 비트 벡터로 사용했다.
  • 인덱스의 n번째 비트 값은 멱집합의 해당 원소가 원래 집합의 n번째 원소를 포함하는지 여부를 알려준다.
  • AbstractCollection 을 활용해서 Collection 구현체를 구현할 때는 Iterable 용 메서드 외에 2개만 더 구현하면 된다.
    • containssize 이다.
    • 이 두개 메서드를 구현하기 어려울 때는 컬렉션보다는 스트림이나 Iterable을 반환하는 것이 낫다.

스트림으로 반환하는 경우

  • 때로는 단순히 구현하기 쉬운 쪽을 선택하기도 한다.
  • 예를 들어 입력 리스트의 부분 리스트를 모두 반환하는 메서드를 만든다고 하자.
  • 부분리스트를 만들어 표준 컬렉션에 담는 코드는 매우 간단하지만, 이 컬렉션은 입력 리스트 크기의 거듭제곱만큼 메모리를 차지한다.
  • 멱집합 예제처럼 전용 컬렉션을 구현하기도 애매하다.
  • 하지만 입력 리스트의 모든 부분 리스트를 스트림으로 구현하기는 어렵지 않다.

부분 리스트 예제 코드

  • 첫 번째 원소를 포함하는 부분 리스트를 그 리스트의 prefix라고 하자.
  • 마지막 원소를 포함하는 부분 리스트를 그 리스트의 suffix라고 하자.
  • 어떤 리스트의 부분 리스트는 그 리스트의 프리픽스의 서픽스 + 빈 리스트 이다.
Stream.concat 활용
public class SubLists {
  public static <E> Stream<List<E>> of(List<E> list) {
    return Stream.concat(Stream.of(Collcetions.emptyList()),
                        prefixes(list).flatMap(SubLists::suffixes));
  }
  
  private static <E> Stream<List<E>> prefixes(List<E> list) {
    return IntStream.rangeClosed(1, list.size())
      .mapToObj(end -> list.subList(0, end));
  }
  
  private static <E> stream<List<E>> suffixes(List<E> list) {
    return Intstream.range(0, list.size())
      .mapToObj(start -> list.subList(start, list.size()));
  }
}
  • flatMap 메서드로 모든 프리픽스의 모든 서픽스로 구성된 하나의 스트림을 만든다.
  • 그리고 Stream.concat 메서드로 빈 리스트와 합쳐주었다.
반복문 형태의 스트림 변환
  • 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));
  }
}
  • 이 코드를 그대로 스트림으로 변환할 수 있다.
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);
}
  • 이 코드는 위 반복문처럼 빈 리스트는 포함하지 않는다.
  • 앞의 concat 을 활용한 코드보다 간결하지만 읽기에는 더 좋지 않다.

정리

  • 원소 시퀀스를 반환하는 메서드를 작성할 때는 스트림과 반복 모두를 고려하는 것이 좋다.
    • 컬렉션을 반환할 수 있다면 그렇게 하자.
  • 반환 전부터 이미 원소들을 컬렉션에 담아 관리하고 있거나, 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면 ArrayList 같은 표준 컬렉션에 담아 반환한다.
  • 그렇지 않으면 전용 컬렉션을 구현하는 것도 좋다.
  • 컬렉션을 반환하는게 불가능하다면 스트림과 Iterable 중에 더 자연스러운 것을 반환한다.

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

  • 동시성 프로그래밍을 할 때는 안전성(safety)과 응답 가능(liveness) 상태를 유지하기 위해 애써야 하는데, 병렬 스트림 파이프라인 프로그래밍에서도 마찬가지이다.
    • 참고 : https://ko.wikipedia.org/wiki/%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%8B%88%EC%8A%A4
    • 라이브니스 : 동시성 컴퓨팅에서, 동시에 실행되는 프로세스가 임계 영역을 교대로 사용하면서 교착 상태나 기아 상태로부터 자유로우면서 시스템이 정상적으로 동작하도록하는 속성

병렬화하기 좋은 스트림 소스

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

공통점

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

    • 참조 지역성 : 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다.
    • 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 시간을 허비하게 된다.

    • 참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다.

병렬화하기 좋은 종단 연산

  • 종단 연산의 작업량이 전체 작업에서 상당한 비중을 차지하면서 순차적인 연산이라면 병렬화의 효과는 제한된다.
  • 종단 연산 중 병렬화에 가장 적합한 것은 축소이다.
    • Streamreduce 메서드 중 하나, min , max , count , sum 메서드를 선택해 수행한다.
  • 가변 축소를 수행하는 Streamcollect 메서드는 병렬화에 적합하지 않다.

정리

  • 스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한다.
  • 다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.
  • 조건이 잘 갖춰지면 parallel 메서드 호출 하나로 프로세서 코어 수에 비례하는 성능 향상을 얻을 수 있다.