타입 소거와 런타임 한계
컴파일러가 타입 인자를 지우는 타입 소거 때문에 실행 중에 T를 검사하거나 생성할 수 없는 이유를 이해하고, raw 타입이 일으키는 힙 오염과 Class 객체·Supplier로 타입 정보를 넘기는 방법을 배웁니다.
지금까지 본 제네릭의 도움은 모두 컴파일할 때 일어났습니다. 개 병원에 고양이를 넣는 코드도, 와일드카드 상자에 개를 넣는 코드도 컴파일 오류로 막혔습니다. 그렇다면 컴파일이 끝나고 프로그램이 실행될 때도 Box<String> 객체는 자기가 문자열 상자라는 것을 알고 있을까요?
답은 '모른다'입니다. 제네릭 클래스와 타입 매개변수의 '더 깊이'에서 잠깐 보았듯이, 컴파일러는 타입 검사를 마친 뒤 실행되는 코드에서 타입 인자를 지웁니다. 이를 타입 소거(type erasure)라고 합니다. 이 글에서는 타입 소거 때문에 실행 중에 할 수 없게 되는 일을 컴파일 오류로 확인하고, raw 타입이 소거된 구조를 파고들어 엉뚱한 곳에서 예외를 일으키는 과정을 따라갑니다. 그리고 실행 중에 정말 타입 정보가 필요할 때 쓰는 방법을 배웁니다.
new T()와 instanceof T는 컴파일되지 않는다
제네릭 클래스를 만들다 보면 T 타입의 객체를 새로 만들거나, 받은 값이 T인지 검사하고 싶어집니다. 다음 클래스는 그 두 가지를 그대로 써 봅니다.
public final class ErasedTypeOperationFailure<T> {
public boolean matches(Object value) {
return value instanceof T;
}
public T create() {
return new T();
}
}lab/ErasedTypeOperationFailure.java:3: error: Object cannot be safely cast to T
return value instanceof T;
^
where T is a type-variable:
T extends Object declared in class ErasedTypeOperationFailure
lab/ErasedTypeOperationFailure.java:7: error: unexpected type
return new T();
^
required: class
found: type parameter T
where T is a type-variable:
T extends Object declared in class ErasedTypeOperationFailure
2 errors두 오류는 같은 이유에서 나옵니다. 실행 중의 이 객체는 T가 무엇인지 모르기 때문입니다. value instanceof T를 실행하려면 '지금 T가 무엇인가'를 알아야 하는데, 그 정보가 실행되는 코드에 남아 있지 않습니다. 첫 메시지는 'Object를 T로 안전하게 캐스팅할 수 없다'는 뜻입니다. instanceof 검사는 결국 '이 값을 T로 캐스팅해도 되는가'를 묻는 것인데, 그 답을 실행 중에 확인할 수 없다는 말입니다.
new T()도 마찬가지입니다. 객체를 만들려면 어떤 클래스의 생성자를 부를지 알아야 하는데, 이 코드가 실행될 때는 T가 String인지 Post인지 알 수 없습니다. 게다가 T가 인자 없는 생성자를 가진다는 보장도 없습니다. 그래서 메시지가 '여기에는 클래스가 와야 하는데 타입 매개변수가 왔다'고 말합니다.
같은 이유로 막히는 표현이 몇 가지 더 있습니다. T.class는 cannot select from a type variable 오류가 나고, new T[10]은 generic array creation 오류가 납니다. static T shared;처럼 클래스의 T를 static 필드에 쓰는 것도 안 됩니다. 이것은 소거와는 다른 이유인데, 클래스의 T는 객체마다 정해지는 반면 static 필드는 모든 객체가 하나를 함께 쓰기 때문입니다. Box<String>과 Box<Integer>가 같은 필드를 나눠 쓰는데 그 필드의 타입이 T라면, T가 무엇인지 정할 수 없습니다.
instanceof T 대신 instanceof Object를 쓰면 컴파일은 되지만 쓸모가 없습니다. null이 아닌 모든 참조가 Object이므로 언제나 참이 되어, 호출한 쪽이 고른 타입인지 가려내지 못합니다.
컴파일된 코드에는 T가 남지 않는다
실행 중에 T를 모른다는 것을 직접 확인해 보겠습니다. 다음 프로그램은 서로 다른 타입 인자로 상자 두 개를 만들고, 두 객체의 실행 시점 클래스를 비교합니다.
public final class ErasureRuntimeObservation {
public static void main(String[] args) {
Box<String> titles = new Box<>("제네릭 질문");
Box<Integer> viewCounts = new Box<>(13);
System.out.println("same-class=" + (titles.getClass() == viewCounts.getClass()));
String title = titles.get();
Integer viewCount = viewCounts.get();
System.out.println(title + "=" + viewCount);
}
private record Box<T>(T value) {
T get() { return value; }
}
}same-class=true
제네릭 질문=13getClass()는 객체가 실제로 어떤 클래스로 만들어졌는지를 나타내는 Class 객체를 돌려줍니다. 두 상자의 Class 객체가 같으므로 첫 줄은 same-class=true입니다. 소스 코드에서는 Box<String>과 Box<Integer>가 서로 다른 타입이지만, 실행되는 클래스는 Box 하나뿐이고 두 객체 모두 그 클래스로 만들어졌다는 뜻입니다.
그런데 둘째 줄을 보면 각 상자에서 꺼낸 값은 캐스팅 없이 String과 Integer 변수에 담겼습니다. 이것이 가능한 이유는 컴파일러가 두 가지 일을 하기 때문입니다. 먼저 Box 안의 T를 모두 Object로 바꿔 클래스를 하나만 만듭니다. 그리고 String title = titles.get();처럼 값을 꺼내는 곳마다 String으로의 캐스트를 직접 넣습니다. 실행되는 코드만 보면 Object 하나로 합친 상자와 다를 바가 없는 셈입니다. 차이는 컴파일러가 set이나 생성자로 들어오는 값을 미리 검사했다는 점이고, 그 덕분에 자기가 넣은 캐스트가 실패하지 않는다는 것을 알고 있습니다.
T가 무엇으로 바뀌는지는 상한이 정합니다. 상한이 없는 T는 Object로 바뀌고, <T extends Animal>의 T는 Animal로 바뀝니다. 상한이 여러 개면 맨 앞의 것이 쓰입니다. 그래서 타입 상한과 메서드 추론의 Hospital<T extends Animal>은 컴파일된 뒤에도 필드가 Animal 타입이라 animal.size()를 그대로 호출할 수 있습니다. 반환값을 Dog 변수에 받는 곳에는 Dog로의 캐스트가 들어갑니다.
더 깊이: 자바가 소거를 택한 이유
자바에 제네릭이 들어온 것은 Java 5(2004년)입니다. 그때는 이미 List나 Map을 타입 인자 없이 쓰는 코드와 라이브러리가 아주 많았습니다. 타입 인자마다 실행 시점의 클래스를 따로 두는 방식을 택했다면, 기존 클래스 파일과 새 제네릭 코드가 서로를 쓸 수 없게 되었을 것입니다. 자바는 그 대신 타입 인자를 지워 List<String>과 옛 List가 실행 중에 같은 클래스가 되게 했고, 그래서 옛 코드와 새 코드를 한 프로그램에서 섞어 쓸 수 있었습니다. Java 언어 명세(JLS)의 4.7절도 이 결정의 가장 큰 동기로 기존 코드와의 호환성을 듭니다. 이 글에서 보는 제약은 그 호환성의 대가입니다.
실행 중에도 타입 정보가 온전히 남는 타입을 실체화 가능 타입(reifiable type)이라고 합니다. String, int, raw 타입 List, 비한정 와일드카드 List<?>가 여기에 속하고, List<String>이나 T는 속하지 않습니다. value instanceof List<?>는 컴파일되지만 value instanceof List<String>은 컴파일되지 않는 이유가 여기에 있습니다. 다만 이미 List<String>으로 알려진 변수를 names instanceof ArrayList<String>처럼 검사하는 것은 컴파일됩니다. 이 경우 타입 인자는 컴파일러가 이미 확인했고, 실행 중에는 ArrayList인지만 보면 되기 때문입니다.
소거가 모든 정보를 지우는 것은 아닙니다. 클래스 파일에는 class Box<T> 같은 선언의 제네릭 정보가 시그니처(signature)라는 부가 정보로 남아서, 리플렉션으로 필드나 메서드, 상위 클래스의 선언된 제네릭 타입을 읽을 수 있습니다. 하지만 이것은 선언에 적힌 정보일 뿐입니다. 어떤 객체 하나가 Box<String>으로 만들어졌는지 Box<Integer>로 만들어졌는지는 객체에 기록되지 않으므로, 실행 중 검사를 이 정보에 기대서는 안 됩니다.
raw 타입은 소거된 구조를 그대로 드러낸다
컴파일러가 넣은 캐스트가 실패하지 않는다는 믿음에는 조건이 있습니다. 모든 값이 컴파일러의 검사를 거쳐 들어와야 한다는 것입니다. 제네릭 클래스와 타입 매개변수에서 본 raw 타입은 이 검사를 건너뛰는 길입니다. 타입 인자 없이 Box라고만 쓴 변수로는 set에 아무 값이나 넘길 수 있고, 소거된 Box 안에는 무엇이든 담기는 Object 필드가 있으니 실행 중에도 막히지 않습니다.
public final class RawGenericHeapPollutionFailure {
public static void main(String[] args) {
Box<String> strings = new Box<>();
strings.set("safe");
Box raw = strings;
raw.set(13);
String title = strings.get();
System.out.println(title);
}
private static final class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
}이 프로그램은 컴파일됩니다. 기본 설정의 javac는 다음 두 줄의 안내만 출력합니다.
Note: lab/RawGenericHeapPollutionFailure.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.실행하면 다음 예외로 끝납니다. 컴파일 단계에서는 안내만 나왔던 문제가 실행 중에는 ClassCastException이 되고, 스택 트레이스는 9번째 줄을 가리킵니다.
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
at RawGenericHeapPollutionFailure.main(RawGenericHeapPollutionFailure.java:9)실행 순서대로 따라가 보겠습니다. 3, 4번 줄에서 문자열 상자를 만들고 "safe"를 넣습니다. 6번 줄의 raw는 새 상자가 아니라 같은 상자를 가리키는 raw 타입 변수입니다. 7번 줄의 raw.set(13)은 타입 검사 없이 컴파일되고, 실행되는 set의 매개변수는 소거되어 Object이므로 Integer 13이 그대로 들어갑니다. 이제 strings는 여전히 Box<String> 타입인데 실제로는 Integer를 담고 있습니다. 이처럼 매개변수화 타입의 변수가 그 타입이 아닌 객체를 가리키게 되는 상태를 힙 오염(heap pollution)이라고 합니다.
예외는 오염이 일어난 7번 줄이 아니라 9번 줄에서 납니다. 9번 줄의 strings.get()에 컴파일러가 넣어 둔 String 캐스트가 Integer를 만나 실패하는 것입니다. 원인과 증상이 다른 줄에 있다는 점이 힙 오염을 찾기 어렵게 만듭니다. 이 예제에서는 두 줄 차이지만, 실제 프로그램에서는 오염시킨 코드와 값을 꺼내다 실패하는 코드가 서로 다른 클래스에 있을 수도 있습니다. 아래 그림에서 정상적인 상자 사용과 raw 타입으로 오염된 상자가 어느 단계에서 갈라지는지 확인해 보세요.
정상 타입 조회와 raw 별칭 이후의 실패
raw 별칭이 13을 쓰고 typed String 읽기의 컴파일러 삽입 cast가 실패합니다. 정상/오염 사례의 쓰기와 읽기 단계를 나눠 멀리 떨어진 실패의 원인을 드러냅니다.
정상 예제에서는 Box<String>과 Box<Integer>가 같은 런타임 클래스를 공유하면서 각각 타입에 맞는 값을 반환한다. 다른 raw 예제는 Integer 저장으로 String 약속을 깨고 조회 후 캐스팅에서 실패한다.
타입 소거 · 안전한 조회와 오염
정상 타입 조회와 raw 별칭 이후의 실패
서로 다른 두 프로그램입니다. 정상 Box 조회는 타입에 맞게 읽고, raw로 Integer를 쓴 프로그램은 String 조회의 캐스팅에서 실패합니다.
정상 제네릭 조회
- 저장·조회 경계
Box<String>→ 문자열Box<Integer>→ Integer각 변수의 타입에 맞게 조회- 관찰 결과
same-class=true제네릭 질문=13
raw 별칭으로 잘못 쓰기
- 저장·조회 경계
raw.set(13)에 unchecked 경고String title = strings.get()에서 삽입된 캐스팅 수행- 관찰 결과
- ClassCastExceptionInteger를 String으로 캐스팅할 수 없음
println(title)미도달
두 행은 서로 다른 프로그램의 Box 선언입니다. same-class=true는 첫 프로그램 안의 두 매개변수화 Box를 비교한 결과입니다.
이 예의 조건
- 정상과 raw 오염은 독립 원문이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 정상 타입 조회와 raw 별칭 이후의 실패 정상 타입 조회와 raw 별칭 이후의 실패 정상 예제에서는 Box<String>과 Box<Integer>가 같은 런타임 클래스를 공유하면서 각각 타입에 맞는 값을 반환한다. 다른 raw 예제는 Integer 저장으로 String 약속을 깨고 조회 후 캐스팅에서 실패한다. 타입 소거 · 안전한 조회와 오염 정상 타입 조회와 raw 별칭 이후의 실패 서로 다른 두 프로그램입니다. 정상 Box 조회는 타입에 맞게 읽고, raw로 Integer를 쓴 프로그램은 String 조회의 캐스팅에서 실패합니다. 정상 제네릭 조회 저장·조회 경계 Box<String> → 문자열 Box<Integer> → Integer 각 변수의 타입에 맞게 조회 관찰 결과 same-class=true 제네릭 질문=13 raw 별칭으로 잘못 쓰기 저장·조회 경계 raw.set(13) 에 unchecked 경고 String title = strings.get() 에서 삽입된 캐스팅 수행 관찰 결과 ClassCastException Integer를 String으로 캐스팅할 수 없음 println(title) 미도달 두 행은 서로 다른 프로그램의 Box 선언입니다. same-class=true 는 첫 프로그램 안의 두 매개변수화 Box를 비교한 결과입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원 타입 상태 정상 String/Integer Box와 오염할 String Box를 생성합니다. |
|
|
| raw 쓰기 오염 예제의 같은 박스에 Integer 13을 저장합니다. |
|
|
| 읽기 결과 정상은 출력하고 오염은 String cast에서 멈춥니다. |
|
|
정상 타입 조회와 raw 별칭 이후의 실패
raw 별칭이 13을 쓰고 typed String 읽기의 컴파일러 삽입 cast가 실패합니다. 정상/오염 사례의 쓰기와 읽기 단계를 나눠 멀리 떨어진 실패의 원인을 드러냅니다.
정상 예제에서는 Box<String>과 Box<Integer>가 같은 런타임 클래스를 공유하면서 각각 타입에 맞는 값을 반환한다. 다른 raw 예제는 Integer 저장으로 String 약속을 깨고 조회 후 캐스팅에서 실패한다.
타입 소거 · 안전한 조회와 오염
정상 타입 조회와 raw 별칭 이후의 실패
서로 다른 두 프로그램입니다. 정상 Box 조회는 타입에 맞게 읽고, raw로 Integer를 쓴 프로그램은 String 조회의 캐스팅에서 실패합니다.
정상 제네릭 조회
- 저장·조회 경계
Box<String>→ 문자열Box<Integer>→ Integer각 변수의 타입에 맞게 조회- 관찰 결과
same-class=true제네릭 질문=13
raw 별칭으로 잘못 쓰기
- 저장·조회 경계
raw.set(13)에 unchecked 경고String title = strings.get()에서 삽입된 캐스팅 수행- 관찰 결과
- ClassCastExceptionInteger를 String으로 캐스팅할 수 없음
println(title)미도달
두 행은 서로 다른 프로그램의 Box 선언입니다. same-class=true는 첫 프로그램 안의 두 매개변수화 Box를 비교한 결과입니다.
이 예의 조건
- 정상과 raw 오염은 독립 원문이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 정상 타입 조회와 raw 별칭 이후의 실패 정상 타입 조회와 raw 별칭 이후의 실패 정상 예제에서는 Box<String>과 Box<Integer>가 같은 런타임 클래스를 공유하면서 각각 타입에 맞는 값을 반환한다. 다른 raw 예제는 Integer 저장으로 String 약속을 깨고 조회 후 캐스팅에서 실패한다. 타입 소거 · 안전한 조회와 오염 정상 타입 조회와 raw 별칭 이후의 실패 서로 다른 두 프로그램입니다. 정상 Box 조회는 타입에 맞게 읽고, raw로 Integer를 쓴 프로그램은 String 조회의 캐스팅에서 실패합니다. 정상 제네릭 조회 저장·조회 경계 Box<String> → 문자열 Box<Integer> → Integer 각 변수의 타입에 맞게 조회 관찰 결과 same-class=true 제네릭 질문=13 raw 별칭으로 잘못 쓰기 저장·조회 경계 raw.set(13) 에 unchecked 경고 String title = strings.get() 에서 삽입된 캐스팅 수행 관찰 결과 ClassCastException Integer를 String으로 캐스팅할 수 없음 println(title) 미도달 두 행은 서로 다른 프로그램의 Box 선언입니다. same-class=true 는 첫 프로그램 안의 두 매개변수화 Box를 비교한 결과입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원 타입 상태 정상 String/Integer Box와 오염할 String Box를 생성합니다. |
|
|
| raw 쓰기 오염 예제의 같은 박스에 Integer 13을 저장합니다. |
|
|
| 읽기 결과 정상은 출력하고 오염은 String cast에서 멈춥니다. |
|
|
캐스트가 들어가는 곳은 'String이 필요한 곳'뿐입니다. 꺼낸 값을 Object any = strings.get();처럼 Object 변수에 담으면 캐스트가 필요 없으므로 예외 없이 13이 담깁니다. 그래서 오염된 값은 그것을 String으로 쓰려는 코드를 만날 때까지 조용히 퍼질 수 있습니다.
오염을 일찍 발견하는 방법
앞의 컴파일 메시지가 권하는 대로 -Xlint:unchecked를 붙여 다시 컴파일하면, 문제가 되는 줄을 정확히 짚어 줍니다. 이 옵션은 안내를 자세한 경고로 바꿔 줄 뿐이므로, 마지막 줄의 1 warning처럼 경고가 나와도 컴파일 자체는 성공합니다.
lab/RawGenericHeapPollutionFailure.java:7: warning: [unchecked] unchecked call to set(T) as a member of the raw type Box
raw.set(13);
^
where T is a type-variable:
T extends Object declared in class Box
1 warning'raw 타입 Box의 멤버인 set(T)를 검사 없이 호출했다'는 경고로, 예외가 난 9번 줄이 아니라 원인인 7번 줄을 가리킵니다. 빌드 설정에 -Xlint:unchecked -Werror를 함께 주면 이런 경고가 하나라도 있을 때 컴파일이 실패하므로, 힙 오염의 가능성이 실행 전에 드러납니다.
제네릭 이전에 만들어진 라이브러리처럼 raw 타입을 돌려주는 코드를 꼭 써야 할 때는, 그 값을 받는 메서드 한 곳에서 원소를 하나씩 검사해 새 제네릭 컬렉션으로 옮겨 담습니다. 다음 프로그램에서 legacyTitles가 그런 오래된 라이브러리 역할을 합니다.
import java.util.ArrayList;
import java.util.List;
public final class LegacyListAdapter {
public static void main(String[] args) {
List<String> titles = toStrings(legacyTitles(false));
System.out.println("titles=" + titles);
try {
toStrings(legacyTitles(true));
} catch (IllegalArgumentException error) {
System.out.println("rejected=" + error.getMessage());
}
}
// 제네릭 이전에 만들어진 라이브러리를 흉내 낸 메서드
@SuppressWarnings({"rawtypes", "unchecked"})
private static List legacyTitles(boolean broken) {
List list = new ArrayList();
list.add("java");
list.add("generic");
if (broken) {
list.add(13);
}
return list;
}
private static List<String> toStrings(List<?> source) {
List<String> result = new ArrayList<>();
for (Object element : source) {
if (!(element instanceof String text)) {
throw new IllegalArgumentException("not a String: " + element);
}
result.add(text);
}
return result;
}
}titles=[java, generic]
rejected=not a String: 13toStrings는 raw 목록을 List<?>로 받습니다. 앞 글에서 보았듯이 List<?>로는 null 말고는 아무것도 넣을 수 없으므로 목록을 더 망가뜨릴 걱정이 없고, 꺼낸 원소는 Object로 다룹니다. 원소마다 instanceof와 오버라이딩에서 배운 패턴 매칭으로 String인지 확인한 뒤 새 ArrayList<String>에 옮겨 담으므로, 이 메서드가 돌려준 목록에는 문자열만 들어 있다는 것이 확실합니다. 첫 줄처럼 정상적인 목록은 그대로 변환되고, 둘째 호출처럼 정수가 섞여 있으면 값을 꺼내 쓰는 먼 곳이 아니라 이 메서드 안에서 바로 거부됩니다. raw 타입을 이렇게 한 메서드 안에 가두면 나머지 코드는 평범한 제네릭 코드로 남습니다.
실행 중에 타입이 필요하면 직접 넘긴다
그래도 실행 중에 T를 검사하거나 T 객체를 만들어야 할 때가 있습니다. 소거된 T에서 정보를 되살릴 방법은 없으므로, T를 아는 쪽, 즉 객체를 만드는 쪽이 필요한 정보를 직접 넘겨줘야 합니다. 검사에는 Class<T> 객체를, 생성에는 Supplier<T>를 넘기는 것이 흔한 방법입니다.
import java.util.function.Supplier;
public final class ClassTokenAndFactory {
public static void main(String[] args) {
TypedFactory<Post> factory = new TypedFactory<>(
Post.class,
() -> new Post("untitled", 1));
System.out.println("matches=" + factory.matches(new Post("java", 40)));
System.out.println("created=" + factory.create());
}
private record TypedFactory<T>(Class<T> type, Supplier<T> supplier) {
boolean matches(Object value) { return type.isInstance(value); }
T cast(Object value) { return type.cast(value); }
T create() { return supplier.get(); }
}
private record Post(String title, int viewCount) { }
}matches=true
created=Post[title=untitled, viewCount=1]Post.class는 Post 클래스를 나타내는 Class<Post> 객체입니다. 이렇게 타입을 알리려고 넘기는 Class 객체를 흔히 타입 토큰(type token)이라고 부릅니다. type.isInstance(value)는 value instanceof Post와 같은 검사를 실행 중에 해 주므로, 첫 줄은 matches=true입니다. Supplier<T>는 java.util.function 패키지의 인터페이스로, 인자 없이 T를 돌려주는 get 메서드 하나를 가지고 있습니다. main이 넘긴 람다 () -> new Post("untitled", 1)이 그 구현이므로, create()가 supplier.get()을 부를 때마다 새 Post가 만들어져 둘째 줄이 출력됩니다.
ClassTokenAndFactory는 Post.class로 받은 Class 토큰을 matches에서 사용하고, Supplier로 받은 생성 코드를 create에서 사용한다. T에서 런타임 정보를 자동 복원하는 동작이 아니다.
런타임 전략 · 검사와 생성
호출자가 전달한 타입 검사와 생성 방법
Post.class가 검사할 클래스를, Supplier가 생성 방법을 전달합니다. 소거된 T에서 정보를 자동 복원하는 동작은 없습니다.
Post.classPost 객체를 검사하는 토큰
Post.class- 실제 호출되는 연산
factory.matches(...)type.isInstance(value)- 원문 결과
matches=true
Supplier<Post>untitled · 조회 수 1을 만드는 람다
Supplier<Post>- 실제 호출되는 연산
factory.create()supplier.get()- 원문 결과
created=Post[title=untitled, viewCount=1]
cast(Object)도 선언되어 있지만 이 main에서는 호출하지 않습니다. 검사할 클래스와 생성 방법은 호출자가 명시적으로 전달합니다.
두 정보 모두 컴파일러가 몰래 되살린 것이 아니라 main이 명시적으로 넘긴 것입니다. 생성 방법을 Supplier로 받으면 T에 인자 없는 생성자가 있어야 한다는 조건도 필요 없습니다. 검증이 들어간 생성자나 정적 팩터리 메서드를 람다로 감싸 넘기면 됩니다. Class 객체의 getDeclaredConstructor().newInstance()를 리플렉션으로 호출해 객체를 만드는 방법도 있지만, 그러면 인자 없는 생성자가 있어야 하고 없을 때의 오류도 실행 중에야 드러납니다.
Class 객체가 알려 주지 않는 것
TypedFactory에는 main이 호출하지 않은 cast 메서드도 있습니다. main에 몇 줄을 더해 isInstance와 cast가 어떻게 다른지 확인해 보면 다음과 같습니다.
| 호출 | 결과 |
|---|---|
factory.matches(null) | false |
factory.cast(null) == null | true |
factory.matches("text") | false |
factory.cast("text") | ClassCastException: Cannot cast java.lang.String to ClassTokenAndFactory$Post |
isInstance는 instanceof처럼 null에 대해 거짓을 돌려줍니다. 반면 cast는 일반 캐스트처럼 null을 그대로 통과시키고, 타입이 맞지 않는 값에만 예외를 던집니다. Class의 isInstance와 cast 설명도 이 두 동작을 따로 정의하고 있습니다. 그러니 '값이 null이 아니어야 한다'는 조건은 cast로 걸러지지 않으므로 따로 검사해야 합니다. 마찬가지로 타입 토큰은 객체의 클래스만 확인할 뿐, 제목이 비어 있지 않다거나 조회 수가 0 이상이라는 Post의 규칙까지 검사해 주지는 않습니다.
Class 객체에는 또 한 가지 한계가 있습니다. List<String>.class 같은 표현은 없고 List.class만 있으므로, Class 객체 하나로는 List<String>과 List<Integer>를 구분할 수 없습니다. JSON 라이브러리가 List<Post> 같은 중첩된 제네릭 타입을 다룰 때 Class 대신 별도의 타입 표현 클래스를 쓰는 것도 이 때문입니다. 그런 클래스는 객체에는 남지 않지만 필드나 상위 클래스 같은 선언에 적힌 제네릭 타입은 클래스 파일에 시그니처(signature)라는 부가 정보로 남는다는 점을 이용해, 그 정보를 리플렉션으로 읽습니다.
배열과 제네릭은 섞기 어렵다
new T[10]과 함께 new List<String>[3]도 generic array creation 오류가 납니다. 이유는 배열과 제네릭이 타입을 검사하는 시점이 반대이기 때문입니다. 제네릭 클래스와 타입 매개변수의 '더 깊이'에서 본 것처럼, 배열은 String[]을 Object[] 변수에 대입하는 것을 허용하는 대신 실행 중에 원소를 넣을 때마다 실제 원소 타입을 검사합니다. Object[] objects = new String[2];에 정수를 넣으면 ArrayStoreException이 나는 것이 그 검사입니다.
그런데 실행 중의 배열이 아는 원소 타입은 소거된 타입뿐입니다. List<String>[]을 만들 수 있다고 해도 실행 중에는 그냥 List[]이므로, 배열은 정수 목록이 들어오는 것을 막지 못합니다. 이를 직접 보려면 경고를 숨기고 우회해서 만들어야 합니다.
import java.util.List;
public final class GenericArrayHole {
public static void main(String[] args) {
@SuppressWarnings("unchecked")
List<String>[] titleLists = (List<String>[]) new List<?>[1];
Object[] objects = titleLists;
objects[0] = List.of(42);
System.out.println("stored=" + titleLists[0]);
String title = titleLists[0].get(0);
System.out.println(title);
}
}stored=[42]
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
at GenericArrayHole.main(GenericArrayHole.java:10)실행 중에도 타입 정보가 온전히 남는 타입을 실체화 가능 타입(reifiable type)이라고 합니다. String 같은 보통 클래스와 raw 타입 List, 그리고 List<?>처럼 타입 인자가 모두 비한정 와일드카드인 타입이 여기에 속하고, List<String>처럼 타입 인자가 지워지는 타입은 속하지 않습니다.
배열은 실체화 가능 타입으로만 만들 수 있으므로 new List<?>[1]은 만들 수 있고, 이것을 List<String>[]으로 캐스팅하면 컴파일은 됩니다. 8번 줄에서 Object[] 변수로 정수 목록을 넣을 때 배열의 검사는 '원소가 List인가'만 확인하므로 통과하고, 9번 줄은 그대로 [42]를 출력합니다. 결국 10번 줄에서 꺼낸 원소를 String으로 쓰려는 순간 예외가 납니다. 앞에서 본 힙 오염과 같은 모양입니다. 자바가 제네릭 배열 생성을 아예 금지하는 것은 이런 구멍이 생기지 않게 하려는 것입니다. 그래서 T 타입의 원소를 여러 개 보관하려면 배열 대신 List<T>를 쓰는 것이 기본입니다. 직접 목록을 구현할 때는 내부에 Object[]를 두고 꺼낼 때 캐스팅하는데, 이 방법은 제네릭 목록과 타입 안전성에서 다룹니다.
가변 인자도 배열이다
static <T> List<T> listOf(T... values)처럼 제네릭 타입의 가변 인자(varargs)를 쓰면, 호출할 때마다 인자들을 담을 배열이 만들어집니다. T가 List<String>이라면 금지된 제네릭 배열이 몰래 만들어지는 셈이라, -Xlint:unchecked로 컴파일하면 메서드 선언에는 Possible heap pollution from parameterized vararg type T가, listOf(a, a) 같은 호출에는 unchecked generic array creation for varargs parameter 경고가 나옵니다.
메서드가 그 배열에 다른 값을 넣지 않고 배열을 바깥으로 내보내지도 않는다면 오염이 생기지 않습니다. 이것을 확인한 경우에만 메서드에 @SafeVarargs를 붙여 경고를 없앱니다.
이 애노테이션은 재정의될 수 없는 메서드에만 붙일 수 있습니다. static 메서드, final 인스턴스 메서드, 생성자, 그리고 Java 9부터는 private 인스턴스 메서드까지입니다. 재정의 가능한 메서드에 붙이면 하위 클래스가 안전하지 않게 재정의할 수 있으므로 Invalid SafeVarargs annotation 오류가 납니다. 표준 라이브러리의 List.of(E...)나 Arrays.asList(T...)에도 이 애노테이션이 붙어 있습니다. 경고를 없애려고 확인 없이 붙여서는 안 되며, 자신이 없다면 List<T>를 인자로 받는 편이 낫습니다.
소거되면 같아지는 메서드
소거는 오버로딩에도 영향을 줍니다. 다음 두 메서드는 매개변수의 타입 인자가 다르니 서로 다른 메서드처럼 보이지만, 함께 선언할 수 없습니다.
static void process(List<String> titles) { }
static void process(List<Integer> counts) { }error: name clash: process(List<Integer>) and process(List<String>) have the same erasure타입 인자를 지우면 두 메서드는 모두 process(List)가 되어 클래스 파일 안에서 구분할 수 없기 때문입니다. 메시지의 have the same erasure가 '소거한 결과가 같다'는 뜻입니다. 이럴 때는 processTitles, processCounts처럼 역할이 드러나는 이름으로 나누거나, 원소 타입과 상관없이 같은 일을 한다면 제네릭 메서드 하나로 합칩니다.
이 장에서 배운 도구 고르기
이 장에서 배운 도구를 해결하려는 문제에 따라 정리하면 다음과 같습니다.
| 해결하려는 문제 | 쓸 도구 |
|---|---|
| 넣은 타입과 꺼내는 타입을 컴파일할 때 맞추기 | 타입 매개변수(제네릭 클래스, 제네릭 메서드) |
| 여러 매개변수화 타입을 한 매개변수로 받기 | 와일드카드와 PECS |
| 실행 중에 타입 검사하기 | Class 객체나 직접 정한 타입 식별자 |
| 실행 중에 T 객체 만들기 | Supplier나 팩터리 객체 |
| raw 타입을 돌려주는 오래된 코드 쓰기 | 한 메서드에서 검사하고 새 컬렉션으로 옮기기 |
| 타입마다 동작이 크게 다르기 | 제네릭 대신 다형성이나 타입별 클래스 |
제네릭은 타입 사이의 관계를 설명할 수 있을 때 가장 쓸모가 있습니다. <A, B, C, D>처럼 타입 매개변수가 많은데 서로 어떻게 연결되는지 한 문장으로 설명하기 어렵다면, 더 작은 객체나 이름 있는 중간 타입으로 나누는 편이 낫습니다. 캐스팅을 감추려고 @SuppressWarnings("unchecked")를 붙인 도우미 메서드를 만들기 전에, 그 캐스트가 왜 안전한지 먼저 설명할 수 있는지 따져 보세요.
정리
- 컴파일러는 타입 검사를 마친 뒤 타입 인자를 지웁니다. T는 상한으로, 상한이 없으면
Object로 바뀌고, 값을 꺼내는 곳에는 컴파일러가 캐스트를 넣습니다. 그래서Box<String>과Box<Integer>는 실행 중에 같은 클래스입니다. - 실행 중에 T를 모르므로
new T(),instanceof T,T.class,new T[]는 컴파일되지 않습니다. 클래스의 T는static문맥에서도 쓸 수 없습니다. - raw 타입으로 넣은 잘못된 값은 소거된
Object필드에 그대로 들어가 힙 오염을 일으키고, 나중에 컴파일러가 넣은 캐스트에서ClassCastException이 됩니다.-Xlint:unchecked로 원인이 된 줄을 찾고, 오래된 raw 코드는 한 메서드에서 검사해 옮겨 담습니다. - 실행 중에 타입 정보가 필요하면
Class<T>로 검사하고Supplier<T>로 생성하도록, 정보를 아는 쪽이 직접 넘깁니다. - 제네릭 배열은 만들 수 없고, 제네릭 가변 인자는 확인한 경우에만
@SafeVarargs를 붙입니다. 소거 결과가 같은 오버로딩은 선언할 수 없습니다.
연습 문제
문제 1: 장 전체를 묶는 유닛 도구 만들기
이 장에서 배운 세 도구를 한 프로그램에 모아 봅니다. name()과 hp()를 가진 BioUnit 인터페이스와, 이를 구현하면서 squad 번호도 가진 레코드 Marine(String name, int hp, int squad)를 만드세요. 그리고 다음 세 가지를 작성합니다.
- 체력이 큰 유닛을 같은 타입으로 돌려주는 제네릭 메서드
maxHp. 체력이 같으면 첫 번째 인자를 돌려줍니다. BioUnit의 하위 타입 한 가지만 싣는Shuttle<T extends BioUnit>.in(T)는 유닛 하나를 보관하고out()은 그 유닛을 T로 돌려줍니다.- 어떤
Shuttle이든 받아 실린 유닛을이름:체력형식으로 출력하는 와일드카드 메서드printUnit.
Marine("M1", 40, 1)과 Marine("M2", 55, 2) 가운데 maxHp로 고른 유닛을 Shuttle<Marine>에 싣고 printUnit으로 출력한 뒤, main에서 고른 유닛의 squad=번호를 출력하세요. 두 번째 유닛의 체력만 40으로 바꾸면 출력이 어떻게 달라지는지도 예상해 보세요.
해설 보기
public final class GenericUnitToolkitExercise {
public static void main(String[] args) {
Marine first = new Marine("M1", 40, 1);
Marine second = new Marine("M2", 55, 2);
Marine stronger = maxHp(first, second);
Shuttle<Marine> shuttle = new Shuttle<>();
shuttle.in(stronger);
printUnit(shuttle);
System.out.println("squad=" + stronger.squad());
}
private static <T extends BioUnit> T maxHp(T left, T right) {
return left.hp() >= right.hp() ? left : right;
}
private static void printUnit(Shuttle<? extends BioUnit> shuttle) {
BioUnit unit = shuttle.out();
System.out.println(unit.name() + ":" + unit.hp());
}
private static final class Shuttle<T extends BioUnit> {
private T unit;
void in(T unit) { this.unit = unit; }
T out() { return unit; }
}
private interface BioUnit { String name(); int hp(); }
private record Marine(String name, int hp, int squad) implements BioUnit { }
}M2:55
squad=2maxHp의 반환 타입이 T이므로, Marine 두 개를 넘기면 결과도 Marine입니다. 40 >= 55가 거짓이라 M2가 선택되고, main은 이것을 Marine 변수 stronger에 담습니다. Shuttle<Marine>은 in과 out이 모두 Marine을 다루므로 실은 유닛의 타입이 그대로 유지됩니다. 다만 maxHp 자체는 서로 다른 BioUnit 하위 타입을 함께 넘기는 호출을 막지 않습니다. 그럴 때는 타입 상한과 메서드 추론에서 본 것처럼 T가 공통 상위 타입으로 넓어질 뿐입니다.
printUnit은 꺼낸 유닛의 이름과 체력만 읽으므로 ? extends BioUnit이면 충분합니다. 꺼낸 값이 BioUnit으로만 보이니 squad()는 호출할 수 없지만 이 메서드에는 필요하지도 않습니다. squad=2는 printUnit이 아니라 Marine 타입인 stronger를 가진 main이 출력합니다. 같은 객체라도 어떤 타입의 변수로 보느냐에 따라 호출할 수 있는 메서드가 달라지는 것입니다. 아래 그림에서 세 도구가 같은 M2를 어떻게 넘겨받는지 확인해 보세요.
Marine 예제에서 이어지는 세 타입 계약
maxHp가 선택한 M2를 Shuttle에 저장/조회하고 부모 뷰와 squad 결과를 출력합니다. 같은 참조를 고정하여 각 호출에서 타입 계약이 유지되는 실제 흐름을 추적합니다.
Marine 두 개의 maxHp 호출은 Marine을 반환하고 Shuttle<Marine>은 그 값을 보관한다. printUnit은 BioUnit 관점으로 이름과 체력을 읽으며 squad 출력은 main의 Marine 참조에서 수행한다.
세 타입 계약 · 선택과 보관과 관찰
Marine 예제에서 이어지는 세 타입 계약
maxHp는 이 호출의 Marine을 반환하고 Shuttle<Marine>이 보관합니다. printer는 BioUnit 기능만 읽으며 squad는 main에서 출력합니다.
maxHp(first, second)- 타입 관계
Marine stronger- 실제로 하는 일
- 체력 40과 55 비교Marine
M2반환
Shuttle<Marine>- 타입 관계
in(Marine)out()의 반환은 Marine- 실제로 하는 일
- 선택한
M2를 보관·조회
printUnit(shuttle)- 타입 관계
Shuttle<? extends BioUnit>BioUnit unit- 실제로 하는 일
- 이름·체력 읽기
M2:55
squad=2는 printUnit이 아니라 main에서 stronger.squad()로 출력합니다.
이 예의 조건
- printUnit은 squad를 읽지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printUnit은 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printUnit이 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다. |
| 4 | M1 |
| 5 | 40 |
| 6 | 1 |
| 8 | 55 |
| 9 | 2 |
| 16 | printUnit의 부모 관찰 |
| 23 | printUnit은 BioUnit 기능만 읽음 · main의 Marine stronger로 고유 squad를 읽음 |
| 구분 | 내용 |
|---|---|
| 설명 | maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printUnit은 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printUnit이 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다. |
| 전체 읽기 정보 | Marine 예제에서 이어지는 세 타입 계약 Marine 예제에서 이어지는 세 타입 계약 Marine 두 개를 인수로 받은 maxHp 호출은 Marine을 반환하고 Shuttle<Marine>은 그 값을 보관한다. printUnit은 BioUnit 관점으로 이름과 체력을 읽으며 squad 출력은 main의 Marine 참조에서 수행한다. 세 타입 계약 · 선택과 보관과 관찰 Marine 예제에서 이어지는 세 타입 계약 maxHp는 이 호출의 Marine을 반환하고 Shuttle<Marine>이 보관합니다. printUnit은 BioUnit 기능만 읽으며 squad는 main에서 출력합니다. maxHp(first, second) 타입 관계 Marine stronger 실제로 하는 일 체력 40과 55 비교 Marine M2 반환 Shuttle<Marine> 타입 관계 in(Marine) out() 의 반환은 Marine 실제로 하는 일 선택한 M2 를 보관·조회 printUnit(shuttle) 타입 관계 Shuttle<? extends BioUnit> BioUnit unit 실제로 하는 일 이름·체력 읽기 M2:55 squad=2 는 printUnit 이 아니라 main에서 stronger.squad() 로 출력합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Marine 선택 M1/40/1과 M2/55/2에서 maxHp를 호출합니다. |
|
|
| 적재/조회 Shuttle<Marine>에 같은 stronger를 넣고 꺼냅니다. |
|
|
| 각 API 관찰 부모 printUnit 뒤 main의 고유 멤버를 읽습니다. |
|
|
Marine 예제에서 이어지는 세 타입 계약
maxHp가 선택한 M2를 Shuttle에 저장/조회하고 부모 뷰와 squad 결과를 출력합니다. 같은 참조를 고정하여 각 호출에서 타입 계약이 유지되는 실제 흐름을 추적합니다.
Marine 두 개의 maxHp 호출은 Marine을 반환하고 Shuttle<Marine>은 그 값을 보관한다. printUnit은 BioUnit 관점으로 이름과 체력을 읽으며 squad 출력은 main의 Marine 참조에서 수행한다.
세 타입 계약 · 선택과 보관과 관찰
Marine 예제에서 이어지는 세 타입 계약
maxHp는 이 호출의 Marine을 반환하고 Shuttle<Marine>이 보관합니다. printer는 BioUnit 기능만 읽으며 squad는 main에서 출력합니다.
maxHp(first, second)- 타입 관계
Marine stronger- 실제로 하는 일
- 체력 40과 55 비교Marine
M2반환
Shuttle<Marine>- 타입 관계
in(Marine)out()의 반환은 Marine- 실제로 하는 일
- 선택한
M2를 보관·조회
printUnit(shuttle)- 타입 관계
Shuttle<? extends BioUnit>BioUnit unit- 실제로 하는 일
- 이름·체력 읽기
M2:55
squad=2는 printUnit이 아니라 main에서 stronger.squad()로 출력합니다.
이 예의 조건
- printUnit은 squad를 읽지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printUnit은 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printUnit이 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다. |
| 4 | M1 |
| 5 | 40 |
| 6 | 1 |
| 8 | 55 |
| 9 | 2 |
| 16 | printUnit의 부모 관찰 |
| 23 | printUnit은 BioUnit 기능만 읽음 · main의 Marine stronger로 고유 squad를 읽음 |
| 구분 | 내용 |
|---|---|
| 설명 | maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printUnit은 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printUnit이 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다. |
| 전체 읽기 정보 | Marine 예제에서 이어지는 세 타입 계약 Marine 예제에서 이어지는 세 타입 계약 Marine 두 개를 인수로 받은 maxHp 호출은 Marine을 반환하고 Shuttle<Marine>은 그 값을 보관한다. printUnit은 BioUnit 관점으로 이름과 체력을 읽으며 squad 출력은 main의 Marine 참조에서 수행한다. 세 타입 계약 · 선택과 보관과 관찰 Marine 예제에서 이어지는 세 타입 계약 maxHp는 이 호출의 Marine을 반환하고 Shuttle<Marine>이 보관합니다. printUnit은 BioUnit 기능만 읽으며 squad는 main에서 출력합니다. maxHp(first, second) 타입 관계 Marine stronger 실제로 하는 일 체력 40과 55 비교 Marine M2 반환 Shuttle<Marine> 타입 관계 in(Marine) out() 의 반환은 Marine 실제로 하는 일 선택한 M2 를 보관·조회 printUnit(shuttle) 타입 관계 Shuttle<? extends BioUnit> BioUnit unit 실제로 하는 일 이름·체력 읽기 M2:55 squad=2 는 printUnit 이 아니라 main에서 stronger.squad() 로 출력합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Marine 선택 M1/40/1과 M2/55/2에서 maxHp를 호출합니다. |
|
|
| 적재/조회 Shuttle<Marine>에 같은 stronger를 넣고 꺼냅니다. |
|
|
| 각 API 관찰 부모 printUnit 뒤 main의 고유 멤버를 읽습니다. |
|
|
두 번째 유닛의 체력을 40으로 바꾸면 40 >= 40이 참이므로 첫 번째 인자인 M1이 선택되어 M1:40과 squad=1이 출력됩니다. 선택된 유닛이 바뀌어도 Shuttle의 타입이나 printUnit의 동작은 그대로입니다.
문제 2: 컴파일되는 줄 고르기
다음 클래스에서 컴파일되지 않는 줄을 모두 찾고, 각각 이유를 설명하세요. 주석으로 1부터 5까지 번호를 붙인 다섯 줄이 검토할 대상입니다. 이유를 쓸 때는 타입 소거 때문인지, 다른 규칙 때문인지도 구분해 보세요.
import java.util.ArrayList;
import java.util.List;
public class Repository<T> {
private T[] array = new T[10]; // 1
private List<T> items = new ArrayList<>(); // 2
boolean accepts(Object value) { return value instanceof T; } // 3
boolean isList(Object value) { return value instanceof List<?>; } // 4
private static T empty; // 5
}해설 보기
1번, 3번, 5번이 컴파일되지 않습니다. 1번은 실행 중의 배열이 원소 타입 T를 알 수 없으므로 generic array creation 오류가 납니다. 원소 여러 개는 2번처럼 List<T>에 담으면 되고, 2번은 컴파일됩니다. 3번은 실행 중에 T가 무엇인지 모르므로 Object cannot be safely cast to T 오류가 납니다. 실행 중에 검사해야 한다면 생성자에서 Class<T>를 받아 type.isInstance(value)로 바꿉니다.
4번은 컴파일됩니다. List<?>는 실체화 가능 타입이라, 실행 중에 'List인가'만 확인하면 되기 때문입니다. 5번은 소거가 아니라 static 때문에 막힙니다. 클래스의 T는 객체마다 정해지는데 static 필드는 모든 객체가 하나를 함께 쓰므로, non-static type variable T cannot be referenced from a static context 오류가 납니다.
문제 3: 예외가 나는 줄 찾기
다음 코드는 경고와 함께 컴파일됩니다. 실행하면 무엇이 출력되고 몇 번째 줄에서 어떤 예외가 나는지 예상하세요. 그리고 근본 원인이 되는 줄은 어디인지도 말하세요.
List<String> names = new ArrayList<>(); // 1
List raw = names; // 2
raw.add(42); // 3
Object first = names.get(0); // 4
System.out.println("first=" + first); // 5
String text = names.get(0); // 6
System.out.println("text=" + text); // 7해설 보기
first=42가 출력된 뒤, 6번 줄에서 ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String이 발생하고 7번 줄은 실행되지 않습니다.
3번 줄이 raw 타입 변수로 Integer를 넣어 names를 오염시킨 근본 원인입니다. 4번 줄은 꺼낸 값을 Object로 받으므로 컴파일러가 캐스트를 넣지 않고, 그래서 예외 없이 42가 담깁니다. 6번 줄은 String 변수에 담으므로 컴파일러가 넣은 String 캐스트가 실행되고, 여기서 실패합니다. -Xlint:unchecked로 컴파일하면 3번 줄에 unchecked call to add(E) as a member of the raw type List 경고가 나와 원인을 바로 찾을 수 있습니다.
다음 글
선형 자료구조 구현 장에서는 이 장에서 배운 제네릭으로 배열 기반 목록과 연결 목록을 직접 만듭니다. 내부에 Object[]를 두고 꺼낼 때 캐스팅하는 방식이 이 글에서 본 소거와 어떻게 이어지는지도 그 장에서 다시 만나게 됩니다.