자바 7까지는 반환타입으로 다음과 같이 사용했다.
Iterable Interface: for-each(enhance) 문에서만 쓰이거나 반환된 원소 시퀀스가 일부 Collection 메소드를 구현할 수 없을 때
Collection 인터페이스를 활용할 상황이 없거나, 일부 메소드를 구현할 수 없다면 더 나은 선택지?란 것 같다. ex) contains(Object)
Array: 반환 원소들이 Primitive Type이거나 성능에 민감한 상황
Collection Interface: 위 상황들을 제외한 기본적인 상황
그리고 자바 8 이후 Stream도 등장하면서 선택지는 더 늘어났다.
Collection이 최적의 반환 타입인 이유
Stream은 반복(iteration)을 지원하지 않기에, 스트림과 반복을 알맞게 조합해야한다.
그런데 Stream인터페이스는 Iterable 인터페이스가 정의한 추상 메소드를 전부 포함하고 Iterable 인터페이스가 정의한대로 동작한다.
Iterable 인터페이스는 iterator 추상 메소드와 spliterator, forEach 디폴트 메소드를 갖는다.

그런데도 for-each로 스트림을 반복할 수 없는 이유는 Stream 인터페이스가 Iterable 인터페이스를 확장하지 않아서다.
*Iterable의 디폴트 메소드인 spliterator는 BaseStream 인터페이스의 추상메소드로, forEach는 Stream 인터페이스의 추상메소드로 존재한다.
그러면 Stream의 iterator 메소드에 메소드 참조를 건넨다면? 다음과 같은 예시를 들 수 있다.

타입 추론이 되지 않는다니 Iterable로 형변환을 시키면 될 것 같다!
하지만 형변환을 시키면 직관성이 떨어지고 난잡해진다. Adapter 메소드를 만들어 따로 형변환 코드를 작성하지 않게 해보자.
// 작동 성공! But 직접 쓰기는 난잡하고 직관성 하락!
public static void main(String[] args) {
for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator) {
}
}
// --> Stream<E>를 Iterator<E>로 중개해주는 어댑터, 반환 타입을 정해주자
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
public static void main(String[] args) {
//어떤 스트림도 for-Each문 가능!
for(ProcessHandle p:iterableOf(ProcessHandle.allProcesses())){
}
}
이렇게 stream을 Iterable로 변환하여 반환하였지만, 반대로 API가 Iterable만 반환하는 경우도 있다.
하지만 위와 같은 Adapter를 이용해 Iterable을 Stream으로 반환하게 하면 된다!
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
StreamSupport는 저수준 Stream을 직접 사용하는데 도움을 주는 유틸리티 클래스로 8가지 정적 메소드를 제공한다.
iterable의 spliterator를 첫 번째 인자로 넘기고 두 번째 인자로 parallel 여부를 넘긴다.
공개 API를 작성할 땐 Stream 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두를 배려해서 만들어야 한다.
Collection 인터페이스는 Iterable 하위 타입이고 stream 메소드도 제공하니 반복과 스트림을 동시에 지원한다.

그래서 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로는 최선이다.
Arrays 역시 Arrays.asList와 Stream.of 메소드로 쉽게 반복문과 스트림을 지원할 수 있다.
추가+
for-each 문에 Iterable이나 Iterable을 구현한 구현체에서 사용이 가능한 것은 확인했다. 하지만 배열을 넣어도 잘 동작한다.
그래서 for-each문을 List와 배열을 사용해 컴파일된 class를 확인해봤다.
// 컴파일 전 ForEachTrans.java
public static void main(String[] args) {
List<String> lists = new ArrayList<>();
Integer[] nums = new Integer[5];
for (Integer num : nums) {
}
for (String list : lists) {
}
}
//컴파일 후 ForEachTrans.class
public static void main(String[] args) {
List<String> lists = new ArrayList();
Integer[] nums = new Integer[5];
Integer[] var3 = nums;
int var4 = nums.length;
for(int var5 = 0; var5 < var4; ++var5) {
Integer var10000 = var3[var5];
}
String var8;
for(Iterator var7 = lists.iterator(); var7.hasNext(); var8 = (String)var7.next()) {
}
}
컴파일러에 의해 Iterable의 구현체 List의 경우 iterator() 메소드를 호출하고 Iterator를 이용하게 변환된 것을 알 수 있고,
배열의 경우 일반적으로 배열에 반복문을 사용하는 것처럼 변환된 것을 알 수 있다.
참고+
아이템 45 아나그램에서 Stream 버전은 Files.lines 메소드를 이용하고 반복 버전은 스캐너를 이용했었다. 파일을 읽는 중 발생하는 모든 예외를 처리해주는 점에서 Files.lines가 우수하다. 스트림만 반환하는 API가 반환한 값을 for-each로 반복하길 원하면 이를 감수해야한다. 자세한 내용은 아이템45를 참조하자
전용 컬렉션 구현
반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는게 최선일 수 있지만, 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서도 안된다.
반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하는 방안도 있다.
주어진 집합의 멱집합(한 집합의 모든 부분집합을 원소로 하는 집합)을 반환하는 상황을 예로 들어보자.
ex) {a,b,c}의 멱집합: {{}, {a}, {b}, {c}, {a,b}, {a,c}, {b,c}, {a,b,c}} --> 즉 2^n개의 원소를 지님
public static final <E> Collection<Set<E>> of(Set<E> s) {
List<E> src = new ArrayList<>(s);
// Collectiondml size가 int 값을 반환해서 Integer.MAX_VALUE인 (2^31)-1로 제한된다.
// 크기가 더 커도 (2^31)-1을 반환해도 되지만 만족스러운 해법은 X
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) {
// o가 Set 클래스가 맞는지, 리스트가 o를 전부 포함하는지 여부 반환
return o instanceof Set && src.containsAll((Set)o);
}
@Override public Set<E> get(int index) {
Set<E> result = new HashSet<>();
// &연산을 통해서 어떤 원소가 있는지 유추할 수 있다. 오른쪽 시프트하며 다음 원소로 계속해서 이동한다.
// 그리고 원소가 인덱스에 있다면 result에 추가하고 최종적으로 result를 반환한다.
// ex) {a,b,c}의 5(101)인덱스를 오른쪽 시프트하며 멱집합 {a,c}임을 알 수 있다!
for (int i = 0; index != 0; i++, index >>= 1)
if ((index & 1) == 1)
result.add(src.get(i));
return result;
}
};
}
n개의 원소로도 2^n개가 필요한 상황에도 비트벡터로 이용해 AbstractList를 쉽게 구현할 수 있음을 알 수 있다.
그렇다면 AbstractCollection을 활용해야 할 때도 마찬가지다. Iterable용 메소드와 contains, size 메소드만 구현해주면 된다.
내가 직접 코드를 구현해 봤을 때는, Iterable용 메소드와 size 메소드만 구현해도 컴파일은 됐었다. 하지만 contains를 구현하지 않고 AbstractCollection에 정의된 메소드를 이용하면, Object를 인자로 받기 때문에 재정의해서 사용하기 위함이라고 생각했다.
public static void main(String[] args) {
List<String> lists = Arrays.asList("a","b","c");
Collection<String> strings = new AbstractCollection<>() {
@Override
public Iterator<String> iterator() {
return lists.iterator();
}
@Override
public int size() {
return lists.size();
}
@Override
public boolean contains(Object o) {
// 재정의를 해서 매개변수가 String이 맞는지 확인해야 올바르게 구현된 형태!
return o instanceof String && super.contains(o);
}
};
// strings.size() = 3true
System.out.println("strings.size() = " + strings.size() + strings.contains("a"));
}
반복이 시작되기 전에는 시퀀스의 내용을 확정할 수 없는 등의 사유로 contains와 size 구현이 불가능할 경우
컬렉션보다는 스트림이나 Iterable을 반환하는 편이 낫다. 혹은 별도의 메소드를 두어 두 방식을 모두 제공하거나!
입력 리스트의 부분 리스트 전체 반환하기
단순히 구현하기 쉬운 쪽을 선택하는 것이 좋을수도 있다.
리스트의 부분 리스트를 모두 반환하는 메소드를 만들어야 한다면, 필요한 부분 리스트를 만들어 표준 컬렉션에 담는 코드로 3줄이면 충분하지만, 이 컬렉션은 리스트 크기의 거듬 제곱만큼 메모리를 차지한다.
ex) {a,b,c} -> {}, {a}, {a,b}, {b}, {a,b,c}, {b,c},{c}
멱집합보다 낫지만 좋은 방법도 아니고, 자바가 이럴 때 쓸 골격 Iterator를 제공하지 않고, 전용 컬렉션을 만드는게 지루할 수 있다.
구현 내용은 주석을 참조하자.
// 입력 리스트의 모든 부분리스트를 스트림으로 반환
// 즉, list에 {"a","b","c"}가 있다면 prefix 결과 {"a"}, {"a","b"}, {"a", "b", "c"}를
// 각각 마지막 원소를 포함하게 하면 {a}, {a,b}, {b}, {a,b,c}, {b,c},{c}가 나올 수 있다.
// 모든 부분 리스트니 빈 리스트도 필요하고, 이를 Stream으로 만들어 concat을 통해 붙히면
// 첫 번째 스트림 인자 뒤로 두 번째 스트림 인자가 붙어서 최종적으로 부분 리스트들이 Stream으로 만들어져 반환된다.
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) {
//rangeClosed: 특정 범위의 숫자를 차례대로 생성, [startInclusive,endInclusive]
// 즉, list에 {"a","b","c"}가 있다면 mapToObj를 통해
// [0,1) ~ [0,3)까지를 결과를 List형태로 Stream에 담는다.--> {"a"}, {"a","b"}, {"a", "b", "c"}
return IntStream.rangeClosed(1, list.size())
.mapToObj(end -> list.subList(0, end));
}
// 마지막 원소를 포함하는 부분리스트를 반환
private static <E> Stream<List<E>> suffixes(List<E> list) {
//range: 특정 범위의 숫자를 차례대로 생성, [startInclusive,endInclusive)
// 즉, list에 {"a","b","c"}가 있다면 mapToObj를 통해
// [0,3) ~ [2,3)까지의 결과를 List형태로 Stream에 담는다. --> {"a", "b", "c"}, {"b","c"}, {"c"}
return IntStream.range(0, list.size())
.mapToObj(start -> list.subList(start, list.size()));
}
public static void main(String[] args) {
List<String> lists = Arrays.asList("a","b","c");
//[], [a], [a, b], [b], [a, b, c], [b, c], [c] --> ","는 개행
SubLists.of(lists).forEach(System.out::println);
}
참고: map과 flatMap 차이 / range와 rangeClosed / Stream map 메소드 전체
이 관용구는 정수 인덱스를 사용한 표준 for 반복문의 스트림 버전이라 할 수 있다. 그래서 아래 코드와 취지는 같다.
//단, 빈배열은 여기서 출력을 안한다.
for(int start=0;start<lists.size();start++){
for(int end=start+1;end<=lists.size();end++){
System.out.println(lists.subList(start,end));
}
}
이 반복문도 스트림으로 변환할 수 있다.
public static <E> Stream<List<E>> ofDirect(List<E> list) {
//flatMap을 쓴 이유는, mapToObj 결과가 Stream<Stream<List<E>> 형태기 때문에 평탄화가 필요하다.
// 빈 배열을 포함 안시키려면 (int)Math.signum(start)을 1로 고치자.
return IntStream.range(0, list.size())
.mapToObj(start ->
IntStream.rangeClosed(start+(int)Math.signum(start), list.size())
.mapToObj(end -> list.subList(start, end)))
.flatMap(x -> x);
}
앞서 구현보단 간결해지지만, 읽기에는 더 안좋아진다.
그리고 빈 배열을 포함 하지만, 포함 안하려면 첫 코드처럼 Stream.concat을 사용하거나, rangeClosed에 첫 매개변수를 수정하자.
Math.signum은 인자로 들어온 값이 양수: 1.0 / 음수: -1.0 / 0: 0.0 을 반환한다. 따라서 start가 0일때 start, start가 양수로 넘어가면 start+1이되기 때문에 빈 인자를 1개만 존재하도록 할 수 있는 것이다.
스트림을 반환하는 두 구현은 모두 쓸만하지만 반복을 사용하는게 더 자연스러운 상황에서도 사용자는 Stream을 쓰거나 Stream을 Iterable로 변환해주는 Adapter를 이용해야한다. Adapter는 클라이언트 코드를 어수선하게 만들고 속도도 느리게 만든다.
직접 구현한 전용 컬렉션이 지저분하지만 빠르고, 스트림을 활용한 구현보다도 빠름을 기억하자.
정리
- 원소 시퀀스를 반환하는 메소드를 작성할 때 Stream과 Iterable으로 처리하는 사용자 모두를 만족시키자.
- 컬렉션을 반환할 수 있다면 하고, 반환 전부터 이미 컬렉션에 원소들을 담아 관리하고 있거나 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면 ArrayList 같은 표준 컬렉션에 담아 반환하자
- 2번 여건이 되지 않는 경우 전용 컬렉션 구현을 고민하자
- 컬렉션 반환이 불가능하면 Stream과 Iterable 중 더 자연스러운 것을 반환하자
- 제일 베스트는 Stream 인터페이스가 차후 Iterable을 지원해주는 것이다
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.
'Reading > Effective Java' 카테고리의 다른 글
| [Effective-Java] Item 49. 매개변수가 유효한지 검사하라 (0) | 2021.11.08 |
|---|---|
| [Effective-Java] Item 48. 스트림 병렬화는 주의해서 적용하라 (0) | 2021.11.08 |
| [Effective-Java] Item 46. 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2021.11.01 |
| [Effective-Java] Item 45. 스트림은 주의해서 사용하라 (0) | 2021.11.01 |
| [Effective-Java] Item 44. 표준 함수형 인터페이스를 사용하라 (0) | 2021.11.01 |