ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 제네릭 - Item 31. 한정적 와일드카드를 사용해 API 유연성을 높이라
    Study/Effective Java 2023. 1. 10. 15:36

    한정적 와일드카드를 사용해 API 유연성을 높이라

    한정적 와일드카드 사용의 필요성

    매개변수화 타입은 불공변이다. 즉, 서로 다른 타입 Type1 Type2가 있을 때 List<Type1>은 List<Type2> 하위 타입도 상위 타입도 아니다.

    예를 들어, List<String>은 List<Object> 하위 타입이 아니다. 그 이유는 List<String>이 List<Object>가 하는 일을 제대로 수행하지 못하기 때문이다. 이는 리스코프 치환원칙을 위배한다.

    이렇게 매개변수화 타입은 불공변이지만, 때로는 유연하게 API를 설계해야 하는 상황이 있다. 대표적으로 public API를 설계할 때 필요하다.

     

    한정적 와일드카드를 사용하지 않은 경우(1) - pushAll()
    public class Stack<E> {
        private List<E> list = new ArrayList<>();
    
        public void pushAll(Iterable<E> src) {
            for (E e : src) {
                push(e);
            }
        }
    
        public void push(E e) {
            list.add(e);
        }
    }

    위 소스코드는 Stack 클래스에 일련의 원소를 스택에 넣는 pushAll() 메서드를 추가한 것이다. 이러한 pushAll() 메서드는 결함이 존재한다. Iterable의 원소 타입이 스택의 원소 타입과 일치하면 잘 작동하지만 타입이 다를 경우 매개변수화 타입이 불공변이기 때문에 컴파일 에러가 발생하게 된다.

     

    @Test
    void pushAllTest() {
        Stack<Number> stack = new Stack<>();
        List<Integer> intList = Arrays.asList(1, 2, 3, 4);
        
        stack.pushAll(intList);   // 컴파일 에러 발생
    }

    Stack<Number>로 선언한 후, pushAll(intList)을 호출하면 어떻게 될까? 여기서 intList는 Integer 타입이다. Integer는 Number의 하위 타입이니 잘 동작할 것으로 기대되지만, 컴파일 에러가 발생한다. 이러한 이유는 매개변수화 타입이 불공변이기 때문에 Integer가 Number의 하위 타입인 것은 전혀 상관이 없게 된다.

     

    그렇다면 이러한 문제를 어떻게 해결할 수 있을까? 바로, 한정적 와일드카드 타입을 이용하여 이를 해결할 수 있다.

     

    한정적 와일드카드를 사용한 경우(2) - pushAll()

    자바에서는 위와 같은 상황에 대처할 수 있는 한정적 와일드카드 타입이라는 특별한 매개변수화 타입을 지원한다.

     

    public void pushAll(Iterable<? extends E> src) {
        for (E e : src) {
            push(e);
        }
    }

    pushAll() 메서드의 입력 매개변수 타입은 E의 Iterable이 아니라, E의 하위 타입의 Iterable이어야 한다. 와일드카드 타입 Iterable<? extends E>은 E의 하위 타입의 Iterable이라는 뜻이며, 깔끔하게 컴파일되는 것을 확인할 수 있다.

     

    한정적 와일드카드를 사용하지 않은 경우(2) - popAll()
    public void popAll(Collection<E> dst) {
        while (!isEmpty()) {
            dst.add(pop());
        }
    }

    이번에는 pushAll() 메서드와 대응되는 popAll() 메서드를 작성해 보겠다. popAll() 메서드는 Stack안의 모든 원소를 주어진 컬렉션으로 옮겨 담는 역할을 수행한다.

    위 코드 역시 주어진 컬렉션의 원소 타입이 스택의 원소 타입과 일치한다면, 정상적으로 컴파일되고 문제없이 동작하게 된다.

     

    void popAllTest() {
        Stack<Number> stack = new Stack();
        List<Integer> intList = Arrays.asList(1, 2, 3, 4);
        stack.pushAll(intList);
        
        List<Object> objList = new ArrayList<>();
        
        stack.popAll(objList);   // 컴파일 에러 발생
    }

    하지만, Stack<Number>의 원소를 Object 용 컬렉션으로 옮기려 한다면, 앞에서 보았던 것처럼 컴파일 에러가 발생한다. 해당 에러 역시 제네릭이 불공변하기 때문에 발생한다. 이 문제점 또한 와일드카드 타입으로 해결이 가능하다.

     

    한정적 와일드카드를 사용한 경우(2) - popAll()
    public void popAll(Collection<? super E> dst) {
        while (!isEmpty()) {
            dst.add(pop());
        }
    }

    popAll() 메서드의 입력 매개변수의 타입은 E의 Collection이 아니라 E의 상위 타입의 Collection이어야 한다. 와일드카드 타입을 사용한 Collection<? super E>가 정확히 이런 의미이다.

     

    PECS 공식 : producer-extends, consumer-super

    앞선 예제에서 살펴보았듯이 유연성을 극대화하기 위해서는 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용해야 한다. 한편, 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 와일드카드 타입을 써도 좋을 게 없다. 타입을 정확히 지정해야 하는 상황으로, 이때는 와일드카드 타입을 쓰지 말아야 한다.

    매개변수화 타입 T가 생상자라면 <? extends T>를 사용하고, 소비자라면 <? super T>를 사용해야 한다.

     

    예시 01

    public Choose(Collection<T> choices)

    Choose()  생성자로 넘겨지는 choices 컬렉션은 T 타입의 값을 생산하고 저장하는 역할을 수행한다.

     

    public Choose(Collection<? extends T> choices) {
        choiceLise = new ArrayList<>(choices);
    }

    PECS 공식을 떠올리며, T를 확장하는 와일드카드 타입을 사용하는 방식으로 수정해 보았다.

    Chooser<Number>의 생성자에 List<Integer>를 넘기고 싶다고 가정해 보겠다. 수정 전 생성자로는 컴파일조차 되지 않겠지만, 한정적 와일드카드 타입으로 선언한 수정 후 생성자에서는 문제가 발생하지 않는다.

     

    예시 02

    public static <E> Set<E> union(Set<E> s1, Set<E> s2);

    union() 메서드는 두 개의 Set 자료구조를 매개변수로 받고 합집합에 대한 Set 자료구조를 반환하는 메서드이다.

     

    public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2)

    s1과 s2 모두 E의 생산자이니 PECS 공식에 따라 위와 같이 선언해야 한다.

     

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

    와일드카드와 관련해서 논의해야 할 주제가 남아있다. 타입 매개변수와 와일드카드에는 공통되는 부분이 있어서, 메서드를 정의할 때 둘 중 어느 것을 사용해도 괜찮을 때가 많다.

     

    public static <E> void swap(List<E> list, int i, int j);   // 타입 매개변수 방식
    
    public static void swap(List<?> list, int i, int j);   // 와일드카드 방식

    위 코드는 주어진 리스트에서 명시한 두 인덱스의 아이템들을 교환하는 swap() 메서드를 두 방식으로 정의한 것이다. 첫 번째는 비한정적 타입 매개변수를 사용했고, 두 번째는 비한정적 와일드카드를 사용했다.

     

    두 가지 중 어떤 방식이 나을까? public API라면 간단한 두 번째 방식이 더 낫다. 어떤 리스트든 이 메서드에 넘기면 명시한 인덱스의 원소들을 교환해 줄 것이다. 신경 써야 할 타입 매개변수도 없다.

     

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

    하지만, 두 번째 swap() 메서드에는 문제가 하나 있는데, 위와 같이 아주 직관적으로 구현한 코드가 컴파일되지 않는다는 것이다. 원인은 아시다시피 리스트 타입이 List<?>인데, List<?>에는 null 외에는 어떠한 값도 넣을 수 없기 때문이다.

     

    이러한 경우에는 도우미 메서드라는 것을 사용하면 된다. 도우미 메서드의 역할은 와일드카드 타입의 실제 타입을 알려주는 private 메서드라고 할 수 있다.

     

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

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

     

    최종 정리

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


    출처

     이펙티브 자바 Effective Java 3/E. 조슈아 블로크 저자(글) · 개앞맵시(이복연) 번역

    https://github.com/woowacourse-study/2022-effective-java/blob/main/05%EC%9E%A5/%EC%95%84%EC%9D%B4%ED%85%9C_31/%ED%95%9C%EC%A0%95%EC%A0%81%20%EC%99%80%EC%9D%BC%EB%93%9C%EC%B9%B4%EB%93%9C%EB%A5%BC%20%EC%82%AC%EC%9A%A9%ED%95%B4%20API%20%EC%9C%A0%EC%97%B0%EC%84%B1%EC%9D%84%20%EB%86%92%EC%9D%B4%EB%9D%BC.md

     

    728x90

    댓글

Designed by Tistory.