7. Reification
지금까지 Generic 기초 및 동작 원리에 대해서 살펴봤습니다.
Generic을 활용한 Type erasure 동작 방법을 정리하면 다음과 같습니다.
- 경계가 표시된 Type은 경계로 컴파일시에 치환된다
- Unbounded Wildcard(?) 타입은 Object 타입으로 치환된다
- 타입 안정성 보장을 위해서 캐스팅 연산을 수행한다.
- Generic Type에 대한 다형성 지원을 위해서 Bridge 메소드를 추가한다.
개인적으로 Generic이 도입은 Java를 사용함에 있어 많은 편의성을 가져다 줬지만, 이에 못지않은 불편함 또한 주었다고 생각합니다. 즉 Type erasure로 인하여 하위 호환성을 유지할 순 있었지만, 한참이 지난 지금도 런타임시 타입을 알지 못하는 불편함으로 인해 개발자가 코드 작성시에 주의를 기울여야합니다.
이번 포스팅에는 Type erasure에 의해서 런타임시 표현되는 Unknown Type 불일치로 인해 주의해야할 점을 다루도록 하겠습니다.
Reifiable Type
Java에서 Runtime시에 완전하게 오브젝트 정보를 표현할 수 있는 타입을 가르켜 Reifiable 하다고 합니다.
즉 Compile 단계에서 Type erasure에 의해서 지워지지 않는 타입 정보를 말합니다.
Reifiable 가능한 타입 정보는 다음과 같습니다.
- 원시 타입(int, double, float, byte 등등)
- Number, Integer와 같은 일반 클래스와 인터페이스 타입
- Unbounded Wildcard가 포함된 Parameterized Type(List<?>, ArrayList<?> 등)
- Raw Type(List, ArrayList, Map 등)
Unbounded Wildcard는 애초에 타입 정보를 전혀 명시하지 않았기 때문에, 컴파일시에 Type erasure 한다고 해도 잃을 정보가 없습니다. 따라서 Reifiable 하다고 볼 수 있으며, 컴파일시점에 Object로 치환됩니다.
그외 나머지는 Generic을 사용하지 않았기 때문에 타입 정보가 그대로 남습니다.
반면 아래 타입의 경우에는 Reifiable 하지 않습니다.
- Generic Type(T)
- Parameterized Type(List<Number, ArrayList<String> 등)
- 경계가 포함된 Parameterized Type(List<? extends Number>, List<? super String> 등)
Reifiable 하지 않은 경우에 대해서 차근차근 살펴보겠습니다.
우선 Generic Type은 Type erasure에 의하여 삭제되는 것은 이전의 포스팅들을 통해서 이해되셨을 것으로 생각합니다.
Parameterized Type의 경우에는 Type erasure에 의하여 컴파일시에 Raw Type으로 변경됩니다.
가령 List<String>, List<Integer>, List<List<String>>의 타입정보는 컴파일 시에 타입 안정성 검증 용도로 사용될 뿐 컴파일이 완료되면 Raw Type인 List로 치환됩니다. 따라서 Reifiable 하지 않습니다.
경계가 포함된 Parameterized Type 또한, 컴파일 시 타입 안정성 검증 용도로만 사용되므로 동일합니다.
그렇다면 위와같은 특성으로 인하여 주의해야할 점이 무엇이 있을까요?
Instance Test
public class Main {
public static void main(String[] args) {
Integer a = 3;
System.out.println(isIntegerType(a));
}
public static boolean isIntegerType(Object b){
return b instanceof Integer;
}
}
전달받은 타입이 Integer인지 확인하는 메소드를 구현한다고 가정합시다. 가장 심플한 방법은 instanceof 문법을 사용해서 Integer 임을 확인할 수 있습니다.
그럼 해당 메소드를 Generic 메소드로 변경해도 문제 없을까요?
public class Main {
public static void main(String[] args) {
Integer a = 3;
System.out.println(isIntegerType(a));
}
public static <T> boolean isIntegerType(T b){
return b instanceof Integer;
}
}
위 코드를 수행하면 문제없이 동작합니다. 그 이유는 Integer 타입은 런타임시에도 Integer 타입이기 때문입니다.
public class Main {
public static void main(String[] args) {
List<Integer> ints = Arrays.asList(1,2,3);
System.out.println(isIntegerList(ints));
}
public static <T> boolean isIntegerList(T b){
return b instanceof List<Integer>; //Compile 에러 발생
}
}
이번에는 List<Integer> 타입임을 확인하는 메소드를 생성한다고 가정해봅시다. 위와같이 만들었을 때 정상적으로 동작할까요?
우리는 런타임시에 List의 Type Argument가 Integer임을 확인하고 싶으나 컴파일 이후에는 Raw Type으로 변경됩니다. 따라서 List<Integer> 임을 확인할 수 없기 때문에 애초에 컴파일 에러가 발생합니다.
public class Main {
public static void main(String[] args) {
List<Integer> ints = Arrays.asList(1,2,3);
System.out.println(isList(ints));
}
public static <T> boolean isList(T b){
return b instanceof List;
}
}
물론 Raw Type 자체는 Reifiable 하기 때문에, 위와 같이 Raw type으로 명시하면 컴파일 에러도 발생하지 않고 정상적으로 동작합니다. 하지만 여전히 해당 List의 Element 타입이 Integer임은 알 수 없습니다.
public class Main {
public static void main(String[] args) {
List<Integer> ints = Arrays.asList(1,2,3);
System.out.println(isList(ints));
}
public static <T> boolean isList(T b){
return b instanceof List<?>;
}
}
가급적이면 Raw type을 사용하는 것은 지양하는 것이 좋으므로 Reifiable한 Wildcard를 사용하여 동일한 구문을 표현할 수 있습니다.
일반적으로 instanceof 문법을 사용한 코드는 code smell로 표현하여 잘 사용하지는 않지만, 만약 사용한다 할지라도 반드시 Reifiable한 타입에만 사용할 수 있다는점을 유의합시다.
캐스팅
public class Main {
public static void main(String[] args) {
List objs = Arrays.asList(1, 2, 3);
List<Integer> ints = (List<Integer>)objs;
}
}
위와 같이 Raw Type의 List에 Integer 타입 원소만 있다고 가정할 때, 이를 List<Integer> 타입으로 캐스팅할 수 있을까요?
실제로 어플리케이션으로 실행하면, 정상적으로 수행되는 것으로 보입니다.
하지만 자세히 보면, cast 관련 Warning이 발생된 것을 확인할 수 있습니다.
그 이유는 마찬가지로 컴파일이 완료된 이후에는 타입정보가 없어지는데, List<Integer> 타입으로의 안전한 캐스팅을 보장할 수 없기 때문입니다.
그렇다면 Instance Test에서는 에러를 발생시켰는데, 캐스팅에서는 Warning을 발생시킨 것일까요?
다음 예제코드를 보며, 그 이유를 알아보도록 하겠습니다.
public static List<Integer> getAsIntegerList(List<?> objs){
objs.forEach(o->{
if(!(o instanceof Integer))
throw new ClassCastException();
});
return (List<Integer>)objs;
}
List 오브젝트를 전달받아, 해당 List안에 있는 원소가 모두 Integer 일경우에는 List<Integer>로 캐스팅하여 반환하는 메소드를 구현한다고 가정해봅시다.
위 코드에는 이미 List 원소를 탐색하면서 Integer가 아닌 원소가 하나라도 존재한다면 ClassCastException이 발생합니다.
즉 List<Integer>로 캐스팅하는 코드에 도달한다면 이미 objs 리스트에는 모든 원소가 Integer 임이 반드시 보장됩니다.
따라서 java에서는 개발자가 의도적으로 위 코드와 같은 캐스팅을 수행할 수 있을 수도 있기때문에 에러가 아닌 Warning을 발생시킵니다.
따라서, 만약 위와같이 의도적으로 Non-Reifiable 타입에 대해서 캐스팅을 수행했다면 @SuppressWarnings("unchecked") 어노테이션을 붙여서 컴파일 시점에 Warning이 발생하지 않도록 처리해야합니다.
Exception
public class MyException<T> extends Exception{}
try {
....
}catch (MyException<Integer> e){
....
}
Throwable을 상속받는다면 Generic 클래스를 생성할 수 없습니다. 또한 catch 구문에서 Parameterized Type을 사용할 수 없습니다. 이는 이전의 설명과 마찬가지로 런타임 시점에 타입확인이 불가하기 때문입니다.