Collections.of에 대한 의문

홍혁준·2023년 3월 26일
0

0. 들어가기 전에

Collections.of 는 다양한 override 메서드를 지니고 있습니다. List를 예로 들면 List.of (E e1), List.of(E e1, E e2), … ,List.of(E … elemtns)까지 있는데, 하나의 List.of(E … elements)면 되지 않을까? 라는 질문에서 Collections.of를 학습하게 되었습니다.

1. Collections.of란?

Returns an unmodifiable Collections containing elements.

많은 크루들이 사용하였겠지만, List.of, Set.of, Map.of 등 다양한 Collection을 of라는 팩터리 메서드로 생성할 수 있습니다.
생성된 Collection은 Unmodifiable 즉 불변한 객체로서, 값을 추가, 삭제, 변경하려는 메서드를 호출하면 UnsupportedOperationException이 발생합니다.

2. 모던 자바인 액션의 Collections.of

모던 자바인 액션에서 위 질문에 대한 답이 있습니다.

내부적으로 가변 인수 버전은 추가 배열을 할당해서 리스트로 감싼다.
따라서 배열을 할당하고 초기화하며 나중에 가비지 컬렉션을 하는 비용을 지불해야 한다. 고정된 숫자의 요소(최대 열개까지)를 API로 정의하므로 이런 비용을 제거할 수 있다.

  • 모던 자바 인 액션 중

처음에 해당 글을 보고, 의문이 해결되었습니다. 가변인자로 전부 받는 경우에는 내부적으로 배열이 생성되어 비용이 소모된다. 실제로 그런지 한번 List.of의 구현부를 확인해봤습니다.

3. 실제 Collections.of 구현

여기서는 List를 예로 들겠습니다.

리스트의 사이즈가 0~2인 경우

static <E> List<E> of() {
    return ImmutableCollections.emptyList();
}

static <E> List<E> of(E e1) {
    return new ImmutableCollections.List12<>(e1);
}

static <E> List<E> of(E e1, E e2) {
    return new ImmutableCollections.List12<>(e1, e2);
}

static final class List12<E> extends AbstractImmutableList<E>
            implements Serializable {

        @Stable
        private final E e0;

        @Stable
        private final E e1;

        List12(E e0) {
            this.e0 = Objects.requireNonNull(e0);
            this.e1 = null;
        }

        List12(E e0, E e1) {
            this.e0 = Objects.requireNonNull(e0);
            this.e1 = Objects.requireNonNull(e1);
        }
    ...
}

보시면 요소가 0,1,2개인 경우에는 위와 같이, 가변인자를 사용(내부적으로 배열을 사용)하지 않고, 각각 emptyList와 List12라는 구현체로 List를 생성하고 있었습니다.
이 부분을 봤을 땐, 모던 자바인 액션에서 이야기하는 “가변인자를 사용할때의 비용을 절감”한다는 설명에 수긍을 했지만 size가 3이상일 때 부터는 그러지 않았습니다.

리스트의 사이즈가 3이상 인 경우

static <E> List<E> of(E e1, E e2, E e3) {
    return new ImmutableCollections.ListN<>(e1, e2, e3);
}

static <E> List<E> of(E e1, E e2, E e3, E e4) {
    return new ImmutableCollections.ListN<>(e1, e2, e3, e4);
}

...

static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9, E e10) {
    return new ImmutableCollections.ListN<>(e1, e2, e3, e4, e5,
                                            e6, e7, e8, e9, e10);
}

static final class ListN<E> extends AbstractImmutableList<E>
            implements Serializable {

        // EMPTY_LIST may be initialized from the CDS archive.
        static @Stable List<?> EMPTY_LIST;

        static {
            VM.initializeFromArchive(ListN.class);
            if (EMPTY_LIST == null) {
                EMPTY_LIST = new ListN<>();
            }
        }

        @Stable
        private final E[] elements;

        @SafeVarargs
        ListN(E... input) {  // <------- 요 부분, 어차피 생성자에서 가변인자로 받음.
            // copy and check manually to avoid TOCTOU
            @SuppressWarnings("unchecked")
            E[] tmp = (E[])new Object[input.length]; // implicit nullcheck of input
            for (int i = 0; i < input.length; i++) {
                tmp[i] = Objects.requireNonNull(input[i]);
            }
            elements = tmp;
        }
		...
}

사이즈가 3 이상일 때부터는 이상합니다. 구현체 ImmutableCollections.ListN<>을 반환하는데, ListN의 생성자에서는 “가변인자로 값을 받습니다”. 결국, List.of의 오버로딩 버전도 내부적으로 가변인수를 사용하는 것입니다.

그렇다면 모던자바 인 액션에서 나온 “고정된 숫자의 요소(최대 열개까지)를 API로 정의하므로 이런 비용을 제거할 수 있다.” 라는 말이 틀린 것입니다.

그래서 실제 가변 인수로 받는 List.of(E… elemtns)도 궁금하여 확인해보았습니다.

static <E> List<E> of(E... elements) {
    switch (elements.length) { // implicit null check of elements
        case 0:
            return ImmutableCollections.emptyList();
        case 1:
            return new ImmutableCollections.List12<>(elements[0]);
        case 2:
            return new ImmutableCollections.List12<>(elements[0], elements[1]);
        default:
            return new ImmutableCollections.ListN<>(elements);
    }
}

신기하게도 List.of(E… elements)에서도, 똑같이 emptyList, List12, ListN을 반환하는 모습을 볼 수 있었습니다.

가변인자와 오버로딩이 같이 있는 경우

그리고, List.of(E… elements)에 매칭될 일도 없는 length가 0~10인 경우 또한 정의가 되어있습니다.

가변인자와 오버로딩 메서드가 같이 있는경우, 오버로딩 메서드를 호출하도록 결정됩니다.

@Test
void test1() {
    for (int i = 0; i < 10; i++) {
        print(1, 2);
    }
}

public void print(int... ints) {
    System.out.println("얘는 가변인수 버전입니다.");
}

public void print(int int1, int int2) {
    System.out.println("얘는 오버로딩 버전입니다.");
}

//호출해보니 print(int int1, int2) 만 호출됨.

그렇다면 List.of(E… elements)에서 elements.length가 0~2인경우도 필요 없고,

단순히 구현 내부가 return new ImmutableCollections.ListN<>(elements) 한 줄로 끝나야 하는게 아닐까요?

실제로 올 일이 없는 elements.length가 0,1,2인 경우에 대해 조건검사를 하고 있으니까요.

4. 총 의문점

Q. 어차피 List.of의 size가 3~10인 경우나, N(N>10)개인 경우나 가변인수로 배열을 생성하는데, 왜 오버로딩 버전이 따로 있나?

추측 중 하나는 JDK를 설계했을 시에는, 배열을 생성하지 않도록(성능 차이가 나지 않도록) 오버로딩 버전과 가변인수 버전을 나눠놓았는데, JDK를 구현하면서, correto JDK가 위처럼 설계되지 않았을까... 하는 추측이 있습니다.

너무 궁금하고, 답을 찾지 못해서 stackoverflow에 글을 남겨보았습니다.
링크

근데 글을 봐도 명확한 답을 모르겠네요. 시간이 나면 바이트 코드를 까볼 듯합니다.

Q. List.of(E… elements)에서, case 0~2인 경우가 오지 않는데, 왜 case 0~2인 경우를 체크를 하나?

어차피 List.of(), List.of(E e1), List.of(E e1, E e2)에서 elements.length가 0~2인 경우가 매핑되어 List.of(E... elements)에서 length가 0~2인 경우는 오지 않습니다.
하지만 List.of(E... elements) 메서드에서 요소의 개수에 제한이 없기 때문에, 길이가 0, 1, 2인 경우를 처리해주는 것이 좋은 프로그래밍 습관입니다. 또한, JDK 개발자들도 예외 상황에 대비하여 길이가 0, 1, 2인 경우를 처리하는 코드를 추가하였을 수 있습니다. 명시적으로 표현하기 위해 위처럼 썼다는게 제 생각입니다.

+) List.of()의 인자로 E[] elements가 오는 경우도 있으니,
이에 대한 케이스를 처리하기 위해 List.of(E... elements)에서도
사이즈에 대한 체크를 하고 있는 것 같습니다.

profile
끊임없이 의심하고 반증하기

0개의 댓글