ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Java] 반환 타입으로는 스트림보다 컬렉션이 낫다
    Java 2023. 2. 3. 10:25

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

    • 자바 7까지는 이런 메서드의 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스, 혹은 Iterable이나 배열을 썼다.
    • 이 중 가장 적합한 타입을 선택하기란 그다지 어렵지 않았다.
      • for-each 문에서만 쓰이거나 반환된 원소 시퀀스가 일부 Collection 메서드를 구현할 수 없을 때는 Iterable 인터페이스를 썼다.
      • 반환 원소들이 기본 타입이거나 성능에 민감한 상황이라면 배열을 썼다.

    스트림은 반복을 지원하지 않는다.

    그런데 자바 8이 스트림이라는 개념을 들고 오면서 이 선택이 아주 복잡한 일이 되었다.

    • 스트림은 반복(iteration)을 지원하지 않는다.
    • 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다.
    • 사실 Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함한다.
      • 그럼에도 for-eaach로 스트림을 반복할 수 없는 까닭은 바로 Stream이 Iterable을 확장하지 않아서다.
    • 이 문제를 해결할 우회로는 없다.

    스트림을 반복하기 위한 우회 (API에서 Stream만 반환하는 경우)

    Stream의 iterator 메서드에 메서드 참조를 건네면 스트림을 반복할 수 있을 것 같지만, 컴파일 오류가 난다.

    //컴파일 오류: 타입 추론 한계
    for(ProcessHandle ph : ProcessHandle.allProcesses()::iterator){
    
    }

    Iterable로 명시적 형변환을 해준다면 ?

    //작동은 하지만 너무 난잡하고 직관성이 떨어진다.
    for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator){
       
    }

    어댑터 메서드를 사용하면 상황이 나아진다.

    Stream<E>를 Iterable<E>로 중개해주는 어댑터

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

    어댑터를 사용하면 어떤 스트림도 for-each 문으로 반복할 수 있다.

    for(ProcessBandle p : iterableOf(ProcessBandle.allProcesses())){
     
    }

    API에서 Iterator만 반환하는 경우

    반대 상황으로 API가 Iterable만 반환한다면 비슷하게 구현할 수 있다.

    Iterable<E>를 Stream<E>로 중개해주는 어댑터

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

    객체 시퀀스를 반환하는 메서드를 작성하는데, 이 메서드가 오직 스트림 파이프라인에서만 쓰인 걸 안다면 마음 놓고 스트림을 반환하게 해주자.

    반대로 반환된 객체들이 반복문에서만 쓰일 걸 안다면 Iterable을 반환하자.

    하지만 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두를 배려해야 한다.

    원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위타입을 쓰는 것이 일반적이다.

    전용 컬렉션 구현

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

    예) 입력 집합의 멱집합을 전용 컬렉션에 담아 반환한다.

    멱집합이란, 한 집합의 모든 부분집합을 원소로 하는 집합이다.
    예를 들어 {a,b,c}의 멱집합은 {{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b,c}} 다.
    원소의 갯수가 n개면 멱집합의 원소 개수는 2^n개가 된다.

    멱집합을 표준 컬렉션 구현체에 저장하려는 생각은 위험하지만, AbstractList를 이용하면 전용 컬렉션을 쉽게 구현할 수 있다.

    비결은 멱집합을 구성하는 원소의 인덱스를 비트 벡터로 사용하는 것이다.
    {a,b,c}의 멱집합은 원소가 8개이므로 유효한 인덱스는 0-7이며, 이진수로는 000-111이다.

    • 멱집합의 000번째 원소는{}
    • 001번째 원소는 {a}
    • 111번째 원소는 {c,b,a}가 되는 식이다.
    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) {
                           result.add(src.get(i));
                       }
                   }
                   return result;
               }
           };
        }
    }
    • 입력 집합의 원소 수가 30을 넘으면 Power.of가 예외를 던진다.
      • size() 메서드의 리턴타입은 int이기 떄문에 최대길이는 Integer.MAX_VALUE 혹은 2^31-1로 제한된다.
    • 이는 Stream이나 Iterable이 아닌 Collection을 쓸 때의 단점을 보여준다.

    Stream이 나은 경우도 있다.

    위의 예제처럼 AbstrractCollection을 활용해서 Collection 구현체를 작성할 때는 Iterable용 메서드 외에 2개만 더 구현하면 된다. 바로 contains와 size다.

    하지만 반복이 시작되기 전에는 (시퀀스의 내용을 확정할 수 없는 등의 사유로) contains와 size를 구현하는 게 불가능할 때는 컬렉션보다는 스트림이나 Iterable을 반환하는 편이 낫다.

    예) 입력 리스트의 모든 부분리스트를 스트림으로 반환한다.

    아래 예제는 첫 번째 원소를 포함하는 부분리스트(prefix) 와 마지막 원소를 포함하는 부분리스트(suffix)를 스트림으로 구현하였다.

    • (a,b,c)의 prefixes는 (a),(a,b),(a,b,c)이다.
    • (a,b,c)의 suffixes는 (c),(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()));
        }
    }

    Stream.concat 메서드는 반환되는 Stream에 빈 리스트를 추가하며, flatMap은 모든 Stream을 하나의 Stream으로 만든다.

    위의 로직을 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));
        }
    }

    위의 로직을 Stream 중첩을 이용해 구현한 코드

    prefix, suffix를 나눠서 합친 코드보다는 간결해지지만, 아마도 읽기에는 더 안 좋을 것이다.

    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 같은 표준 컬렉션에 담아 반환하라.
    • 그렇지 않고 멱집합 에처럼 원소의 개수가 많다면, 전용 컬렉션을 구현할지 고민하라.
    • 만약 나중에 Stream 인터페이스가 Iteratable을 지원하도록 자바가 수정된다면, 그때는 안심하고 Stream을 반환하면 될 것이다.
Designed by Tistory.