💡 핵심 정리
• 원소 시퀀스 반환 메서드 작성 시, Stream 처리와 Iterable 처리 양쪽을 모두 만족시키려 노력하라.
• Collection을 반환할 수 있다면 그렇게 하라.
• 반환 전부터 이미 원소들을 Collection에 담아 관리하고 있거나, 하나 더 만들어도 될 정도로 작다면 표준 컬렉션(ex. ArrayList)에 담아 반환하라.
• 그렇지 않다면 전용 Collection 구현을 고민하라.
• 컬렉션 반환이 불가능하다면 Stream과 Iterable 중 더 자연스러운 것을 반환하라.
📌 Java 8 이전
- 원소 시퀀스, 일련의 원소를 반환하는 메서드 반환 타입은 Collection, Set, List 같은 Collection 인터페이스, 혹은 Iterable이나 배열을 사용했다.
- Collection 인터페이스 : 기본
- Iterable : for-each문에서만 쓰이거나, 반환된 원소 시퀀스가 일부 Collection 메서드 구현이 불가능한 경우
- 배열 : 반환 원소가 기본 타입이거나 성능에 민감한 경우
Java 8에서 Stream이 등장하면서 문제가 발생했다.
public interface Stream<T> extends BaseStream<T, Stream<T>> { ... }
- Stream은 반복(iteration)을 지원하지 않는다.
- API를 스트림만 반환하도록 짜놓으면 for-each로 반복하길 원하는 사용자가 불만을 가진다.
- for-each로 스트림을 반복할 수 없는 까닭은 Stream이 Iterable을 확장하지 않았기 때문
- Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함한다.
- Iterable 인터페이스가 정의한 방식대로 동작한다.
📌 Stream의 iterator method에 메서드 참조를 넘긴다면?
Stream 자체는 반복이 안 되지만, Iterable method를 사용한다면 해결할 수 있지 않을까?
for (rocessHandle ph : ProcessHandle.allProcesses()::iterator) { ... }
이 상태로는 자바 타입 추론 한계로 컴파일 되지 않아, 명시적 형변환 단계를 수반해야 한다.
for (ProcessHandle ph : (Iterable<ProcessHandle>)ProcessHandle.allProcesses()::iterator) { ... }
작동은 하지만 난잡하고 직관성이 떨어진다.
📌 Adapter(어댑터)
1️⃣ Stream → Iterable
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
이렇게 하면 어떤 스트림도 for-each 문으로 반복할 수 있다.
자바의 타입 추론이 문맥을 잘 파악하기 때문에 어댑터 메서드 안에서 따로 형변환하지 않아도 된다.
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())){ ... }
✒️ Stream만 반환하는 API를 for-each로 반복하길 원하는 경우
Item 45에서 Itreator 버전에서는 Scanner를 사용했고, Stream 버전에서는 Files.lines를 사용했다.
사실 Files.lines가 중간 과정의 모든 예외를 알아서 처리해준다는 점에서 더 낫다.
이상적으로는 반복 버전에서도 Files.lines를 써야 했으나, Stream만을 반환값으로 주기에 for-each로 반복하려는 프로그래머가 감수해야 하는 부분이다.
2️⃣ Iterable → Stream
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
마찬가지로 Iterable만 반환하는 API를 Stream으로 사용하기 위한 어댑터를 만들 수도 있다.
- 메서드가 오직 Stream 파이프라인에서만 쓰일 걸 안다면 Stream을 반환해라
- 반대로 반복문에서만 쓰일 걸 안다면 Iterable을 반환하라.
- 하지만 공개 API를 작성할 때는 명확한 근거가 없다 양쪽 모두를 배려해야 한다.
📌 Collection 인터페이스
💡 원소 시퀀스를 반환하는 공개 API 반환 타입은 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.
하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.
- Colletion 인터페이스는 Iterable의 하위 타입이자 stream 메서드를 제공한다.
- 표준 Collection : 원소 사이즈가 작은 경우
- 전용 Collection : 원소 사이즈가 큰 경우
- 반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 Collection 구현을 검토하라.
📌 전용 컬렉션
🤔 문제 상황
- 주어진 집합의 멱집합(한 집합의 모든 부분집합을 원소로 하는 집합)을 반환하는 경우
- {a, b, c}의 멱집합은 {{}, {a}, {b}, {c}, {a, b}, {b, c}, {a, c}, {a, b, c}}
- 원소 개수가 n개 → 멱집합 원소 개수 2^n개
- 표준 컬렉션은 위험하나, AbstractList를 이용하면 전용 Collection 구현 가능
✅ 해결책
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의 src.size() 제곱
}
@Override public boolean contains(Object o) {
return o instanceof Set && src.containsAll((Set)o);
}
// 인덱스 n 번째 비트 값
@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<String> s = new HashSet<>(Arrays.asList("a", "b", "c"));
System.out.println(PowerSet.of(s)); // [[], [a], [b], [a, b], [c], [a, c], [b, c], [a, b, c]]
}
}
- 멱집합을 구성하는 각 원소를 인덱스 비트 벡터로 사용한다.
- 인덱스 n번째 bit 값은 멱집합의 해당 원소가 원래 집합의 n번째 원소를 포함 여부를 알려준다.
- 000 : {}
- 001 : {a}
- ...
- 111 : {a, b, c}
- 0 ~ 2^(n)-1 까지 이진수와 원소 n개인 집합의 멱집합과 매핑된다.
AbstractCollection을 활용해서 Collection 구현체 작성 시, 아래 3개 메서드는 반드시 구현해야 한다.
- Iterable 용 메서드
- contains
- size
만약 contains와 size 구현이 불가능하다면, Collection보다는 Stream이나 Iterable을 반환하는 편이 좋다.
원한다면 별도 메서드로 두 방식을 모두 제공해도 된다.
(저자 기준) 전용 Collection을 구현하는 것이 Stream보다 약 1.4배 빨랐고, Adpator 형식은 Stream보다 2.3배 정도 느렸다.
✒️ Collection의 단점
입력 집합 원소수가 30을 넘으면 PowerSet.of가 예외를 던진다.
• Collection의 size 메서드가 int 값을 반환하므로 최대 길이는 2^(31)-1로 제한된다.
• 명세에 따르면 컬렉션이 더 크거나 무한대일 때 size가 2^(31)-1해도 되지만 만족스러운 해법은 아니다.
📌 Stream 타입 반환
때로는 단순히 구현하기 쉬운 쪽을 선택하기도 한다.
🤔 문제 상황
- 입력 리스트의 연속적인 부분 리스트를 모두 반환하는 메서드 작성 시
❌ As-is
for (int start = 0; start < src.size(); start++
for (int end = start + 1; end <= src.size(); end++)
System.out.println(src.subList(start, end));
- O(N^2)의 시간 및 공간 복잡도를 가짐
- 전용 Collection을 구현하기 힘듦
- 자바에서 이럴 때 쓸만한 골격 Iterator를 제공하지 않음
✅ To-be
class SubList {
public static <E> Stream<List<E>> of(List<E> list) {
return Stream.concat(Stream.of(Collections.emptyList()),
prefixes(list).flatMap(SubList::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()));
}
}
SubList.of(Arrays.asList("a", "b", "c")).forEach(System.out::println);
[]
[a]
[a, b]
[b]
[a, b, c]
[b, c]
[c]
- 모든 부분 리스트를 "prefix + suffix + {}" 로 분리할 수 있다.
- (a, b, c)의 prefix는 (a), (a, b), (a, b, c)
- (a, b, c)의 suffix는 (a, b, c), (b, c), (c)
- 어떤 리스트의 부분 리스트는 그 리스트의 prefix의 suffix(혹은 suffix의 prefix)에 빈 리스트 하나를 추가하면 된다.
- Stream.concat() : 두 개의 Stream을 하나로 합친 새로운 Stream 반환
- Stream.flatMap() : 중복된 Stream을 1차원 평면화
- IntStream.range와 IntStream.rangeClosed 관용구는 표준 for 반복문의 스트림 버전이라 할 수 있다.
- 이를 굳이 별도의 메서드로 분리한 이유는 가독성 측면을 고려했기 때문이다.
✒️ Stream.flatMap()
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
flatMap 메서드 동작 방식이 잘 이해가 안 가서 몇 가지 예제를 작성해보았다.
1️⃣ 반복문 사용
public class FlatMapTest {
public static void main(String[] args) {
// Apple만 제외한 String[] 배열을 생성하고 싶은 경우
String[][] nested = new String[][]{
{"Apple", "Cherry"}, {"Mango", "Orange"}, {"Grape", "BlueBerry"}};
// 1. 반복문 사용
List<String> fruits = new ArrayList<>();
for (String[] fs : nested) {
for (String f : fs) {
if (!f.equals("Apple")) fruits.add(f);
}
}
String[] exclude = fruits.toArray(new String[fruits.size()]);
System.out.println(Arrays.toString(exclude)); // [Cherry, Mango, Orange, Grape, BlueBerry]
}
}
- 가장 흔한 방법
2️⃣ Stream.filter() 사용 - 원하는 결과가 아니다
public class FlatMapTest {
public static void main(String[] args) {
// 2. Stream filter 사용 - 문제가 있다.
List<String[]> fruits2 = Arrays.stream(nested)
.filter(fs -> {
for (String f: fs)
return !f.equals("Apple");
return false; // 애초에 걸리지 않는다. 컴파일 통과용
})
.collect(toList());
System.out.println(fruits2); // [[Ljava.lang.String;@4f3f5b24, [Ljava.lang.String;@15aeb7ab]]
fruits2.forEach(fs -> System.out.println(Arrays.toString(fs))); // [[Mango, Orange], [Grape, BlueBerry]]
}
}
- "2차원 배열 → 1차원 배열 → 요소"로 접근해야 하므로 가독성이 안 좋아짐
- List<String>이 아니라 List<String[]> 타입의 결과를 반환함
- Apple만 제거되는 것이 아니라, Apple이 포함된 [Apple, Cherry] 배열 자체가 필터링 됨
3️⃣ Stream.flatMap()
public class FlatMapTest {
public static void main(String[] args) {
// 3. flatMap 사용
Arrays.stream(nested)
.flatMap(Arrays::stream)
.filter(f -> !f.equals("Apple"))
.forEach(System.out::println);
}
}
- 중첩된 배열을 모두 평면화 시켜 배치함 (Stream)
- 모두 같은 계층에 있으므로 개별 요소로써 접근 가능
아래는 추가 예제
public class FlatMapTest {
public static void main(String[] args) {
List<String> animals = Arrays.asList("cat", "dog", "lion", "tiger");
List<String[]> result = animals.stream()
.map(a -> a.split(""))
.collect(toList());
for (String[] strings : result) {
System.out.println(Arrays.toString(strings));
}
/*
[c, a, t]
[d, o, g]
[l, i, o, n]
[t, i, g, e, r]
*/
System.out.println();
// <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
List<String> result2 = animals.stream()
.map(a -> a.split(""))
.flatMap(Arrays::stream)
.collect(toList());
System.out.println(result2); // [c, a, t, d, o, g, l, i, o, n, t, i, g, e, r]
}
}