전공공부
[아이템 47] 반환 타입으로는 스트림보다 컬렉션이 낫다. 본문
자바8 이전의 반환 타입
- 자바 7까지는 일련의 원소를 반환하는 메서드를 사용 할 때 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스 또는 Iterable이나 배열을 썼다.
- for-each 문에서만 쓰이거나, 반환된 원소 시퀸스가 일부Collection 메서드를 구현할 수 없을 때는 Iterable 인터페이스를 썼다. (contains(Obejct) 같은 경우)
- 반환 원소들이 기본 타입이거나 성능에 민감한 상황이라면 배열을 썼다.
자바8 이후의 반환 타입
- 자바8에서 스트림이 등장하면서 반환 타입의 선택이 복잡한 일이 되었다.
Steam과 Iterable
- 원소 시퀸스를 반환할 때는 당연히 스트림을 사용해야한다. 하지만, 스트림은 반복(iteration)을 지원하지 않는 문제가 있다.
스트림이 반복을 지원하지 않는 이유 : Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함하며, Iterable 인터페이스가 정의한 방식대로 동작 하지만 Stream이 Iterable을 확장(extend)하지 않아서 for-each로 스트림을 반복할 수 없다.
- 이 문제를 해결 할 수 있는 방법은 현재의 자바에서 존재하지 않는다.
Stream의 iterator 메서드에 메서드 참조를 건네면 문제가 해결될 것 같지만, 다음 코드는 컴파일 오류를 낸다.
// 해당 코드는 Stream에서 iterator를 직접 구현하지 않아서 사용 할 수 없다.
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// 프로세스를 시작한다.
}
- 이 오류를 고치려면, 반환된 Stream을 Iterable로 적절히 형변환해야 하나 작동은 하지만 실전에 쓰기에 난잡하고, 직관성이 떨어진다.
// 스트림을 반복하기 위한 '끔찍한' 우회 방법
for(ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator) {
// 프로세스를 처리한다.
}
어댑터 메서드를 사용하면 상황이 나아진다. 자바는 이런 메서드를 제공하지 않지만 다음과 같이 쉽게 만들 수 있다.
- 자바의 타입 추론이 문맥을 잘 파악하여 어댑터 메서드 안에서 따로 형변환하지 않아도 된다.
// Stream<E>을 Iterable<E>로 중개해주는 어댑터
public static<E> Iterable<E> iterableOf(Stream<E> stream) {
return steam::iterator;
}
어댑터를 사용하면 어떤 스트림도 for-each 문으로 반복할 수 있다.
API가 Iterable만 반환하면, 이를 스트림 파이프라인에서 처리하기 위한 어댑터를 구현하여 문제를 해결할 수 있다.
-> 자바는 이를 위한 어댑터도 제공하지 않기에 직접 구현하여야 한다.
- 객체 시퀸스를 반환하는 메서드를 작성하는데, 이 메서드가 오직 스트림 파이프라인에서만 쓰일 걸 안다면 스트림을 반환하자.
- 반환된 객체들이 반복문에서만 쓰일 걸 안다면 Iterable을 반환하자.
아이템 45 - Anagrams 예시 - (Stream의 기능이 필요한 경우)
- Steam의 기능이 필요한 경우도 있다.
- 스캐너와 스트림을 이용해서 파일을 읽어 올 때 둘 중 스트림은 파일을 읽는 동안 발생하는 모든 예외를 알아서 처리해준다는 점에서 Files.lines 쪽이 더 우수하다.
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Interger.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try(Scanner s = new Scanner(dictionary)){
while(s.hasNext()){
String word = s.next();
groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
}
}
for(Set<String> group : groups.values())
if(group.size() >= minGroupSize)
System.out.println(group.size() + ":" + group);
}
public static String alphabetize(String s){
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
public class HybridAnagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word))) //groupingBy : Map<String,List<String>>
.values().stream() // 맵의 값(List<String>)들로만 이루어진 스트림 제작
.filter(group -> group.size() >= minGroupSize) //해당 List<String> 그룹의 사이즈와 minGroupSize 비교
.forEach(g -> System.out.println(g.size() + ": " + g)); // List<String> g의 사이즈 및 list 출력
}
}
}
위 예제는 어댑터를 쓰는 예제는 아니지만 Stream이 우수한 경우이다.
// Iterable<E>를 Stream<E>로 중개해주는 어댑터
public static<E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
Collection 인터페이스
- 원소를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.
- Collection 인터페이스는 Stream과 달리 바로 반환이 가능한데, 그 이유가Iterable의 하위 타입(extend)이고, Stream 메서드도 제공하니 반복과 스트림을 동시에 지원하기 떄문이다.
- Arrays 역시 마찬가지로 Arrays.asList와 Stream.of 메서드로 손쉽게 반복과 스트림을 지원할 수 있다.
전용 컬렉션
반환할 시퀸스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하는 방안을 검토하자.
아래 예제는 주어진 집합의 멱집합을 반환하는 상황이다.
(*멱집합: 한 집합의 모든 부분집합을 원소로 하는 집합)
{a, b, c}의 멱집합은 {{},{a} {b},{a, b},{c}, {c, a}, {c, b}, {c, b, a}}다. 원소 개수가 n개면 멱집합의 원소 개수는 2^n이 된다. 따라서 멱집합을 표준 컬렉션 구현체에 저장하면 엄청나게 큰 시퀸스가 메모리에 올라 갈 수 있다.
하지만, AbstractList를 이용하면 훌륭한 전용 컬렉션을 손쉽게 구현할 수 있다.
비결은 멱집합을 구성하는 각 원소의 인덱스를 비트 벡터로 사용하는 것이다. 인덱스의 n번째 비트 값은 멱집합의 해당 원소가 원래 집합의 n번째 원소를 포함하는지 여부를 알려준다. 따라서 0부터 2^n - 1까지의 이진수와 원소 n개인 집합의 멱집합과 자연스럽게 매핑된다.
// 입력 집합의 멱집합을 전용 컨렉션에 담아 반환한다.
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();
}
@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) { //index는 멱집합을 가지고 있는 Set<E>의 비트 연산을 위함
//src는 각 멱집합의 원소를 가진 객체
//index가 5이면 -> 101 이므로 a,c 를 가지고 있을 것이다.
result.add(src.get(i));
}
}
return result; // 만들어진 집합 리턴
}
};
}
}
이렇게 전용 컬랙션을 사용하여서 메모리를 아끼고 반환 할 수 있다.
Collection을 반환 타입으로 쓸 때의 단점
Collection의 size 메서드가 int 값을 반환하므로 PowerSet.of가 반환되는 시퀸스의 최대 길이는 Integer.MAX_VALUE 혹은 2^32 -1로 제한된다.
Collection 명세에 따르면 컬렉션이 더 크거나 심지어 무한일 때 size가 2^31 -1을 반환해도 되지만 만족스러운 해법은 아니다.
Stream을 사용하여 반환 하는 방법
표준 컬렉션을 사용한 예시
입력 리스트의 연속적인 부분리스트를 모두 반환하는 메서드를 작성하는 상황을 가정하자
필요한 부분리스트를 만들어 표준 컬렉션에 담는 코드는 3줄이면 되지만, 이 컬렉션은 입력 리스트 크기의 거듭제곱(O(n^2))만큼 메모리를 차지한다. 그리고, 해당 조건은 멱집합 때처럼 전용 컬렉션을 구현 하기는 어렵다.
import java.util.ArrayList;
import java.util.List;
public class SubListExample {
public static <T> List<List<T>> getSubLists(List<T> list) {
List<List<T>> subLists = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
for (int j = i + 1; j <= list.size(); j++) {
subLists.add(list.subList(i, j));
}
}
return subLists;
}
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
List<List<Integer>> subLists = getSubLists(list);
for (List<Integer> subList : subLists) {
System.out.println(subList);
}
}
}

스트림으로 부분 리스트를 구현한 예시
prefixes 메서드는 1부터 list.size()까지 모두 index를 걸어둔다. 그리고 그 index 값이 end 변수로 들어가서 각각의 서브 리스트를 {a},{a,b},{a,b,c}를 만든다
suffixes 메서드는 0,마지막 끝까지 모두 index를 걸고 start 변수에 해당 인덱스 값을 넘겨준다. 그래서 start 위치부터 서브 리스트를 만든다. {a,b,c},{b,c},{c} 이런식으로 만들게 된다.
of 메서드는 Stream.concat 을 통해서 반환되는 스트림에 빈 리스트를 추가하고, flatMap 메서드(아이템 45)는 prefix로 만든 부분 리스트({a},{a,b},{a,b,c})를 다시 suffix를 돌려서 각각의 부분 리스트가 인덱스로 지정되고 ([{a},{a,b},{a,b,c}],[{a,b},{a,b,c}],[{a,b,c}]) 이런식으로 값이 출력 될 것이다.
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()));
}
public static void main(String[] args) {
List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,4,5));
SubList.of(list).forEach(System.out::println);
}
}
결론
1. 원소를 반환하는 메서드를 작성할 때, 스트림으로 처리하기를 원하는 사용자와 반복으로 처리하길 원하는 사용자가 모두 있음을 떠올리고, 양쪽을 다 만족시키려 노력해야 한다. 그리고, 컬렉션을 반환할 수 있다면 그렇게 해야 한다.
2. 원소 시퀸스가 표준 컬랙션을 사용하기 부담 스러울 정도로 큰 메모리 사이즈를 차지 한다면 전용 컬랙션의 제작을 고려하여야 한다.
3. 컬렉션을 반환하는 게 불가능하면 스트림과 Iterable 중 더 자연스러운 것을 반환하자
'Study > Java' 카테고리의 다른 글
[아이템 70] 복구할 수 있는 상황에서는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라 (0) | 2023.06.25 |
---|---|
[아이템 54] null이 아닌, 빈 컬렉션이나 배열을 반환하라 (0) | 2023.06.20 |
[아이템 42] 익명 클래스 보다는 람다식을 사용하라 (0) | 2023.05.01 |
[아이템 38] 확장 할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 (0) | 2023.04.25 |
[아이템 31] 한정적 와일드카드를 사용해 API 유연성을 높이라 (0) | 2023.04.04 |