도당탕탕

Item47 : 반환 타입으로는 스트림보다 컬렉션이 낫다 본문

JAVA

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

backlo 2023. 1. 5. 15:11

원소 시퀀스, 즉 일련의 원소를 반환하는 메서드는 수없이 많다. 자바 7까지는 이런 메서드의 반환 타입으로 다음과 같은 타입을 사용했다.

  1. Collection, Set, List
  2. E[]와 같은 배열
  3. Iterable 인터페이스

즉 기본인 컬렉션 인터페이스를 사용했다. for-each문에서만 쓰이거나 반환된 원소 시퀀스가 일부 Collection 메서드를 구현할 수 없을 때는 Iterable 같은 인터페이스를 사용했다. 아니면 성능이 민감한 상황에서는 배열을 썼다.

하지만 자바8에 스트림이 나오면서 다음과 같은 선택들이 더 복잡해지게 되었다.

스트림 문제

스트림 문제는 다음과 같다.

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

따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다. API를 스트림만 반환하도록 짜놓으면 반환된 스트림을 for-each로 반복하길 원하는 개발자는 당연히 불만을 가질 것이다.

번외로 사실 스트림 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 포함하고 있지만, Iterable을 확장하지 않아 for-each로 스트림을 반복할 수 없다.

그렇다면 stream의 iterator 메소드에 메서드 참조를 건네면 해결되지 않을까라고 생각할 수 있지만, 그렇게 완벽한 방법은 아니다. 다음 예를 보자

  • 자바 타입 추론의 한계로 컴파일 되지 않은 코드
// ProcessHandle class
static Stream<ProcessHandle> allProcesses() {
    return ProcessHandleImpl.children(0);
}

// concrete class or business logic class
static void test() {
  ...

  for (ProcessHandle ph: ProcessHandle.allProcesses()::iterator) {
    // handle process
  }
}

위 코드는 자바 타입추론의 한계로 컴파일되지 않는다.

Test.java:6 error: method reference not expected here
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
^

이 오류를 바로 잡으려면 메소드 참조를 매개변수화 된 Iterable로 적절히 형변환 해줘야 한다.

  • 오류 해결 코드
for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator) {
    // handle process
}

이렇게 타입 캐스팅을 통해 형변환을 시켜주면 동작을 한다. ( 하지만 실제로는 ClassCastException이 발생 )

하지만 이 코드는 실전에서 사용하기에는 너무 난잡하고 직관성이 떨어진다. 다행히 어댑터 메서드를 사용하면 상황이 나아진다.

  • Stream를 Iterable로 중개해주는 어댑터
// 자바의 타입 추론이 문맥을 잘 파악하여 어댑터 메소드안에서는 따로 형변환하지 않아도 된다.
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}

...

 for (ProcessHandle ph: iterableOf(ProcessHandle.allProcesses())) {
    // handle process
  }

...

이렇게 어댑터로 손쉽게 해결할 수 있다.

Iterable 문제

API가 Iterable만 반환하면 이를 스트림 파이프라인에서 처리하려는 프로그래머는 불편할 것이다. 따라서 이 또한 어댑터를 제공해서 해결할 수 있다.

  • Iterable를 Stream로 중개해주는 어댑터
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false);
}

객체 시퀀스를 반환하는 메소드를 작성하는데, 이 메서드가 오직 스트림 파이프라인에서만 쓰일 걸 안다면 마음 놓고 스트림을 반환하게 해 주면 된다.

결론

하지만 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두 배려 해야한다. 즉 Collection 인터페이스를 사용해 둘 다 만족하는 코드를 작성하는 것이 좋다.

Collection 인터페이스는 iterable의 하위 타입이고 stream 메소드도 제공하니 반복과 스트림을 동시에 지원한다. 따라서 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다. 예를 들어 Arrays 역시 Arrays.asList와 Stream.of 메소드로 손쉽게 반복과 스트림을 지원할 수 있다.

하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안된다. 만약 반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하는 방안을 검토해보는 것이 좋다. 다음 멱집합 예제를 한번 보자.

  • 입력 집합의 멱집합을 전용 컬렉션에 담아 반환한다.
public class PowerSet {
     // {a, b, c} 멱집합 : {a}. {b}, {c}, {a, b}, {a. c}, {b, c}, {a, b, c}
     // 멱집합의 원소 개수 2^n -> 표준 컬렉션 구현체에 저장하면 절대 안됌
  // AbstractList를 이용하면 전용 컬렉션을 손쉽게 구현할 수 있음
  public static final <E> Collection<Set<E>> of(Set<E> s) {
      List<E> src = new ArrayList<>(s);
    // 입력 집합의 원소수가 30을 넘으면 power.of 예외를 던진다. (size는 int이기 때문에 2^31 -1 로 제한)
    // 이는 Stream, Iterable이 아닌 Collection을 쓸 때 단점을 보여준다. (stream이나 iterable은 사이즈 고민 x)
        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;
            }
        };
  }
}

위의 예제처럼 AbstractCollection을 활용해서 Collection 구현체를 작성할 때는 Iterable용 메서드 외의 2개만 더 구현하면 된다. 바로 contain, size이다. 이 메서드는 손쉽게 효율적으로 구현할 수 있다.

하지만 contains와 size를 구현하는 게 불가능할 경우 Collection 보다는 Stream, Iterable을 반환하는 편이 낫다. 원한다면 별도의 메서드를 두어 두 방식을 모두 제공해도 된다.

때로는 스트림이 나을 때도 있다.

예를 들어 입력 리스트의 부분리스트를 모두 반환하는 메소드를 작성한다고 하면, 필요한 부분 리스트를 만들어 표준 컬렉션에 담는 코드는 단 3줄이면 충분하다. 하지만 이 컬렉션은 입력리스트 크기의 거듭제곱만큼 메모리를 차지한다. 기하급수적으로 늘어나는 멱집합보다는 낫지만, 역시나 좋은 방법은 아니다.

하지만 입력 리스트의 모든 부분리스트를 스트림으로 구현하기는 어렵지 않다. 다음 예를 보자.

  • 입력 리스트의 모든 부분리스트를 스트림으로 반환한다.
public class SubList {

    public static <E> Stream<List<E>> of(List<E> list) {
        return Stream.concat(Stream.of(Collections.emptyList()), 
                             prefixes(list).flatMap(SubList::suffixes));
    }

    public static <E> Stream<List<E>> prefixes(List<E> list) {
        return IntStream.rangeClosed(1, list.size())
                        .mapToObj(end -> list.subList(0, end));
    }

    public static <E> Stream<List<E>> suffixes(List<E> list) {
        return IntStream.rangeClosed(0, list.size())
                        .mapToObj(start -> list.subList(start, list.size()));
    }
}

이렇게 약간의 통찰력으로 prefixes와 suffixes를 사용해 손쉽게 해결할 수 있다.

Stream.concat 메서드는 반환되는 스트림에 빈 리스트를 추가하며, flatMap으로 모든 프리픽스의 모든 서픽스로 구성된 하나의 스트림을 만든다. 마지막으로 프리픽스들과 서픽스들의 스트림은 IntStream.range와 IntStream.rangClosed가 반환하는 연속된 정숫값들을 매핑해 만들면 쉽게 해결할 수 있다.

이뿐만 아니라 다양하게 구현할 수 있다. 다음 예제를 보면

  • for-loop를 이용한 코드
for (int start = 0; start < src.size(); start++) {
    for (int end = start + 1; end <= src.size(); end++) {
        System.out.println(src.subList(start, end));
    }
}
  • stream 중첩
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);
}

이상으로 스트림을 반환하는 두 가지 구현을 알아봤는데, 모두 쓸만하다. Stream이나 Iterable을 리턴하는 API에는 Stream -> Iterable, Iterable -> Stream으로 변환하기 위한 어댑터 메서드가 필요하다.

어댑터는 클라이언트 코드를 어수선하게 만들고 책에서는 2.3배 정도 느리다고 하기 때문에 원소 시퀀스를 반환하는 메서드를 작성할 때는 Stream, Iterator를 모두 지원할 수 있게 작성하는 것이 좋다. 즉 되도록 Collection으로 하는 것이 좋다.

또한 원소의 개수가 많다면, 멱집합의 예처럼 전용 컬렉션을 리턴하는 방법을 고민해 보자. 그리고 만약 나중에 Stream 인터페이스가 iterable을 지원하도록 수정된다면, 그때는 안심하고 Stream을 반환하면 된다.

Comments