Intro::
이펙티브 자바 정리본입니다.
결론
•
배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소거됩니다.
•
배열은 런타임에 타입 안전하지만 컴파일 타임에는 그렇지 않습니다.
•
제네릭은 런타임에 타입 안전하지 않지만 컴파일 타임에 안전합니다.
•
배열과 제네릭을 섞어 쓰는것은 쉽지않고, 만약 섞어 사용하다가 컴파일 오류나 경고를 만난다면 배열을 리스트로 대체하는 방법을 적용해보자.
배열과 제네릭 차이
공변 불공변
Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 됩니다. 이와 같이 함께 변한다는 의미를 공변이라고 합니다.
배열이 공변인 반면, 제네릭은 불공변입니다. 서로 다른 타입 Type1과 Type2가 있을 때, List<Type1>은 List<Type2>의 하위 타입도 아니고 상위 타입도 아닙니다.
// 런타임 실패
public static void main(String[] args) {
Object[] objects = new Long[1];
objects[0] = "타입이 달라 넣을 수 없습니다.";
}
Java
복사
// 컴파일 실패
public static void main(String[] args) {
List<Object> list = new ArrayList<Long>();
list.add("타입이 달라 넣을 수 없습니다!");
}
Java
복사
어느 쪽이든 에러가 발생하지만 컴파일 시에 알아차리는 것을 선호할 것이다.
실체화 유무
배열은 실체화됩니다. 즉, 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인합니다. 이전 예시에서 확인할 수 있듯, Long 배열에 String을 넣으려 하면 ArrayStoreException이 발생합니다. 반면, 제네릭은 타입 정보가 런타임에는 소거됩니다.원소 타입을 컴파일타임에만 검사하며, 런타임에는 알수조차 없다는 뜻입니다. 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘으로, 자바 5가 제네릭으로 순조롭게 전환될 수 있도록 해줬습니다.
제네릭 배열을 만들지 못하게 한 이유
•
타입 안전하지 않기 때문입니다.
•
이를 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException이 발생할 수 있습니다.
◦
이는 런타임에 ClassCastException이 발생하는 것을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋납니다.
public static void main(String[] args) {
// 컴파일 에러 Generic array creation not allowed
List<String>[] stringLists = new List<String>[10];// (1)
List<Integer> integerList = List.of(42);// (2)
Object[] objects = stringLists;// (3)
objects[0] = integerList;// (4)
String s = stringLists[0].get(0);// (5)
}
Java
복사
•
만약 제네릭 배열이 된다고 가정하면
◦
(3)은 (1)에서 생성한 List<String> 배열을 Object 배열에 할당합니다. 런타임에는 List<String>[] 이 단순히 List[] 로 소거 되기 때문에 공변이 발생하기 때문입니다.
◦
(4) 또한 마찬가지로 (2)에서 생성한 인스턴스를 저장합니다.
◦
(5)는 stringLists의 처음 리스트에서 첫 원소를 꺼내려 하는데, 컴파일러는 꺼낸 원소를 자동으로 String 으로 형변환합니다. 이때, 이 원소는 Integer이므로 런타임에 ClassCastException이 발생하게 됩니다.
E, List<E>, List<String> 같은 타입을 실체화 불가 타입이라고 합니다. 쉽게 말해, 실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입임을 의미합니다. 소거 메커니즘 때문에 매개변수화 타입 가운데 실체화될 수 있는 타입은 List<?>와 Map<?,?>같은 비한정적 와일드카드 타입뿐입니다.
배열을 제네릭으로 만들 수 없어서 귀찮은 경우
제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는 게 보통은 불가능합니다.
또한 제네릭 타입과 가변인수 메서드를 함께 쓰면 해석하기 어려운 경고 메시지를 받게 됩니다. 가변인수 메서드를 호출할 때마다 가변인수 매개변수를 담을 배열이 하나 만들어지는데, 이때 그 배열의 원소가 실체화 불가 타입이라면 경고가 발생하는 것입니다.
배열로 형변환할 때 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 대부분은 배열인 E[] 대신 List<E>를 사용하면 해결 가능합니다. 코드가 조금 복잡해지고 성능이 살짝 나빠질 수 있지만, 타입 안전성과 상호운용성은 좋아집니다.
배열 대신 컬렉션 사용 예시
// 배열 예시
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection choices) {
choiceArray = choices.toArray();
}
public Objectchoose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}
Java
복사
해당 코드는 매번 Object에서 형변환을 해주어야 한다. 제네릭으로 수정해보자.
public class Chooser<T> {
private final T[] choiceList;
public Chooser(Collection<T> choices) {
choiceList = (T[]) choices.toArray();// (1)
}
public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList[rnd.nextInt(choiceList.length)];
}
}
Java
복사
(1) 에서 Object 배열을 T[] 배열로 형변환을 해줍니다. 해당 프로그램은 동작은 하지만, 컴파일러가 안전을 보장하지 못합니다.
T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장을 할 수 없습니다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에서는 무슨 타입인지 알 수 없기 때문입니다.
// 코드 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()));
}
}
Java
복사
질문
제네릭의 경우 타입 정보가 런타임에는 소거된다고 하는데 이게 무슨 말인가?
제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 구체적인 타입을 알 수 없게 됩니다. 이때 소거된 제네릭 타입 파라미터는 상한이 지정되지 않은 경우 Object로, 상한이 지정된 경우 그 상한 타입으로 대체됩니다.
// 타입 상한
List<T extends Number>...
Java
복사
그래서 제네릭과 배열은 뭐가 다른건가??
1.
배열은 공변(covariant):
자바 배열은 공변성을 가집니다. 즉, String[]은 Object[]의 하위 타입입니다. 그래서 배열에서는 다음과 같은 코드가 컴파일됩니다:
Object[] objects = new String[10]; // 가능
Java
복사
하지만, 배열의 공변성 때문에 문제가 발생할 수 있습니다:
Object[] objects = new String[10];
objects[0] = 42; // 런타임에서 ArrayStoreException 발생
Java
복사
여기서 objects는 String[] 배열로 실제로 만들어졌기 때문에, 정수 값을 넣으면 런타임에 ArrayStoreException이 발생합니다. 즉, 배열은 런타임에 타입을 체크합니다.
2.
제네릭은 불공변(invariant):
제네릭 타입은 불공변입니다. 즉, List<String>은 List<Object>와 아무런 관련이 없습니다. 제네릭스는 컴파일 시 타입을 체크하며, 런타임에는 타입 소거(type erasure)가 일어나 타입 정보가 사라집니다.
예를 들어, 다음 코드는 컴파일되지 않습니다:
List<Object> list = new ArrayList<String>(); // 컴파일 오류
Java
복사
이는 제네릭스가 타입 안정성을 컴파일 시점에서 보장하려고 설계되었기 때문입니다.
컴파일러가 T[] 대신 Object[]로 처리하는 이유
제네릭 배열을 사용할 때 컴파일러는 배열을 만들 수 없습니다. 그 이유는 타입 소거와 관련이 있습니다.
1.
타입 소거(Type Erasure):
제네릭스는 런타임에 타입 정보가 사라지게 됩니다. 즉, 컴파일된 자바 바이트코드에서는 제네릭 타입이 지워지기 때문에, T와 같은 타입 매개변수는 런타임에 더 이상 알 수 없습니다. 이는 런타임에 제네릭 타입을 구체적으로 추적할 수 없음을 의미합니다.
예를 들어:
T[] array = new T[10]; // 컴파일 오류
Java
복사
이런 코드를 허용하면, 런타임에 T가 무엇인지 알 수 없기 때문에 안전하지 않으며, 자바는 이를 막기 위해 컴파일 오류를 발생시킵니다.
2.
배열의 런타임 타입 체크:
배열은 런타임에 타입을 검사합니다. 제네릭스는 컴파일 시점에만 타입을 검사하고 런타임에는 타입 정보가 소거되므로, 제네릭 배열은 런타임에 타입 안전성을 보장할 수 없습니다. 만약 T[] 배열을 허용하면 런타임에 T 타입을 검사할 수 없기 때문에 배열의 타입 안전성이 깨집니다.
예를 들어, 만약 T[]가 허용되었고 T가 런타임에 String이라고 추론된다고 하더라도, 런타임에 정확한 타입을 추적할 수 없기 때문에 안전하지 않습니다. 그래서 컴파일러는 T[] 대신 Object[]로 처리합니다.
요약:
1.
배열의 공변성과 런타임 타입 체크: 배열은 런타임에 타입을 검사하고, 공변성을 가집니다. 하지만 제네릭스는 불공변성을 가지며 런타임에는 타입 정보가 소거되므로, 제네릭 배열은 런타임에 타입 안전성을 보장할 수 없습니다.
2.
타입 소거로 인한 런타임 정보 부족: 제네릭스는 런타임에 타입 정보가 소거되기 때문에, 컴파일러는 T[] 배열을 허용하지 않고 대신 Object[]로 처리합니다. 이는 런타임 타입 안전성을 보장하기 위함입니다.
따라서, 자바는 컴파일 시점에서 타입 안정성을 보장하기 위해 제네릭 배열 생성을 제한하며, 런타임에 안전하지 않은 상황을 방지하기 위해 배열을 Object[]로 처리합니다.
비한정적 와일드카드 타입이 왜 실체화 가능 타입인가?
실체화 가능 타입(Reifiable Type)이란 런타임에 완전한 타입 정보를 유지하는 타입을 말합니다. 즉, 타입 정보가 소거되지 않고, 런타임에서도 그 타입에 대한 정보를 가지고 있는 타입을 가리킵니다.
하지만, 제네릭 타입은 컴파일 타임에만 타입 정보를 사용하고, 런타임에서는 그 타입 정보가 소거되므로, 대부분의 제네릭 타입은 실체화 불가능(Non-Reifiable) 타입에 해당합니다.
실체화 가능 타입의 예
•
기본 타입(Primitive types): int, char, boolean 등은 런타임에서도 타입 정보를 유지하므로 실체화 가능 타입입니다.
•
비제네릭 타입(Non-generic types): 예를 들어, List 같은 타입은 제네릭 정보가 전혀 없으므로 실체화 가능합니다.
•
비한정적 와일드카드 타입(?): List<?>와 같은 타입은 실체화 가능 타입으로 간주됩니다.
List<?>의 경우 컴파일타임에서 타입소거가 되어 런타임에는 List 타입으로 간주됩니다. 이때 List<?>는 런타임에서 List<Object>와 같은 역할을 수행하기 때문에 실체화 가능하다고 판단할 수 있습니다.
References::
이펙티브 자바 / 조슈아 블로크 지음 (프로그래밍 인사이트)