Java로 주로 개발하다보면 Generic(제네릭) 이 사용되는 코드를 많이 볼 수 있습니다. 예를 들어, String 이라는 값을 Hold하고 있는 클래스를 하나 정의해보겠습니다.
public StringHolder {
private String value;
public String getValue() {
return this.value;
}
public void setValue(String value) {
this.value = value;
}
}
간단하게 생각하면 위와 같은 코드를 생각해볼 수 있습니다. 하지만 Integer 혹은 Custom한 Class를 Hold하고 있는 클래스를 동시에 정의해야한다면, 코드 중복으로 이어질 수 있습니다. Java의 Generic을 이용하면 Type을 파라미터처럼 사용할 수 있습니다.
public TypeHolder<T> {
private T value;
public T getValue() {
return this.value;
}
public void setValue(T value) {
this.value = value;
}
public static void main(String[] args) {
TypeHolder<String> typeHolder = new TypeHolder<>();
typeHolder.setValue("Hello World!");
String getValue = typeHolder.getValue();
System.out.println(getValue);
}
}
TypeHolder는 이제 전달되는 타입 T에 따라서, 별개의 코드로써 동작이 가능해집니다. Generic은 오버로딩, 오버라이딩처럼 다형성을 이용하는 또 다른 한가지 방법입니다.
와일드 카드
만약 메서드 파라미터로 제네릭의 타입을 굳이 제한할 필요가 없는 경우에는 와일드 카드 타입을 사용하면 됩니다. 만약 List의 모든 원소를 출력하고 싶다면 굳이 원소의 타입을 제한할 필요가 없습니다.
public void printList(List<?> list) {
for(int i = 0; i < list.size(); i++) {
System.out.println(String.valueOf(list.get(i)));
}
}
와일드 카드 타입에는 어떤 타입이든 올 수 있는데요, 단 List 내부 원소는 전부 해당 타입이어야 합니다. 실제로 List를 사용할때 제네릭을 명시하지않고 Raw Type으로 사용하면 List내에 어떤 타입이든 넣고 제거할 수 있습니다. 하지만 Raw Type은 Java의 하위 호환성을 위해서 남겨둔 기능일 뿐 사용해서는 안됩니다. Raw Type은 타입 안정성을 보장하지 않을 뿐더러, Runtime Error를 일으킬 수 있습니다.
굳이 사용한다면 Reflection을 이용할때만 사용합니다.
공변성? 반공변성? 불공변성?
Generic을 공부하다보면 나오는 개념이 바로 공변성 (covariant) 입니다.
공변성이란, T' => T 로 타입 변환이 가능할때, C<T'> => C<T>로 타입 변환이 가능하다.
이 조건을 만족하는 특성을 의미합니다.
예를 들어 Java에서 Integer는 Object 로 타입 변환이 가능하기 때문에 List<Integer>가 List<Object> 로 타입변환이 가능할때 공변성을 가진다고 볼 수 있습니다.
하지만 자바에서 제네릭은 기본적으로 불공변성입니다. 제네릭에 공변성을 부여하기 위해서는 extends 키워드를 사용해야합니다. 다음과 같이 사용합니다.
List<Integer> list = List.of(1, 2, 3, 4);
List<? extends Integer> list2 = list;
Type Erasure
지금까지 살펴본 Generic은 타입 안정성을 제공하는, Type Safe하게 코드를 짤 수 있게 도와주는 굉장히 좋은 문법입니다. Java의 Generic은 Java 1.5 버전에서 등장했으며, 그 이전까지는 Raw Type을 사용했습니다. 이로 인해, 자바는 제네릭을 만들때 하위 호환성 때문에 실제 Java Byte Code에는 Generic Type 정보를 남기지 않습니다. 즉 컴파일타임에 존재하는 타입 정보를, 런타임에는 잃어버립니다. 이를 비 구체화 타입 (non-reifiable type)이라고 하며. 런타임에 컴파일타임보다 더 정보를 적게 가진다는것을 의미합니다.
이 특징을 확인하는 가장 쉬운 방법은 컴파일한 자바 코드를 확인해서 타입 정보를 가지고 있는지 확인하는 것입니다. 위에서 봤던 TypeHolder 코드를 javac를 통해서 컴파일 후에, javap를 통해서 역어셈블 해보면 다음과 같은 결과가 나옵니다.
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package me.upperleaf.typetoken;
public class TypeHolder<T> {
private T value;
public TypeHolder() {
}
public T getValue() {
return this.value;
}
public void setValue(T var1) {
this.value = var1;
}
public static void main(String[] var0) {
TypeHolder var1 = new TypeHolder();
var1.setValue("Hello World!");
String var2 = (String)var1.getValue();
System.out.println(var2);
}
}
위 코드는 Java Byte Code를 역어셈블 했을때 얻게되는데, 이를 통해 알 수 있는점이 여러가지가 있습니다.
1. 자바에서는 런타임(클래스 파일)에 로컬변수의 제네릭 타입 정보만 소거된다는 것입니다. main 메서드 내에서 기존 자바 코드는 TypeHolder<String> 이였지만, 클래스 파일에서는 TypeHolder로써만 사용됩니다.
2. Method의 파라미터, Member Field, Class Declaration 부분에서는 제네릭 타입정보가 사라지지 않습니다.
다음에는 TypeToken과 Super Type Token에 대해서 포스팅 하겠습니다.