티스토리 뷰
아이템 28에서 이야기했듯 매개변수화 타입은 불공변입니다.
즉, 서로 다른 타입 Type1과 Type2가 있을 때 List<Type1>은 List<Type2>의 하위 타입도 상위 타입도 아닙니다.
List<string>은 List<object>의 하위 타입이 아니라는 뜻인데, 곰곰이 따져보면 사실 이쪽이 말이 됩니다.
List<Object>에는 어떤 객체든 넣을 수 있지만 List<String>에는 문자열만 넣을 수 있습니다.
즉, List<String>은 List<Object>가 하는 일을 제대로 수행하지 못하니 하위 타입이 될 수 없습니다.
⇒ 리스코프 치환 원칙에 어긋납니다. (아이템 10)
하지만 때론 불공변 방식보다 유연한 무언가가 필요합니다.
아이템 29의 Stack 클래스를 떠올려봅시다.
여기 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);
}
}
이 메서드는 깨끗이 컴파일되지만 완벽하지는 않습니다.
Iterable src의 원소 타입이 스택의 원소 타입과 일치하면 잘 작동합니다.
🤔
Stack<Number>로 선언한 후 pushAll(intVal)을 호출하면 어떻게 될까요?
여기서 intVal은 Integer 타입입니다.
Integer는 Number의 하위 타입이니 잘 동작합니다.
아니, 논리적으로는 잘 동작해야 할 것 같습니다.
Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ...;
numberStack.pushAll(integers);
하지만 실제로는 다음의 오류 메시지가 뜹니다.
매개변수화 타입이 불공변이기 때문입니다.
StackTest.java:7: error: incompatible types: Iterable<Integer>
cannot be converted to Iterable<Number>
numberStack.pushAll(integers);
다행히 해결책은 있습니다.
자바는 이런 상황에 대처할 수 있는 한정적 와일드카드 타입이라는 특별한 매개변수화 타입을 지원합니다.
pushAll의 입력 매개변수 타입은 'E의 Iterable'이 아니라 'E의 하위 타입의 Iterable'이어야 하며, 와일드 카드 타입 Iterable<? extends E>가 정확히 이런 뜻입니다.
사실 extends라는 키워드는 이 상황에 딱 어울리지는 않습니다. 하위 타입이란 자기 자신도 포함하지만, 그렇다고 자신을 확장(extends)한 것은 아니기 때문입니다. 아이템 29 참조
와일드카드 타입을 사용하도록 pushAll 메서드를 수정해봅시다.
public void pushAll(Iterable<? extends E> src) {
for (E e : src) push(e);
}
이번 수정으로 Stack은 물론 이를 사용하는 클라이언트 코드도 말끔히 컴파일됩니다.
Stack과 클라이언트 모두 깔끔히 컴파일되었다는 건 모든 것이 타입 안전하다는 뜻입니다.
이제 pushAll과 짝을 이루는 popAll 메서드를 작성할 차례입니다.
popAll 메서드는 Stack 안의 모든 원소를 주어진 컬렉션으로 옮겨 담습니다.
다음처럼 작성했다고 해봅시다.
public void popAll(Collection<E> dst) {
while(!isEmpty()) dst.add(pop());
}
이번에도 주어진 컬렉션의 원소 타입이 스택의 원소 타입과 일치한다면 말끔히 컴파일되고 문제없이 동작합니다.
하지만 이번에도 역시나 완벽하진 않습니다.
Stack<Number>의 원소를 Object용 컬렉션으로 옮기려 한다고 해봅시다.
컴파일과 동작 모두 문제가 없을까요?
Stack<Number> numberStack = new Stack<>();
Collection<Object> objects = ...;
numberStack.popAll(objects);
이 클라이언트 코드를 앞의 popAll 코드와 함께 컴파일하면 pushAll을 사용했을 때와 비슷한 오류가 발생합니다.
이번에도 와일드카드 타입으로 해결할 수 있습니다.
이번에는 popAll의 입력 매개변수의 타입이 'E의 Collection'이 아니라 'E의 상위 타입의 Collection'이어야 합니다.
(모든 타입은 자기 자신의 상위 타입입니다.)
와일드카드 타입을 사용한 Collection<? super E>가 정확히 이런 의미입니다.
이를 popAll에 적용해봅시다.
public void popAll(Collection<? super E> dst) {
while(!isEmpty()) dst.add(pop());
}
이제 Stack과 클라이언트 코드 모두 말끔히 컴파일됩니다.
메시지는 분명합니다.
🐻
유연성을 극대화하려면 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용하라.
한편, 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 와일드카드 타입을 써도 좋을 게 없습니다.
타입을 정확히 지정해야 하는 상황으로, 이때는 와일드카드 타입을 쓰지 말아야 합니다.
다음 공식을 외워두면 어떤 와일드카드 타입을 써야 하는지 기억하는 데 도움이 될 것입니다.
팩스(PECS): producer-extends, consumer-super
즉, 매개변수화 타입 T가 생산자라면 <? extends T>를 사용하고, 소비자라면 <? super T>를 사용합시다.
Stack 예에서 pushAll의 src 매개변수는 Stack이 사용할 E 인스턴스를 생산하므로 src의 적절한 타입은 Iterable<? extends E>입니다.
PECS 공식은 와일드카드 타입을 사용하는 기본 원칙입니다.
나프탈린과 와들러는 이를 겟풋 원칙으로 부릅니다.
이 공식을 기억해두고 이번 장의 앞 아이템들에서 소개한 메서드와 생성자 선언을 다시 살펴봅시다.
아이템 28의 Chooser 생성자는 다음과 같이 선언했습니다.
public Chooser(Collection<T> choices)
이 생성자로 넘겨지는 choices 컬렉션은 T 타입의 값을 생산하기만 하니 T를 확장하는 와일드카드 타입을 사용해 선언해야 합니다.
다음은 이 공식에 맞게 수정한 모습입니다.
public Chooser(Collection<? extends T> choices)
이렇게 수정하면 실질적인 차이가 생길까요?
그렇습니다. Chooser<Number>의 생성자에 List<Integer>를 넘기고 싶다고 해봅시다.
수정 전 생성자로는 컴파일조차 되지 않겠지만, 한정적 와일드카드 타입으로 선언한 수정 후 생성자에서는 문제가 사라집니다.
이번엔 union 메서드를 봅시다.
public static <E> Set<E> union(Set<E> s1, Set<E> s2)
s1과 s2 모두 E의 생산자이니 PECS 공식에 따라 다음처럼 선언해야 합니다.
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2)
💡
반환 타입은 여전히 Set<E>임에 주목합시다.
반환 타입에는 한정적 와일드카드 타입을 사용하면 안 됩니다.
유연성을 높여주기는커녕 클라이언트 코드에서도 와일드카드 타입을 써야 하기 때문입니다.
수정한 선언을 사용하면 다음 코드도 말끔히 컴파일됩니다.
Set<Integer> integers = Set.of(1, 3, 5);
Set<Double> doubles = Set.of(2.0, 4.0, 6.0);
Set<Number> numbers = union(integers, doubles);
제대로만 사용한다면 클래스 사용자는 와일드카드 타입이 쓰였다는 사실조차 의식하지 못할 것입니다.
받아들여야 할 매개변수를 받고 거절해야 할 매개변수는 거절하는 작업이 알아서 이뤄집니다.
클래스 사용자가 와일드카드 타입을 신경써야 한다면 그 API에 무슨 문제가 있을 가능성이 큽니다.
앞의 코드는 자바 8부터 제대로 컴파일됩니다.
자바 7까지는 타입 추론 능력이 충분히 강력하지 못해서 문맥에 맞는 반환 타입을 명시해야 했습니다.
예컨대 앞 코드에서 union 호출의 목표 타입은 Set<Number>입니다.
자바 7까지는 (Set.of 팩터리를 다른 것으로 적절히 변경한 후) 이 코드를 컴파일하면 다음처럼 아주 길고 난해한 오류 메시지가 나옵니다.
Union.java:14: error: incompatible types
Set<Number> numbers = union(integers, doubles);
required: Set<Number>
found: Set<INT#1>
where INT#1, INT#2 are intersection types:
INT#1 extends Number, Comparable<? extends INT#2>
INT#2 extends Number, Comparable<?>
다행히 해결할 수 있는 오류입니다.
컴파일러가 올바른 타입을 추론하지 못할 때면 언제든 명시적 타입 인수를 사용해서 타입을 알려주면 됩니다.
목표 타이핑은 자바 8부터 지원하기 시작했는데, 그 전 버전에서도 이런 문제가 흔하진 않았습니다.
명시적 타입 인수는 코드를 지저분하게 하니, 이런 문제가 빈번하지 않은 건 그나마 다행이었습니다.
어쨌든 다음처럼 명시적 타입 인수를 추가하면 자바 7 이하에서도 깨끗이 컴파일됩니다.
// 자바 7까지는 명시적 타입 인수를 사용해야 한다.
Set<Number> numbers = Union.<Number>union(integers, doubles);
이번에는 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가 Comparable<E>를 확장한다고 정의했는데, 이 때 Comparable<E>는 E 인스턴스를 소비합니다. (그리고 선후 관계를 뜻하는 정수를 생산합니다.)
그래서 매개변수화 타입 Comparable<E>를 한정적 와일드카드 타입인 Comparable<? super E>로 대체했습니다.
Comparable은 언제나 소비자이므로, 일반적으로 Comparable<E> 보다는 Comparable<? super E>를 사용하는 편이 낫습니다.
Comparator 역시 Comparator<E> 보다는 Comparator<? super E>를 사용하는 편이 낫습니다.
수정된 버전의 max는 이 책에서 가장 복잡한 메서드 선언입니다.
이렇게까지 복잡하게 만들만한 가치가 있을까요? ⇒ 정답은 그렇다입니다.
그 근거로, 다음 리스트는 오직 수정된 max로만 처리할 수 있습니다.
List<ScheduledFuture<?>> scheduledFutures = ...;
수정 전 max가 이 리스트를 처리할 수 없는 이유는 (java.util.concurrent 패키지의) ScheduledFuture가 Comparable<ScheduledFuture>를 구현하지 않았기 때문입니다.
ScheduledFuture는 Delayed의 하위 인터페이스이고, Delayed는 Comparable<Delayed>를 확장했습니다.
다시 말해, ScheduledFuture의 인스턴스는 다른 ScheduledFuture 인스턴스뿐 아니라 Delayed 인스턴스와도 비교할 수 있어서 수정 전 max가 이 리스트를 거부하는 것입니다.
더 일반화해서 말하면, Comparable을 구현하지 않고, 직접 구현한 다른 타입을 확장한 타입을 지원하기 위해 와일드카드가 필요합니다.
코드로는 다음처럼 선언되어 있습니다.
public interface Comparable<E>
public interface Delayed extends Comparable<Delayed>
public interface ScheduledFuture<V> extends Delayed, Future<V>
와일드카드와 관련해 논의해야 할 주제가 하나 더 남았습니다.
타입 매개변수와 와일드카드에는 공통되는 부분이 있어서, 메서드를 정의할 때 둘 중 어느 것을 사용해도 괜찮을 때가 많습니다.
예를 들어 주어진 리스트에서 명시한 두 인덱스의 아이템들을 교환(swap)하는 정적 메서드를 두 방식 모두로 정의해봅시다.
다음 코드에서 첫 번째는 비한정적 타입 매개변수(아이템 30)를 사용했고 두 번째는 비한정적 와일드카드를 사용했습니다.
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 선언에는 문제가 하나 있는데, 다음과 같이 아주 직관적으로 구현한 코드가 컴파일되지 않는다는 것입니다.
public static void swap(List<?> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
이 코드를 컴파일하면 그다지 도움이 되지 않는 오류 메시지가 나옵니다.
Swap.java:5 error: incompatible types: Object cannot be
converted to CAP#1
list.set(i, list.set(j, list.get(i)));
^
where CAP#1 is a fresh type-variable:
CAP#1 extends Object from capture of ?
방금 꺼낸 원소를 리스트에서 다시 넣을 수 없다니, 이게 대체 무슨 일일까요?
원인은 리스트의 타입이 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의 존재를 모른 채 그 혜택을 누린 것입니다.
💡
도우미 메서드의 시그니처는 앞에서 "public API로 쓰기에는 너무 복잡하다"는 이유로 버렸던 첫 번째 swap 메서드의 시그니처와 완전히 똑같다.
정리
- 조금 복잡하더라도 와일드카드 타입을 적용하면 API가 훨씬 유연해진다.
- 그러나 널리 쓰일 라이브러리를 작성한다면 반드시 와일드카드 타입을 적절히 사용해줘야 한다.
- PECS 공식을 기억하자.
- Comparable과 Comparator는 모두 소비자라는 사실도 잊지 말자.
'JAVA > 이펙티브 자바' 카테고리의 다른 글
아이템[33]. 타입 안전 이종 컨테이너를 고려하라 (0) | 2022.04.17 |
---|---|
아이템[32]. 제네릭과 가변인수를 함께 쓸 때는 신중하라 (0) | 2022.04.17 |
아이템[30]. 이왕이면 제너릭 메서드로 만들라 (0) | 2022.04.13 |
아이템[29]. 이왕이면 제네릭 타입으로 만들라 (0) | 2022.04.13 |
아이템[28]. 배열보다는 리스트를 사용하라 (0) | 2022.04.13 |
- Total
- Today
- Yesterday
- 디자인패턴
- Spring
- BAEKJOON
- 클린 아키텍처
- 테라폼
- Java
- 알고리즘
- Kotlin
- MSA
- 백준
- C++
- 클린 코드
- 정규표현식
- Algorithm
- Effective Java
- 코테
- 객체지향
- node.js
- 이펙티브 자바
- 이팩티브 자바
- programmers
- 프로그래머스
- kotest
- AWS
- BOJ
- Spring Boot
- Olympiad
- 디자인 패턴
- JPA
- kkoon9
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |