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

이진호·2022년 9월 5일
0

Effective Java

목록 보기
3/11
post-thumbnail

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

스프림 API

  • 자바 8부터 추가된 다량의 데이터 처리 작업(순차적, 병렬적) 지원 API.
  • 핵심 추상 개념
    • 스트림(stream): 데이터 원소의 유한 혹은 무한 시퀀스(sequence)를 뜻하는 개념.
    • 스트림 파이프라인(stream pipeline): 원소들로 수행하는 연산 단계를 표현하는 개념.

스트림의 소스

  • 스트림의 원소들은 컬랙션, 배열, 파일, 정규표현식 패턴 매처(matcher), 난수 생성기, 다른 스트림 등 어디서든 올 수 있음.
  • 스트림 안의 데이터 원소들은 객체 참조나 기본 타입값이며, 기본 타입값은 int, long, double 세 가지를 지원.

스트림의 연산

  • 소스 스트림에서 시작해 종단 연산(terminal operation)으로 끝남.
  • 그 사이 스트림을 변환(transform)하는 하나 이상의 중간 연산(intermediate operation)이 존재.
  • 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가함.

지연 평가

  • 스트림 파이프라인은 종단 연산이 호출될 때 지연 평가(lazy evaluation) 됨.
  • 종단 연산에 쓰이지 않는 데이터는 계산에 쓰이지 않음.

메서드 연쇄

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

병렬 파이프라인

  • 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출하여 파이프라인을 병렬로 실행할 수 있다.
  • 하지만 효과를 볼 수 있는 상황이 많지 않다(Item 48)

언제 스트림을 써야 하는가?

스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

// 코드 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);
    }
}
  • 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
  • 도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다 스트림 파이프라인에서 훨씬 크다. 파이프라인에서는 타입 정보가 명시 되지 않거나 임시 변수를 자주 사용하기 때문.

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

  • 자바는 char용 스트림을 지원하지 않으며, 지원하더라도 아래와 같이 기대와 다른 결과가 나온다.
// 예상한 결과와 다르다.
"Hello world!".chars().forEach(System.out::print); // 721011081081113211911111410810

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

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

  • 스트림으로 바꾸는 게 가능할지라도 코드 가독성과 유지보수 측면에서 손해를 볼 수도 있음.
  • 스트림과 반복문을 적절히 조합하는 게 최선.

스트림 vs 반복 코드

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

스트림보다는 반복 코드가 더 적절한 경우

  • 함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일인 경우 반복 코드가 더 적절.
    • 코드 블록에서는 범위 안의 지역변수를 읽고 수정 가능하지만, 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역 변수를 수정하는 것은 불가능.
    • 코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다. 또한 메서드 선언에 명시된 검사 예외를 던질 수 있다. 하지만 람다로는 불가능.

반복 코드보다 스트림이 더 적절한 경우.

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

스트림 사용이 어려운 경우

  • 한 데이터가 파이프라인의 여러 단계(stage)를 통과할 때 각 단계의 값들에 동시 접근이 필요한 경우.
  • 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값을 잃는 구조이기 때문.

스트림과 반복 중 어느 쪽을 써야 할지 바로 알기 어려운 경우

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());
}
  • 위와 같은 경우 프로그래머의 선호도에 따라 갈림.

핵심정리

스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있다. 그리고 수많은 작업이 이 둘을 조합했을 때 가장 멋지게 해결된다. 어느 쪽을 선택하는 확고부동한 규칙은 없지만 참고할 만한 지침 정도는 있다. 어느 쪽이 나은지가 확연히 드러나는 경우가 많겠지만, 아니더라도 방법은 있다. 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.

출처

0개의 댓글