Effective Java : 아이템31. 한정적 와일드카드를 사용해 API 유연성을 높여라

    들어가기 전

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


    이 글의 요약

    • 제네릭 사용 시, 유연성을 제공하기 위해 한정적 와일드카드를 제공하자
      • Producer 타입 (매개변수로 전달된 Collection에서 객체를 꺼내쓰는 것)에는 <? extends E>를 사용한다
        • 상위 Collection에 하위 타입을 넣는 것은 문제가 없다. 왜냐하면 상위 타입 인터페이스로 하위 타입을 사용할 것이기 때문에 Runtime Exception이 발생하지 않는다.
      • Consumer 타입 (매개변수에서 특정 인스턴스의 Collection의 객체를 꺼내쓰는 경우)에는 <? super E>를 사용한다. 
        • 하위 타입 <E>의 인스턴스를 상위 타입 Collection에 넣는 것은 문제가 없다. 왜냐하면 상위 타입 인터페이스로 하위 타입 인스턴스를 사용할 것이기 때문이다.
    • 상위 클래스에서만 Comparator, Comparable을 구현하고, 제네릭으로 타입을 한정할 경우 <E extends Comparable<? super E>> 형태로 사용해야 컴파일 에러가 발생하지 않는다.
    • 와일드카드 <?> 하나만 사용하는 것은 헬퍼 메서드 등을 재정의 해야하기 때문에 비효율적이다. 와일드카드 <?> 하나만 사용할꺼라면 타입 한정 매개변수 <E>를 사용하고, <?>는 PECS 따를 때만 사용하는 것이 좋다.

    핵심 정리 1 : Chooser와 Union API 개선 (사진 붙이기)

    • 와일드 카드(?)를 사용할 때는 PECS 일 때만 사용하자.
    • PECS: Producer-Extends, Consumer-Super
    • Producer-Extends
      • Producer는 매개변수로 전달된 녀석들 중 Collection 객체이면서, 인스턴스에게 Collection의 객체를 공급하는 녀석을 의미한다.
      • Object의 Collection에 Number나 Integer를 넣어도 문제 없다.
      • Number의 Collection에 Integer를 넣어도 문제 없다.
      • 문제가 없는 이유는 추상화 된 Number, Object의 인터페이스만 사용할 것이기 때문이다.
    • Consumer-Super
      • Consumer는 현재 인스턴스가 가진 Collection에서 인스턴스를 꺼내 자신의 Collection에 담는 녀석들을 의미한다. 
      • 현재 인스턴스가 Collection<Integer>를 가지고 있다면, 여기서 인스턴스를 꺼내 Collection<Number>에 담을 수 있다. 
      • 현재 인스턴스가 Collection<Integer>를 가지고 있다면, 여기서 인스턴스를 꺼내 Collection<Object>에 담을 수 있다. 

     

     

    Producer - Extends 관련 살펴보기

    먼저 Stack 클래스의 전체 구조를 살펴보자. 클래스의 코드는 다음과 같이 작성되어 있다.

    // 와일드카드 타입을 이용해 대략 작업을 수행하는 메서드를 포함한 제네릭 스택 (181-183쪽)
    public class Stack<E> {
        private E[] 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 Oject[DEFAULT_INITIAL_CAPACITY]; // E[] -> Number[]로 변경
            this.elements = (E[])new Object[DEFAULT_INITIAL_CAPACITY];
        }
    
        public void push(E e) {
            ensureCapacity();
            elements[size++] = e;
        }
    
        public E pop() {
            if (size == 0)
                throw new EmptyStackException();
    
            E result = elements[--size];
            elements[size] = null; // 다 쓴 참조 해제
            return result;
        }
    
        public boolean isEmpty() {
            return size == 0;
        }
    
        private void ensureCapacity() {
            if (elements.length == size)
                elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    
        // 코드 31-1 와일드 카드 타입을 사용하지 않은 pushAll 메서드 - 결함이 있다! (181쪽)
        public void pushAll(Iterable<E> src) {
            for (E e : src) {
                push(e);
            }
        }
    
        // 코드 31-2 E 생산자 (Producer) 매개변수에 와일드카드 타입 적용 (182쪽)
        /*public void pushAll(Iterable<? extends E> src) {
            for (E e : src) {
                push(e);
            }
        }*/
    
        // 코드 31-3 와일드카드 타입을 사용하지 않은 popAll 메서드 - 결함이 있다! (183쪽)
        public void popAll(Collection<E> dst) {
            while (!isEmpty()) {
                dst.add(pop());
            }
        }
    
        // 코드 31-4 E 소비자(consumer) 매개변수에 와일드카드 타입 적용 (183쪽)
    /*    public void popAll(Collection<? super E> dst) {
            while (!isEmpty()) {
                dst.add(pop());
            }
        }*/
    
    
        public static void main(String[] args) {
            Stack<Number> numberStack = new Stack<>();
            Iterable<Integer> integers = Arrays.asList(3, 1, 4, 1, 5, 9);
            numberStack.pushAll(integers); // 컴파일 에러 발생.
        }
    }

    제네릭을 선언할 때는 <E>와 같은 형태로 작성되고 동작한다.

    • <E>는 어떤 타입이든 상관없이 단 하나의 타입만 지칭한다. 예를 들어 <E>가 String으로 지정되면, <E>에는 String을 제외한 어떠한 타입도 들어올 수 없다.
    • 제네릭은 불공변이기 때문에 List<String>, List<Object>는 서로 호환할 수 없다. 반면 배열은 String[], Object[]는 서로 호환이 가능하다. 

    따라서 제네릭을 사용한 Stack에서 Stack<Object>라고 선언하면, Stack에는 Object 타입만 들어올 수 있다. 하위 타입인 String, Integer는 전혀 들어올 수 없게 된다. 아래 코드에서 좀 더 자세히 살펴보자. 

    만약 Stack<Number>를 만들고, Stack<Number>에 Iterable<Integer>를 pushAll() 메서드를 이용해서 넣으려고 하면 위와 같이 컴파일 에러가 발생한다. pushAll() 메서드는 아래와 같다. 그러면 어째서 이런 컴파일 에러가 발생할까? 

    • Stack의 pushAll() 메서드는 <E> 타입을 받는데, <E>는 Number로 선언되어있다.
    • 제네릭은 불공변이므로 <Number>와 <Integer>는 서로 다른 타입이다.
    • 따라서 Stack<Number>의 pushAll() 메서드는 Iterable<Number> 타입만 받을 수 있기 때문에 컴파일 에러가 발생한다. 

    그렇다면 Stack<Number>에 하위타입인 Integer를 넣는 것이 정말로 위험한 행위일까? Stack<Number>에 Integer 객체를 넣는다면, 타입은 Integer가 들어가지만 Number 인터페이스를 기준으로 객체가 사용될 것이다. 추상화 된 타입인 Number를 사용한다고 하면 전혀 문제가 없다. 즉, Producer 기준으로 <E> 타입의 하위 타입은 공급되어도 문제가 없다. 그렇지만 제네릭 <E>는 단지 E만 들어올 수 있다. 여기서는 <Number>로 정의가 되었기 때문에 <Integer>가 들어올 수 없다. 

    이런 문제는 한정적 와일드카드를 이용하면 해결할 수 있다. 

     

    한정적 와일드카드 → Producer에 사용하라.

    제네릭을 이용할 때, 한정적 매개변수 타입과 한정적 와일드카드 타입은 서로 다른 역할을 한다.

    • 한정적 타입 : <E extends Number>
    • 한정적 와일드카드 : <? extends E>

    <? extends E>는 제네릭 E의 하위 클래스라면 무엇이든지 가능하다는 것을 암시한다. 예를 들어 <E>가 <Number>가 되었다면, <? extends E>는 Number의 하위 타입인 Integer, Double 등을 허용한다. 

    // 코드 31-2 E 생산자 (Producer) 매개변수에 와일드카드 타입 적용 (182쪽)
    public void pushAll(Iterable<? extends E> src) {
        for (E e : src) {
            push(e);
        }
    }

    위와 같이 pushAll() 메서드의 제네릭을 한정적 와일드카드로 변경하면, 이전에 발생하던 컴파일 에러가 발생하지 않는 것을 확인할 수 있다.

     

    Producer 란? 

    • 메서드의 매개변수들 중 일부는 자신이 가지고 있는 객체를 인스턴스의 내부 컨테이너에 적재하는 역할을 한다. 이런 매개변수들을 Producer라고 한다. 
    • Producer를 매개변수로 가지고 있는 메서드라면, Producer의 타입을 한정적 와일드카드를 이용해서 처리하면 좀 더 유연한 API 처리가 가능해진다. 일반적인 제네릭만 사용한다면 하나의 타입만 가능하지만, 한정적 와일드카드를 사용하면 상속관계까지 감안해서 받을 수 있는 API가 되기 때문이다. 

    아래 코드는 Producer 매개변수 'src'다. 만약 <E>가 <Number>라면 Integer, Double 등의 하위타입까지 전달 받을 수 있는 유연한 pushAll() API를 제공한다.

    // 코드 31-2 E 생산자 (Producer) 매개변수에 와일드카드 타입 적용 (182쪽)
    public void pushAll(Iterable<? extends E> src) {
        for (E e : src) {
            push(e);
        }
    }

     


    Consumer - Super 관련 살펴보기

    앞에서는 Producer라는 개념과 제네릭을 사용하는 방법을 살펴봤다. Consumer라는 개념도 존재한다. Consumer는 Producer와 반대 역할을 한다.

    • Consumer : 특정 인스턴스가 가진 컨테이너로부터 값을 꺼내오는 역할을 하는 객체 

    아래의 popAll()메서드에 전달된 Collection 객체는 Consumer의 한 예시가 될 수 있다. 

    // 코드 31-3 와일드카드 타입을 사용하지 않은 popAll 메서드 - 결함이 있다! (183쪽)
    public void popAll(Collection<E> dst) {
        while (!isEmpty()) {
            dst.add(pop());
        }
    }
    
    public static void main(String[] args) {
        // Consumer
        ArrayList<Object> objects = new ArrayList<>();
        numberStack.popAll(objects);
    }

    위 코드를 실행하려고 하면 아래 이미지에서 볼 수 있듯이 컴파일 에러가 발생한다. 이유를 살펴보면 다음과 같다.

    • Stack의 <E>는 <Number>로 설정되었다. 
    • popAll()은 Collection<E> 타입을 매개변수로 받는다. 
    • 전달된 인자는 <Object>다. 그런데 Collection<Object>와 Collection<Number>는 제네릭으로 불공변이기 때문에 서로 다르다. 

    위와 같은 이유로 호환될 수 없기 때문에 popAll() 메서드를 호출하는 시점에 컴파일 에러가 발생한다. 

    그런데 다시 한번 생각해보자. Collection<String>의 인자가 Collection<Object>로 전달되는 것이 정말로 위험할까? 

    • String 인스턴스가 Object로 전달되면, Object의 인스턴스만 사용할 것이다. 
    • 하위 타입의 인스턴스를 상위 타입의 Collection으로 전달하는 것은 더욱 추상화 된 인터페이스를 사용하겠다는 것이다. 

    즉, Collection의 하위 타입 제네릭을 Collection의 상위 타입 제네릭으로 Consume해도 문제가 없다는 것이 결론이다. 그러면 Collection 하위 타입 제네릭이 상위 타입 제네릭으로도 전달될 수 있도록 코드가 작성이 되어야 할텐데, 이 부분 역시 한정적 와일드카드를 사용해서 해결할 수 있다.

    // 코드 31-4 E 소비자(consumer) 매개변수에 와일드카드 타입 적용 (183쪽)
    public void popAll(Collection<? super E> dst) {
        while (!isEmpty()) {
            dst.add(pop());
        }
    }

    위에서 볼 수 있듯이 Collection<? super E>로 수정해준다. 이렇게 수정하면 E보다 상위 타입의 모든 Collection을 인자로 전달받을 수 있는 것을 의미한다. 만약 <E>가 <Integer>라면 <? super E>에는 Object, Number 등이 올 수 있다. 


    정리

    • 한정적 와일드 카드 (? extends E, ? super E)를 사용하면 제네릭을 사용하는 API의 유연성을 높일 수 있따.
    • 한정적 와일드 카드를 사용하는 규칙은 PECS다.
    • Producer 타입의 매개변수 일 때는 extends 한정적 와일드카드를 사용한다
    • Consumer 타입의 매개변수 일 때는 super 한정적 와일드카드를 사용한다. 

    핵심 정리 2 : Comparator와 Comparable은 소비자.  (그림)

    • Comparable을 직접 구현하지 않고, 직접 구현한 다른 타입을 확장한 타입을 지원하려면 와일드카드가 필요하다.
    • 예시
      • ScheduledFuture는 Delayed의 하위 클래스다.
      • ScheduledFuture는 Comparable을 직접 구현하지 않았지만, 그 상위 타입 (Delayed)이 구현하고 있다.  

    앞서서 PECS를 설명했다. Consumer는 Super 형식의 와일드카드를 사용해야한다는 것이다. 여기서 Consumer 이야기를 하는 것은 Comparator, Comparable도 Consumer로 동작하기 때문에 필요한 경우라면 Super 키워드의 와일드카드를 사용해야 한다는 것이다. 


    Producer와 extends 예제

    이전에 사용했던 Chooser 클래스를 살펴보자.

    • Choose 클래스는 생성자에서 특정 Collection을 받아서 Chooser 클래스의 필드로 추가한다. 
    • 생성자 메서드에 전달된 Collection은 Producer의 역할을 한다. 
    • Producer의 Collection 타입이 <T>로 되어있으면 단 하나의 타입만 들어올 수가 있다.

    이런 상태이기 때문에 만약 Chooser<Number>로 선언했으면, Number만 들어올 수 있다. 따라서 아래의 main() 메서드는 컴파일 에러가 발생한다. 좀 더 유연한 API를 제공하기 위해서 Collection<? extends Number>로 한정적 와일드카드를 제공하면 컴파일 에러가 제공된다. 

    Collection에는 하위 타입이 들어가도 되는데, 추상화 된 상위 타입의 인터페이스로만 동작할 것이기 때문에 안전하다. 따라서 Producer에서는 <? extends E> 등의 한정적 와일드카드로 처리하면 더 좋아진다. 

    // T 생산자 매개변수에 와일드카드 타입 적용
    public class Chooser<T> {
        private final List<T> choiceList;
        private final Random rnd = new Random();
    
        // 코드 31-5 T 생산자 매개변수에 와일드카드 타입 적용 (184쪽)
        public Chooser(Collection<? extends T> choices) {
            this.choiceList = new ArrayList<>(choices);
        }
    
        public T choose() {
            return this.choiceList.get(rnd.nextInt(choiceList.size()));
        }
    
        public static void main(String[] args) {
            List<Integer> intList = List.of(1, 2, 3, 4, 5, 6);
            Chooser<Number> chooser = new Chooser<>(intList); // 컴파일 에러 없애기 위해 한정적 와일드카드
            for (int i = 0; i < 10; i++) {
                Number choice = chooser.choose();
                System.out.println(choice);
            }
        }
    }

     


    Producer와 extends 또 다른 예제

    Union도 보자. Union 클래스의 union() 메서드를 살펴보자.

    • union() 메서드는 Set 타입 매개변수를 두 개 전달 받아서 두 개의 Set을 합쳐서 하나의 Set으로 만든다.
    • 각각의 Set 타입 매개변수는 Producer의 역할을 한다. 

    Producer의 역할을 하는 매개변수들이고, 하위 타입을 받아서 인스턴스로 가지고 있다가 상위 타입의 인터페이스로 처리해도 큰 문제가 없다. 따라서 Union 클래스의 union() 메서드도 한정적 와일드카드 (? extends E)를 이용해서 좀 더 유연한 API를 사용하면 좋다. 

    // 코드 30-2의 제네릭 union 메서드에 와일드카드 타입을 적용해 유연성을 높였다. (185-186쪽)
    public class Union {
        public static <E> Set<E> union(
                Set<? extends E> s1,
                Set<? extends E> s2) {
            Set<E> result = new HashSet(s1);
            result.addAll(s2);
            return result;
        }
    
        // 향상된 유연성을 확인해주는 맛보기 프로그램 (185쪽)
        public static void main(String[] args) {
            Set<Integer> integers = new HashSet<>();
            integers.add(1);
            integers.add(3);
            integers.add(5);
    
            Set<Double> doubles = new HashSet<>();
            doubles.add(2.0);
            doubles.add(4.0);
            doubles.add(6.0);
    
            Set<Number> numbers = union(integers, doubles);
    
            // 코드 31-6 자바 7까지는 명시적 타입 인수를 사용해야 한다. (186쪽)
            /*Set<Number> numbers = Union.<Number>union(integers, doubles);
            System.out.println(numbers);*/
        }
    }
    

     


    Consumer, Super → RecursiveTypeBound

    아래 RecursiveTypeBound는 제네릭을 선언할 때 재귀적인 타입 한정을 사용했다. 이 때, RecursiveTypeBound를 좀 더 유연하게 사용하려면 어떻게 해야할까?

    • 먼저 제공되는 Collection<E>는 인스턴스로부터 어떠한 객체를 가져가지 않고, 자신이 가지고 있는 객체를 제공한다. 따라서 Producer로 동작하기 때문에 한정적 와일드카드 타입 <? extends E>를 이용하면 좀 더 유연하게 사용할 수 있다.
    • Comparable 인터페이스는 다른 Collection에 있는 인스턴스를 꺼내 비교하는 인터페이스다. 따라서 Consumer로 동작하기 때문에 한정적 와일드카드 <? super E>를 이용해서 처리하는 것이 좋다.

    아래 코드를 살펴보자. 아래 코드에서 제네릭 타입을 선언하는 <E extends Comparable<? super E>>는 어떤 것을 의미하는 것일까? 이것은 E 타입의 상위 클래스 중에 Comparable을 구현한 클래스가 있고, 그 클래스의 비교 연산을 빌려서 처리하고 싶은 제네릭 타입에서 사용한다. 

    • Comparable<? super E>는 E 타입의 상위 클래스 중 Comparable을 구현한 타입을 의미한다.
    • E extends Comparable<? superE>는 결과적으로 E의 상위 타입 중 Comparable을 구현한 인터페이스가 있는 타입을 의미한다. 
    public static <E extends Comparable<? super E>> E max(List<? extends E> c) {

    위와 같이 구현하고, 아래와 같이 사용해 볼 수 있다.

    public class RecursiveTypeBound {
        // 코드 30-7 컬렉션에서 최대값을 반환한다. - 재귀적 타입 한정 사용 (179쪽)
        public static <E extends Comparable<? super E>> E max(List<? extends E> c) {
            if (c.isEmpty())
                throw new IllegalArgumentException("컬렉션이 비어 있습니다.");
    
            E result = null;
            for (E e : c)
                if (result == null || e.compareTo(result) > 0) // 값을 꺼내서 비교한다. 따라서 Consumer임.
                    result = Objects.requireNonNull(e);
    
            return result;
        }
    
        public static void main(String[] args) {
            List<String> argList = List.of("keesun", "whiteship");
            System.out.println(max(argList));
        }
    }

     

     

    Consumer, Super → 또 다른 예시

    부모 클래스에서 Comparable 인터페이스를 구현하고, 자식 클래스에서는 Comparable 인터페이스를 구현하지 않은 경우를 고려해보자. 이 때, 위 내용을 제네릭으로 전달받기 위해서는 Super 타입의 와일드카드를 사용해야한다. 

    기본적으로 Comparable은 Collection에 있는 객체를 Consume해서 사용하기 때문에 Consumer의 역할을 한다. 따라서 Consumer 역할을 하는 Comparable에게는 한정적 와일드카드인 '?'를 사용하면 좀 더 유연한 코드가 가능하다. 아래에서 차근차근히 살펴보자

     

    Box 클래스

    • Box 클래스는 Comparable<T> 클래스를 상속/구현한 클래스 T만 제네릭 타입으로 받을 수 있다. 
    • Box 클래스는 제네릭 타입 T에 대한 Comparable 인터페이스도 구현한다. 

    예를 들면 제네릭 T는 String 같은 것들이 가능하다.

    한 가지 의아한 부분은 anotherBox.value를 반환했을 때 전달되는 타입이 Comparable이라는 것이다. 왜 이렇게 동작하는 것일까?

    • anotherBox의 타입인 Box는 현재 Raw 타입이다. 따라서 어떤 타입인지 추론할 수 없다. 
    • 그런데 Box는 Comparable 인터페이스를 구현했다. 따라서 Box의 추상화된 인터페이스는 Comparable이다.
    • anotherBox의 타입을 T로 추론할 수 없기 때문에 Comparable 인터페이스를 반환한다. 

    또한 공급되는 anotherBox의 제네릭 타입 <T>와 현재 인스턴스 Box의 제네릭 타입 <T>가 같은 타입이라는 것도 추론할 수 없다. 이런 이유들 때문에 다음 두 가지 작업이 이루어진다.

    • anotherBox.value의 타입은 Comparable이 된다. 
    • anotherBox.value는 비교하기 위해 T로 타입 캐스팅 되어야 한다. 
    public class Box<T extends Comparable<T>> implements Comparable<Box<T>> {
    
        protected final T value;
    
        public Box(T value) {
            this.value = value;
        }
    
        @SuppressWarnings("unchecked")
        @Override
        public int compareTo(Box anotherBox) {
            // Comparable value1 = anotherBox.value; // 다른 Box 매개변수는 무슨 타입 T인지 모른다.
            return this.value.compareTo((T) anotherBox.value);
        }
    
        @Override
        public String toString() {
            return "Box{" +
                    "value=" + value +
                    '}';
        }
    }

     

    IntegerBox 클래스

    IntegerBox 클래스는 Box<Integer> 클래스를 상속받았다. 따라서 Box의 기능을 그대로 사용할 수 있다. 

     

    public class IntegerBox extends Box<Integer> {
    
        private final String message;
    
        public IntegerBox(int value, String message) {
            super(value);
            this.message = message;
        }
    
    
        @Override
        public String toString() {
            return "IntegerBox{" +
                    "message='" + message + '\'' +
                    ", value=" + value +
                    '}';
        }
    }

     

    사용하는 쪽

    사용하는 쪽 코드는 다음과 같다. Box, IntegerBox를 다시 한번 정리해보자.

    • Box는 IntegerBox보다 추상화되었으며, Comparable을 구현했다.
    • IntergerBox는 구체화되어있고, Comparable을 구현하고 있지 않다.

    이 때 List<IntegerBox>를 생성하고 이곳에 IntegerBox 인스턴스 두 개를 추가했다. 그리고 max()를 호출해보면 List<IntegerBox>에 대해 정상적으로 동작하는 것을 볼 수 있다. 어떻게 이렇게 동작할 수 있을까? 

    public class RecursiveTypeBound {
        // 코드 30-7 컬렉션에서 최대값을 반환한다. - 재귀적 타입 한정 사용 (179쪽)
        public static <E extends Comparable<E>> E max(List<? extends E> c) {
            if (c.isEmpty())
                throw new IllegalArgumentException("컬렉션이 비어 있습니다.");
    
            E result = null;
            for (E e : c)
                if (result == null || e.compareTo(result) > 0) // 값을 꺼내서 비교한다. 따라서 Consumer임.
                    result = Objects.requireNonNull(e);
    
            return result;
        }
    
        public static void main(String[] args) {
            List<String> argList = List.of("keesun", "whiteship");
            System.out.println(max(argList));
        }
    
        public static void hello() {
            List<IntegerBox> list = new ArrayList<>();
            list.add(new IntegerBox(1, "keesun"));
            list.add(new IntegerBox(2, "whiteship"));
    
            IntegerBox max = max(list); // 컴파일 에러 발생 -> 확장자 와일드카드 사용하지 않으면.
    
    
            System.out.println(max(list));
        }
    }
    

    제네릭을 떠나서 IntegerBox의 부모 클래스인 Box가 Comparable 인터페이스를 구현했다. 따라서 자식 클래스인 IntegerBox가 자연스럽게 부모 클래스 Box의 CompareTo() 메서드를 사용해서 비교한 것이다.

    그러면 한정적 와일드카드를 사용하면 어떤 부분이 해결되는 것일까? 만약 한정적 와일드카드를 사용하지 않았다면 아래 부분에서 컴파일 에러가 발생한다.

    먼저 List<? extends E>에 List<IntegerBox>가 전달되기 때문에 <E>는 최소한 IntegerBox 하위 타입이라는 것을 추론할 수 있다.

    • 반환 값은 <E> 타입을 반환해야하는데 위의 이유 때문에 IntegerBox가 된다. 그런데 <E> 타입은 다음 조건을 만족해야한다.
    • Comparable<E> : Comparable<IntegerBox>를 구현해야한다.

    그런데 여기서 IntegerBox는 Comparable<Box<>>를 상속받긴 하지만 Comparable<IntegerBox>를 구현하진 않았다. 따라서 컴파일 에러가 발생하는 것이다. 그렇다면 좀 더 유연하게 사용하기 위해서 제네릭 타입을 와일드카드를 이용해서 한정할 필요가 있다.

    public class RecursiveTypeBound {
        // 코드 30-7 컬렉션에서 최대값을 반환한다. - 재귀적 타입 한정 사용 (179쪽)
        public static <E extends Comparable<? super E>> E max(List<? extends E> c) {
            ...
        }
    }

    위와 같이 선언해주면 IntegerBox, Box 타입 한정에 잘 맞게 된다. 따라서 잘 한정되므로 컴파일 에러 없이 유연하게 사용할 수 있게 된다. 

    • Comparable<? super E>를 하면 IntegerBox의 부모 클래스의 Comparable을 구현한 인터페이스를 의미한다. 이 때, Box는 Comparable<Box>를 구현했다. 
    • E extends Comparable<? super E>는 부모 클래스의 Comparable을 구현한 상속 클래스를 의미한다. 
     

     


    핵심 정리 3 : 와일드카드 활용 팁

    • 결론부터 이야기할꺼면 <?> 단독으로만 사용하면 헬퍼 메서드를 사용해야만 해서 코드가 늘어난다. <?>는 반드시 한정형 와일드카드로만 사용하되, 와일드카드 단독으로만 사용하지 마라. 
    • 아래 내용은 책의 내용이지만, 실제로는 위의 내용이 더욱 현실성 있는 듯 함. 
    • 메서드 선언에 타입 매개변수가 한 번만 나오면 와일드카드로 대체하라
      • 한정적 타입이라면 한정적 와일드카드로
      • 비한정적 타입이라면 비한정적 와일드카드로 
    • 주의!
      • 비한정적 와일드카드(?)로 정의한 타입에는 null을 제외한 아무것도 넣을 수 없다. 

    책에서는 <E> 형식으로 매개변수에 주어진다면 <?>을 이용할 것을 권장한다. 그렇지만 그렇게 작성할 경우, 타입을 추론할 수 없어서 Collection인 경우 null만 넣을 수 있게 된다. 따라서 이 부분을 해결하기 위해서 헬퍼 메서드를 사용하는데, 이러면 유지보수 해야 할 코드의 양이 늘어나기 때문에 별로다. 따라서 책에서 권장하는 것과는 반대로 <?> 단독으로는 쓰지 말고 <? extends E> 같은 형태로만 사용하는 것이 더욱 실용적이다. 아래에서는 왜 별로인지에 대해서 하나씩 알아보고자 한다. 


    메서드에 타입 매개변수가 한 번만 나오면 와일드카드로 대체하라

    <?>는 불특정 타입을 의미하고, <E>는 특정 타입을 의미한다. 아래 swap 클래스를 살펴보자. Swap 클래스의 인자에 <E>만 존재하므로 <?>로 바꿀 수 있다. 따라서 해당 인자를 바꿔보자.

    public class Swap {
    
        public static <E> void swap(List<E> list, int i, int j) {
            list.set(i, list.set(j, list.get(i)));
        }
    }

    <E>를 <?>로 바꾸게 되면, list.set()에서 컴파일 에러가 발생한다. 왜 컴파일 에러가 발생할까?

    • List<?>에는 어떠한 타입이든 들어올 수 있다. 따라서 list.get()을 했을 때 얻어지는 타입은 ?다. 
    • list.set()을 할 때, 오른쪽 항에는 ? 타입이 들어와야한다. 

    두번째 줄에서 문제가 발생한다. 왜냐하면 list.get()을 해서 나오는 타입 <?>와 list.set()에서 원하는 타입 <?>가 같은 타입, 호환 가능한 타입인지 컴파일러는 추론할 수가 없기 때문이다. 따라서 컴파일 에러가 발생한다. 

    이 문제를 해결하기 위해서는 아래와 같이 헬퍼 메서드를 사용해주어야 한다. 

    • 헬퍼 메서드에서는 <E>로 타입 한정한다. 따라서 list.set()에서 원하는 타입 <E>와 list.get()에서 나오는 타입 <E>를 같은 타입으로 보장할 수 있다. 

    이렇게 사용한다면 위의 컴파일 에러를 해결할 수 있다. 그렇지만 swap() 메서드에 이미 선언되어 있던 메서드를 다시 한번 헬퍼 메서드에서 반복해서 사용하는 형태가 된다. 유지보수 관점에서 좋아보이지는 않는다. 

    따라서 <?>를 써서 동일한 메서드를 헬퍼 메서드로 빼서 다시 한번 타입 한정을 하며 유지보수 해야 할 코드를 늘리기 보다는, 기존 메서드에서 <?> 대신 <E>를 사용하는 것이 더욱 좋다.

     


    또 다른 단독 와일드카드 사용 예시

    먼저 이전에 설명했던 Box<T>, IntegerBox를 사용하고자 한다. IntegetBox는 Box<Integer>를 상속받은 녀석이다. 

    • Box<Integer>는 change()를 했을 때 컴파일 에러가 발생하지 않는다.
    • Box<?>는 change()를 했을 때 컴파일 에러가 발생한다. 

    왜 Box<?> 객체에는 change() 메서드를 정상적으로 호출할 수 없는 것일까?

    • IntegerBox는 Box<Integer> 타입인 것을 알고 있다. 하지만 좌항에 Box<?>가 선언되었기 때문에 Box에는 어떠한 타입도 들어올 수 있게 된다. 
    • 컴파일러는 이것을 보고 런타임에 들어올 타입 <?>와 Integer가 호환될 것이라고 보장하지 못한다. 왜냐하면 <?>는 그야말로 무슨 타입인지 모르는 타입이기 때문이다. 

    컴파일 에러의 내용은 Required Type이 'capture of ?'인데 Int를 넣었기 때문에 문제가 생겼다고 한다. ? 타입 중에 유일하게 타입을 한정 지을 수 있는 것은 null이다. 그 외에는 어떠한 인스턴스도 ? 타입을 한정 지을 수 없기 때문에 <?> 제네릭을 쓰는 인스턴스에는 값을 배정할 수가 없다. 

     

    와일드카드 하나만 사용했을 때의 장점

    와일드카드는 어떠한 타입이든 들어올 수 있다. 따라서 메서드 앞에 <E>가 어떤 녀석인지 정의를 하는 부분을 생략할 수 있기 때문에 코드가 조금 더 간결해 질 수 있다. 아래 화면에서 볼 수 있듯이 <E>는 흑백 처리가 되어있고, 삭제해도 아무 문제없이 동작한다. 

    대신 <?> 하나만 사용한다면, <?>는 어떠한 타입인지 절대로 추론할 수 없다. 따라서 List에서 값은 꺼낼 수 있지만, 타입을 추론할 수 없어서 null을 제외한 어떠한 값도 넣을 수 없다. 이 때문에 <?> 제네릭을 사용하는 객체에 값을 넣어주려고 한다면, 헬퍼 메서드를 하나 정의해서 넣어줘야하는 수고스러움이 있다. 앞서 봤던 이런 swapHelper() 메서드를 생성해야한다. 

    public static <E> void swap(List<?> list, int i, int j) {
        swapHelper(list, i, j);
        //list.set(i, list.set(j, list.get(i)));
    }
    
    // 와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 메서드
    private static <E> void swapHelper(List<E> list, int i, int j) {
        list.set(i, list.set(j, list.get(i)));
    }

     

    언제 <?> 하나만 사용하는 것이 좋을까? 

    • 제네릭 타입의 인스턴스를 꺼내서 출력만 해보는 거라면 굳이 구체적인 타입이 필요없다. 따라서 이런 경우라면 <?>를 사용해도 괜찮다.
    • 반면 제네릭 타입의 인스턴스의 값을 수정하거나 넣는 경우에는 타입 한정 매개변수 <E>를 사용하는 것이 좋다. <?> 는 오로지 PECS 원칙을 따를 때만 extends, super 키워드와 함께 사용하는 것을 추천한다. 

    댓글

    Designed by JB FACTORY