배열과 제네릭 타입의 중요한 두 가지 차이
1) 배열은 공변(covariant)이다.
- 어려워보이지만 뜻은 간단하다.
- Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다.(공변, 즉 함께 변한다는 뜻이다)
- 반면 제네릭은 불공변(invariant)이다. 즉, 서로 다른 타입 Type1과 Type2가 있을때,
List<Type1>
은List<Type2>
의 하위 타입도 아니고 상위 타입도 아니다. - 사실 문제가 있는건 배열쪽이다. 아래 코드는 문법상 허용되는 코드다.
1
2
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다."; // ArrayStoreException을 던진다.
- 위의 코드는 런타임에
ArrayStoreException
이 발생하여 실패한다.
1
2
List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입이다.
o1.add("타입이 달라 넣을 수 없다.");
- 하지만 위의 제네릭 타입의 경우엔 컴파일 조차 되지 않는다.
- 어느 쪽이든 Long용 저장소에 String을 넣을 순 없다.
- 다만 배열에선 그 실수를 런타임에야 알게 되지만, 리스트를 사용하면 컴파일시 바로 알 수 있다.
2) 배열은 실체화(reify)된다.
- 제네릭은 타입 정보가 런타임에는 소거(erasure)된다. 원소 타입을 컴파일타임에만 검사하며 런타임에는 알 수조차 없다는 뜻이다.
- 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘이다.
위와 같은 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다!
- 예컨대 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다.
- 즉, 코드를
new List<E>[]
,new List<String>[]
,new E[]
이런식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다.
제네릭 배열을 막은 이유는 타입 안전하지 않기 때문이다.
- 이를 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에
ClassCastException
이 발생할 수 있다. 런타임에ClassCastException
이 발생하는 일을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋나는 것이다. - 예제 코드 28-3을 참고하자.
E, List, List 같은 타입을 실체화 불가 타입(non-reifiable type)이라 한다.
- 실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입이다.
Chooser 클래스 예제
- 생성자에서 컬렉션을 받는
Chooser 클래스
를 예로 살펴보자.- 이 클래스는 컬렉션 안의 원소 중 하나를 무작위로 선택해 반환하는
choose()
메서드를 제공한다. - 생성자 안의 어떤 컬렉션을 넘기느냐에 따라 주사위판, 매직 8볼, 몬테카를로 시물레이션용 데이터 소스 등으로 사용할 수 있다.
- 이 클래스는 컬렉션 안의 원소 중 하나를 무작위로 선택해 반환하는
- 아래 예제 코드는 제네릭을 쓰지 않고 구현한 가장 간단한 버전이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// 코드 28-4 Chooser - 제네릭을 시급히 적용해야 한다.
public class Chooser<T> {
private final Object[] choiceArray;
public Chooser(Collection<T> choices) {
choiceArray = choices.toArray();
}
public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}
- 이 클래스를 사용하려면 choose 메서드를 호출할 때마다 반환된 Object를 원하는 타입으로 형변환해야 한다.
- 혹시나 타입이 다른 원소가 들어 있었다면 런타임에 형변환 오류가 날 것이다.
- 이를 제네릭으로 만들어보자.
1
2
3
4
5
6
7
8
9
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices) {
choiceArray = choices.toArray();
}
// choose 메서드는 그대로
}
- 이 클래스를 컴파일하면 아래와 같은 오류 메시지가 출력될 것이다.
1
2
3
4
5
6
Chooser.java:9: error: incompatible types: Object[] cannot be
converted to T[]
choiceArray = choices.toArray();
^
where T is a type-variable:
T extends Object declared in class Chooser
- 걱정할 거 없다. Object 배열을 T로 형변환하면 된다.
1
choiceArray = (T[]) choices.toArray();
그런데 이번엔 경고가 뜬다.
1
2
3
4
5
6
Chooser.java:9: warning: [unchecked] unchecked cast
choiceArray = (T[]) choices.toArray();
^
required: T[], found: Object[]
where T is a type-variable:
T extends Object declared in class Chooser
- T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지다.
- 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억하자!
- 위 코드는 정상적으로 동작하지만 컴파일러가 안전을 보장하지 못한다.
- 코드를 작성하는 사람이 안전하다고 확신한다면 주석을 남기고 애너테이션을 달아 경고를 숨겨도 된다.
- 하지만 애초에 경고의 원인을 제거하는 것이다 낫다! 비검사 형변환 경고를 제거하려면 배열 대신 리스트를 쓰면 된다.
- 다음 Chooser는 오류나 경고 없이 컴파일된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 코드 28-6 리스트 기반 Chooser - 타입 안전성 확보! (168쪽)
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()));
}
public static void main(String[] args) {
List<Integer> intList = List.of(1, 2, 3, 4, 5, 6);
Chooser<Integer> chooser = new Chooser<>(intList);
for (int i = 0; i < 10; i++) {
Number choice = chooser.choose();
System.out.println(choice);
}
}
}
- 이번 버전은 코드양이 조금 늘었고 아마도 족므 더 느릴테지만, 런타임에
ClassCastException
을 만날 일은 없으니 그만한 가치가 있다.
핵심 정리: 배열과 제네릭은 매우 다른 타입 규칙이 적용된다. 배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소거된다. 그 결과 배열은 런타임에는 타입 안전하지만 컴파일타임에는 그렇지 않다. 제네릭은 반대다. 그래서 둘을 섞어 쓰기란 쉽지 않다. 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해보자.