
자바의 제네릭(Generic)에 대한 이해를 기본으로 하는 글입니다.
Generic과 Wildcard의 이해 : 타입 안정성 및 다형성을 통해
제네릭에 대한 개념 및 사용법에 대해 확인할 수 있습니다.
제네릭(Generic)을 사용하다 보면, 많은 컴파일 에러와 마주치게 된다.
------------------------------------------------------------------
List<String> list = new ArrayList<Integer>(); // error
java: incompatible types: java.util.ArrayList<java.lang.Integer> cannot be converted to java.util.List<java.lang.String>
------------------------------------------------------------------
------------------------------------------------------------------
static T item; // error
...
static int compare(T t1, T t2){ ... } // error
java: non-static type variable T cannot be referenced from a static context
------------------------------------------------------------------
------------------------------------------------------------------
T[] tmpArr = new T[itemArr.length]; // error
java: generic array creation
------------------------------------------------------------------
이번 포스팅에서는 제네릭을 사용할 때의 주의점과
컴파일 단계에서 일어나는 에러의 원인을 알아보고자 한다.
다음과 같은 Box 제네릭 클래스를 만든다 가정하여 보자
class Box<T> {
static T item; // error
static int compare(T t1, T t2) { ... } // error
}
주석을 통해 표시한 것처럼, 위 코드는 컴파일 단계에서 에러가 발생한다.
static 멤버는 모든 객체에 대해 동일하게 동작해야하기에 타입 변수 T는 사용이 불가하다.
제네릭 클래스의 타입 변수의 경우 인스턴스를 생성하는 시점에
Box<String>인지 Box<Integer>인지 실제 타입이 결정되기 때문이다.
추가로 설명하자면 static 멤버는 타입 변수에 지정된 타입(=대입된 타입)의
종류에 관계없이 동일한 것이어야 하기 때문이다.
Box<Apple>().item과 Box<Grape>.item이 서로 다른 것이어서는 안된다는 뜻이다.
static 메서드의 경우에도 마찬가지로, 고정된 인자가 아닌
인스턴스 생성 이후에 결정되는 타입변수를 인자의 참조변수로 사용하기에 불가하다.
class Box<T> {
T[] itemArr; // ok
...
T[] toArray() {
T[] tmpArr = new T[itemArr.length]; // error
...
return tmpArr;
}
}
클래스의 멤버 변수로 제네릭 타입 배열을 만드는 것과,
메서드 실행 내에서 제네릭 타입 배열을 생성하는 것은 차이가 있다.
클래스 내 멤버 변수로 제네릭 타입 배열을 사용하는 경우
컴파일 시점에 제네릭 타입 T가 결정되며, 이를 기반으로
클래스 내부 멤버 변수인 itemArr를 대입된 타입에 따라 바이트 코드로 컴파일한다.
그러나 new 키워드를 통해 생성하는 배열의 경우
컴파일 시점이 아닌, 런타임에 실제 타입 정보가 필요한데
컴파일 시점에 사용된 제네릭 타입 변수 T의 정보는 런타임 시점에 존재하지 않는다.
이는 자바의 제네릭 타입 소거(Type Erasure) 개념을 통해 설명 가능하다.
컴파일러는 제네릭 타입을 이용해 소스파일 확인하여 필요한 곳에 형변환을 넣어준다.
이후 컴파일된 바이트코드(.class)에는 제네릭 타입에 대한 정보가 존재하지 않는다.
이를 제네릭 타입 소거(Type Erasure)라 한다.
왜 이렇게 제네릭 타입 소거 과정을 거칠까? 정보를 남겨두면 안될까?
이는 제네릭 도입 이전의 소스코드와의 호환성을 유지하기 위해서 도입되었다.
즉, 이전 원시타입을 사용하여 작성된 코드들을 위한 것인데List li = new ArrayList(); // 원시타입 사용언젠가 새로운 기능을 위해 하위 호환성을 포기하게 될 때가 올 수 있다.
뿐만 아니라 다음과 같은 이유로 원시타입을 사용하는 것은 문제를 야기한다.
- 컴파일 타임에 타입 체크가 되지 않아 런타임 에러 위험
- 불필요한 형변환 코드가 늘어남
- IDE와 컴파일러의 unchecked warning 발생
때문에 코드를 작성할 때 가능하면 원시 타입을 사용하지 않도록 하자
제네릭 타입 제거의 기본적인 과정은 다음과 같다.
1. 제네릭 타입의 경계(bound) 제거
제네릭 타입이 <T extends Fruit>로 되어있으면
타입변수 T는 Fruit로 치환된다.
또한 <T>인 경우는 Object로 치환된다. 이를 코드로 살펴보면
class Box<T extends Fruit> {
void add(T t) {
...
}
}
위 제네릭 클래스는 제네릭 타입 제거를 통해 아래처럼 치환된다.
class Box {
void add(Fruit t) {
...
}
}
2. 제네릭 타입 제거 이후, 타입 불일치 시 추가 형변환 진행
실제 자바 표준 라이브러리 내 List 인터페이스의 get() 메서드를 보면
public interface List<E> extends SequencedCollection<E> {
...
E get(int index);
...
위와 같이 작성되어 있는 것을 확인할 수 있다.
E 타입변수는 (1) 경계 제거 과정에서 Object 클래스로 변환된다.
따라서 컴파일 후 바이트코드에서 get()메서드의 반환타입은 Object가 되는데
// 소스 코드
List<String> list = new ArrayList<>();
String s = list.get(0);
// 컴파일 후 (컴파일러가 형변환 자동 삽입)
List list = new ArrayList();
String s = (String) list.get(0);
실제 생성된 List 내부에 들어간 객체의 타입과 불일치가 발생한다 : Obejct, String
따라서 String s = (String) list.get(0);처럼 적절한 형변환이 추가되는 것이다.
이러한 형변환 내용은 컴파일 타임에 추가하여, 이후 런타임 과정에서 실제 형변환이 일어난다.
추가로, 와일드 카드(Wildcard)가 있는 경우에는
다음처럼 적적한 타입으로 컴파일 과정에서 형변환이 추가된다.
static Juice makeJuice(FruitBox<? extends Fruit> box) {
String tmp = "";
for (Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
( 위 소스코드를 컴파일하여 아래와 같은 소스코드처럼 동작하는 바이트코드로 )
static Juice makeJuice(FruitBox box) {
String tmp = "";
Iterator it = box.getList().iterator();
while(it.hasNext()) {
tmp += (Fruit) it.next() + " ";
}
return new Juice(tmp);
}
<? extends Fruit> 제거
it.next()는 Object를 반환하기에 (Fruit) 형변환 코드 추가