[Effective Java] 제네릭과 가변인수를 함께 쓸 때는 신중하라

들어가며

가변인수(varargs) 메소드와 제네릭은 잘 어우러지지 않는다. 가변인수 구현 방식에 허점이 있기 때문이다. 가변인수 메소드를 호출하면 가변인수를 담기 위한 배열이 자동으로 하나 만들어진다. 그런데 이 배열을 내부로 감췄어야 하나, 클라이언트에게 노출하는 문제가 생겼다. 그 결과 varargs 매개변수에 제네릭이나 매개변수화 타입이 포함되면 컴파일 경고가 발생한다.

실체화 불가 타입은 런타임에는 컴파일타임보다 타입 관련 정보를 적게 담고 있다. 메소드를 선언할 때 실체화 불가 타입으로 varargs 매개변수를 선언하면 컴파일러가 경고를 보낸다. 가변인수 메소드를 호출할 때도 varargs 매개변수가 실체화 불가 타입으로 추론되면 그 호출에도 경고를 낸다.

매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생한다. 다른 타입 객체를 참조하는 상황에서는 컴파일러가 자동 생성한 형변환이 실패할 수 있으니 제네릭 타입 시스템이 약속한 타입 안전성의 근간이 흔들려버린다. 다음 코드를 보자.

1
2
3
4
5
6
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList; //heap pollution
String s = stringLists[0].get(0); //ClassCastException
}

이 메소드는 마지막줄에 컴파일러가 생성한 형 변환에 의해 ClassCastException이 발생한다. 이처럼 타입 안정성이 깨지니 제네릭 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않다.

그럼 Arrays.asList(T... a), Collections.addAll(Collection<? super T> c, T... elements)와 같은 메소드들이 타입 안전한 이유는 무엇일까?

@SafeVarargs

자바7 이전에는 제네릭 가변인수 메소드의 작성자가 호출자 쪽에서 발생하는 경고에 대해서 해줄 수 있는 일이 없었다. 하지만 @SafeVarargs 애너테이션이 추가됨으로써 제네릭 가변인수 메소드 작성자가 클라이언트 측에서 발생하는 경고를 숨길 수 있게 되었다. @SafeVarargs 애너테이션은 메소드 작성자가 그 메소드 타입 안전함을 보장하는 장치다. 메소드 안전히 확실하지 않다면 이 애너테이션을 달아선 안된다.

그렇다면 안전함은 어떻게 확신할 수 있을까? 가변인수 메소드를 호출하면 varargs 매개변수를 담는 제네릭 배열이 만들어진다는 사실을 기억하자. 메소드가 이 배열에 아무것도 저장하지 않고(그 매개변수들을 덮어쓰지 않고) 그 배열의 참조가 밖으로 노출되지 않는다면(신뢰할 수 없는 코드가 배열에 접근할 수 없다면) 그 메소드는 안전하다. 이때 varargs 매개변수 배열에 아무것도 저장하지 않고도 타입 안전성을 깰 수도 있으니 주의해야한다. 다음 코드를 보자.

1
2
3
static <T> T[] toArray(T... args) {
return args;
}

이 메소드가 반환하는 배열의 타입은 이 메소드에 인수를 넘기는 컴파일타임에 결정되는데 그 시점에는 컴파일러에게 충분한 정보가 주어지지 않아 타입을 잘못 판단할 수 있다. 따라서 자신의 varargs 매개변수 배열을 그대로 반환하면 힙 오염을 이 메소드를 호출한 쪽의 콜스택으로까지 전이하는 결과를 낳을 수 있다. 다음 코드와 함께 보자

1
2
3
4
5
6
7
8
static <T> T[] pickTwo(T a, T b, T c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return toArray(a,b);
case 1: return toArray(a,c);
case 2: return toArray(b,c);
}
throw new AssertionError(); //unreachable
}
1
String[] attributes = pickTwo("aa", "bb", "cc");

toArray가 만드는 배열의 타입은 Object[]이다. 그 이유는 pickTwo에 어떤 타입의 객체를 넘기더라도 담을 수 있는 가장 구체적인 타입이기 때문이다. 그리고 toArray 메소드가 돌려준 이 배열이 그대로 pickTwo를 호출한 클라이언트까지 전달된다. 즉, pickTwo는 항상 Object[] 타입 배열을 반환한다.

아무런 문제 없는 코드이니 경고없이 컴파일된다. 하지만 실행하면 ClassCastException을 던진다. pickTwo의 반환값을 attributes에 저장하기 위해 컴파일러가 Object[]를 String[]로 형 변환 하는 구간을 놓친것이다. Object[]는 String[]의 하위 타입이 아니기 때문에 형 변환은 실패한다. 이 예는 제네릭 varargs 매개변수 배열에 다른 메소드가 접근하도록 허용하면 안전하지 않다를 다시 상기시킨다. 단, 두 가지 예외가 있다.

첫째로 @SafeVarargs로 제대로 애노테이트된 또 다른 varargs 메소드에 넘기는 것은 안전하다. 둘째로 그저 이 배열 내용의 일부 함수를 호출만 하는(varargs를 받지 않는) 일반 메소드에 넘기는 것도 안전하다. 다음은 제네릭 varargs 매개변수를 안전하기 사용하는 전형적인 예이다.

1
2
3
4
5
6
7
@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
List<T> result = new ArrayList<>();
for(List<? extends T> list : lists)
result.addAll(list);
return result;
}

@SafeVarargs 애너테이션이 달려 있으니 선언하는 쪽과 사용하는 쪽 모두에서 경고를 내지 않는다. @SafeVarargs 애너테이션을 사용해야 할 때를 정하는 규칙은 간단하다. 제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 모든 메소드에 @SafeVarargs를 달라. 단, 안전한 메소드에만 말이다. 만약 통제할 수 있는 메소드 중 제네릭 varargs 매개변수를 사용하며 힙 오염 경고가 뜨는 메소드가 있다면 그 메소드가 진짜 안전한지 점검해야 한다. 다음 조건을 만족하는 제네릭 varargs 메소드는 안전하다.

  • varargs 매개변수 배열에 아무것도 저장하지 않는다
  • 그 배열(혹은 복제본)을 신뢰할 수 없는 코드에 노출하지 않는다

@SafeVarargs만이 정답은 아니다. 다음과 같이 코드를 작성하면 메소드에 임의의 개수 인자를 넘길 수 있다.

1
2
3
4
5
6
static <T> List<T> flatten2(List<List<? extends T>> lists) {
List<T> result = new ArrayList<>();
for(List<? extends T> list : lists)
result.addAll(list);
return result;
}
1
audience = flatten2(List.of(friends, romans, countrymen));

List.of는 Java9 이상에서 지원한다. 나는 Java8을 사용하고 있으니 다음과 같이 작성했다.

1
audience = flatten2(Arrays.asList(Arrays.asList("aa", "bb"), Arrays.asList("cc", "dd")));

이게 가능한 이유는 List.of나 Arrays.asList에는 @SafeVarargs 애너테이션이 달려있기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Returns a fixed-size list backed by the specified array. (Changes to
* the returned list "write through" to the array.) This method acts
* as bridge between array-based and collection-based APIs, in
* combination with {@link Collection#toArray}. The returned list is
* serializable and implements {@link RandomAccess}.
*
* <p>This method also provides a convenient way to create a fixed-size
* list initialized to contain several elements:
* <pre>
* List&lt;String&gt; stooges = Arrays.asList("Larry", "Moe", "Curly");
* </pre>
*
* @param <T> the class of the objects in the array
* @param a the array by which the list will be backed
* @return a list view of the specified array
*/
@SafeVarargs
@SuppressWarnings("varargs")
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

이 방식의 장점은 컴파일러가 이 메소드의 타입 안정성을 검증하는데 있다. @SafeVarargs 애너테이션을 직접 달지 않아도 되며 실수로 안전하다고 판단할 걱정도 없다. 단점이라면 클라이언트 코드가 살짝 지저분해지고 속도가 조금 느려지는 정도다. 또한 이 방식은 toArray처럼 메소드를 안전하게 작성하는게 불가능한 상황에서도 쓸 수 있다.

1
2
3
4
5
6
7
8
static <T> List<T> pickTwo(T a, T b, T c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return Arrays.asList(a,b);
case 1: return Arrays.asList(a,c);
case 2: return Arrays.asList(b,c);
}
throw new AssertionError();
}
1
List<String> attributes = pickTwo("aa", "bb", "cc");

결과 코드는 배열 없이 제네릭만 사용하므로 타입 안전하다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/12/Languages/Effective%20JAVA/item32/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.