✒️ 핵심 정리
배열과 제네릭에는 매우 다른 타입 규칙이 적용된다. 배열은 공변이고 실체화 되는 반면, 제네릭은 불공변이고 타입 정보가 소거된다.
그 결과 배열은 런타임에는 type-safe 하지만 컴파일 타임에는 그렇지 않다. 제네릭은 반대다.
둘을 섞어 쓰긴 쉽지 않다. 컴파일 오류나 경고를 만나면 가장 먼저 배열을 리스트로 대체해보라.
📌 배열과 제네릭 타입의 두 가지 차이점
1️⃣ 배열은 공변(covariant)이다.
배열은 공변, 즉 Sub가 Super의 하위 타입이라면 Sub[]은 Super[]의 하위 타입이 된다.
반면, 제네릭은 불공변이다. 서로 다른 타입 Type1, Type2가 있을 때, List<Type1>은 List<Type2>의 하위/상위 타입 모두 아니다.
위 개념만 봤을 때 제네릭에 문제가 있을 수 있다고 생각할 수 있지만, 문제가 있는 쪽은 배열이다.
- 문법 상 허용되지만 런타임에 실패
// 런타임에 실패한다.
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다." // ArrayStoreException을 던진다.
- 문법에 맞지 않아 컴파일에 실패
List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입니다.
ol.add("타입이 달라 넣을 수 없다.");
어느 쪽이든 Long 저장소에 String을 넣을 수는 없다.
배열은 그것을 런타임에서야 알게 되지만, 리스트를 사용하면 컴파일할 때 바로 알 수 있다.
2️⃣ 배열은 실체화(reify)된다.
배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다.
위의 예제에서 Long 배열에 String을 넣으려 하면 ArrayStoreException이 발생한다.
반면, 제네릭은 타입 정보가 런타임에는 소거(erasure)된다.
✒️ 소거 (erasure)
• 원소 타입을 컴파일 타임에만 검사하며, 런타임에는 알 수 조차 없다.
• 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘
• 자바 5가 제네릭으로 순조롭게 전환될 수 있도록 해줬다.
✒️ Generic Type Erasure
1️⃣ unbounded Type(<?>, <T>)은 Object로 변환
public class Node<T> {
private T data;
private Node<T> next;
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
public T getData() { return data; }
// ...
}
// 런타임(타입 소거 후)
public class Node {
private Object data;
private Node next;
public Node(Object data, Node next) {
this.data = data;
this.next = next;
}
public Object getData() { return data; }
// ...
}
type erasure가 적용되면서 특정 타입으로 제한되지 않은 <T>는 Object로 대체된다.
// 컴파일
public static <T> int count(T[] anArray, T elem) {
int cnt = 0;
for (T e : anArray)
if (e.equals(elem))
++cnt;
return cnt;
}
// 런타임
public static int count(Object[] anArray, Object elem) {
int cnt = 0;
for (Object e : anArray)
if (e.equals(elem))
++cnt;
return cnt;
}
제네릭 메서드에서도 동일하다. T는 비한정적 타입이므로, 컴파일러가 Object로 변환한다.
2️⃣ bound type(<E extends Comparable>)의 경우는 Comprarable로 변환
// 컴파일 할 때 (타입 변환 전)
public class Node<T extends Comparable<T>> {
private T data;
private Node<T> next;
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
public T getData() { return data; }
// ...
}
// 런타임 시
public class Node {
private Comparable data;
private Node next;
public Node(Comparable data, Node next) {
this.data = data;
this.next = next;
}
public Comparable getData() { return data; }
// ...
}
한정 타입의 경우 컴파일 시점에 제한된 타입으로 변환된다.
3️⃣ 제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메서드에만 소거 규칙 적용
4️⃣ 타입 안전성 보존을 위해 필요 시, type casting
5️⃣ 확장된 제네릭 타입에서 다형성 보존을 위해 bridge method 생성
public class Node<T> {
public T data;
public Node(T data) { this.data = data; }
public void setData(T data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node<Integer> {
public MyNode(Integer data) { super(data); }
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
위 두 가지 클래스가 존재한다고 가정했을 때, 다음 실행 코드를 확인해보자.
MyNode mn = new MyNode(5);
Node n = mn; // A raw type - compiler throws an unchecked warning
n.setData("Hello"); // Causes a ClassCastException to be thrown.
Integer x = mn.data;
타입이 소거된 후에는 다음과 같이 적용되며, 런타임 시 ClassCastExeption를 발생시킨다.
MyNode mn = new MyNode(5);
Node n = (MyNode)mn; // A raw type - compiler throws an unchecked warning
n.setData("Hello"); // Causes a ClassCastException to be thrown.
Integer x = (String)mn.data;
타입 소거 후에 Node와 MyNode는 다음과 같이 변환된다.
소거 후에는 Node 시그니처 메서드가 setData(T data)에서 setData(Ojbect data)로 바꾸기 때문에 MyNode의 setData(Integer data)를 오버라이딩할 수 없게 된다.
런타임 시에는 다음과 같이 타입이 소거된 상태로 변한다.
public class Node {
public Object data;
public Node(Object data) { this.data = data; }
public void setData(Object data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node {
public MyNode(Integer data) { super(data); }
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
Object로 변하게 되는 경우에 대한 불일치를 없애기 위해 컴파일러는 런타임에 해당하는 제네릭 타입의 타입 소거를 위한 bridge method를 생성해준다.
class MyNode extends Node {
// Bridge method generated by the compiler
public void setData(Object data) {
setData((Integer) data);
}
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
...
}
그렇기 때문에 ClassCastException 예외를 던진다.
📌 배열과 제네릭의 부조화
- 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다.
- new List[], new List[], new E[] 식으로 작성하면 컴파일 할 때 제네릭 배열 생성 오류가 발생한다.
- 제네릭 배열 생성을 막은 이유
- type-safe 하지 않기 때문이다.
- 이를 허용하면 컴파일러가 자동 생성한 형변환 코드에서 런타임 시 ClassCastException 발생 우려
- 런타임 시 ClassCastExeption이 발생하는 일을 막아준다는 제네릭 타입 시스템 취지에 어긋난다.
제네릭 배열이 가능한 경우를 가정했을 때, 아래 예제를 살펴 보자
List<String>[] stringLists = new List<String>[1];
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList;
String s = stringLists[0].get(0);
1. 제네릭 배열 생성이 가능하다고 가정
List<String>[] stringLists = new List<String>[1];
2. 원소가 하나인 List 생성
List<Integer> intList = List.of(42);
3. List의 배열을 Object 배열에 할당
- 배열은 공변이므로 Object[] objects = List<String>[1]; 이 가능하다.
Object[] objects = stringLists;
4. List 인스턴스를 Object 배열의 첫 번째 원소로 저장
- (2)에서 생성한 List<Integer>를 List<String>[] 타입 objects 첫 번째 원소로 저장한다.
- 제네릭은 소거 방식으로 구현되므로 런타임에서 이 또한 성공한다.
- List<Integer> 인스턴스는 List가 된다.
- List<String>[] 인스턴스는 List[]가 된다.
objects[0] = intList;
5. 배열의 처음 리스트에서 첫 원소를 꺼내려하는 경우
- List<String> 인스턴스만 담겠다고 선언한 stringLists 배열에 List<Integer>가 저장되어 있다.
- 컴파일러는 꺼낸 원소를 자동으로 String으로 형변환 하는데, 이 원소는 Integer이므로 런타임에 ClassCastException이 발생한다.
String s = stringLists[0].get(0);
결국 위의 문제를 방지하기 위해서는 (1)번 단계부터 컴파일 오류를 냈어야 한다.
📌 실체화 불가 타입 (non-reifiable type)
- E, List<E>, List<String> 같은 타입을 실체화 불가 타입이라 한다.
- 실체화가 되지 않아서 런타임에는 컴파일 타입보다 타입 정보를 적게 가진다.
- 소거 매커니즘으로 인해 매개변수화 타입 가운데 실체화 가능한 타입은 List<?>와 Map<?, ?>같은 비한정적 와일드 카드 타입 뿐이다. (Item 26)
- 배열을 비한정적 와일드 카드 타입으로 만들 수는 있지만, 유용하게 쓰일 일은 거의 없다.
📌 배열을 제네릭으로 만들 수 없어 귀찮은 경우
- 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는 게 보통 불가능하다.
- 완벽하지는 않지만 대부분 상황에서 이 문제를 해결해주는 방법은 나중에 설명 (Item 33)
- 제네릭 타임과 가변인수 메서드를 함께 쓰면 해석하기 어려운 경고 메시지를 받게 된다.
- 가변인수 메서드를 호출할 때마다 가변인수 매개변수를 담을 배열이 하나 만들어진다.
- 이때 그 배열의 원소가 실체화 불가 타입이라면 경고가 발생한다.
- 이 문제는 @SafeVarargs 어노테이션(Item 32)으로 대체할 수 있다.
- 배열로 형변환할 때 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜬다.
- 대부분 배열인 E[] 대신 컬렉션인 List<E>를 사용하면 해결된다.
- 코드가 조금 복잡해지고 성능이 저하되지만, type-safe와 상호운용성이 좋아진다.
제네릭을 쓰지 않은 가장 기본적인 Chooser 클래스 예시를 보자.
import java.util.concurrent.ThreadLocalRandom;
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection choices) {
choiceArray = choices.toArray();
}
public Object choose() { // 해당 메서드 호출 시 반환된 Object를 원하는 타입으로 형변환 필요
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}
이 클래스를 사용하려면 choose 메서드 호출마다 반환된 Object를 형변환해야 한다.
혹여 다른 타입 원소가 들어 있다면 런타임에 형변환 오류가 발생할 것이다.
우선 제네릭으로 만들기 위한 첫 번째 시도를 해보자.
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices) {
choiceArray = choices.toArray();
}
...
}
이 클래스는 toArray() 타입을 Object[] 타입으로 형변환할 수 없어 예외를 발생시킨다.
이럴 때는 형변환을 시키면 된다.
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices) {
choiceArray = (T[]) choices.toArray();
}
}
다만 T가 무슨 타입인지 알 수 없어 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지를 띄운다.
제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타임인지 알 수 없음을 기억하자.
해당 코드는 동작은 하지만 컴파일러가 안전을 보장하지 못한다.
만약 안전하다고 확신하는 경우 주석을 남기고 어노테이션을 달아 경고를 숨겨도 되지만, 경고의 원인을 제거하는 편이 낫다.
📌 비검사 형변환 경고를 제거하기 위한 리스트 사용
비검사 형변환 경고를 제거하려면 배열 대신 리스트를 쓰면 된다.
코드 양이 조금 늘고 느려졌을 테지만, 런타임에 ClassCaseExeption을 만날 일은 없으므로 그만한 가치가 있다.
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));
}
}