티스토리 뷰

아이템 28에서 이야기했듯 매개변수화 타입은 불공변입니다.

즉, 서로 다른 타입 Type1과 Type2가 있을 때 List<Type1>은 List<Type2>의 하위 타입도 상위 타입도 아닙니다.

List<string>은 List<object>의 하위 타입이 아니라는 뜻인데, 곰곰이 따져보면 사실 이쪽이 말이 됩니다.

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

즉, List<String>은 List<Object>가 하는 일을 제대로 수행하지 못하니 하위 타입이 될 수 없습니다.

⇒ 리스코프 치환 원칙에 어긋납니다. (아이템 10)

 

SOLID [3]. 리스코프 치환 원칙

‘개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴' 책을 보고 정리한 글입니다. 리스코프 치환 원칙은 개방 폐쇄 원칙을 받쳐 주는 다형성에 관한 원칙을 제공한다. 리스코프 치환 원칙(s

kkoon9.tistory.com

하지만 때론 불공변 방식보다 유연한 무언가가 필요합니다.

아이템 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는 모두 소비자라는 사실도 잊지 말자.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
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
글 보관함