Effective Java : 아이템29. 완벽공략 43. 한정적 타입 매개변수

    들어가기 전

    이 글은 인프런 백기선님의 이펙티브 자바 강의를 복습하며 작성한 글입니다. 


    이 글의 요약

    • 한정적 타입 매개변수를 이용해서 특정한 타입만 제네릭에서 사용할 수 있다. 
    • <E>인 경우 E[]는 컴파일 이후 Object[]로 변한다. 
    • <E extends Number>인 경우 E[]는 컴파일 이후 Number[]로 변한다. 
    • <E extends Number>일 때 (E[]) new Object[]를 하게 되면 classCastException이 발생한다. 
      • Object[]을 Number[]로 캐스팅하는 것인데 이 형식의 다운 캐스팅은 불가능하다.
    • 한정적 타입 매개변수를 사용하고, 배열인 경우라면 E[]가 아니라 Number[] 형식으로 선언해준다. 
    • 한정적 타입 매개변수를 사용하면, 해당 매개변수의 최상위 타입이 제공하는 메서드는 사용 가능하다.
    • 한정적 타입 매개변수의 조건을 여러개로 사용할 수 있다. 
      • <E extends Number & Serializable)

    완벽 공략 43. 한정적 타입 매개변수 (Bounded Type Parameters)

    • 매개변수화 타입을 특정한 타입으로 한정짓고 싶을 때 사용할 수 있다.
      • <E extends Number>, 선언할 수 있는 제네릭 타입을 Number를 상속(extends) 했거나 구현한(implements)한 클래스로 제한한다. 
    • 한정적 타입 매개변수로 제한한 타입의 인스턴스를 만들거나, 메서드를 호출할 수도 있다.
      • <E extends Number>, Number 타입이 제공하는 메서드를 사용할 수 있다.
    • 한정적 타입 매개변수는 다수의 타입으로 한정 할 수 있다. 이 때 클래스 타입을 가장 먼저 선언해야 한다.
      • <E extends Number & Serializable>, 선언할 제네릭 타입은 Integer와 Number를 모두 상속 또는 구현한 타입이어야 한다. 

    이 글에서는 한정적 타입 매개변수의 사용 예시를 좀 더 자세히 살펴보고자 한다. 


    매개변수화 타입을 특정한 타입으로 한정짓고 싶을 때 사용할 수 있음. 

    매개변수화 타입을 특정한 타입으로 한정할 수 있는데, 주로 <E extends Number> 같은 형태로 사용한다. 그런데 이 타입 한정 제네릭이 바이트 코드에서 어떻게 동작하는지를 알아야 제네릭 사용 시 발생하는 문제들을 잘 해결할 수 있다. 먼저 아래 코드를 살펴보자. 아래의 Stack<E> 클래의 elements 필드를 살펴보자. 여기서 E[]은 컴파일되면 어떻게 될까?

    • 바이트 코드에서는 E[] → Object[]가 된다. 
    public class Stack<E> {
        private E[] elements;
        private int size = 0;
        ...
    }

    이것은 <E>에 어떠한 타입도 들어올 수 있어서 사실상 Object랑 같이 때문에 컴파일러가 Object[]로 컴파일 해주는 것이다. 이어서 인텔리제이를 이용해서 elemtnts가 정말로 Object[] 인지를 살펴본다. 

    • 바이트 코드를 보면 [LJava/lang/Object; elements로 선언되어있다. L은 배열인데, Object로 되어있다. 따라서 elements는 바이트코드에서 Object 배열로 선언되어있다. 

     


    타입 한정 하기

    제네릭 <E>를 사용하면 어떠한 타입이든 들어올 수 있는 상황이다. 그런데 Number 클래스와 관련된 클래스만 들어올 수 있도록 하고 싶다면 <E Extends Number>를 이용해서 타입을 한정 지을 수 있다. 그렇다면 <E Extends Number>로 선언하면 어떤 바이트코드가 생성될까? 

    public class Stack<E extends Number> {
        private E[] elements;
        private int size = 0;
        ...
    }

    아래 코드에서 elements의 타입이 무엇인지를 살펴보자.

    • elements의 타입은 [Ljava/lang/Number가 된다. 따라서 제네릭 E는 바이트 코드에서 Number[]로 변환된다.
    • 즉, 이 과정은 E[] elements → Number[] element로 변환시킨다. 

    배열을 생성하는 부분도 살펴보자. 배열을 생성하는 코드는 다음과 같다.

    @SuppressWarnings("unchecked")
    public Stack() {
        this.elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
    }

    배열 생성하는 부분의 바이트 코드는 다음과 같이 바뀐다. 아래에서 보면 이렇다.

    • ANEWARRAY : Object 타입의 배열을 생성한다
    • CHECKCAST : LJava/lang/Number. 생성된 Object[]를 Number[]로 변경한다. 

    Object[] 배열을 만들고, 이것을 Number[]로 캐스팅 한다. 이것은 (E[])를 이용해서 Object 배열을 변경하기 때문이다. 한정적 타입 매개변수를 쓰면 배열을 생성하는 과정에서도 캐스팅이 발생한다. 

    따라서 한정적 타입 매개변수를 사용하면 다음과 같이 제네릭의 타입이 변한다는 것을 알 수 있다. 

    • <T>인 경우, T는 Object로 치환된다. 왜냐하면 아무거나 들어와도 되기 때문이다
    • <T extends Numer>인 경우, T는 Number로 치환된다. 왜냐하면 Number 하위 타입만 들어와야하기 때문이다.

    코드 실행 시, ClassCastException 에러 발생

    아래 Main 메서드를 실행하면 에러가 발생한다. 어떤 에러가 발생할까?

    public class Stack<E extends Number> {
        private E[] elements;
    	
        @SuppressWarnings("unchecked")
        public Stack() {
            this.elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
        }
        
        ...
        // 코드 29-5 제네릭 Stack을 사용하는 맛보기 프로그램 (174쪽)
        public static void main(String[] args) {
            Stack<Integer> stack = new Stack<>(); // 여기서 에러 발생
            for (Integer arg : List.of(1, 2, 3))
                stack.push(arg);
            while (!stack.isEmpty())
                System.out.println(stack.pop());
        }
    
    }

    바로 아래와 같이 ClassCastException이 발생한다. 에러의 내용은 다음과 같다

    Exception in thread "main" java.lang.ClassCastException: class [Ljava.lang.Object; cannot be cast to class [Ljava.lang.Number; ([Ljava.lang.Object; and [Ljava.lang.Number; are in module java.base of loader 'bootstrap')
    	at com.example.effectivejava1.chapter29.bounded_type.Stack.<init>(Stack.java:22)
    	at com.example.effectivejava1.chapter29.bounded_type.Stack.main(Stack.java:49)

    이 에러는 Object[]를 Number[]로 다운 캐스팅 할 수 없기 때문에 발생한다. 그렇다면 어째서 이런 에러가 발생했을까? 이유는 제네릭과 바이트 코드에 감추어져 있다. 

    • E[]는 컴파일 이후에 Number[]이 된다. 
    • Object[]을 생성하고 E[]로 다운 캐스팅할 때는 Number[]로 바꾸는 것인데, Object → Number로는 다운 캐스팅이 안된다. 

    따라서 에러가 발생하는 것이다. 이전에 한정적 타입 매개변수를 사용하지 않았을 때는 문제가 없다. 왜냐하면 Object[] 배열을 생성했고, <E>는 모든 타입이 들어올 수 있기 때문에 사실상 Object였다. 따라서 E[]는 Object[] 였고, Object 배열을 생성 후 E[]로 형변환은 당연히 문제 없이 가능했다. 

    public class Stack<E extends Number> {
        // E[]는 컴파일 이후 Number[]로 변환됨.
        private E[] elements; 
        
        @SuppressWarnings("unchecked")
        public Stack() {
            // 처음 생성된 배열은 Object[] 
            // Object[]을 Number[] 으로 변환하는 작업. 그런데 다운 캐스팅이 안됨. 
            this.elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
        }
    ]

     


    제네릭에서 발생하는 ClassCastException 해결방법

    • 배열은 E[]가 아니라 Number[]로 선언해줘야한다. E[]로 선언하면 컴파일 이후에 Number[]로 변환된다. 그러니 처음부터 Number[]로 선언하는 것을 추천한다.
    • 배열을 생성할 때 Number[]로 바로 생성하고, 따로 형변환을 하지 않는다. 애초에 E[]는 컴파일 이후 Number[]가 되기 때문이다. Object[]을 생성하고 Number나 E로 타입 변환은 불가능하다. 다운캐스팅이기 때문이다. 
    • 데이터를 꺼낼 때 (pop 메서드)에서는 형변환을 해서 사용하도록 한다. Number에는 Integer, Double 등이 있을 수 있기 때문이다. 즉, E에는 Interger, Double 등이 올 수 있기 때문에 Number → Integer, Double 등으로 변환해서 사용한다.
    public class Stack<E extends Number> {
        // ClassCastException 발생
        // private E[] elements;
        private Number[] elements;
        private int size = 0;
        private static final int DEFAULT_INITIAL_CAPACITY = 16;
    
    
        // 코드 29-3 배열을 사용한 코드를 제네릭으로 만드는 방법 1 (172쪽)
        // 배열 elements는 push(E)로 넘어온 E 인스턴스만 담는다.
        // 따라서 타입 안정성을 보장하지만,
        // 이 배열의 런타임 타입은 E[]가 아닌 Object[]다!
    
        @SuppressWarnings("unchecked")
        public Stack() {
            // this.elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY]; // E[] -> Number[]로 변경
            this.elements = new Number[DEFAULT_INITIAL_CAPACITY];
        }
    
        ...
    
        public E pop() {
            if (size == 0)
                throw new EmptyStackException();
    
            @SuppressWarnings("unchecked") E result = (E) elements[--size];
            elements[size] = null; // 다 쓴 참조 해제
            return result;
        }
    
    	...
    
        // 코드 29-5 제네릭 Stack을 사용하는 맛보기 프로그램 (174쪽)
        public static void main(String[] args) {
            Stack<Integer> stack = new Stack<>();
            for (Integer arg : List.of(1, 2, 3))
                stack.push(arg);
            while (!stack.isEmpty())
                System.out.println(stack.pop());
        }
    }

     


    한정적 매개변수 타입의 인스턴스를 만들거나, 메서드를 호출할 수도 있다.

    E가 Number의 하위 클래스로 제네릭 선언되어있으면,  E 타입이 제공하는 인스턴스를 생성하거나 메서드를 호출할 수도 있다. 예를 들어 아래와 같이 e.byteValue()를 호출할 수 있다. 이것은 E의 최상위 클래스 Number에서 byteValue()라는 메서드를 제공해주기 때문이다. 

    public class Stack<E extends Number> {
    
        public void push(E e) {
            e.byteValue() // E 타입 인스턴스에서 제공하는 메서드.
            ensureCapacity();
            elements[size++] = e;
        }
    }

     


    다수의 타입으로 한정 할 수 있다. (클래스 타입을 가장 먼저 선언해야 한다.)

    제네릭을 쓸 때 다수의 타입으로 한정할 수도 있다. 예를 들면 아래와 같이 사용할 수 있다. 

    public class Stack<E extends Number & Serializable> {
    	...
    }

    아래의 내용은 Number / Serializable 클래스를 모두 구현 / 상속받은 클래스만으로 타입을 제한하겠다는 것이다. 

    댓글

    Designed by JB FACTORY