본문 바로가기

나(다)/책

이펙티브 자바 - 7장 : 람다와 스트림

반응형

람다와 스트림

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

특정 함수나 동작에 사용하는 인터페이스를 함수 객체라하며 메서드를 하나만 담은 인터페이스를 의미한다.

  • 익명 클래스 방식은 코드가 너무 길어서 자바는 함수형 프로그래밍에 적합하지 않았다.
  • 람다식이 나오면서 문백을 살펴 타입을 추론해주어 한결 간략한 코드가 된다.
public class Item42 {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>();
        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())); // 람다식
        Collections.sort(words, comparingInt(String::length)); // 람다 자리에 비교자 생성 메서드를 사용
        words.sort(comparingInt(String::length)); // List에 추가 된 sort
    }
}
  • 타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하자.
  • 컴파일가 "타입을 알 수 없다."는 오류를 낼 때만 해당 타입을 명시하면 된다.
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);
        }
    }
  • Operation에 람다를 사용한 경우

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

  • 람다는 한 줄일 때 가장 좋고 길어야 세 줄 안에 끝내는게 좋다.

람다로 대체 할 수 없는 경우

  • 람다는 함수형 인터페이스에서만 쓰이고 추상 클래스의 인스턴스를 만들 때는 람다를 쓸 수 없어 익명 클래스를 사용해야 한다.
  • 추상 메서드가 여러 개인 인터페이스의 인스턴스를 만들 때도 익명 클래스를 쓸 수 있다.
  • 람다는 자신을 참조할 수 없어 람다에서의 this 키워드는 바깥 인스턴스를 가리키고 익명 클래스에서의 this는 익명 클래스의 인스턴스 자신을 가리킨다.
  • 람다, 익명 클래스는 직렬화하는 일은 극히 삼가야 하고 해야 한다면(Comparator처럼) private 정적 중첩 클래스의 인스턴스를 사용하자

람다 자리에 바교자 생성 메서드를 사용하면 더 간결한 코드를 만들 수 있다.(아이템14,43), 함수 인터페이스(아이템44), private 정적 중첩 클래스(아이템24)

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


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

람다는 익명 클래스보다 간결한 특징이 있는데 메서드 참조를 이용하면 더 간결하게 작성할 수 있다.

public static void main(String[] args) {
    Map<String, Integer> map = new HashMap();
    map.merge(key, 1, (count, incr) -> count + incr);
        map.merge(key, 1, Integer::sum);
}
  • java 8에서 Map에 merge 추가되었으며 Integer 클래스에서 람다와 기능이 같은 정적 메서드 sum을 제공하기 시작했다.
  • 람다로 작성할 코드를 새로운 메서드에 담은 다음, 람다 대신 그 메서드 참조를 사용하는 방식으로 하면 메서드 참조에는 기능이 잘 들어내는 이름을 지어줄 수 있고 친절한 설명을 문서로 남길수 도 있다.

예외의 경우

  • 메서드와 람다가 같은 클래스에 있는 경우
      service.execute(GoshThisClassNameIsHumongous::action);
      service.execute(() -> action());
  • 같은 선상에서 java.util.function 패키지가 제공하는 제네릭 정적 팩터리 메서드인 Function.identity()를 사용하기보다는 똑같은 기능의 람다(x → x) 를 직접 사용하는 편이 코드도 짧고 명확하다.
  • 수신 객체(참조 대상 인스턴스)를 특정하는 한정적 인스턴스 메서드 참조의 경우
    • 함수 객체가 받는 인수와 참조되는 메서드가 받는 인수가 똑같다
  • 다른 하나는 수신 객체를 특정하지 않는 비한정적 인스턴스 메서드 참조이다.
    • 함수 객체를 적용하는 시점에 수신 객체를 알려준다.

비한정적 참조는 주로 스트림 파이프라인에서의 매핑과 필터 함수로 쓰인다.(아이템45)

메서드 참조는 람다의 간단명료한 대안이 될 수 있다. 메서드 참조 쪽이 짧고 명확하다면 메서드 참조를 쓰고, 그렇지 않을 때만 람다를 사용하라.


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

  • 람다를 지원하면서 API 작상 모범 사례도 변화되어 기본 메서드를 재정의하는 템플릿 메서드 패턴의 매력이 크게 줄었고 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 방법이 현대적 해법이다.
    • 함수 객체를 매개변수로 받는 생성자와 메서드를 더 많이 만들어야 한다.
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    return size() > 100;
}
  • removeEldestEntry는 인스턴스 메서드라서 size()를 호출해 맵 안의 원소 수를 알아낼 수 있다.
  • 팩터리나 생성자를 호출 할 때 생성자에서 넘기는 함수 객체는 인스턴스 메서드가 아니므로 자기 자신도 함수 객체에 건네줘야 하는데 이를 위해 함수형 인터페이스를 선언하여 사용할 수 있다.
@FunctionalInterface interface EldestEntryRemovalFunction<K,V>{ // 잘 동작하지만 사용할 이유가 없다.
    boolean remove(Map<K, V> map, Map.Entry<K, V> eldest); 
}
  • 필요한 용도 맞는 게 있다면, 직접 함수형 인터페이스를 구현하지 말고 표준 함수형 인터페이스를 활용하자
    • java.util.function 패키지에 다양한 표준 함수형 인터페이스가 담겨 있다.

java.util.function 패키지

  • 총 43개의 인터페이스가 있지만 기본 인터페이스 6개만 기억하면 나머지는 유추할 수 있다.
  • 표준 함수형 인터페이스는 대부분 기본 타입만 지원한다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자.
    • 동작은 하지만 "박싱된 기본 타입 대신 기본 타입을 사용하라"라는 아이템61을 위배하여 성능이 처참하게 느려진다.
  • Operator 인터페이스 : 반환 값과 인수의 타입이 같은 함수를 의미
    • 인수가 1개인 UnaryOperator
    • 인수가 2개인 BinaryOperator
  • Predicate 인터페이스 : 인수 하나를 받아 boolean을 반환하는 함수를 의미
    • int, long, double 용으로 각 3개씩 변형이 생겨나고 기본 타입 이름이 붙여 지어졌다.
      • IntPredicate, LongBinaryOperator 등
  • Function 인터페이스 : 인수와 반환 타입이 다른 함수를 의미
    • Function은 6개는 SrcToResult를 접두어로 사용했고 입력을 매개변수화하는 3개는 ToResult를 사용하여 9개의 변형을 가진다.
      • 6개 ex) long을 받아 int로 반환 : LongToIntFunction
      • 3개 ex) int[] 인수를 받아 long을 반환 : ToLongFunction<int[]>
  • Supplier 인터페이스 : 인수를 하나 받고 반환 값은 없는(특히 인수를 소비하는) 함수를 의미
    • BooleanSupplier 인터페이스는 boolean을 반환하도록 한 Supplier의 변형이다.
  • 기본 함수형 인터페이스 중 3개에는 인수를 2개씩 받는 변형이 있다.
    • BiPredicate<T, U>, BiFunction<T, U, R>, BiConsumer<T, U>
      • BiFunction은 기본 타입을 반환하는 세 변형으로 ToIntBiFunction<T,U>, ToLongBiFunction<T,U>, ToDoubleBiFunction<T,U>가 있다.
      • Consumer에는 객체 참조와 기본 타입 하나로 인수를 2개 받는 변형으로 ObjDoubleConsumer ObjIntConsumer, ObjLongConsumer가 있다.

표준 함수형 인터페이스가 아닌 직접 작성할 경우

  • 용도에 맞는 게 없다면 직접 작성해야 한다.
  • Comparator 특성과 같이 아래 세 가지 중 하나 이상을 만족한다면 전용 함수형 인터페이스를 구현할지 진중히 고민한다.
    1. 자주 쓰이며, 이름 자체가 용도를 명확히 설명해준다.
    2. 반드시 따라야 하는 규약이 있다.
    3. 유용한 디폴트 메서드를 제공할 수 있다.

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

  • @Override를 사용하는 이유와 비슷하며 프로그래머의 의로를 명시하는 것으로 크게 세 가지 목적이 있다.
    1. 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려준다.
    2. 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일 되게 해준다.
    3. 그 결과 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막는다.

함수형 인터페이스를 API에서 사용할 때의 주의할 점

  • 서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안된다.
    • ExecutorService의 submit 메서드는 Callable를 받는 것과 Runnable을 받는 것을 다중정의했다.
  • 위 문제를 회피하기 위해서는 서로 다른 함수형 인터페이스를 같은 위치의 인수로 사용하는 방법이다.

아주 주의해서 설계해야 한다는 뜻(아이템21), "박싱된 기본 타입 대신 기본 타입을 사용하라"(아이템61), 올바른 메서드를 알려주기 위해 형변환해야 할 때가 왕왕 생긴다.(아이템52)

API를 설계 할 때 람다도 염두에 두어야 하며 입력값과 반환값에 함수형 인터페이스 타입을 활용하라. java.util.function 패키지의 표준 함수형 인터페이스를 사용하는 것이 가장 좋은 선택이며 흔치 않지만 직접 새로운 함수형 인터페이스를 만들어 쓰는 편이 나을 수도 있음을 잊지 말자.


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

  • 다량의 데이터 처리 작업(순차적이든 병렬적이든)을 돕고자 java 8에서 스트림 API가 추가되었고 추상 개념 중 핵심은 두 가지다.
    1. 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
    2. 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
  • 스트림의 원소들은 어디로부터든 올 수 있고 데이터 원소들은 객체 참조나 기본 타입으로 int, long, double을 지원한다.
    • 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있고 각 중간 연산은 스트림을 어떠한 방식으로 변환한다.
    • 중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환 된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있다.
    • 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가하고 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력하는 식이다.
  • 스트림 파이프라인은 지연 평가되며 평가는 종단 연산이 호출 될 때 이뤄지고 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않으며 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다.
    • 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니, 종단 연산을 빼먹는 일이 절대 없도록 하자.
  • 스트림 API는 다재다능하여 사실상 어떠한 계산도 할 수 있지만 해야 한다는 의미는 아니다.
  • 스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다. 스트림을 언제 써야 하는지를 규정하는 확고부동한 규칙은 없지만, 참고할 만한 노하우는 있다.
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Stream;

import static java.util.stream.Collectors.groupingBy;

public class Anagrams {
    public static void main(String[] args) throws IOException {

        // case 1 : 일반적은 코드
//        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);

        // case 2 : 스트림 남용
//        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);
//
//        }

        // case 3 : 적절한 스트림
        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);
    }
}
  • 위 세 가지 코드가 모두 동일하다.

char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.

  • 스트림은 char 스트림을 지원하지 않아 반환값에 문제가 될 수 있으므로 형변환을 명시적으로 해주어야 한다.
    • "Hello world!".chars().forEach(x → System.out.print((char) x));

상황에 따라 스트림을 사용할지 반복문을 사용할지

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

  • 함수 객체로는 할 수 없지만 코드 블록으로는 할 수있는 일들인 경우
    • 코드 블로에서 범위 안의 지역 변수를 읽고 수정할 수 있지만 람다에서는 final이고 지역변수를 수정하는건 불가능하다.
    • 코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나 break나 continue문으로 블록 바깥의 반복문을 종료하거나 반복을 건너뛸 수도 있고 메서드 선언에 명시된 검사 예외를 던질 수 있지만 람다는 할 수 없다.
  • 계산 로직에서 위의 일들은 스트림과 맞지 않지만 아래는 안성맞춤이다.
    • 원소들의 시퀀스를 일관되게 변환한다.
    • 원소들의 시퀀스를 필터링한다.
    • 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.(더하기, 연결하기, 최솟값 구하기 등)
    • 원소들의 시퀀스를 컬렉션에 모은다.(아마도 공통된 속성을 기준으로 묶어가며)
    • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

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

  • 스트림으로는 한 데이터가 파이프라인의 여러 단계를 통과할 때 각 단계에서의 값들에 동시에 접근하기가 어려운 경우이다.
    • 스트림은 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이다.
static Stream<BigInteger> primes() {
    return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

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);
}
  • 스트림을 반환하는 메서드 이름은 원소의 정체를 알려주는 복수 명사로 쓰기를 강력히 추천한다.
private static List<Car> 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<Car> newDeck() {
    return Stream.of(Suit.values))
        .flatMap(suit -> 
            Stream.of(Rank.values())
                .map(rank -> new Card(suit, rank))
        .collect(toList());
}
  • 위 두 가지 방식 중 편한 코드로 작성해도 되는데 스트림이 더 나아보이니 동료들도 괜찮다면 스트림으로 하라.

효과를 볼 수 있는 상황은 많지 않다.(아이템48), 이 맵은 단어들을 아나그램끼리 묶어놓은 것으로(아이템46)

스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있으며 잘 조합했을 때 가장 멋지게 해결된다. 스트림과 반복문 중 어느 쪽이 더 나은지 확신하기 어렵다면 둘 다 해보고 더 나은쪽을 택하라.


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

  • 스트림은 함수형 프로그래밍에 기초한 패러다임으로 처음봐서는 이해하기 어려울 수 있지만 핵심은 계산을 일련의 변환으로 재구성하는 부분으로 각 변환 단계는 가능한 이전 단계의결과를 받아 처리하는 순수 함수여야 한다.
    • 순수 함수란 입력만이 결과에 영향을 주는 함수를 의미한다.
    • 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
    • 스트림 연산에 건네는 함수 객체는 모두 부작용(side effetc)이 없어야 한다.

forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.

public static void main(String[] args) {
        Map<String, Long> freq = new HashMap<>();
        try(Stream<String> words = new Scanner(file).tokens()) { // 스트림 패러다임으 이해하지 못한 채 API만 사용했다. 따라하지 말것
            words.forEach(word -> { // 연산 결과를 보여주는 일 이상을 하는 forEach를 보니 나쁜 코드일 것 같은 냄새
                freq.merge(word.toLowerCase(), 1L, Long::sum);  
            });
        }
    }
  • 위 코드를 스트림 코드라고 할 수 없고 스트림 코드를 가장한 반복적 코드이다.
Map<String, Long> freq;
try(Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}
  • 위 코드가 스트림 API를 제대로 사용했다.

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

  • 메서드는 39개가 있고 타입 매개변수는 5개가 있다. java 10에서는 43 메서드가 되었다.
  • 스트림의 원소들을 객체 하나에 취합하는 용도로 사용한다.
  • 수집기는 toList(), toSet(), toCollection(collectionFactory)가 있다.
List<String> toTen = freq.keySet().stream() // 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인
                .sorted(comparing(freq::get).reversed())
                .limit(10)
                .collect(toList());

Collectors 메서드 요약 설명

  • 가장 간단한 맵 수집기는 toMap(keyMapper, valueMapper)이다.
      private static final Map<String, Operation> stringToenum =
                  Stream.of(values()).collect(
                          toMap(Object::toString, e -> e));
    1. 각 원소가 고유한 키에 매핑되어 있을 때 적합하고 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지면 종료된다.
    2. 병합 함수의 형태는 BinaryOperator이며, 여기서 U는 해당 맵의 값 타입으로 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다.
    3. 인수 3개를 받는 toMap은 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들 때 유용한다.
       Map<Artist, Album> toHits = albums.collect(
                       toMap(Album::artist, a->a, maxBy(comparing(Album::sales)));
      • 스트림의 결과가 비결정적인 경우에 충돌이 나면 마지막 값을 취하는 수집기를 만들 때 유용하게 사용되며 매핑 함수가 키 하나에 연결해준 값들이 모두 같을 때, 혹은 값이 다러다라도 모두 허용되는 값일 때 이렇게 동작하는 수집기가 필요하다.
        toMap(KeyMapper, valueMapper, (oldVal, newVal) -> newVal) // 마지막에 쓴 값을 취하는 수집기
    • 세 가지 toMap에는 변종이 있는데 toConcurrentMap은 병렬 실행된 후 결과로 ConcurrentHashMap 인스턴스를 생성한다.
  • groupingBy는 입력으로 분류 함수를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.
    • 분류 함수는 입력 받은 원소가 속하는 카테고리를 반환하고 해당 원소의 맵 키로 쓰인다.
    • 다중 정의 된 groupingBy 중 형태가 가장 간단한 것은 분류 함수 하나를 인수로 받는 맵을 반환한다.
    • 반환된 맵에 담긴 각각의 값은 해당 카테고리에 속하는 원소들을 모두 담은 리스트다.
      words.collect(groupingBy(word -> alphabetize(word))); // 아나그램 프로그램에서 사용한 수집기이다.
    • groupingBy가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생성하게 하려면, 분류 함수와 함께 다운스트림 수집기도 명시해야 한다.
      • 다운스트림 수집기의 역할은 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일이다.
      • 가장 간단한 방법은 toSet()을 넘겨 groupingBy는 원소들의 리스트가 아닌 집합을 값으로 갖는 맵을 만들어낸다.
      • toCollection(collectionfactory)를 건네서 컬렉션을 값으로 갖는 맵을 생성하면 원하는 컬렉션 타입을 선택할 수 있는 유연성을 가진다.
        Map<String, Long> freq = words
                          .collect(groupingBy(String::toLowerCase, counting()));
  • 많이 쓰이진 않지만 groupingBy의 사촌격인 partitioningBy도 있다.
    • 분류 함수 자리에 predicate를 받고 키가 Boolean인 맵을 반환한다.
  • counting 메서드가 반환하는 수집기는 다운스트림 수집기 전용이다.
    • Stream의 count 메서드를 직접 사용하여 같은 기능을 수행할 수 있으니 collect(counting())형태로 사용할 일은 전혀 없다.
  • summing, averagin, summarizing으로 시작하고 int, long, double 스트림용으로 하나씩 존재한다.
  • 다중 정의 된 reducing 메서드들, filtering, mapping, flatMapping, collectingAndThen 메서드가 있다.
  • 수집과 관련 없는 메서드로 minBy와 maxBy가 있다.
  • 마지막인 joining은 CharSequence 인스턴스의 스트림에만 적용될 수 있다.
    • 매개변수가 없는 joining은 단순히 원소들을 연결하는 수집기를 반환한다.
    • 매개변수가 1개인 joining은 타입 구분문자를 받으며 연결 부위에 구분 문자를 삽입한다.
    • 매개변수가 3개인 joining은 구분문자에 더해 접두문자와 접미문자도 받는다.

comparing 메서드는 키 추출 함수를 받는 비교자 생성 메서드다.(아이템14)

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있고 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.


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

  • 기본은적으로 반환 타입으로 컬렉션 인터페이스를 사용하고 for-each문에서만 쓰이거나 반환된 원소 시퀀스가(주로 contains(Object) 같은) 일부 Collection 메서드를 구현할 수 없을 때는 Iterable 인터페이스를 사용했다. 반환 원소들이 기본 타입이거나 성능에 민감한 상황이라면 배열을 사용했지만 스트림이 자바 8에서 들어오면서 선택이 복잡한 일이 되어버렸다.
  • 스트림은 반복을 지원하지 않기에 반환 할 때 스트림은 적합하지 않으므로 스트림과 반복문을 조합해야 좋은 코드가 나온다.
  • Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함할 뿐아니라, Iterable 인터페이스가 정의한 방식대로 동작하지만 for-each로 스트림을 반복할 수 없는 이유는 Stream이 iterable을 확장하지 않아서이다.
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) { // 자바 타입 추론의 한계로 컴파일 되지 않는다.
// 프로세스 처리
}

for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcess()::iterator) { // 형변환을 하면 사용가능하지만 난잡한 코드가 되어 사용하기 어렵다.
// 프로세스를 처리한다.
}

// 어댑터를 이용해 iterator 형태로 반환
public static <E> iterable<E> iterableOf(Stream<E> stream) { // 어댑터 메서드 안에서 따로 형변환하지 않아도 된다.
    return stream::iterator; // Stream<E>를 Iterable<E>로 중개해주는 어댑터
}
for (ProcessHandle p : iterableOf(ProcessHandle.allprocesses())) { // 어떤 스트림도 for-each문으로 반복할 수 있다.
    // 프로세스 처리한다.
}
// 어댑터로 stream 형태로 반환
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false); // Iterable<E>를 Stream<E>로 중개해주는 어댑터
}
  • 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는게 일반적으로 최선이다.
  • Collection 인터페이스는 Iterable의 하위 타입이고 stream메서드도 제공하니 반복과 스트림을 동시에 지원한다.
  • Arrays도 Arrays.asList와 Stream.of 메서드로 쉽게 반복과 스트림을 지원할 수 있는데 반환하는 시퀀스의 크기가 메모리에 올려도 될만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는게 최선일 수 있다.
  • 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안된다.

반환할 시퀀스가 크지만 표현이 간결할 수 있다면 전용 컬렉션을 구현하는 방안을 검토해보자

  • 멱집합(한 집합의 모든 부분집하을 원소로 하는 집합)울 표준 컬렉션 구현체에 저장하려는 생각은 위험하므로 AbstractList를 이용하여 전용 컬렉션을 구할 수 있다.
    • 멱집합을 구성하는 각 원소의 인덱스를 비트 벡터로 사용하면 인덱스의 n번째 비트 값은 멱집합의 해당 원소가 원래 집합의 n번째 원소를 포함하는지 여부를 알려준다.
    • 0부터 2^n-1까지의 이진수와 원소 n개인 집합의 멱집합과 자연스럽게 매핑된다.
import java.util.*;

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

            @Override
            public int size() {
                return 1 << src.size();
            }

            @Override
            public boolean contains(Object o) {
                return o instanceof Set && src.containsAll((Set) o);
            }
        };
    }
}
  • 입력 리스트의 부분리스트를 모두 반환하는 메서드를 작성한다고 하면 필요한 부분리스트를 만들어 표준 컬렉션에 담는 코드는 단 3줄이면 충분하지만 입력 리스트 크기의 거듭제곱만큼 메모리를 차지한다.
  • 입력 리스트의 모든 부분리스트를 스트림으로 구현하는 방법은 아래와 같다.
    • 원소를 포함하는 부분리스트를 리스트 prefix라 했을 때 (a, b, c)는 (a), (a, b), (a, b, c)가 되며 마지막 원소를 포함하는 부분리스트를 suffix라 했을 때 (a, b, c)는 (a, b, c), (b, c), (c)가 되어 prefix와 suffix의 사이에 빈 리스트 하나만 추가하면 된다.
      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));
        }
      
        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()));
        }
      
        public static void main(String[] args) { // prefixes와 suffixes와 취지가 비슷하다.
            for (int start = 0; start < src.size(); start++) {
                for (int end = start + 1; end <= src.size(); end++) {
                    System.out.println(src.subList(start, end));
                }
            }
                    IntStream.range(0, list.size()) // 중첩 for문을 stream으로 변경한 코드
                    .mapToObj(start -> IntStream.rangeClosed(start + 1, list.size())
                        .mapToObj(end -> list.subList(start, end)))
                                        .flatMap(x -> x);
        }
      }

스트림은 반복을 지원하지 않는다(아이템45)

원소 시퀀스를 반환하는 메서드를 작성할 때는, 스트림으로 처리하기를 원하는 사용자와 반복으로 처리하길 원하는 사용자가 모두 있을 수 있음을 떠올리고 양쪽을 모두 만족시키려고 노력하자.
컬렉션을 반환할 수 있다면 그렇게 하라. 반환 전부터 이미 원소들을 컬렉션에 담아 관리하고 있거나 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 젃다면 ArrayList 같은 표준 컬렉션에 담아 반환하라. 그렇지 않으면 앞서의 멱집합 예처럼 전용 컬렉션을 구현할지 고민하라.
컬렉션을 반환하는 게 불가능하면 스트림과 Iterable 중 더 자연스러운 것을 반환하라. 만약 나중에 Stream 인터페이스가 Iterable을 지원하도록 자바가 수정된다면, 그때는 안심하고 스트림을 반환하면 될 것이다.(스트림 처리와 반복 모두에 사용할 수 있으니)


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

동시성 프로그래밍에서는 안전성과 응답 가능상태를 유지하기 위해서 애써야 하는데 병렬 스트림 파이프라인 프로그래밍에서도 다를 바 없다.

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.stream.Stream;

import static java.math.BigInteger.ONE;
import static java.math.BigInteger.TWO;

public class Item48 {
    public static void main(String[] args) {
        primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
                .filter(mersenne -> mersenne.isProbablePrime(50))
                .limit(2)
                .forEach(System.out::println);
    }

    static Stream<BigInteger> primes() {
        return Stream.iterate(TWO, BigInteger::nextProbablePrime);
    }
}
  • 위 소스를 구동하면 12.5초만에 소수 결과를 내지만 속도를 높이고 싶어서 parallel()을 호출하면 CPU 90% 잡아먹고 무한히 계속되는데 이유는 파이프라인을 병렬화하는 방법을 찾아내지 못해서이다.
  • 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.
  • 파이프라인 병렬화는 limit를 다룰 때 cpu 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정하기에 스트림 파이프라인을 마구잡이로 병렬화하면 안된다.
  • 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다.
    • 해당 자료 구조들은 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 다수의 스레드에 일을 분배하기에 좋다.
    • 참조 지역성으로 메모리에 연속해서 저장되어 있는데 만약 실제 객체가 메모리에서 서로 떨어져 있으면 참조 지역성이 안좋아지고 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 대부분 시간을 멍하니 보내게 된다.
      • 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소가 되며 가장 뛰어난 자료구조는 기본 타입의 배열이다.
  • 스트림 파이프라인의 종단 연산의 동작 방식도 병렬 수행 효율에 영향을 주는데 전체 작업에서 상당 비중을 차지하면서 순차적인 연산이라면 파이프라인 병렬 수행의 효과는 제한될 수밖에 없고 종단 연산 중 병렬화에 가장 적합한 것은 축소이다.
    • 축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업으로, Stream의 reduce 메서드 중 하나, 혹은 min, max, count, sum 같이 완성된 형태로 제공되는 메서드 중 하나를 선택해 수행한다.
  • anyMatch, allMatch, nonematch처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합하다.
  • 가변 축소를 수행하는 Stream의 Collect 메서드는 컬렉션들을 합치는 부담으로 병렬화에 적합하지 않다.

스트림을 잘못 병렬화하면 (응담 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있다.

  • 직접 구현한 Stream, Iterable, Collection이 병렬화의 이점을 제대로 누리게 하고 싶다면 spliterator메서드를 반드시 재정의하고 결과 스트림의 병렬화 성능을 강도 높게 테스트 하라.
  • 결과가 잘못되거나 오동작하는 것을 완전 실패라고 하는데 병렬화한 파이프라인이 사용하는 mappers, filters, 혹은 프로그래머가 제공한 다른 함수 객체가 명세대로 동작하지 않을 때 벌어질 수 있다.
    • Stream 명세는 이때 사용되는 함수 객체에 관한 엄중한 규약으로 Stream의 reduce 연산에 건네지는 accumulator(누적기)와 combiner(결합기) 함수는 반드시 결합법칙을 만족하고, 간섭받지 않고, 상태를 갖지 않아야 한다.
    • 출력 순서를 순차 버전처럼 정렬하고 싶다면 종단 연산 forEach를 forEachOrdered로 바꿔주면 된다.

성능이 향상 될지를 추정하는 방법과 병렬화를 사용해야 할 경우

  • 스트림 안의 원소 수와 원소당 수행되는 코드 줄 수를 곱했을 때 최소 수십만은 되어야 성능 향상을 맛볼 수 있다.
  • 스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한고 반드시 병렬화를 사용할 가치가 있는지를 확인해야 한다.
  • 스트림 파이프라인을 많이 사용하는 프로그래머가 그 중 스트림 병렬화가 효과를 보는 경우는 많지 않지만 조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 향상을 만끽할 수 있다.
  • 무작위 수들로 이뤄진 스트림을 병렬화하려면 ThreadLocalRandom보다는 SplittableRandom 인스턴스를 이용하자.
    • ThreadLocalRandom은 단일 스레드에서 쓰고자 만들어졌고 Random은 모든 연산을 동기화히기 때문에 병렬 처리하면 최악의 성능을 보일 것이다.

성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.(아이템67)

계산도 올바로 수행하고 성능도 빨라질거라는 확신 없이는 스트림 파이프라인 병렬화는 시도조차 하지 말라.


반응형