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

제네릭, 여섯 번째 아이템

Posted by SungBeom on April 08, 2020 · 7 mins read

한정적 와일드카드 타입

매개변수화 타입은 불공변(invariant)이다. 하지만 때로는 불공변(invariant) 방식보다 유연한 무언가가 필요하다. 자바는 해결책으로 한정적 와일드카드 타입이라는 특별한 매개변수화 타입을 지원한다. 먼저 와일드카드 타입을 사용하지 않는 메소드를 보자.

1
2
3
4
5
// 와일드카드 타입을 사용하지 않는 pushAll 메소드 - 결함이 있다!
public void pushAll(Iterable<E> src) {
    for (E e : src)
        push(e);
}
cs

이 메소드는 깨끗이 컴파일되지만 완벽하지 않다. Iterable src의 원소 타입이 스택의 원소 타입과 일치하면 잘 작동하지만, Stack<Number>로 선언한 후 Inter 타입인 intVal을 이용해 pushAll(intVal)을 호출하면 오류 메시지가 뜬다. 매개변수화 타입이 불공변이기 때문이다. 이번에는 와일드카드 타입을 사용하는 pushAll 메소드를 보자.

1
2
3
4
5
// 생산자(producer) 매개변수에 와일드카드 타입 적용
public void pushAll(Iterable<extends E> src) {
    for (E e : src)
        push(e);
}
cs

이번 수정으로 Stack은 물론 이를 사용하는 클라이언트 코드도 말끔히 컴파일 된다. Stack과 클라이언트 모두 깔끔히 컴파일되었다는 건 모든 것이 타입 안전하다는 뜻이다.

이제 pushAll과 짝을 이루는 popAll 메소드를 보자. 먼저 와일드카드 타입을 사용하지 않는 메소드다.

1
2
3
4
5
// 와일드카드 타입을 사용하지 않는 popAll 메소드 - 결함이 있다!
public void popAll(Collection<E> dst) {
    while (!isEmpty())
        dst.add(pop());
}
cs

이번에도 주어진 컬렉션의 원소 타입이 스택의 원소 타입과 일치한다면 말끔히 컴파일되고 문제없이 동작한다. 하지만 Stack<Number>의 원소를 Object용 컬렉션으로 옮기려 하면 와일드타입을 사용하지 않는 pushAll과 비슷한 오류가 발생한다. pushAll 메소드와 동일하게 와일드카드 타입으로 해결할 수 있는데, 이번에는 popAll의 입력 매개변수의 타입이 'E의 Collection'이 아니라 'E의 상위 타입의 Collection'이어야 한다. 이를 popAll에 적용해보자.

1
2
3
4
5
// 소비자(consumer) 매개변수에 와일드카드 타입 
public void popAll(Collection<super E> dst) {
    while (!isEmpty())
        dst.add(pop());
}
cs

이제 Stack과 클라이언트 코드 모두 말끔히 컴파일 된다. 메시지는 분명한데, 유연성을 극대화하려면 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용하라. 한편, 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 와일드카드 타입을 써도 좋을 게 없다. 타입을 정확히 지정해야 하는 상황으로, 이때는 와일드카드 타입을 쓰지 말아야 한다.

PECS

펙스(PECS): producer-extends, consumer-super
이 공식을 외워두면 어떤 와일드카드 타입을 써야 하는지 기억하는 데 도움이 될 것이다. 즉, 매개변수화 타입 T가 생산자라면 <? extends T>를 사용하고, 소비자라면 <? super T>를 사용하라. PECS 공식은 와일드카드 타입을 사용하는 기본 원칙이다.

PECS를 제대로만 사용한다면 클래스 사용자는 와일드카드 타입이 쓰였다는 사실조차 의식하지 못할 것이다. 받아들어야 할 매개변수를 받고 거절해야 할 매개변수는 거절하는 작업이 알아서 이뤄진다. 클래스 사용자가 와일드카드 타입을 신경 써야 한다면 그 API에 무슨 문제가 있을 가능성이 크다.

타입 매개변수 E를 사용한 예를 보자. Comparable은 언제나 소비자이므로, 일반적으로 Comparable<E>보다는 Comparable<? super E>를 사용하는 편이 낫다. Comparator도 마찬가지로 알반적으로 Comparator<E>보다는 Comprator<? super E>를 사용하는 편이 낫다.

타입 매개변수와 와일드카드에는 공통되는 부분이 있어서, 메소드를 정의할 때 둘 중 어느 것을 사용해도 괜찮을 때가 많다. 두 인덱스의 아이템들을 교환(swap)하는 정적 메소드를 비한정적 타입 매개변수를 사용한 방법과 비한정적 와일드카드를 사용한 방법 모두를 보자.

1
2
3
// swap 메소드의 두 가지 선언
public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, int i, int j);
cs

어떤 선언이 낫고 더 나은 이유는 무엇일까? public API라면 간단한 두 번째가 낫다. 기본 규칙은 메소드 선언에 타입 매개변수가 한 번만 나오면 와일드 카드로 대체하라. 이때 비한정적 타입 매개변수라면 와일드카드로 바꾸고, 한정적 타입 매개변수라면 한정적 와일드카드로 바꾸면 된다.

두 번째 swap 선언에서 리스트의 타입이 List<?>인데, List<?>에는 null 외에는 어떤 값도 넣을 수 없어서 문제가 생긴다. 다행히 해결할 길이 있는데, 와일드카드 타입의 실제 타입을 알려주는 메소드를 private 도우미 메소드로 따로 작성하여 활용하는 방법이다. 실제 타입을 알아내려면 이 도우미 메소드는 제네릭 메소드여야 한다.

1
2
3
4
5
6
7
8
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)));
}
cs

핵심 정리

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