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

자바 7까지는 일련의 원소를 반환하는 메서드의 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스, 혹은 Iterable이나 배열을 썼다. 이 중 가장 적합한 타입을 선택하기란 그다지 어렵지 않았다. 기본은 컬렉션 인터페이스다. for-each 문에서만 쓰이거나 반환된 원소 시퀀스가 일부 Collection 메서드를 구현할 수 없을 때는 Iterable 인터페이스를 썼다. 반환 원소들이 기본 타입이거나 성능에 민감한 상황이라면 배열을 썼다. 그런데 자바 8이 스트림이라는 개념을 들고 오면서 이 선택이 아주 복잡한 일이 되어버렸다.

Stream과 Iterable

스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다. API를 스트림만 반환하도록 짜놓으면 반환된 스트림을 for-each로 반복하길 원하는 사용자가 불편할 것이다.

Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함할 뿐 아니라, Iterable 인터페이스가 정의한 방식대로 동작한다. 그럼에도 for-each로 스트림을 반복할 수 없는 까닭은 바로 Stream이 Iterable을 확장(extend)하지 않아서다.

스트림을 반복하기 위한 잘못된 우회 방법

for(ProcessHandle ph : (Iterable<ProcessHandle>)
                        ProcessHandle.allProcesses()::iterator) {
    ...
}

메서드 참조를 매개변수화된 Iterable로 형변환 했다. 작동은 하지만 실전에 쓰기에는 너무 난잡하고 직관성이 떨어진다. 어댑터 메서드를 사용하면 상황이 나아진다.

Stream<E>를 Iterable<E>로 중개해주는 어댑터

public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}
for(ProcessHandle ph : iterableOf(ProcessHandle.allProcesses())) {
    ...
}

어댑터를 사용하면 어떤 스트림도 for-each문으로 반복할 수 있다.

Iterable<E>를 Stream<E>로 중개해주는 어댑터

public static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false);
}

객체 시퀀스를 반환하는 메서드를 작성하는데, 이 메서드가 오직 스트림 파이프라인에서만 쓰인 걸 안다면 스트림을 반환하자. 반대로 반환된 객체들이 반복문에서만 쓰인 걸 안다면 Iterable을 반환하자. 하지만 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두를 배려해야 한다.

컬렉션을 반환

Collection 인터페이스는 Iterable의 하위 타입이고 stream 메서드도 제공하니 반복과 스트림을 동시에 지원한다. 따라서 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다. Arrays 역시 Arrays.asList와 Stream.of 메서드로 쉽게 반복과 스트림을 지원할 수 있다. 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는 게 최선일 수 있다. 하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.

반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하는 방안을 검토하자. 원소 개수가 많을 때 표준 컬렉션 구현체에 저장하려는 생각은 위험하다. 하지만 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;
            }
        };
    }

    public static void main(String[] args) {
        Set s = new HashSet(Arrays.asList(args));
        System.out.println(PowerSet.of(s));
    }
}

AbstractCollection을 활용해서 Collection 구현체 작성할 때는 contains와 size만 구현하면 된다. 반복이 시작되기 전에는 시퀀스의 내용을 확정할 수 없는 등의 사유로 contains와 size를 구현하는 게 불가능할 때는 컬렉션보다 스트림이나 Iterable을 반환하는 편이 낫다. 원한다면 별도의 메서드로 두 방식을 모두 제공해도 된다.

스트림 반환

입력 리스트의 부분리스트를 만들어 표준 컬렉션에 담는 코드는 단 3줄이면 가능하다. 하지만 이 컬렉션은 입력 리스트 크기의 거듭제곱만큼 메모리를 차지한다. 입력 리스트의 모든 부분리스트를 스트림으로 구현하기는 어렵지 않다.

// 입력 리스트의 모든 부분리스트를 스트림으로 반환하는 두 가지 방법
public class SubLists {
    // 방법1
    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()));
    }

//    // 방법2
//    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);
//    }

    public static void main(String[] args) {
        List<String> list = Arrays.asList(args);
        SubLists.of(list).forEach(System.out::println);
    }
}

Stream.concat 메서드는 반환되는 스트림에 빈 리스트를 추가하며, flatMap 메서드는 모든 prefixes의 모든 suffixes로 구성된 하나의 스트림을 만든다. 마지막으로 prefixes들과 suffixes들의 스트림은 IntStream.rangeIntStream.rangeClosed가 반환하는 연속된 정숫값들을 매핑해 만들었다. 쉽게 말해 이 관용구는 정수 인덱스를 사용한 표준 for 반복문의 스트림 버전이라 할 수 있다. 따라서 이 구현은 for반복문을 중첩해 만든 것과 취지가 비슷하다.


스트림을 반환할 경우, 반복을 사용하는 게 더 자연스러운 상황에서도 사용자는 그냥 스트림을 쓰거나 Stream을 Iterable로 변환해주는 어댑터를 이용해야 한다. 하지만 이러한 어댑터는 클라이언트 코드를 어수선하게 만들고 더 느리다.

원소 시퀀스를 반환하는 메서드를 작성할 때는, 이를 스트림으로 처리하기를 원하는 사용자와 반복으로 처리하길 원하는 사용자가 모두 있을 수 있다. 컬렉션을 반환할 수 있다면 그렇게 하자. 반환 전부터 이미 원소들을 컬렉션에 담아 관리하고 있거나 컬렉션을 하나 더 만들어도 될 정도로 원소가 개수가 적다면 ArrayList 같은 표준 컬렉션에 담아 반환하라. 그렇지 않으면 전용 컬렉션을 구현할지 고민하자.
컬렉션을 반환하는 게 불가능하면 스트림과 Iterable 중 더 자연스러운 것을 반환하자.

Comments