본문 바로가기
Java/Effective Java

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

by 쁘니쁘나 2022. 6. 29.

 

원소 시퀀스, 일련의 원소를 반환하는 메서드는 많다.

그 중 stream 을 사용해 원소를 반환할 수 있다는 얘기가 있겠지만, stream 은 반복(iteration)을 지원하지 않는다!

그래서 stream 과 반복을 알맞게 조합해야 좋은 코드가 나온다.

 

그런데❗❗

 

stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함하며, Iterable 인터페이스가 정의한 방식대로 동작한다!!
그럼에도 for-each 로 stream 을 반복할 수 없는 이유는 Stream 이 Iterable을 확장하지 않아서이다..!

 

자바 타입 추론의 한계로 컴파일되지 않는 예시

for (ProcessHandle ph : ProcessHandle.allProcesses() :: iterator) {
                        ^   
}

 

위 오류를 바로잡기 위한 메서드 참조를 매개변수화된 Iterable 로 적절히 형변환해보자.

for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses( )::iterator){
	// 프로세스를 처리한다.
}

 

작동은하나 실전에 쓰기에는 난잡하고 직관성이 떨어짐. 어뎁터 메서드를 사용하면 해결할 수 있다.

 

Stream<E>을 Iterable<E>로 중개해주는 어뎁터 iterableOf

public static <E> Iterable<E> iterableOf(Stream<E> stream) {
	return stream::iterator;
}

 

위에서 생성한 iterableOf 어뎁터를 사용하여 for-each문으로 반복해보자.

for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
	// 프로세스를 처리한다.
}

➡️ 어뎁터를 사용하면 어떤 stream도 for-each 문으로 반복할 수 있다.

 

그럼 API가 Iterable만 반환한다면 이를 Stream으로 처리하고 싶은 개발자는 어떻게해야하나❓

 

 

자바는 이러한 어댑터를 제공하진 않지만, 아래와 같이 손쉽게 구현할 수 있다.

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

 

✅ 결론

공개 API를 작성할 때는 Stream을 사용하는 개발자와 Iterable 쓰려는 사람 모두를 배려해야 한다.

 

Iterable 의 하위 타입이면서 stream 메서드도 제공하는건 없을까❓

 

Collection 인터페이스 등장❗❗

Iterable 의 하위 타입이며 stream 메서드도 제공하니 반복과 스트림을 동시에 지원한다. 따라서 원소 시퀀스를 반환하는 공개API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는게 일반적으로 최선이다! (Arrays 역시 Arrays.asList 와 Stream.of 가 있어 가능하다)

 

💡주의

반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같이 표준 컬렉션 구현체를 반환하는게 최선일 수 있다. 하지만 덩치가 큰 시퀀스인데 단지 컬렉션을 반환한다는 이유로 메모리에 올리지는 말자! 반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하자.

 

만약 예를들어 집합의 멱집합(한 집합의 모든 부분 집합을 원소로 하는 집합)을 반환하는 상황이라면, 원소 개수가 n개면 멱집합의 원소 개수는 2ⁿ개가 된다. 이러한 멱집합을 표준 컬렉션 구현체에 저장하려는 생각은 위험하다. 하지만 AbstractList를 이용하면 훌륭한 전용 컬렉션을 손쉽게 구현할 수 있다.

 

아래 PowerSet 클래스는 멱집합을 구성하는 각 원소의 인덱스를 비트 벡터로 사용하는 예시이다.

 

입력 집합의 멱집합을 전용 컬렉션에 담아 반환하는 예시

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("집합에 원소가 너무 많습니다(최대 307H). : " + 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;
			}

		};
	}
}

➡️ 입력 집합의 원소 수가 30을 넘으면 PowerSet.of 가 예외를 던진다. 이는 Stream이나 Iterable 이 아닌 Collection 을 반환 타입으로 쓸 때의 단점을 잘 보여준다. Collection 의 size 메서드가 int 값을 반환하므로 PoserSet.of 가 반환되는 시퀀스의 최대 길이는 Integer.MAX_VALUE 혹은 2³¹-1로 제한된다. Collection 명세에 따르면 컬렉션이 더 크거나 심지어 무한대일때 size가 2³¹-1을 반환해도 되지만 완전히 만족스러운 해법은 아니다.

 

이번엔 스트림을 반환하는 두가지 구현을 알아보자.

 

첫번째 원소를 포함하는 부분리스트를 그 리스트의 prefix 라 하고, 마지막 원소를 포함하는 부분리스트를 suffix라 한다.

 

입력 리스트의 모든 부분리스트를 스트림으로 반환하는 예시1

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(l, 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()));
	}

}

➡️ 직관적으로 구현할 수 있지만 쉽게 말하면 이 관용구는 정수 인덱스를 사용한 표준 for 반복문의 스트림 버전이라 할 수 있다. 따라서 이 구현은 아래처럼 for 반복문을 중첩해 만든 것과 취지가 비슷하다.

 

for (int start = 0; start < src.size(); start++)
	for (int end = start + 1; end <= src.size(); end++)
		System.out.println(src. sublist(start, end) ) ;

➡️ 이 반복문은 그대로 스트림으로 변환할 수 있다. 그러면 앞의 구현보다 간결해지지만, 읽기에는 더 안좋다.

 

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);

}

➡️ 바로 앞의 for문 처럼 빈 리스트는 반환하지 않는다. 이 부분을 고치려면 앞에서처럼 concat을 사용하거나 rangeClosed 호출 코드의 1 을 (int)Math.signum(start) 로 고쳐주면 된다.

 

 

✅ 결론

Stream을 반환하는 두가지 구현이 모두 쓸만은 하지만, 반복을 사용하는게 더 나은 상황에서도 사용자는 그냥 Stream을 쓰거나 Stream을 Iterable로 변환해주는 어뎁터를 이용해야한다. 하지만 이런한 어뎁터는 코드도 어수선하고 느리다. 직접 전용 Collection을 구현하여 사용하면 코드는 훨씬 지저분해지지만 Stream을 활용한 구현보다 빠르다.

 

 

 

참고


도서 - Effective Java Item47

 

 

댓글