지난 시간에는 제네릭의 종류와, 사용법에 대해서 알아봤습니다.
이번시간에는 제네릭에 대해 컴파일러가 수행하는 타입 소거와, 공식문서에서 제안하는 제네릭 사용시 제안 사항에 대해서 알아보겠습니다.
✅ Type Erasure(타입 소거)
타입 소거는 제네릭의 사용 방식이라기 보단 컴파일러가 제네릭을 대하는 방식이라고 생각할 수 있습니다.
제네릭은 컴파일시 엄격한 유형 검사를 제공합니다. 자바 컴파일러는 제네릭 구현을 위해 Type Erasure 기능을 적용합니다.
타입 소거에 대한 규칙은 다음과 같습니다.
- 제네릭 타입의 모든 타입 매개변수를 해당 경계 혹은 Object 타입으로 교체합니다.
- 제네릭 타입을 제거한 후 타입이 일치하지 않는다면 타입 캐스팅을 합니다.
- 확장된 제네릭 타입의 다형성을 보존하기 위해 브릿지 메서드를 생성합니다.
타입 소거는 매개변수화된 유형에 대해 새 클래스가 생성되지 않도록 보장하므로 제네릭은 런타임 오버헤드가 발생하지 않습니다.
제네릭 타입 소거(첫번째 규칙)
다음과 같은 Box 제네릭 클래스가 있습니다.
public class Box<T> {
private T value;
public T getValue() {
return value;
}
public void setValue(final T value) {
this.value = value;
}
}
Box 클래스를 컴파일하고 바이트코드를 살펴보면 타입 매개변수 T를 Object 타입으로 변경합니다.
// class version 55.0 (55)
// access flags 0x21
// signature <T:Ljava/lang/Object;>Ljava/lang/Object;
// declaration: chapter21/Box<T>
public class chapter21/Box {
// compiled from: Box.java
// access flags 0x2
// signature TT;
// declaration: value extends T
private Ljava/lang/Object; value
...
}
Box<T>
에서 T는 언바운드 타입이므로 default로 Object 클래스로 변경
됩니다. 만약 타입 매개변수가 바인딩된 경우(Upper or Lower) 해당 타입으로 바인딩합니다.
바운드된 타입인 Box를 아래와 같이 정의하면
public class Box<T extends Integer> {
private T value;
public T getValue() {
return value;
}
public void setValue(final T value) {
this.value = value;
}
}
다음과 같이 T의 타입이 Integer로 정의된 바이트코드를 볼 수 있습니다.
// class version 55.0 (55)
// access flags 0x21
// signature <T:Ljava/lang/Integer;>Ljava/lang/Object;
// declaration: chapter21/Box<T extends java.lang.Integer>
public class chapter21/Box {
// compiled from: Box.java
// access flags 0x2
// signature TT;
// declaration: value extends T
private Ljava/lang/Integer; value
...
}
위의 타입 소거의 과정은 제네릭 메서드에서도 적용됩니다.
public static <T> int count(T[] anArray, T elem) {
int cnt = 0;
for (T e : anArray)
if (e.equals(elem))
++cnt;
return cnt;
}
자바 컴파일러는 위의 코드에서 T 타입
이 언바운드 타입이므로 Object로 대체합니다.
public static int count(Object[] anArray, Object elem) {
int cnt = 0;
for (Object e : anArray)
if (e.equals(elem))
++cnt;
return cnt;
}
일치하지 않는 타입에 대한 타입 캐스팅(두번째 규칙)
다음과 같이 List<String>
타입의 리스트에 요소를 추가하고, 꺼내는 코드가 있습니다.
List<String> list = new ArrayList<>();
list.add("hello");
System.out.println(list.get(0));
해당 코드의 디컴파일된 클래스 파일을 살펴보면 다음과 같이 String으로 명시적인 형변환이 적용됩니다.
List<String> list = new ArrayList();
list.add("hello");
System.out.println((String)list.get(0));
Java generics type erasure: when and what happens? - Stackoverflow
타입 소거 및 브릿지 메서드의 효과(세번째 규칙)
타입 소거로 인해 예상치 못한 상황이 발생할 수 있습니다.
이런 상황에 대비해 자바 컴파일러는 브릿지 메서드라고 하는 합성 메서드를 생성하여 예상치 못한 상황을 해결합니다.
Node 클래스와 해당 클래스를 확장하는 MyNode 클래스가 다음과 같이 존재합니다.
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);
}
}
매개변수화된 클래스, 인터페이스(제네릭 타입)을 확장하여 컴파일할때 컴파일러는 타입 소거 프로세스의 일부로 브릿지 메서드를 생성합니다. 위에서 작성한 Node와 MyNode 클래스는 타입 소거에 의해 다음과 같은 형태로 변경됩니다.
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);
}
}
Node는 T가 언바운드 타입이므로 Object로 변경되고, MyNode는 Integer 타입으로 바운딩 되어 있으므로 Integer로 변환됩니다.
하지만 타입 소거 이후 Node, MyNode의 setData메서드는 메서드 시그니처가 달라지며 Node.setData를 MyNode.setData가 재정의하지 않게 됩니다.
이런 문제를 해결하기 위해 타입 소거 이후에도 제네릭 타입의 다형성을 유지하기 위한 브릿지 메서드를 컴파일러가 생성합니다.
public class MyNode extends Node {
public MyNode(Integer data) { super(data); }
// 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);
}
}
MyNode.setData(Object)는 MyNode.setData(Integer)로 위임하게 됩니다.
Reifiable Types
자바에서는 Runtime에 타입 정보를 완전히 사용할 수 있는 타입들을 Reifiable Types
라고 말합니다.
Reifiable Types의 종류는 다음과 같습니다.
- Primitive types
- Non-Generic Types
- Unbounded Wildcards
- Raw Types
Unbounded Wildcards는 애초에 타입에 대한 정보 자체가 Unknown으로 취급되므로 컴파일시에 Type Erasure 한다고 해도 잃을 정보가 없기에 Reifiable Types에 포함됩니다.
Non-Reifiable Types
Non-Reifiable Types는 컴파일 시 타입 소거에 의해 타입 정보가 제거된 유형을 말합니다.
따라서 런타임시에 타입 정보가 남아있지 않게 됩니다.
Non-Reifiable Types의 종류는 다음과 같습니다.
- Generic Type(T, Unbounded Wildcards 제외)
- Parameterized Type(
List<Number>
,ArrayList<String>
등) - 상한, 하한 경계가 있는 Parameterized Type(
List<? extends Nubmer>
등)
공식문서에는 Non-Reifiable Types을 사용할때 발생할 수 있는 힙 오염(Heap Pollution)에 대해서 설명하고 있으나 현재에서는 생략합니다. 자세한 내용은 해당 문서를 참조하시면 됩니다!
https://docs.oracle.com/javase/tutorial/java/generics/nonReifiableVarargsType.html
✅ Restrictions On Generics(제네릭 제한사항)
제네릭을 효과적으로 사용하기 위해서는 몇가지 제한사항을 고려하면서 사용해야 합니다!
원시 타입으로 제네릭 타입을 인스턴스화 할 수 없습니다.
원시 타입을 사용할 수 없는 이유로는 타입 소거와도 관련이 있습니다. 타입 소거의 첫번째 규칙은 제네릭 타입의 모든 타입 매개변수를 해당 경계 혹은 Object 타입으로 교체합니다.
입니다. 하지만 원시 타입은 Object 클래스의 하위 타입이 아니므로 제네릭에서 사용할 수 없습니다.
따라서 다음과 같은 코드는 불가능합니다.
List<int> ints = new ArrayList<>();
타입 매개변수를 이용해 인스턴스화 할 수 없습니다.
public static <E> void append(List<E> list) {
E elem = new E(); // compile-time error
list.add(elem);
}
위의 제네릭 메서드에서 타입 매개변수로 지정된 E
를 가지고 직접적으로 인스턴스화 시킬 수 없습니다.
인스턴스화가 정말 필요하다면 리플렉션을 이용해 할 수 있습니다.
public static <E> void append(List<E> list, Class<E> cls) throws Exception {
E elem = cls.newInstance(); // OK
list.add(elem);
}
클래스 변수로 타입 매개변수를 이용할 수 없다.
클래스의 정적 필드는 클래스의 모든 non-static 객체가 공유하는 클래스 레벨의 변수이므로 타입 매개변수 유형은 정적 필드로 둘 수 없습니다.
public class MobileDevice<T> {
private static T os; // 불가능
// ...
}
만약에 위의 코드가 허락된다면 다음 코드는 충돌하게 됩니다.
MobileDevice<Smartphone> phone = new MobileDevice<>();
MobileDevice<Pager> pager = new MobileDevice<>();
MobileDevice<TabletPC> pc = new MobileDevice<>();
// os 변수에는 어떤 타입이 할당 되어야 할지 알 수 없습니다.
Parameterized Type에 캐스트 혹은 instanceof 사용은 불가능하다.
자바 컴파일러는 제네릭 코드의 모든 타입 매개변수를 지웁니다. 따라서 런타임시 제네릭 타입에서 어떤 Parameterized Type이 사용되는지 알 수 없습니다. 따라서 다음 코드는 컴파일 에러가 발생합니다.
public static <E> void rtti(List<E> list) {
if (list instanceof ArrayList<Integer>) { // compile-time error
// ...
}
}
위의 메서드로 전달될 수 있는 매개변수의 parameterized type은 다양합니다. (ArrayList<Integer>
, ArrayList<String>
등) 런타임시 타입 매개변수를 추적하지 않으므로 ArrayList<Integer>
와 ArrayList<String>
은 런타임에서 구분할 수 없습니다.
위의 코드에서 최선의 변환은 Unbounded wildcards를 이용하는 방식입니다.
public static void rtti(List<?> list) {
if (list instanceof ArrayList<?>) { // compile-time error
// ...
}
}
일반적으로 parameterized type은 unbounded wildcards 매개변수를 이용하지 않는 한 형변환을 할 수 없습니다.
List<Integer> li = new ArrayList<>();
List<Number> ln = (List<Number>) li; // compile-time error
하지만 컴파일러가 타입 매개변수가 항상 유효하다는것을 알고 있는 몇몇 경우에는 형변환을 허용합니다.
List<String> l1 = new ArrayList<>();
ArrayList<String> l2 = (ArrayList<String>)l1; // OK
Parameterized Types 배열을 생성할 수 없습니다.
다음 코드는 컴파일 에러가 발생합니다.
List<Integer>[] arrayOfLists = new List<Integer>[2]; // compile-time error
일반적으로 특정 타입의 배열에 다른 타입의 요소가 삽입되면 컴파일 에러가 발생합니다.
Object[] strings = new String[2];
strings[0] = "hi"; // OK
strings[1] = 100; // An ArrayStoreException is thrown.
만약 매개변수화된 타입 배열을 생성할 수 있다고 가정하면 다음 코드도 가능해야 합니다.
Object[] stringLists = new List<String>[2];
stringLists[0] = new ArrayList<String>();
stringLists[1] = new ArrayList<Integer>(); // ArrayStoreException이 발생해야 함
하지만 stringLists[1] = new ArrayList<Integer>();
하지만 위의 코드는 해당 요소를 직접 꺼내어 다른 액세스 하는 작업을 하지 않는 이상 런타임에서 감지되지 못할것 입니다.
Parameterized Types를 예외에서 이용할 수 없습니다.
제네릭 클래스는 Throwable
클래스를 상속받을 수 없습니다.
class MathException<T> extends Exception { /* ... */ } // compile-time error
class MathException<T> extends Throwable { /* ... */ } // compile-time error
또한 제네릭 메서드에서도 타입 매개변수의 인스턴스를 catch할 수 없습니다.
public static <T extends Exception, J> void execute(List<J> jobs) {
try {
for (J job : jobs)
// ...
} catch (T e) { // compile-time error
// ...
}
}
하지만 throw 절에서는 타입 매개변수를 이용할 수 있습니다.
class Parse<T extends Exception> {
public void parse(File file) throw T {...} // 가능!
타입 소거 이후 동일한 메서드 시그니처를 갖는 메서드를 2개 이상 둘 수 없다(메서드 오버로드 안됨)
만약 컴파일러에 의한 타입 소거가 이루어졌을때 동일한 메서드 시그니처를 갖는 2개의 메서드가 있다며 컴파일 에러가 발생합니다.
public class Example {
public void print(Set<String> strSet) { }
public void print(Set<Integer> intSet) { }
}
✅ 마치면서
자바에서 제네릭을 왜 도입시켰을까를 조금 이해할 수 있었습니다.
제네릭은 강력한 타입 검사를 지원해주지만, 잘못 알고 사용하면 양날의 검과 같은 문법인것 같습니다.
처음에는 조금 많이 막막했던 제네릭이 어느정도 형태가 보이는것 같아서 뿌듯하기도 하네요 ㅎㅎ
글을 읽으시면서 잘못된 부분이나 수정해야 할 부분이 있다면 언제든지 댓글로 알려주시면 감사하겠습니다!!