[EffectiveJava] item31 - 한정적 와일드카드를 사용해 API 유연성을 높이라
29 Apr 2023해당 Item의 정리에 앞서 자주 등장하는 단어인 불공변(invariant)과 공변(covariant)을 정리하고 가보자.
공변(covariant) : A가 B의 하위 타입 일때, T<A>가 T<B>의 하위 타입이다.
불공변(invariant) : A가 B의 하위 타입 일때, T<A>가 T<B>의 하위 타입이 아니다.
대표적인 예시로 배열은 공변이며, 제네릭은 불공변으로 볼 수 있다.
아래의 예시를 실행해보면, Interger[] 타입을 printArrays
메서드에서 Object[] 타입의 파라미터로 받아 출력을 하는것을 볼 수 있다.
@Test
void arrayTest() {
Integer[] integerArray = new Integer[]{1, 3, 5, 7};
printArrays(integerArray);
}
private void printArrays(Object[] integerArray) {
for (Object obj : integerArray)
System.out.println(obj);
}
1
3
5
7
Integer가 Object의 하위 타입이므로, 배열의 경우도 Integer[]은 Object[] 하위타입으로 인식이되 정상적으로 출력이 되는 것이다.
이를 공변(covariant)이라고 한다. 그렇다면 제네릭은 어떠할까?
@Test
void genericTest() {
List<Integer> integers = List.of(1, 3, 5, 7);
printList(integers);
}
private void printList(List<Object> integers) {
for (Object obj : integers)
System.out.println(obj);
}
제네릭은 불공변(invariant)이다 List<Integer>는 List<Object>의 하위 타입이 아닌 서로 다른 타입이므로, printList(integers)
구간에서 컴파일 에러가 발생한다.
java: incompatible types: java.util.List<java.lang.Integer> cannot be converted to java.util.List<java.lang.Object>
이제 본론으로 들어가보자. 매개변수화 타입 은 불공변(invariant)이다. 즉, 서로 다른 타입 Type1과 Type2가 있을 때 List<Type1>은 List<Type2>의 하위 타입도 상위 타입도 아닌 서로 다른 별개의 타입이다.
예를들어 List<String>은 List<Object>의 관계를 살펴보자.
List<Object>에는 어떤 객체든 넣을 수 있지만, List<String>에는 문자열만 넣을 수 있다. 즉, List<String>은 List<Object>가 하는 일을 제대로 수행하지 못하니 하위 타입이 될 수 없다. (리스코프 치환 원칙에 어긋난다.)
하지만 때론 불공변 방식보다 유연한 설계가 필요할 때가 있다.
StackGeneric 클래스를 통해 예시를 들어보자. 아래는 item28에서 구현한 StackGeneric의 public API를 추린 예시이다.
public class StackGeneric<E> {
public Stack();
public void push(E e);
public E pop();
public boolean isEmpty();
}
Collection<? extends E>
여기서 일련의 List 원소를 스택에 한번에 넣는 메서드를 추가해야 한다고 해보자.
public void pushAll(Iterable<E> iter) {
for (E e : iter)
push(e);
}
이 메서드는 깨끗이 컴파일 되지만 완벽하지 않다.
만약 Iterable<E> iter
의 원소 타입이 Stack<E>
의 원소타입이 일치한다면 문제가 되지 않겠지만, 일치하지 않는다면 문제가 된다.
예를 들어 StackGeneric<Number>로 선언한 뒤에 Iterable<Interger> 타입의 파라미터 intVal를 넣어 pushAll(intVal)
를 호출한다면 어떻게 될까?
@Test
void pushAll_테스트() {
StackGeneric<Number> numberStack = new StackGeneric<>();
Iterable<Integer> integers = List.of(1, 2, 3);
numberStack.pushAll(integers);
}
Integer는 Number의 하위 타입이니 잘 동작해야 겠지만, 이는 논리적으로만 잘 동작한다.
실제로 실행시켰을 때는 다음과 같은 오류 메시지가 실행된다. 위에서 설명했듯이 제네릭은 매개변수화 타입이 불공변이기 때문이다.
D:\workspace\study\basic\src\com\effectiveJava\item29\StackGenericTest.java:17:29
java: incompatible types: java.lang.Iterable<java.lang.Integer> cannot be converted to java.lang.Iterable<java.lang.Number>
그렇다면 해결 방법은 없을까?
다행히 해결책은 있다. 자바는 이런 상황에 대처 할 수 있는 한정적 와일드 카드 타입이라는 특별한 매개변수화 타입을 지원한다.
pushAll의 입력 매개변수 타입은 E의 Iterable이 아니라 E의 하위 타입의 Iterable이어야 하며, 이를 와일드 카드를 통해 나타내면 Iterable<? extends E>
로 나타낼 수 있다.
한번 와일드 카드 타입을 사용해 pushAll 메서드를 수정해보자.
public void pushAll(Iterable<? extends E> iter) {
for (E e : iter)
push(e);
}
해당 코드를 실행하면 말끔히 컴파일 되는것을 확인할 수 있다.
Collection<? super E>
이제 pushAll을 구현했으니 반대되는 popAll을 구현해보도록 하자.
public void popAll(Collection<E> list) {
while (!isEmpty())
list.add(pop());
}
이번에도 pushAll과 동일하게 원소타입이 스택의 원소타입과 일치한다면 문제없이 동작한다. 하지만 이번에도 타입이 일치하지 않는다면 동일한 문제가 발생한다.
StackGeneric<Number>의 원소를 Object용 컬렉션으로 옮겨보도록하자.
@Test
void popAll_테스트() {
StackGeneric<Number> numberStack = new StackGeneric<>();
Collection<Object> objects = Arrays.asList(new Object(), new Object());
numberStack.popAll(objects);
}
이 코드를 popAll 코드와 함께 컴파일 하면 아래와 같은 오류가 출력된다.
D:\workspace\study\basic\src\com\effectiveJava\item29\StackGenericTest.java:17:29
java: incompatible types: java.lang.Iterable<java.lang.Integer> cannot be converted to java.lang.Iterable<java.lang.Number>
하지만 이번에도 와일드 카드 타입으로 해결이 가능하다.
이번에는 popAll의 입력 매개변수의 타입이 E의 Collection 이 아니라 E의 상위 타입의 Collection 이어야 한다(모든 타입은 자기 자신의 상위타입이다.) 이를 와일드 카드로 나타내면 Collection<? super E>
로 나타낼 수 있다.
이를 popAll에 적용해보자.
public void popAll(Collection<? super E> list) {
while (!isEmpty())
list.add((E) pop());
}
이번에도 말끔히 실행되는걸 볼 수 있다.
이를 간단하게 정리하자면 이렇다. 유연성을 극대화 하려면 원소의 생산자나 소비자용 입력 매개변수에 와일드 카드 타입을 사용하라. 한편, 입력 매개변수와 생산자가 소비자 역할을 동시에 한다면 와일드 카드 타입을 써도 좋을게 없다. 이때는 타입을 정확히 지정해야 하는 상황으로 와일드 카드를 쓰지 말아야한다.
펙스(PECS)
위의 상황들을 공식으로 나타낸다면 다음과 같이 나타낼 수 있다.
펙스(PECS) : Producer-Extends, Consumer-Super
즉, 매개변수화 타입 T가 생산자라면 <? extends T>
를 사용하고, 소비자라면 <? super T>
를 사용하라는 뜻이다.
위의 예시에서 StackGeneric의 pushAll의 iter 매개변수는 StackGeneric이 사용할 E 인스턴스를 생산하므로 iter의 적절한 타입은 Iterable<? extends E>
가 된다.
반면, popAll의 list 매개변수는 StackGeneric으로부터 E 인스턴스를 소비하므로 list의 적절한 타입은 Colletion<? super E>
가 된다.
PECS
는 와일드카드 타입을 사용하는 기본 원칙이다. 또는 겟풋원칙(Get and Put Principle)으로 불린다.
생산자의 예시를 들어보자.
public static <E> Set<E> union(Set<E> s1, Set<E> s2)
Item30에서 만든 예시의 union 메소드는 s1, s2를 받아 새로운 Set을 생성하는 생성자이다. s1, s2 모두 E의 생성자 이기 때문에 PECS 공식에 따라 다음과 같이 선언해야 한다.
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2)
여기서 주의 할점은 반환타입은 Set<E>이다. 반환 타입에는 한정적 와일드 카드 타입을 사용하면 안된다. 유연성을 높여주긴 꺼녕 해당 메소드를 사용하는 클라이언트 코드에도 와일드 카드 타입을 써야 하기 때문이다.
union을 호출하는 코드는 아까와 같이 동일하게 사용할 수 있다.
public static void main(String[] args) {
Set<String> fruits = Set.of("apple", "banana", "pineaplle");
Set<String> vegetables = Set.of("carrot", "tomato");
Set<String> food = union(fruits, vegetables);
}
제대로만 사용하다면 클래스 사용자는 와일드 카드 타입이 쓰였다는 사실조차 의식 하지 못할것이다. 옳은 매개변수와, 옳지 않은 매개변수에 대한 분류 작업이 알아서 이루어 진다. 클라이언트 코드가 와일드카드 타입을 신경써야 한다면 그 API는 문제가 있을 가능성이 크다.
이번에는 Item30의 max 메서드를 주목해보자. 원래 버전의 선언은 다음과 같다.
public static <E extends Comparable<E>> E max(List<E> list)
다음은 와일드카드 타입을 사용해 다음은 모습이다.
public static <E extends Comparable<? super E>> E max(List<? extends E> list)
이번에는 PECS 공식을 두 번 적용했다. 각각 적용된 예시를 살펴보도록 하자.
입력 매개변수에서는 E 인스턴스를 생산하므로 원래의 List<E>
에서 List<? extends E>
로 수정되었다.
다음은 더 난해한쪽인 타입 매개변수 E를 살펴보자. 원래의 선언은 <E extends Comparable<E>>
로 E가 Comparable<E> 를 확장한다고 정의가 되었다.
이때 Comparable<E>
는 E 인스턴스를 소비한다(이후 선후 관계를 뜻하는 정수를 생산) 그래서 매개변수화 타입 Comparable<E>
를 한정적 와일드카드 타입인 Comparable<? super E>
로 대체한다.
말이 너무 어려워진다. 간단하게 정리하면 Comparable(또는 Comparator)은 언제나 소비자이므로, 일반적으로는 Comparable<E>
보다는 Comparable<? super E>
를 사용하는 편이 낫다. (Comparator<E>
보다는 Comparator<? super E>
를 사용하자)
비한정적 와일드카드
와일드 카드와 관련해 논의해야 할 주제가 하나 더 있다.
타입 매개변수화 와일드카드에는 공통되는 부분이 있어서, 메서드를 정의할 때 둘중 어느것을 사용해도 괜찮을 때가 많다.
그렇다면 아래의 예시를 살펴보자
public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, int i, int j);
동일한 동작을 메서드지만 public API라면 간단한 두 번째가 낫다. 어떤 리스트든 이 메서드에 넘기면 동작은 동일하게 작동할 것이며, 신경 써야 할 타입 매개변수도 없다.
기본 규칙을 정리하자면 이렇다. 메서드 선언에 타입 매개변수가 한 번만 나오면 와일드 카드로 대체하라.
이때 비한정적 타입 매개변수라면 비한정적 와일드카드로, 한정적 타입 매개변수라면 한정적 와일드 카드로 바꾸면 된다.
여기서 두번째 와일드카드를 이용한 선언은 한가지 문제점이 있다.
해당코드는 컴파일이 되지 않는 코드이다.
public static void swap(List<?> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
@Test
void swap_테스트() {
swap(List.of(1,2,3), 1, 2);
}
이 코드를 컴파일 하면 오류 메시지가 출력된다.
D:\workspace\study\basic\src\com\effectiveJava\item29\StackGenericTest.java:33:41
java: incompatible types: java.lang.Object cannot be converted to capture#1 of ?
방금 꺼낸 원소를 넣을 수 없다는 뜻인데, 원인은 리스트의 타입이 List<?>인데, List<?>에는 null 외에는 어떤 값도 넣을 수 없기 때문이다.
하지만 해결방법이 없는 것은 아니다. 바로 와일드 카드 타입의 실제 타입을 알려주는 메서드를 private 도우미 메서드로 따로 작성하여 활용하는 방법이다.
@Test
void swap_테스트() {
swap(List.of(1,2,3), 1, 2);
}
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 타입의 값이라면 이 리스트에 넣어도 안전함을 알고있다.
그래서 컴파일 에러가 나지 않는다.