Java/The Java

Type Erasure Deep Dive

어썸오184 2022. 3. 6. 13:28
728x90
반응형

제네릭에 사용되는 타입 소거(type erasure)에 대해 알아보고 이로 인해 생기는 제약과 문제들에 대해 탐구합니다.

 

Type Erasure(타입 소거)란 제네릭 타입에 사용된 타입 정보를 컴파일 타임에만 사용하고 런타임에는 소거하는 것을 말한다.

타입 소거 규칙

자바 컴파일러는 아래의 규칙에 따라 타입 소거 과정을 실행한다.

  • 모든 타입 파라미터를 그들의 바운드나 Object 타입으로 교체한다.
  • 제네릭 타입을 제거한 후 타입이 일치하지 않으면 타입 캐스팅을 추가한다
  • 확장된(extended) 제네릭 타입의 다형성을 보존하기 위해 브릿지 메서드를 생성한다.

예를들어 아래처럼 언바운드 타입 T로 제네릭 클래스를 선언하고

컴파일을 한 뒤 바이트코드를 까보면 타입 변수가 사라지고 Object 타입으로 바뀐 것을 확인할 수 있다(첫번째 규칙).

타입 파라미터에 대한 정보가 없다.

만약 아래처럼 바운디드 타입이라면 해당 타입으로 변환한다.

public class Box<T extends Integer> {

    T load;

    T get() {
        return load;
    }
}
//타입 소거 후
public class Box {

    Integer load;

    Integer get() {
        return load;
    }
}

 

이러한 과정은 제네릭 메서드에서도 똑같이 일어난다.

// 배열 내에서 elem이 몇번 등장하는지를 카운트한다
public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray) {
        if (e.equals(elem)) {
            ++cnt;
        }
    }
    return cnt;
}

위의 제네릭 메서드는 컴파일 과정에서 아래와 같이 변한다

public static <T> int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (Object e : anArray) {
        if (e.equals(elem)) {
            ++cnt;
        }
    }
    return cnt;
}

만약 위의 메서드를 count(Integer[] anArray, Integer elem) 같은 형태로 호출하게되면 두번째 규칙에 따라 Object를 Integer로 캐스팅한다.

 

제네릭 타입의 클래스나 인터페이스를 상속하거나 구현할 때, 컴파일러는 필요에 따라 세번째 규칙에 의해 브릿지 메서드를 생성한다.

아래 두 개의 클래스를 살펴보자

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }

    @Override
    public void setData(Integer data) {
        super.setData(data);
    }
}

Node 는 제네릭 클래스이고 MyNode 는 제네릭 클래스를 상속받았다. 위 코드를 첫번째 규칙에 따라 타입 소거를 하면 아래와 같이 바뀐다.

public class Node {

    public Object data;

    public Node(Object data) { this.data = data; }

    public void setData(Object data) {
        this.data = data;
    }
}

public class MyNode extends Node {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        super.setData(data);
    }
}

위 코드를 보면 Node 클래스의 setData() 메서드와 MyNode 클래스의 setData() 메서드의 시그니처가 일치하지 않는다. 즉 MyNode의 setData 메서드가 Node의 setData 메서드를 오버라이드하지 못한다.

자바 컴파일러는 이러한 문제를 해결하고 다형성을 보존하기 위해 세번째 규칙에 따라 아래와같은 브릿지 메서드를 생성한다.

class MyNode extends Node {

    // 컴파일러에 의해 생성된 브릿지 메서드
    public void setData(Object data) {
        setData((Integer) data);
    }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }

    // ...
}

왜 컴파일 과정에서 타입 변수를 제거할까? 하위 호환성 때문이다. 제네릭이 도입된 JDK 5 이전의 코드를 깨뜨리지 않고 제네릭을 지원하기 위해 로 타입(Raw Type - 타입 파라미터를 사용하지 않는 제네릭 클래스) + 타입 소거 방법을 도입했다.

 

실체화 가능 타입과 실체화 불가능 타입

제네릭의 타입 소거로 인해 런타임에는 제네릭 클래스의 정확한 타입을 알지 못한다. 이를 실체화 불가능(non-reifiable)이라고 표현한다.

실체화 불가능 타입(non-reifiable types)는 런타임에 타입 정보를 정확히 알 수 없는 타입을 말한다. 제네릭 타입(unbounded wildcard 제외)이 대표적인 실체화 불가능 타입이다. 컴파일 타임에 타입이 소거되기 때문이다. 예를들어 List와 List은 실체화 불가능 타입으로 JVM은 런타임에 리스트 안에 들어있는 원소의 타입을 알지 못한다.

반대로 실체화 가능 타입(reifiable types)은 런타임에 타입 정보를 완전하게 제공할 수 있는 타입을 말한다. primitive type, non-generic type, raw type, unbound wildcard의 호출, 배열 등이 실체화 가능 타입에 속한다. JVM은 런타임에 이들 타입에 대한 정보를 정확하게 알 수 있다.

추가적으로 자바의 배열은 공변이면서 실체화 가능 타입이다. 제네릭은 공변이 아니면서 실체화 불가능 타입이다.

Object[] objArr1 = new String[1];   // 공변, 컴파일 가능
List<Object> objArr2 = new ArrayList<String>();  //불공변, 컴파일 불가능


이러한 특성 때문에 제네릭과 배열을 섞어쓰면 오류가 발생하기 쉽다. 추가적인 내용은 이펙티브 자바 3판의 아이템 28번을 읽어볼 것을 추천한다. 

 

제네릭의 제한 및 주의점

 

원시 타입을 사용할 수 없음

제네릭에 primitive 타입을 사용하지 못하는 이유는 타입 소거와 관련이 있다.

List<int> intList; //이런 선언은 불가능

제네릭 클래스는 타입 소거의 첫번째 규칙에 의해 타입 파라미터를 Object로 교체하는데 primitive 타입은 Object의 하위 타입이 아니기 때문에 제네릭에서 사용하는 것이 불가능하다.

 

제네릭 배열을 생성할 수 없음

아래의 코드들은 컴파일이 불가능하도록 설계되어있다.

E[] e = new E[];
List<E>[] list = new List<E>[];
List<String>[] strings = new List<>[];

이같은 코드를 허용할 경우 런타임에 ClassCastException이 발생할 수 있다. 무슨 말인지 자세히 알아보자.

예를 들어 아래 코드처럼 제네릭 배열을 생성하는 것을 허용한다고 해보자.

List<String>[] stringLists = new List<String>[1];  //1
List<Integer> intList = List.of(42);               //2
Object[] objects = stringLists;                    //3
objects[0] = intList;                              //4
String s = stringLists[0].get(0);                  //5

3번 라인을 보자. 배열은 공변이기 때문에 하위 타입의 배열을 할당하는 것이 문제가 되지 않는다(Object[] arr = new Long[1]; 이 가능한 것과 똑같다).

4번 라인의 경우도 문제가 발생하지 않는다. List<Integer> 타입은 타입 소거로 인해 런타임에 단순히 List가 된다. List<String>[] 인스턴스의 타입도 List[]가 되기 때문에 ArrayStoreException이 발생하지 않는다.

5번 라인에서 문제가 발생한다. stringLists의 내부에는 지금 List의 인스턴스가 들어있다. get 메서드를 호출할 때 타입 소거의 두 번째 규칙으로 인해 컴파일러가 자동으로 캐스팅 코드를 추가하게 되는데 이 때문에 런타임에는 ClassCastException이 발생하게 된다.

이같은 일을 막으로면 제네릭 배열을 생성하는 것을 애초에 막아야한다.

 

가변인수 사용에 주의

가변인수를 사용하면 가변인수를 담기 위한 배열이 생성된다. 이때 제네릭을 가변인수와 함께 사용하면 제네릭 배열이 생성된다. 문제는 제네릭 배열을 직접 생성하는 것은 컴파일 에러를 발생시키지만 가변인수를 통해 제네릭 배열을 사용하는 것은 경고만 발생할 뿐 컴파일 에러가 발생하지는 않는다는 것이다.

아래의 코드를 보자

import java.util.concurrent.ThreadLocalRandom;

public class Main {

    static <T> T[] pickTwo(T a, T b, T c) {
        switch (ThreadLocalRandom.current().nextInt(3)) {
            case 0: return toArray(a, b);
            case 1: return toArray(b, c);
            case 2: return toArray(a, c);
        }
        throw new IllegalArgumentException();
    }

    static <T> T[] toArray(T... args) {
        return args;
    }

    public static void main(String[] args) {
        String[] strings = pickTwo("pobi", "brown", "jason");
    }
}

파라미터로 String이 전달되고 반환도 String 배열이니까 문제가 없어보인다. 컴파일도 문제없이 잘 된다.

하지만 이 코드를 실행하면 런타임에 ClassCastExcpetion이 발생한다. 왜그럴까?

타입소거로 인해 타입파라미터 T는 Object로 바뀐다.

static Object[] pickTwo(Object a, Object b, Object c) {
    switch(ThreadLocalRandom.current().nextInt(3)) {
    case 0:
        return toArray(a, b);
    case 1:
        return toArray(b, c);
    case 2:
        return toArray(a, c);
    default:
        throw new IllegalArgumentException();
    }
}

따라서 pickTwo 메서드의 반환값은 Object 배열이기 때문에 컴파일러는 자동으로 캐스팅을 추가한다.

String[] strings = (String[])pickTwo("pobi", "brown", "jason");

Object는 String의 하위 타입이 아니기 때문에 캐스팅할 수 없다. 이 부분에서 에러가 발생하는 것이다.

이를 해결하려면 List를 쓰면된다.

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

public class Main {

    static <T> List<T> pickTwo(T a, T b, T c) {
        switch (ThreadLocalRandom.current().nextInt(3)) {
            case 0: return Arrays.asList(a, b);
            case 1: return Arrays.asList(b, c);
            case 2: return Arrays.asList(a, c);
        }
        throw new IllegalArgumentException();
    }

    public static void main(String[] args) {
        List<String> strings = pickTwo("pobi", "brown", "jason");
    }
}

 

참고자료

http://www.angelikalanger.com/GenericsFAQ/FAQSections/TechnicalDetails.html#FAQ404

https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

이펙티브 자바 3판

728x90
반응형