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

2021-04-16

앞에서 이야기 했듯이 매개변수화 타입 (ex: List<String> 등 )은 불공변이다. 즉 List<String>List<Object> 의 하위타입이 아니라는 뜻이다.

List<Object> 에는 어떤 객체든지 넣을 수 있지만 List<String> 에는 문자열만 넣을 수 있다.

불공변 방식의 문제점

다음과 같이 Stack 클래스의 public API가 있다.

public class Stack<E> {
  public Stack();
  public void push(E e);
  public E pop();
  public boolean isEmpty();
}

여기에 일련의 원소를 스택에 넣는 메서드를 추가한다고 가정해보자.

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

이 메서드는 src의 원소타입이 Stack의 타입과 일치할 때는 정상적으로 작동한다.

하지만, Stack의 하위타입을 넣고자 할 때는 오류가 발생한다.

다음과 같이 Stack<Number> 스택을 하나 선언하고, Number의 하위 타입인 Integer 값을 넣어보자.

Stack<Number> 에 Integer 타입 추가

Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ...;
numberStack.pushAll(integers);

위와 같이 코드를 작성하면 호환되지 않는 타입(incompatible types) 이라고 컴파일 오류가 나온다.

왜 그럴까?

앞서 말한 것처럼 매개변수화 타입인 List<String> ,Collection<Integer> 등등은 하위타입이 없고 자기자신 그대로만 받기 때문이다.

아무리 Number의 하위타입이 Integer라고 해도 매개변수화 타입에서 사용되면, 각각 다른 클래스로 보는 것이다.

해결 해보기

Stack<Number> 의 클래스가 Number의 하위타입인 Integer도 받고 싶으면 매개변수에 한정적 와일드카드 타입을 선언하면 된다.

Iterable<? extends E> src 라고 선언하면 된다. 이 코드를 해석하면, Iterable을 받는데, E타입과 E를 확장한 클래스를 타입으로 받겠다. 라고 해석하면 된다.

다시 Number의 예제로 돌아가서, Stack<Number> 로 Stack을 만들게 되면, Iterable<? extends Number> src 이 되기 때문에 정상적으로 컴파일이 된다.

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

parameterized type의 제너릭 소거

Parameterized Type의 경우에는 Type erasure에 의하여 컴파일시에 Raw Type으로 변경된다.

List<String>, List<Integer>, List<List<String>>의 타입정보는 컴파일 시에 타입 안정성 검증 용도로 사용될 뿐 컴파일이 완료되면 Raw Type인 List로 치환된다.

불공변 방식의 문제점2

이번에는 Stack 안의 모든 원소를 주어진 컬렉션으로 옮겨 담는다고 가정해보자.

popAll 메서드

public void popAll(Collection<E> dst) {
	while (!isEmpty()) 
    dst.add(pop())
}

이 메서드의 문제점도 위와 비슷하다. 파라미터 타입은 불공변이기 때문에 Stack이 Number 타입이라면 Collection도 Number 타입이여만 한다.

Collection<Object> 에는 넣지 못하는 코드

Stack<Number> numberStack = new Stack<>();
Collection<Object> objects = ...;
numberStack.popAll(objects); // 오류가 발생한다.

이 오류를 해결하기 위해서는 Number의 상위타입을 받아들인다고 제너릭에게 말을 해야 한다.

매개변수에 와일드카드 타입 적용

public void popAll(Collection<? super E> dst) {
  while (!isEmpty())
    dst.add(pop());
}

PECS 공식

PESC 공식은 producer-extends, consumer-super 의 줄임말이다.

위와 같이 어느 경우에는 extends를 , 다른 경우에는 super를 사용하는데, 다음 공식을 외워두면 어떤 어떤걸 사용할지 기억하는 데 도움이 된다.

첫 번째 예제에서는 Iterable<? extends E> src 로 extends를 사용했다. 여기서 extends가 사용된 이유는 src은 stack에 원소를 추가하는 생산자 역할을 하기 때문이다. 그러므로 생산자 역할을 한다면 extends를 사용한다.

두 번째 예제에서 Collection<? super E> dst 는 super를 사용했다. super가 사용된 이유는 Stack으로부터 E 인스턴스를 소비하므로 super가 사용 되었다.

만약 생산자와 소비자의 역할을 동시에 한다면, 타입을 정확히 지정해야 하는 상황이므로 제너릭 타입을 그대로 쓰면 된다.

불공변 방식의 문제점3

이번에는 좀 더 복잡한 문제를 해결해보자.

public static <E extends Comparable<E>> E max(List<E> list)

이 메서드의 정의를 해석해보자면, E타입의 List를 인자로 받고, 그 List에서 가장 큰 값을 리턴한다. 하지만 리턴되는 타입은 Comparable을 구현해야 한다.

그럼 이 메서드의 인자로 List<ScheduledFutuer<?>> scheduledFutures = ...; 를 전달해보자.

ScheduledFutuer 클래스

ScheduledFuture 클래스의 부모 클래스는 Delayed 클래스이고, Delayed 클래스는 Comparable을 구현했다.

public interface Comparable<E>
public interface Delayed extends Comparable<Delayed>
public interface ScheduledFuture<V> extends Delayed, Future<V>

그러면 결국 ScheduledFuture 클래스도 Delayed 클래스를 상속 받았으니, Comprable을 구현한 것이나 마찬가지다.

그러나 List<ScheduledFutuer<?>> scheduledFutures 을 인자로 넘기게 되면, 매개변수 타입의 불공변 때문에, ScheduledFutuer 클래스는 Comparable을 구현하지 못한 클래스가 되버린다.

해결법

해결하기 위해서는 Delayed 클래스가 Comparable을 구현했으니, 이걸 사용할 수 있도록 만들어 줘야 한다.

public static <E extends Comparable<? super E>> E max(List<E> list)

이렇게 하면 정상적으로 동작한다.

하지만 list은 publisher 역할을 하기 때문에 PECS 공식을 사용하여 다음과 같이 바꿔주면 좋다.

public static <E extends Comparable<? super E>> E max(List<? extends E> list)

와일드카드와 타입 매개변수

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

예를 들어, 주어진 리스트에서 명시한 두 인덱스의 아이템들을 교환하는 정적 메서드를 두 방식으로 모두 정의 해보자.

  1. 비한정적입 매개변수 swap() 2. 비한정적 와일드카드 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라면 두 번째가 낫다. 어떤 리스트든 이 메서드에 넘기면 명시한 인덱스의 원소들을 교환해줄 것이고, 신경 써야 할 타입 매개변수도 없다.

와일드카드와 타입 매개변수을 정하는 규칙

메서드 선언에 타입 매개변수가 한 번만 나오면 와일드카드로 대체하자. 이때 비한정적 타입 매개변수라면 비한정적 와일드카드로 바꾸고, 한정적 타입 매개변수라면 한정적 와일드카드로 바꾸면 된다.

하지만 위에 나온 2번째 swap 메서드는 문제가 하나 있다.

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

이 코드를 컴파일하면 다음의 오류메세지가 나온다.

)

방금 꺼낸 원소를 리스트에 다시 넣을 수 없게 된다. 그 이유는 리스트이 타입이 List<?> 인데 , List<?> 에는 null 외에는 어떤 값도 넣을 수 없다는 데 있다.

이 오류를 해결하기 위해서는 와일드카드 타입의 실제 타입을 알려주는 메서드를 private 도우미 메서드로 따로 작성하여 활용하는 방법이다. 실제 타입을 알아내려면 이 도우미 메서드는 제너릭 메서드여야 한다.

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)));
}

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

swap 메서드 내부에서는 더 복잡한 메서드를 이용했지만, 덕분에 외부에서는 와일드카드 기반의 멋진 선언을 유지할 수 있었다. 즉, swap 메서드를 호출하는 클라이언트는 복잡한 swapHelper의 존재를 모른 채 그 혜택을 누릴 수 있다.