Post

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

서론

  • 자바7 까지는 메서드에서 원소 시퀀스 반환 시, 반환 타입으로 컬렉션 인터페이스, Iterable, 배열을 써왔다.
    • 보통 컬렉션 인터페이스를 많이 사용한다.
    • Iterable 인터페이스
      • for-each 문에서만 쓰일 경우
      • 반환된 원소 시퀀스가 일부 Collection 메서드를 구현할 수 없을 경우
    • 배열
      • 반환된 원소들이 기본 타입일 경우
      • 성능에 민감한 상황일 경우
  • 자바8 이후, 스트림이 도입되면서 이 선택이 복잡해졌다.

Iterable vs Stream

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

1
2
3
4
5
6
List<Integer> numbers = Arrays.asList(1, 2, 3);

// 컴파일 에러 발생
for (int number : numbers.stream()) {
    // ...
}

위 코드는 for-each 문 내부 numbers.stream() 에서 컴파일 에러가 난다.

for-each 문은 List, Set과 같은 Iterable 인터페이스 구현체에서 사용가능하다.

1
2
3
4
5
6
7
8
@Test
void foreach_iterable() {
    List<Integer> numbers = Arrays.asList(1, 2, 3);
    
    for (int number: numbers) {
        assertThat(number <= 3).isEqualTo(true);
    }
}

위 코드는 아무 문제 없이 동작한다.

따라서 반복문 사용을 원하는 사용자에게 Stream 을 반환하는 API를 제공한다면 불만을 토로할 것이다.

만약 이런 불만을 잠재우기 위해서 StreamIterable로 변환하도록 지원하면 될까 ❓

Stream → Iterable로 변환하기

사실 Stream 인터페이스는 Iterable 인터페이스가 정의한 메서드를 전부 포함한다.

뿐만 아니라 Iterable 인터페이스가 정의한 방식대로 동작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface Iterable<T> {

    Iterator<T> iterator();

    default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
    }

    default Spliterator<T> spliterator() {
        return Spliterators.spliteratorUnknownSize(iterator(), 0);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface BaseStream<T, S extends BaseStream<T, S>>
        extends AutoCloseable {

    Iterator<T> iterator();

    Spliterator<T> spliterator();

    // ...
}

public interface Stream<T> extends BaseStream<T, Stream<T>> {

    // ...

    void forEach(Consumer<? super T> action);

    // ...
}
  • Iterable 에 존재하는 iterator, forEach, spliteratorStream 에서도 지원한다.
  • 하지만 for-each로 Stream 을 반복할 수 없는 이유는 Iterable 을 확장(extends)하지 않아서다.

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

Stream의 iterator 메서드에 메서드 참조를 넘긴다면 해결될까 ❓

1
2
3
4
5
6
7
8
@Test
void foreach_stream() {
    Stream<Integer> numberStream = Stream.of(1, 2, 3);

    for (int number: numberStream::iterator) {
        assertThat(number <= 3).isEqualTo(true)
    }
}

TypeInferenceError

  • 컴파일러가 타입 추론을 적절하게 하지 못해서 컴파일 오류가 발생한다.

이를 해결하려면 Iterable 로 명시적 형변환이 필요하다.

1
2
3
4
5
6
7
8
@Test
void foreach_stream() {
    Stream<Integer> numberStream = Stream.of(1, 2, 3);
    
    for (int number: (Iterable<Integer>) numberStream::iterator) {
        assertThat(number <= 3).isEqualTo(true);
    }
}
  • Iterable 로 형변환한 코드
  • 작동은 하지만 난잡하고, 직관성이 떨어진다.
  • 이를 보완하기 위해 어댑터 메서드를 제공할 수 있다.

    📌 + 참고) stream::iterator 가 Iterable 이 되는 이유

    Iterable 인터페이스는 추상 메서드가 iterator 하나이므로 함수형 인터페이스로 볼 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
      ```java
      public interface Iterable<T> {
          Iterator<T> iterator();
        
          default void forEach(Consumer<? super T> action) {
              Objects.requireNonNull(action);
              for (T t : this) {
                  action.accept(t);
              }
          }
        
          default Spliterator<T> spliterator() {
              return Spliterators.spliteratorUnknownSize(iterator(), 0);
          }
      }
      ```
    

    따라서 Iterable 인터페이스는 iterator 구현을 람다로 제공할 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
      ```java
      @Test
      void foreach_stream() {
          // given
          Stream<Integer> numberStream = Stream.of(1, 2, 3);
        
          // when
          for (int number: (Iterable<Integer>) () -> numberStream.iterator()) {
              assertThat(number <= 3).isEqualTo(true);
          }
      }
      ```
    

    앞선 예시인 Iterable 로 형변환한 메서드 참조 코드를 위와 같이 람다로 변환했다.

    람다가 Iterable.iterator 를 구현한 것이므로 메서드 참조로 변환 가능하다.

🔑 Stream → Iterable 어댑터

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void foreach_stream() {
    Stream<Integer> numberStream = Stream.of(1, 2, 3);
    for (int number: iterableOf(numberStream)) {
        assertThat(number <= 3).isEqualTo(true);
    }
}

// Stream -> Iterable 로 중개해주는 어댑터
private static <E> Iterable<E> iterableOf(Stream<T> stream) {
    return stream::iterator;
}

이렇게 하면 어떤 스트림도 for-each 문으로 반복할 수 있다.

자바의 타입 추론이 문맥을 잘 파악하여 어댑터 메서드 안에서 따로 형변환 하지 않아도 된다.

🔑 Iterable → Stream 어댑터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false);
}

@Test
void foreach_stream_adaptor() {
    // given
    List<Integer> numbers = Arrays.asList(1, 2, 3);

    // when
    Stream<Integer> numberStream = streamOf(numbers);

    // then
    numberStream.forEach(number -> assertThat(number <= 3).isTrue());
}

마찬가지로 Iterable 만 반환하는 API를 Stream 으로 사용하기 위한 어댑터를 만들 수도 있다.

Java 표준 라이브러리에서 제공하는 StreamSupport.stream 를 활용했다.

🚑 어댑터 메서드 사용시 Stream 이나 Iterable 만 사용하는 것보다 속도가 느리다.

  • 메서드가 오직 스트림 파이프라인에서만 쓰일 걸 안다면 Stream 을 반환해라
  • 반대로 반복문에서만 쓰일 걸 안다면 Iterable 을 반환하라.
  • 하지만 공개 API를 작성할 때는 명확한 근거가 없다.
  • 양쪽 모두를 배려해야 한다.

Collection 인터페이스

Collection 인터페이스는 Iterable 인터페이스의 하위 타입이고, stream 메서드도 제공한다.

즉, 반복과 스트림을 동시 지원한다.

1
2
3
4
5
6
7
8
9
public interface Collection<E> extends Iterable<E> {
    // ...

    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }

    // ...
}

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

반환하는 컬렉션이 너무 크다면 전용 컬렉션도 고려하라

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

책에서 나온 멱집합 반환 예시를 살펴보자.

  • 주어진 집합의 멱집합(한 집합의 모든 부분집합을 원소로 하는 집합)을 반환하는 경우
    • 원소 개수가 n개 → 멱집합 원소 개수 2^n개
    • 메모리 소비, 성능 저하 이슈를 고려했을 때, 멱집합을 표준 컬렉션 구현체에 저장하려는 생각은 위험하다.

🔨 해결책

AbstractList 를 이용하면 전용 컬렉션 구현이 가능하다.

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
public class PowerSet { 
    public static final <E> Collection<Set<E>> of(Set<E> s) {
        List<E> src = new ArrayList<>(s);
        if(src.size() > 30) {
          // int size이므로 최대길이 제한이 있음(컬렉션 반환 타입 단점)
          throw new IllegalArgumentException("집합에 원소가 너무 많습니다(최대 30개): " + s);
        }
        
        return new AbastractList<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;
            }
        }
    }
}

AbstractCollection 을 활용해서 Collection 구현체를 작성할 때는 아래 3개의 메서드는 반드시 구현해야한다.

  • Iterable용 메서드
  • contains
  • size

만약 containssize를 구현하는게 불가능한 경우 Stream이나 Iterable을 반환하는 편이 좋다. 이렇게 전용 컬렉션을 구현하는 것이 스트림보다 약 1.4배 정도 더 빨랐으며, 어댑터 형식은 스트림보다 약 2.3배 더 느리다고 한다.(이펙티브 자바 저자 컴퓨터 기준)


💡 정리

  1. 원소 시퀀스를 반환하는 메서드를 작성할 때는 Iterable(반복) 과 Stream 두 방식을 모두 작성하자.
  2. 사용자를 생각하여, 반환 값은 가능하면 Iterable 하위 타입이면서 stream 메서드를 지원하는 Collection 하위 타입을 반환하자.
  3. 반환할 컬렉션의 원소 개수가 작다면 ArrayList 와 같은 표준 컬렉션 구현체를 반환하라.
  4. 그렇지 않으면, 전용 컬렉션을 구현할지 고민하라.
  5. 컬렉션을 반환하는게 불가하다면 StreamIterable 중 더 자연스러운 것을 반환하라.
This post is licensed under CC BY 4.0 by the author.