본문 바로가기
Java

한정적 와일드카드(Bounded Wildcard Type)

by 제너럴종 2023. 5. 10.

한정적 와일드카드 도입 배경

매개변수화 타입은 불공변(invariant)이다.

서로 다른 타입 Type1 와 Type2가 있을 때 List<Type1> 과 List<Type2>는 그 누구의 상위 타입도, 하위 타입도 아니다. 따라서 List<String>은 List<Object>의 하위 타입이 아니다.

왜 제네릭 문법은 이렇게 만들어 졌을까?

상위 모듈과 이를 확장한 하위 모듈이 있을 때 상위 모듈의 동작을 하위 모듈이 완전히 대체 할 수 있어야 한다.
(SOLID, Liskov Subsitution Principle : 리스코프 치환 원칙)

List<String> 은 List 구조를 가진 자료구조의 원소로 문자열을 다루는 String 타입 객체만을 다룰 수 있다.

List<Object> 는 List 구조를 가진 자료구조의 원소로 Object 타입 객체만을 다루는 일을 한다.

하지만 자바의 세계에서 Object 객체를 확장하지 않은 객체는 없으므로, 이론상 모든 타입의 객체를 넣을 수 있다.

List<Object> objList = new ArrayList<>();
Object obj = new MyClass();

// 가능!
objList.add(obj)

List<String>이 List<Object>의 동작을 전부 수행 할 수 없으니 리스코프 치환 원칙에 위배 된다. 따라서 매개변수화 타입은 불공변으로 설계 된 것이다.

하지만 가끔은 불공변 방식보다 유도리 있게 제네릭을 사용하고 싶다. 싱글톤 패턴에서 사용한 간단한 스택 예제로 알아보자.

class Stack<T>{
  private int size; // 스택의 현재 사이즈
  private Object[] elements; // 스택 내부 배열

  Stack(int capacity) {
    size = 0;
    elements = new Object[capacity];
  }

  public void push(T t) {
    ensureCapacity();
    elements[++size] = t;
  }

  // 내부 배열이 가득 차면 현재 배열의 크기를 2배로 늘린다.
  private void ensureCapacity() {
    if(size <= 0) {
      elements = Arrays.copyOf(elements, elements.length * 2 + 1);
    }
  }

  public T pop(){
    if(!isEmpty()) {
      @SuppressWarnings("unchecked") T result = (T) elements[--size];
      elements[size] = null;

      return result;
    }
    throw new EmptyStackException();
  }

  public boolean isEmpty() {
    return (size <= 0) ? true : false;
  }

  ...

}

모든 원소를 한번에 스택에 push 해주는 pushAll() 메서드를 추가해 보자. 내부의 원소를 순회 할 수 있는 기능의 규약이 담긴 인터페이스(Iterator)를 구현 한 객체를 파라미터로 받는다.

class Stack<T>{
  ...

  public void pushAll(Iterator<T> src){
    while(src.hasNext()){
      push(src.next());
    }
  }

  ...

}

큰 문제 없는 메서드 같지만, 완벽하진 않다. 아래 예제는 상속 문법을 이용하여 계층 구조를 표현한 People 클래스와 Man 클래스 이다.

abstract class People{
  ...
}

class Man extends People{
  ...
}

public static void main(String[] args) {
  Stack<People> peopleStack = new Stack<>(10);
  List<Man> manlist = new ArrayList<>();

  Iterator<Man> iter = manlist.iterator();
  peopleStack.pushAll(iter);
}

실제로는 peopleStack.pushAll(iter) 에서 컴파일 에러 메시지가 나온다. peopleStack 객체의 매개변수화 타입은 Stack<People>로 peopleStack 객체 내부는 컴파일 타임 때 이미 치환 되어 실체화 되었다.

따라서 peopleStack 객체의 pushAll(Iterator<T> src) 메서드도 컴파일 타임 때 pushAll(Iterator<People> src) 로 실체화 되었다. 컴파일 에러 메시지의 내용은 Iterator<Man> 타입을 Iterator<People> 타입으로 캐스팅 할 수 없다는 메시지다. 컴파일 타임 때 이미 실체화 된 코드를 런타임 중 바꿀 수 있다면 자바 문법은 엉망진창이 될 것이다.

우리는 Iterator 인터페이스 규칙에 따라 People 타입의 원소를 순회 하기만 하면 되고, People 타입 으로 하위 타입 Man 객체의 인스턴스를 참조 하는건 객체지향 세계에선 자연스러운 일인데, 이럴땐 제네릭 문법이 조금 답답해 보인다.

한정적 와일드카드 도입

이 문제를 유연하게 해결 할 수 있는 방법이 바로 한정적 와일드카드 타입이라는 특별한 매개변수화 타입이다.

class Stack<T>{
  ...

  public void pushAll(Iterator<? extends T> src){
    while(src.hasNext()){
      push(src.next());
    }
  }

  ...

}

이제 pushAll의 입력 매개변수화 타입을 풀어 설명하면 "T의 Iterator"가 아니라, "T 또는 T의 하위 타입의 Iterator" 가 된다. "T 또는 T의 하위 타입의 Itertor"의 자바 문법적인 표현이 Iterator<? extends T> 다.

이제 같은 방법으로 popAll() 메서드도 정의해보자.

class Stack<T>{
  ...

  public void popAll(Collection<T> dst){
    while(dst.hasNext()){
      push(dst.next());
    }
  }

  ...

}

파라미터로 Collection 타입의 dst를 파라미터로 받고, 자신의 원소를 비워나가면서(pop) 그 원소를 입력받은 컬렉션 객체에 add 한다.

public static void main(String[] args) {
  Stack<Man> manStack = new Stack<>(10);
  Collection<People> peopleList = new ArrayList<>();

  manStack.popAll(peopleList);
}

Man 클래스는 People 클래스의 하위 클래스 이지만, Collection<People>와 Collection<Man>은 다른 타입이므로 역시 타입 캐스팅이 불가능하다.

class Stack<T>{
  ...

  public void popAll(Collection<? super T> dst){
    while(dst.hasNext()){
      push(dst.next());
    }
  }

  ...

}

이제 popAll() 메서드의 입력 매개변수의 타입이 "T의 Collection"이 아니라 "T 또는 T의 상위 타입 Collection"이 된다.

그렇다면 어떤 상황에서 어떤 한정적 와일드카드 문법을 사용하는 것이 바람직할까? 조슈아 블로크가 제안한 공식이다.

펙스(PECS) 공식

펙스(PECS) : producer-extends, consumer-super

매개변수화 타입 T가 생산자(producer)라면 <? extends T>를 사용하고, 소비자(consumer)라면 <? super T>를 사용하라는 뜻이다.

위 Stack 예제에서 pushAll() 메서드는 Stack 내부의 T 타입 원소를 파라미터로 받은 src 객체를 통해 Stack 객체의 원소를 생산 하므로 생산자(producer)에 속한다.

위 Stack 예제에서 popAll() 메서드는 Stack 내부의 T 타입 원소를 파라미터로 받은 dst 객체로 Stack 객체의 원소를 소비 하므로 소비자(consumer)에 속한다.

단 메서드의 리턴 타입은 한정적 와일드카드가 되어선 안된다.

클라이언트에서 리턴 타입을 받기 위한 객체 또한 한정적 와일드카드 타입으로 받아야 하기 때문이다.

클라이언트는 리턴 타입의 실제 타입 매개변수가 정확히 어떤 타입인지 알 수 없으므로,
클라이언트 측에서 객체를 사용하기 전 타입 캐스팅 코드가 삽입(주로 instanceof) 되는데,

이는 제네릭 문법의 이점을 스스로 버리는 것이다.

한정적 와일드카드 문법은 특히 재귀적 타입 한정 문법과 잘 맞는다.

public static <E extends Comparable<E>> E Max(List<E> list){
  return list.stream().max((e1, e2) -> e1.compareTo(e2)).orElseThrow();
}

이 정적 도우미 메서드는 파라미터로 list<E> 타입의 리스트를 하나 받는다. 이 때 정규 타입 매개변수 E 는 Comparable<E> 인터페이스를 구현 해야 한다. 즉 <E extends Comparable<E>> 는

"E 는 E 자신과 논리적 비교가 가능한 기능을 구현 했다"

라는 뜻이 된다.

그리고 list의 원소 중 Comparable<E> 인터페이스 규약에 맞는 메서드(compareTo)를 이용해, 가장 큰 원소를 리턴 해주는 흔한 max 메서드이다.

이제 이 max 메서드에 한정적 와일드카드 문법을 도입 하여 유연한 메서드로 리팩토링 해보자.

public static <E extends Comparable<? super E>> E Max(List<? extends E> list){
  return list.stream().max((e1, e2) -> e1.compareTo(e2)).orElseThrow();
}

하위 모듈에서 Comparable 인터페이스를 구현 하지 않았더라도, 상위 모듈에서 구현 한 Comparable 인터페이스를 사용 할 수 있다.

Man 클래스가 Comparable 인터페이스를 구현 하지 않았더라도, People 클래스에서 Comparable을 구현 했다면 max 메서드를 정상적으로 사용 할 수 있다.

상위 클래스에서 지원하는 모든 기능은 하위 클래스에서도 정상적으로 작동 해야 하기 때문이다.
(SOLID, Liskov Subsitution Principle : 리스코프 치환 원칙)

Comparable 인터페이스를 구현 했다는 것은 다음과 같은 의미다.

"두 E 타입의 논리적 대소비교를 하는 compareTo() 메서드를 인터페이스 규약에 따라 구현한다"

따라서 Comparable은 거의 대부분 소비자 이다.

객체지향 프로그래밍의 유연함을 제네릭 문법에서도 느낄 수 있다.

"남성은 사람이다(Man is a People)" 라는 말을 들으면 어색하다고 느끼는 사람은 없다.

따라서 People 클래스와 Man 클래스의 상속 관계는 적절하다.

우리는 "People man = new Man()" 과 같이 객체의 클래스 타입을 추상화된 상위 클래스로 선언 하여 사용한다.

하지만 실제 타입 매개변수가 계층 구조상에 있어도, Java에선 컴파일 타임 때 실체화 되는 매개변수화 타입은 상위 타입과 하위 타입의 계층적 구조가 존재하지 않는다.

max 메서드의 역할은 파라미터로 받은 list의 E 타입 원소 중 최대 값을 도출한다. 즉 E 타입의 원소를 사용 하여 E 타입의 최대 값을 생산해 리턴하므로 <? extends E>가 적절하다.

활용

모니터를 표현한 클래스의 규약이 담긴 Monitor 추상 클래스이다.

LowestPriceMonitorManager는 가장 저렴한 Monitor 객체를 관리하는 정적 도우미 클래스이다.

lowestPriceMonitorMap은 모니터를 확장한 하위 클래스의 타입 토큰을 키로 받고, 가장 저렴한 모니터 객체의 제품명을 키로, 가격을 값으로 하는 Map을 값으로 받는다.

"Monitor 객체 혹은 Monitor 클래스를 확장 한 어떤 구체 클래스의 타입 토큰만을 받는다."

를 Class<? extends Monitor> 로 표현하였다.

abstract public class Monitor {
  protected String name;
  protected int cost;
  protected int weight;
  protected int inch;

  Monitor(String name, int cost, int weight, int inch) {
    this.name = name;
    this.cost = cost;
    this.weight = weight;
    this.inch = inch;

    LowestPriceMonitorManager.putMonitor(this);
  }

  public String getName() {
    return name;
  }

  public int getCost() {
    return cost;
  }

  public static final class LowestPriceMonitorManager {
    private static final Map<Class<? extends Monitor>, Map<String, Integer>> lowestPriceMonitorMap =
        new HashMap<Class<? extends Monitor>, Map<String, Integer>>();

    private static final void putMonitor(Monitor monitor) {
      Map<String, Integer> concreteMonitorMap = lowestPriceMonitorMap.get(monitor.getClass());

      if (Objects.nonNull(concreteMonitorMap)) {
        if (Objects.nonNull(concreteMonitorMap.get(monitor.getName()))) {
          if (concreteMonitorMap.get(monitor.getName()) > monitor.getCost()) {
            concreteMonitorMap.put(monitor.getName(), monitor.getCost());
          }
        } else {
          concreteMonitorMap.put(monitor.getName(), monitor.getCost());
        }
      } else {
        Map<String, Integer> map = new HashMap<>();
        map.put(monitor.getName(), monitor.getCost());

        lowestPriceMonitorMap.put(monitor.getClass(), map);
      }
    }

    public static final Map<String, Integer> getLowestPriceMonitorMap(
        Class<? extends Monitor> monitorType) {
      return (Objects.nonNull(lowestPriceMonitorMap.get(Objects.requireNonNull(monitorType))))
          ? lowestPriceMonitorMap.get(monitorType)
          : Collections.emptyMap();
    }

    public static final int getLowestPriceMonitor(Class<? extends Monitor> monitorType) {
      return getLowestPriceMonitorMap(monitorType).values().stream()
          .min((i1, i2) -> i1.compareTo(i2)).orElseThrow();
    }
  }
}

댓글