Intro::
이펙티브 자바 정리본입니다.
결론
•
가변인수와 제네릭은 궁합이 좋지 않습니다.
•
가변인수 기능은 배열을 노출하여 추상화가 완벽하지 못하고, 배열과 제네릭의 타입 규칙이 서로 다르기 때문입니다.
•
메서드에 제네릭 (혹은 매개변수화된) varargs 매개변수를 사용하고자 한다면, 먼저 그 메서드가 타입 안전한지 확인한 다음 @SafeVarargs 애너테이션을 달아 사용하는데 불편함이 없게끔 합시다.
•
가변인수를 실체배열로 바꿔라(아이템 28)
가변인수
가변인수는 메서드에 넘기는 인수의 개수를 클라이언트가 조절할 수 있게 해주는데, 구현 방식에 허점이 있습니다.
가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 자동으로 하나 만들어집니다. 그런데 내부로 감춰야 했을 이 배열을 클라이언트에 노출하는 문제가 발생하였고, 그 결과 varargs 매개변수에 제네릭이나 매개변수화 타입이 포함되면 알기 어려운 컴파일 경고가 발생하게 됩니다.
매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생합니다.
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList;
String s = stringLists[0].get(0);// (1)
}
public static void main(String[] args) {
List<String> list1 = List.of("1", "2", "3");
List<String> list2 = List.of("4", "5", "6");
try {
dangerous(list1, list2);
} catch(Exception e) {// (2)
System.out.println("Error: " + e.getMessage());
}
}
Java
복사
(1) 에서 컴파일러가 생성한 (보이지 않는) 형변환이 숨어있습니다. 이 때문에 (2)에서 ClassCastException 이 발생하고, 이처럼 타입 안전성이 깨지니 제네릭 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않습니다.
그렇다면 제네릭 배열은 허용하지 않으면서 제네릭 varargs 매개변수를 받는 메서드는 선언할 수 있게 허용한 이유가 무엇인가??
제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 메서드가 실무에서 매우 유용하기 때문입니다.
자바 7 전에는 이러한 메서드를 사용하면서 경고를 그냥 두거나 호출하는 곳마다 @SuppressWarnings(”unchecked”) 애너테이선을 달아 경고를 숨겨야 했습니다. 하지만 자바 7에서는 @SafeVarargs 애너테이션을 사용해 메서드 작성자가 그 메서드가 타입 안전함을 보장하도록 할 수 있게되었습니다. 이말인즉슨 안전하지 않다면 @SafeVarargs을 달면 안됩니다.
메서드가 안전한지 확신하는 방법
가변인수 메서드를 호출할 때 varargs 매개변수를 담는 제네릭 배열이 만들어진다는 사실을 기억해야 합니다.
•
메서드가 이 배열에 아무것도 저장하지 않고(그 매개변수들을 덮어쓰지 않고) 그배열의 참조가 밖으로 노출되지 않는다면(신뢰할 수 없는 코드가 배열에 접근할 수 없다면) 타입 안전합니다.
•
이 varargs 매개변수 배열이 호출자로부터 그 메서드로 순수하게 인수들을 전달하는 일만 한다면(varargs의 목적대로만 쓰인다면) 그 메서드는 안전합니다.
이때 varargs 매개변수 배열에 아무것도 저장하지 않고도 타입 안전성을 깰수도 있으니 주의해야 합니다.
// 자신의 제네릭 매개변수 배열의 참조를 노출합니다 - 안전하지 않다!!
static <T> T[] toArray(T... args) {
return args;
}
Java
복사
toArray() 메서드가 반환하는 배열의 타입은 이 메서드가 인수를 넘기는 컴파일 타임에 결정되는데, 그 시점에는 컴파일러에게 충분한 정보가 주어지지 않아 타입을 잘못 판단할 수 있습니다.
즉 toArray() 에서는 컴파일 시점에 Object[]으로 반환되기 때문에 힙오염이 발생할 수 있다는 의미입니다
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();
}
Java
복사
public static void main(String[] args) {
String[] attributes = pickTwo("좋은", "빠른", "저렴한");
}
Java
복사
위의 pickTwo 메서드를 실행하면 ClassCassException이 발생하게 됩니다. 이유인즉슨 위에서 설명했듯이 pickTwo 가 Object[]을 반환하는데 String[]의 하위 타입이 아니기 때문입니다.
해당 예시는 제네릭 varargs 매개변수 배열에 다른 메서드가 접근하도록 허용하면 안전하지 않다는 점을 상기시킵니다.
예외
1.
@SafeVarargs로 제대로 애노테이트된 또 다른 varargs 메서드에 넘기는 것은 안전합니다.
2.
이 배열 내용의 일부 함수를 호출만 하는(varargs를 받지 않는) 일반 메서드에 넘기는 것도 안전합니다.
제네릭 varargs 매개변수를 안전하게 사용하는 전형적인 예시
@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;
}
Java
복사
이 메서드는 @SafeVarargs 애너테이션이 달려 있으니 선언하는 쪽과 사용하는 쪽 모두에서 경고를 내지 않습니다.
그럼 언제 @SafeVarargs을 사용해야 하나??
•
제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 모든 메서드에 @SafeVarargs를 달아라. 그래야 사용자를 헷갈리게 하는 컴파일러 경고를 없앨 수 있습니다.
•
안전하지 않은 varargs 메서드는 절대 작성해서는 안된다는 뜻입니다.
•
재정의할 수 없는 메서드에만 달아야 합니다.
안전한 varargs 메서드
•
varargs 매개변수 배열에 아무것도 저장하지 않는다.
•
그 배열(혹은 복제본)을 신뢰할 수 없는 코드에 노출하지 않습니다.
@SafeVarargs 대신 List 매개변수
궁금한점
제네릭 배열, 제네릭 타입의 경우 컴파일 시점 타입추론이 어떻게 되는지 헷갈린다
1. 제네릭 배열의 타입 추론
•
제네릭 배열을 사용하는 경우, 컴파일 타임에는 T[]와 같은 제네릭 배열이 선언된 것처럼 보이지만, 실제로는 런타임에서 제네릭 타입 정보가 소거되어 Object[]로 처리됩니다.
•
이 때문에 제네릭 배열은 컴파일러가 경고를 내기도 하고, 타입 안전성 문제가 생길 수 있습니다.
예시:
static <T> T[] toArray(T... args) {
return args;
}
Java
복사
•
이 메서드를 호출할 때, 컴파일 타임에는 T를 추론하려고 합니다. 만약 toArray(1, 2, 3)를 호출하면, T는 Integer로 추론됩니다.
•
컴파일 타임: T는 Integer로 추론되지만, 반환하는 배열은 Object[]로 처리됩니다. 그래서 Integer[]로 캐스팅하면 런타임 오류가 발생할 수 있습니다.
Integer[] arr = toArray(1, 2, 3); // 런타임에 Object[]로 반환되기 때문에 오류 발생 가능
Java
복사
•
컴파일 시점 타입 추론은 T를 Integer로 추론하지만, 런타임에 배열은 Object[]로 처리됩니다.
2. 제네릭 타입의 타입 추론
•
제네릭 메서드에서 타입 추론은 메서드를 호출할 때 제공된 인수의 타입을 기반으로 컴파일러가 T의 타입을 추론합니다.
•
제네릭 배열과 달리, 제네릭 타입의 경우에는 컴파일 시점에 T가 정확하게 추론되며, 런타임에서도 타입이 유지됩니다.
예시:
static <T> T pick(T a, T b) {
return a;
}
Java
복사
•
이 메서드를 호출할 때, 컴파일러는 인수의 타입을 기반으로 T를 추론합니다.
•
예를 들어, pick("A", "B")를 호출하면 T는 String으로 추론됩니다.
String result = pick("Hello", "World"); // T는 String으로 추론됨
Java
복사
•
컴파일 타임: T가 String으로 추론되며, 반환 타입도 String입니다.
•
런타임: 타입 정보가 그대로 유지되며, 정확하게 String 타입으로 처리됩니다.
3. 차이점 요약
구분 | 제네릭 배열 (T[]) | 제네릭 타입 (T) |
컴파일 시점 타입 추론 | 컴파일러가 T를 추론하지만, 배열은 Object[]로 처리됨 | 컴파일러가 T를 추론하고, 추론된 타입 그대로 사용 |
런타임 처리 | Object[]로 처리됨 | 타입 정보가 그대로 유지됨 (String, Integer 등) |
타입 안전성 | 런타임 오류 발생 가능 (ArrayStoreException) | 타입 안전성이 보장됨 |
4. 정리
•
제네릭 배열 (T[]): 컴파일러가 타입을 추론하지만, 런타임에 Object[]로 처리되므로 타입 안전성 문제가 발생할 수 있습니다.
•
제네릭 타입 (T): 컴파일 타임에 추론된 타입이 런타임까지 유지되며, 타입 안전성이 보장됩니다.
제네릭 배열의 경우 런타임에 타입 정보가 소거되기 때문에 주의해야 하고, 제네릭 타입은 컴파일러가 타입을 추론한 후 런타임에서도 그 타입을 안전하게 사용할 수 있다는 차이점이 핵심입니다.
References::
이펙티브 자바 / 조슈아 블로크 지음 (프로그래밍 인사이트)