독서찰기(讀書札記)/이펙티브 자바

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

NoodleMan 2022. 3. 9. 00:37

[배경]

원소 시퀀스, 즉 일련의 원소를 반환하는 메서드는 수없이 많다.

  • 자바 7까지는 이런 메서드의 반환 타입으로 컬렉션 인터페이스, Iterable, 배열을 썼다.
  • 그런데 자바 8이 스트림이라는 개념을 들고 오면서 반환 타입의 선택이 아주 복잡해졌다.

스트림은 반복(iteration)을 지원하지 않는다.

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

어댑터를 사용하면 반환값의 Stream → Iterable, Iterable → Stream 변경이 가능하다.

  • 어떤 API가 Stream만 반환하거나 Iterable만 반환한다면 어댑터를 만들어 원하는 반환값을 얻게 할 수 있다.
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false);
}

 

[Why]

공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두를 배려해야 한다.

  • Collection 인터페이스는 Iterable의 하위 타입이고 stream 메서드도 제공하니 반복과 스트림을 동시에 지원한다.
  • 따라서 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.

어댑터는 클라이언트 코드를 어수선하게 만들고 성능도 안 좋다.

 

[How]

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

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

예시) 주어진 집합의 멱집합(power set, 한 집합의 모든 부분집합을 원소로 하는 집합)을 반환하는 경우

  • {a, b, c}의 멱집합은 {{}, {a}, {b}, {c}, {a, b}, {a, c}, {b, c}, {a, b, c}}다.
  • 원소 개수가 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() {
                // 멱집합의 크기는 2를 원래 집합의 원소 수만큼 거듭제곱한 것과 같다.
                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번째 원소를 포함하는지 여부를 알려준다.
    • 따라서 0부터 2^n - 1까지의 이진수와 원소 n개인 집합의 멱집합과 자연스럽게 매핑된다.
    • 예를 들어 {a, b, c}의 멱집합은 원소가 8개이므로 유효한 인덱스는 0~7이며, 이진수로는 000~111이다.
    • 이때 인덱스를 이진수로 나타내면, 각 n번째 자리의 값이 각각 원소 a, b, c를 포함하는지 여부를 알려준다.
    • 즉, 멱집합의 000번째 원소는 {}, 001번째는 {a}, 101번째는 {c, a}, 111번째는 {c, b, a}가 되는 식이다.
  • AbstractCollection을 활용해서 Collection 구현체를 작성할 때는 Iterable용 메서드 외에 2개만 더 구현하면 된다.
    • 바로 contains와 size다.
    • contains와 size를 구현하는 게 불가능할 때는 컬렉션보다는 스트림이나 Iterable을 반환하는 편이 낫다.

예시) 입력 리스트의 (연속적인) 부분 리스트를 모두 반환하는 메서드를 작성하는 경우

필요한 부분리스트를 만들어 표준 컬렉션에 담는 코드는 단 3줄이면 충분하다.

  • 하지만 이 컬렉션은 입력 리스트 크기의 거듭제곱만큼 메모리를 차지한다.
  • 기하급수적으로 늘어나는 멱집합보다는 낫지만, 역시나 좋은 방식이 아님은 명백하다.

약간의 통찰만 있으면, 입력 리스트의 모든 부분리스트를 스트림으로 구현하기는 어렵지 않다.

  • 첫 번째 원소를 포함하는 부분리스트를 그 리스트의 prefix라 해보자.
    • (a, b, c)의 prefix는 (a), (a, b), (a, b, c)가 된다.
  • 같은 식으로 마지막 원소를 포함하는 부분리스트를 그 리스트의 suffix라 하자.
    • (a, b, c)의 suffix는 (a, b, c), (b, c), (c)가 된다.
  • 어떤 리스트의 부분리스트는 단순히 그 리스트의 prefix의 suffix(혹은 suffix의 prefix)에 빈 리스트 하나만 추가하면 된다.
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()));
    }
}
  • Stream.concat 메서드는 반환되는 스트림에 빈 리스트를 추가하며, flatMap 메서드(아이템45)는 모든 prefix의 모든 suffix로 구성된 하나의 스트림을 만든다.
  • 마지막으로 prefix들과 suffix들의 스트림은 IntStream.range와 IntStream.rangeClosed가 반환하는 연속된 정숫값들을 매핑해 만들었다.
  • 쉽게 말해 이 관용구는 정수 인덱스를 사용한 표준 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);
}