Effective Java - item 47
반환 타입으로는 스트림보다 컬렉션이 낫다
들어가며
원소 시퀀스를 반환하는 메서드는 자바 프로그래밍에서 흔히 볼 수 있다. 자바 7까지는 이런 메서드의 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스나 Iterable 또는 배열을 사용했다. 기본은 컬렉션 인터페이스였다. 하지만 자바 8에서 스트림이 도입되면서 선택이 복잡해졌고 원소 시퀀스를 반환할 때 스트림과 컬렉션 중 어떤 것을 반환해야할 지 고민해봐야한다.
스트림은 반복을 지원하지 않는다
스트림은 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함하고 Iterable 인터페이스가 정의한 방식대로 동작한다. 하지만 for-each로 반복할 수 없다. Stream이 Iterable을 확장하지 않았기 때문이다.
1
2
3
4
// 컴파일 오류 발생
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// 처리
}
Stream의 iterator 메서드를 사용하면 우회할 수 있지만 실제 코드에 사용하기에는 너무 난잡하고 직관성이 떨어진다.
1
2
3
4
5
// 우회 방법이지만 난잡함
for (ProcessHandle ph : (Iterable<ProcessHandle>)
ProcessHandle.allProcesses()::iterator) {
// 처리
}
어댑터 메서드를 사용하면 상황이 나아진다.
1
2
3
4
5
6
7
8
9
// Stream을 Iterable로 변환하는 어댑터
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
// 사용
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
// 처리
}
반대로 API가 Iterable만 반환하면 스트림 파이프라인에서 사용하려는 프로그래머에게 불만이 생기기 생긴다. 자바는 이를 위한 어댑터도 제공하지 않지만 쉽게 구현할 수 있다.
1
2
3
4
// Iterable을 Stream으로 변환하는 어댑터
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
공개 API를 작성할 때는 양쪽 모두를 배려하자
객체 시퀀스를 반환하는 메서드를 작성할 때는 스트림 파이프라인에서 쓰일지 반복문에서 쓰일지 고려해야 한다. Collection 인터페이스는 Iterable의 하위 타입이고 stream 메서드도 제공하니 반복과 스트림을 동시에 지원한다. 따라서 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.
Arrays 역시 Arrays.asList와 Stream.of 메서드로 손쉽게 반복과 스트림을 지원할 수 있다. 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는 게 최선일 수 있다. 하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올리면 안된다.
전용 컬렉션을 구현하는 방법
반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하는 방안을 검토해보아야한다. 주어진 집합의 멱집합을 반환하는 방식으로 생각해야한다. 원소 개수가 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
// 입력 집합의 멱집합을 전용 컬렉션에 담아 반환
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() {
return 1 << src.size(); // 2^n
}
@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용 메서드 외에 contains와 size만 더 구현하면 된다. contains와 size를 구현하는 게 불가능할 때는 컬렉션보다는 스트림이나 Iterable을 반환하는 편이 낫다.
때로는 구현하기 쉬운 쪽을 선택하자
입력 리스트의 모든 부분리스트를 스트림으로 반환하는 코드를 작성해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 입력 리스트의 모든 부분리스트를 스트림으로 반환
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()));
}
}
이 코드는 간결하지만 반복문으로 사용하려면 어댑터가 필요하다. 반복문을 사용하는 게 더 자연스러운 상황에서는 다음처럼 구현할 수 있다.
1
2
3
4
5
6
7
8
// 반복 방식으로 구현
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);
}
마치며
원소 시퀀스를 반환하는 메서드를 작성할 때는 스트림으로 처리하길 원하는 사용자와 반복으로 처리하길 원하는 사용자 모두를 배려해야 한다. 컬렉션을 반환할 수 있다면 그렇게 하라. 반환 전부터 이미 원소들을 컬렉션에 담아 관리하거나 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면 ArrayList 같은 표준 컬렉션에 담아 반환하라. 그렇지 않으면 앞서의 멱집합 예처럼 전용 컬렉션을 구현할지 고민하라. 컬렉션을 반환하는 게 불가능하면 스트림과 Iterable 중 더 자연스러운 것을 반환하라.