Study/이펙티브 자바 / / 2023. 12. 2. 14:31

[Effective Java 3E] 한정적 와일드카드를 사용해 API 유연성을 높이라

💥 개요

이전 포스팅에서 얘기했듯이 매개변수화 타입은 불공변이다.

즉 서로 다른  타입 Type1 , Type2가 있을 때 List<Type1>은 List<Type2>의 상위타입도 하위 타입도 아니다.

List<Object>에는 어떤 객체든 넣을 수 있지만 List<String>은 문자열만 넣을 수 있다. 즉, List<String>은 List<Object>가 하는 일을 제대로 수행하지 못하니 하위타입이 될 수 없다.(리스코프 치환 원칙에 어긋난다.)

때로는 불공변 방식보다 유연한 무언가가 필요하다. 

 

🛠️ 와일드 카드 타입 적용

이런 경우 와일드 카드 타입으로 해결이 가능하다.

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() {
        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);

        Collection<Object> objects = new ArrayList<>();
        numberStack.popAll(objects);

        System.out.println(objects);
    }
}

31-1에서 와일드카드 타입을 사용하지 않는 경우 불공변이기 때문에 문제가 발생하게 된다.

Stack은 Number로 선언되었는데, Integer를 push하려고 하기 때문이다.

그렇기에 31-2를 통해 와일드 카드 타입을 적용하여 Stack은 물론 기존에 이를 사용하는 클라이언트 코드도 말끔히 컴파일된다. 

이번에 31-3의 PopAll의 경우도 마찬가지다. Object 타입에 pop을 시도하기 때문에 31-1에서와 비슷하게 오류가 발생할 수 있다. 이 코드도 31-4처럼 와일드카드 타입을 적용해 해결이 가능하다.

즉 유연성을 극대화하려면 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용하라.

하지만 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 타입을 정확히 지정해야 하는 상황으로 와일드카드 타입을 써도 좋을 게 없다. 아래 공식을 통해 어떤 와일드카드 타입을 써야 하는지 기억해두자

 펙스(PECS) : producer-exdends, consumer-super

 

⚠️ 반환 타입에는 한정적 와일드카드 사용을 하면 안된다

public static <E> Set<E> union(Set<E> s1, Set<E> s2)와 같은 경우 반환 타입은 Set<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<E>(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);
    }
}

위 코드는 자바8부터 제대로 컴파일 된다. 자바 7까지는 타입 추론 능력이 강력하지 못해 오류가 발생한다. 해결할 수 있는 바법은 31-6처럼 명시적 타입 인수를 사용해 타입을 알려주면 된다.

 

🔍 언제 와일드카드를 써야할까?

// 와일드카드 타입을 사용해 재귀적 타입 한정을 다듬었다. (187쪽)
public class RecursiveTypeBound {
    public static <E extends Comparable<? super E>> E max(
        List<? extends E> list) {
        if (list.isEmpty())
            throw new IllegalArgumentException("빈 리스트");

        E result = null;
        for (E e : list)
            if (result == null || e.compareTo(result) > 0)
                result = e;

        return result;
    }

    public static void main(String[] args) {
        List<String> argList = Arrays.asList(args);
        System.out.println(max(argList));
    }
}

위 코드에서 max 메서드를 살펴보자 PECS 공식을 두번 적용했다.

입력 매개변수에서 E 인스턴스를 생산하므로 원래의 List<E>를 List<? extends E>로 수정했다.

타입 매개변수인 E는 난해하지만 이 책에서 와일드 타입 변수를 적용한 첫 번째 예다.

원래 선언에서는 E가 Comparable<E>를 확장한다고 정의했는데, 이때 Comparable<E>는 E 인스턴스를 소비한다.

그래서 매개변수화 타입 Comparable<E>를 한정적 와일드카드 타입인 Comparable<? super E>로 대체했다. 

Comparable은 언제나 소비자이므로 일반적으로 Comparable<E>보다 Comparable<? super E>가 낫다.

 

🎈 비한정적 타입 매개변수 vs 비한정적 와일드카드

swap 메소드의 두가지 선언이다. 어떤게 더 나을까?

public static <E> void swap(List<E> list, int i, int j) {
public static void swap(List<?> list, int i, int j) {

어떤게 더 나은 이유는 뭘까> public api라면 두번째가 간단해서 더 낫다. 어떤 리스트든 이 메소드에서 받아 명시한 인덱스의 원소들을 교환해 줄것이다. 신경써야할 타입 매개변수도 없다.

 

기본 규칙은 메소드 선언에 타입 매개변수가 한 번만 나오면 와일드 카드로 대체하라.

하지만 두번쨰 swap 선언은 문제가 있는데 List<?>의 경우 null 외에는 아무 값도 넣을 수 없다는 데 있다. 하지만 실제 타입을 알려주는 private 메소드를 도우미 메소드로 따로 작성해 활용하면 된다. 아래와 같은 코드로 해결이 가능하다.

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

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

    public static void main(String[] args) {
        // 첫 번째와 마지막 인수를 스왑한 후 결과 리스트를 출력한다.
        List<String> argList = Arrays.asList(args);
        swap(argList, 0, argList.size() - 1);
        System.out.println(argList);
    }
}

swapHelper는 리스트가 List<E>임을 알고 있다. 리스트에서 꺼낸 값의 타입은 항상 E고, E타입의 값이라면 이 리스트에 넣어도 안전함을 알고 있다.

다소 복잡해졌지만 이제 깔끔히 컴파일된다. 이상으로 swap 메소드 내부에서는 더 복잡한 제네릭 메서드를 이용했지만, 덕분에 외부에는 와일드카드 기반의 멋진 선언을 유지할 수 있었다. 즉 클라이언트는 복잡한 swapHelper의 존재를 모른 채 그 혜텍을 누린다.

도우미 메소드의 시그니처는 앞에서 "public API로 쓰기에는 너무 복잡하다"는 이유로 버렸던 첫 번째 swap 메소드의 시그니처와 완전히 똑같다.

 

조금 복잡하더라도 와일드카드 타입을 적용하면 API가 훨씬 유연해진다.
그러니 널리 쓰일 라이브러리를 작성한다면 반드시 와일드카드 타입을 적절히 사용해줘야 한다.
PECS 공식을 기억하자. 즉, 생산자(Producer)는 extends를 소비자(Consumer)는 super를 사용한다.
Comparable과 Comparator는 모두 소비자라는 사실을 잊지 말자.