배열과 제네릭 타입에는 중요한 차이가 두 가지 있음
배열은 공변(Covariant)임, Sub
가 Super
의 하위 타입이라면 배열 Sub[]
는 Super[]
의 하위 타입이 되고 함께 변함
제네릭은 불공변(invariant)임 서로 다른 타입 Type1
과 Type2
가 있을 때 List<Type1>
은 List<Type2>
의 하위 타입도 상위 타입도 아님
이를 보면 문제가 제네릭에 있어 보이지만 실제로는 배열에 있음, 아래의 예시를 볼 수 있음
// 런타임에 실패함
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다."; // ArrayStoreException을 던짐
// 컴파일이 되지 않음
List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입임
ol.add("타입이 달라 넣을 수 없다.");
즉, 어느 쪽이든 Long
용 저장소에 String
을 넣을 수는 없음, 이때 배열에선 그 실수를 런타임에서야 알게 되지만, 리스트 사용시 컴파일 할 때 바로 알 수 있음
두 번째 주요 차이로, 배열은 실체화 됨 즉, 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인함
그래서 위처럼 Long
배열에 String
을 넣으려 하면 ArrayStoreException
이 발생함, 반면 앞서 이야기했듯 제네릭은 타입 정보가 런타임에는 소거됨
즉, 원소 타입을 컴파일타임에만 검사하며 런타임에는 알수조차 없다는 뜻임
이와 같은 차이로 배열과 제네릭은 잘 어우러지지 못함, 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없음
이처럼 제네릭 배열을 만들지 못하게 막은 이유는 타입 안전하지 않기 때문임, 이를 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException
이 발생할 수 있음
하지만 런타임에 ClassCastException
이 발생하는 일을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋남
// 컴파일되지 않음
List<String>[] stringLists = new List<String>[1]; // (1)
List<Integer> intList = List.of(42); // (2)
Object[] objects = stringLists; // (3)
objects[0] = intList; // (4)
String s = stringLists[0].get(0); // (5)
제네릭 배열을 생성하는 (1)
이 허용된다고 할 때 (2)
는 원소가 하나인 List<Integer>
를 생성함
(3)
은 (1)
에서 생성한 List<String>
의 배열을 Object
배열에 할당함, 배열은 공변이니 문제 없음
(4)
는 (2)
에서 생성한 List<Integer>
의 인스턴스를 Object
배열의 첫 원소로 저장함
제네릭은 소거 방식으로 구현되어서 이 역시 성공함
즉, 런타임에는 List<Integer>
인스턴스의 타입은 단순히 List
가 되고, List<Integer>[]
인스턴스의 타입은 List[]
가 됨 따라서 (4)
에서도 ArrayStoreException
을 일으키지 않음
이 다음부터가 문제인데 List<String>
인스턴스만 담겠다고 선언한 stringLists
배열에는 지금 List<Integer>
인스턴스가 저장돼 있음, 그리고 (5)
는 이 배열의 처음 리스트에서 첫 원소를 꺼내려함
컴파일러는 꺼낸 원소를 자동으로 String
으로 형변환하는데, 이 원소는 Integer
이므로 런타임에 ClassCastException
이 발생함, 이런 일을 방지하려면 제네릭 배열이 생성되지 않도록 (1)
에서 컴파일 오류를 내야함
E
, List<E>
, List<String>
같은 타입을 실체화 불가 타입(non-reifiable type)이라함, 쉽게 말해 실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입임
소거 메커니즘 때문에 매개변수화 타입 가운데 실체화될 수 있는 타입은 List<?>
와 Map<?,?>
같은 비한정적 와일드카드 타입뿐임(배열도 만들 수 있지만 유용하게 쓰일 일이 거의 없음)
배열을 제네릭으로 만들 수 없어 귀찮을 때도 있음, 예를 들어 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는 게 보통은 불가능함
또한 제네릭 타입과 가변인수 메서드(varargs method)를 함께 쓰면 해석하기 어려운 경고 메시지를 받게됨
가변인수 메서드를 호출할 때마다 가변인수 매개변수를 담을 배열이 하나 만들어지는데, 이 때 그 배열의 원소가 실체화 불가 타입이라면 경고가 발생하는 것임(이 문제는 @SafeVarargs
애너테이션으로 대처가능)
배열로 형변환할 때 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 대부분은 배열인 E[]
대신 컬렉션인 List<E>
를 사용하면 해결됨(코드가 조금 복잡해지고 성능이 나빠질 순 있지만, 그 대신 타입 안전성과 상호운용성은 좋아짐)
// 제네릭 적용이 필요한 클래스
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection choices) {
choiceArray = choices.toArray();
}
public Object choose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}
위 클래스를 생성자에 어떤 컬렉션을 남기냐에 따라 다양한 방식으로 활용할 수 있음, 여기서 제네릭을 활용해서 이를 처리할 수 있음
이 클래스 사용을 위해 choose
메소드를 호출할 때마다 반환된 Object
를 원하는 타입으로 형변환해야 함
혹시나 타입이 다른 원소가 들어 있었다면 런타임에 형변환 오류가 날 것임
이 때 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억하면 좋음
그리고 만약 컴파일러가 안전을 보장하지 못할 경우 코드를 작성하는 사람이 안전하다고 확신한다면 주석을 남기고 애너테이션을 달아 경고를 숨겨도 됨(하지만 애초에 경고의 원인을 제거하는 편이 나음)
그래서 아래와 같이 제네릭 활용과 리스트를 통해서 위의 클래스에 제네릭을 활용할 수 있음
public class Chooser<T> {
private final List<T> choiceList;
public Chooser(Collection<T> choices) {
choiceList = new ArrayList<>(choices);
}
public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}