개발 공부/Java

이펙티브 자바 아이템 31 - 한정적 와일드카드를 사용해 API 유연성을 높이라 - 핵심 정리

개발인생 2022. 12. 17. 13:27
반응형

아이템 31 - 한정적 와일드카드를 사용해 API 유연성을 높이라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

Chooser 와 Union API 개선

// 와일드카드 타입을 이용해 대량 작업을 수행하는 메서드를 포함한 제네릭 스택 (181-183쪽)
public class Stack<E> {
   private E[] elements;
   private int size = 0;
   private static final int DEFAULT_INITIAL_CAPACITY = 16;

   // 코드 29-3 배열을 사용한 코드를 제네릭으로 만드는 방법 1 (172쪽)
   // 배열 elements는 push(E)로 넘어온 E 인스턴스만 담는다.
   // 따라서 타입 안전성을 보장하지만,
   // 이 배열의 런타임 타입은 E[]가 아닌 Object[]다!
   @SuppressWarnings("unchecked")
   public Stack() {
      elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
   }

   public void push(E e) {
      ensureCapacity();
      elements[size++] = e;
   }

   public E pop() {
      if (size==0)
         throw new EmptyStackException();
      E result = elements[--size];
      elements[size] = null; // 다 쓴 참조 해제
      return result;
   }

   public boolean isEmpty() {
      return size == 0;
   }

   private void ensureCapacity() {
      if (elements.length == size)
         elements = Arrays.copyOf(elements, 2 * size + 1);
   }

   // 코드 31-2 E 생산자(producer) 매개변수에 와일드카드 타입 적용 (182쪽)
   public void pushAll(Iterable<E> src) {
      for (E e : src)
         push(e);
   }

   // 코드 31-3 와일드카드 타입을 사용하지 않은 popAll 메서드 - 결함이 있다! (183쪽)
//    public void popAll(Collection<E> dst) {
//        while (!isEmpty())
//            dst.add(pop());
//    }

   // 코드 31-4 E 소비자(consumer) 매개변수에 와일드카드 타입 적용 (183쪽)
   public void popAll(Collection<E> dst) {
      while (!isEmpty())
         dst.add(pop());
   }
}

지금까지는 Stack<E> 를 통해 오직 한 타입만을 지칭해 사용했다.

제네릭은 공변 하지 않기 때문에 그 타입의 상속구조와는 관계없이 하나의 타입만 지칭하게 된다.

만약 Stack<Number>Integer 객체를 넣고 싶다면 넣을 수가 없게된다.

그러나 Stack<Number>Integer 객체를 넣는 행위는 위험한 행위가 아니다.

해당하는 객체의 상위 인터페이스로 사용 하는 건 전혀 문제가 되지 않는다.

   // 코드 31-2 E 생산자(producer) 매개변수에 와일드카드 타입 적용 (182쪽)
   public void pushAll(Iterable<E> src) {
      for (E e : src)
         push(e);
   }

그러나 pushAll(Iterable<E> src) 메서드의 매개변수로 E 타입을 정의했기 때문에

Stack<Number> 에서 Integer 객체를 pushAll 메서드의 매개변수로 전달할 수 없다.

여기서 한정적 와일드 카드 사용하면 어떻게 될까?

한정적 타입Iterable<E extends Number> 와 같이 E 라는 어떠한 한정적인 타입을 지칭하고

한정적 와일드 카드Iterable<? extends Number> 와 같이 어떠한 타입을 지칭하지 않는다.

<? extends Number>Number 클래스를 상속받은 아무 타입 을 의미한다.

   // 코드 31-2 E 생산자(producer) 매개변수에 와일드카드 타입 적용 (182쪽)
   public void pushAll(Iterable<? extends E> src) {
      for (E e : src)
         push(e);
   }

pushAll 메서드의 매개변수를 한정적 와일드 카드 로 변경한다.

E 타입을 상속한 아무 타입의 Iterable 을 매개변수로 받을 수 있다.

// 와일드카드 타입을 이용해 대량 작업을 수행하는 메서드를 포함한 제네릭 스택 (181-183쪽)
public class Stack<E> {
    private E[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    // 코드 29-3 배열을 사용한 코드를 제네릭으로 만드는 방법 1 (172쪽)
    // 배열 elements는 push(E)로 넘어온 E 인스턴스만 담는다.
    // 따라서 타입 안전성을 보장하지만,
    // 이 배열의 런타임 타입은 E[]가 아닌 Object[]다!
    @SuppressWarnings("unchecked") 
    public Stack() {
        elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(E e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public E pop() {
        if (size==0)
            throw new EmptyStackException();
        E result = elements[--size];
        elements[size] = null; // 다 쓴 참조 해제
        return result;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }

     // 코드 31-2 E 생산자(producer) 매개변수에 와일드카드 타입 적용 (182쪽)
    public void pushAll(Iterable<? extends E> src) {
        for (E e : src)
            push(e);
    }

   // 코드 31-3 와일드카드 타입을 사용하지 않은 popAll 메서드 - 결함이 있다! (183쪽)
    public void popAll(Collection<E> dst) {
        while (!isEmpty())
            dst.add(pop());
    }


   // 제네릭 Stack을 사용하는 맛보기 프로그램
    public static void main(String[] args) {
        Stack<Number> numberStack = new Stack<>();
        Iterable<Integer> integers = Arrays.asList(3, 1, 4, 1, 5, 9);
        numberStack.pushAll(integers);
    }
}

한정적 와일드카드 타입 을 사용하면 Stack<Number>pushAll 메서드를 통해 Integer 를 넣을 수 있게된다.

Iterable<? extends E> 를 풀어보면 ENumber 클래스가 되고

Iterable<? extends E> 는 결국 Number 클래스를 상속받은 모든 타입을 의미하므로 Integer 를 매개변수로 전달할 수 있는 것이다.

이렇게 어떤 매개변수를 받아 어딘가에 쌓아두는 역할 을 하는 것을 생산자 혹은 프로듀서 라고 한다.

이러한 경우에는 객체를 쌓아두는 컨테이너의 타입보다 하위 타입 을 넣어줄 수 있다.

전달받은 객체보다 상위타입으로 사용하기 떄문에 컨테이너의 타입보다 하위 타입 을 넣어주어도 안전하기 떄문이다.

ppublic class Main {
   public static void main(String[] args) {
      Stack<Number> numberStack = new Stack<>();
      Iterable<Integer> integers = Arrays.asList(3, 1, 4, 1, 5, 9);
      numberStack.pushAll(integers);

      Iterable<Double> doubles = Arrays.asList(3.1, 1.0, 4.0, 1.0, 5.0, 9.0);
      numberStack.pushAll(doubles);
   }
}

Number 의 하위타입을 전부 넣을 수 있으므로 위와같이 Double 타입도 넣을 수 있게된다.

결국 DoubleNumber 타입으로 사용하게되기 때문에 아무런 문제가 발생하지 않는다.

어떤 매개변수를 받아 어딘가에 쌓아두는 역할 을 하는 것을 생산자 에서는 <? extends E> 와 같이

extends 를 활용할 수 있다.

한정적 와일드카드 타입 을 사용하면 API 의 유연성이 높아진다.

반대로 컨테이너 안에 들어있는 객체를 꺼낼 때 는 해당 타입보다 높은 타입 으로 꺼낼 수 있다.

public class Main {
   public static void main(String[] args) {
      Stack<Number> numberStack = new Stack<>();
      Iterable<Integer> integers = Arrays.asList(3, 1, 4, 1, 5, 9);
      numberStack.pushAll(integers);

      Iterable<Double> doubles = Arrays.asList(3.1, 1.0, 4.0, 1.0, 5.0, 9.0);
      numberStack.pushAll(doubles);

      Collection<Object> objects = new ArrayList<>();
      numberStack.popAll(objects); // 컴파일 에러
   }
}

위처럼 Stack<Number> 에 있는 값을 꺼내 Collection<Object> 에 넣어주고 싶지만 컴파일 에러가 발생한다.

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

popAll 메서드의 매개변수가 Collection<E> 로 타입 하나로 선언되어있기 떄문에

더 상위 타입인 Object 와는 다른 타입인 것이다.

Stack<Number> 안에 있는 값을 Collection<Object> 에서 사용해도 문제가 되지 않는다.

Number 보다 상위타입인 Object 타입으로 사용하기 때문이다.

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

Collection<? super E> 와 같이 super 를 사용한 한정적 와일드 카드 타입 을 사용하여 해결한다.

<? extends E> 와 같이 상위 타입이 한정되어 있는 경우는 상위 한정 이라고 부르고,

<? super E> 는 하위 타입이 한정되어 있어서 하위 한정 이라고 부른다.

<? super E>E 타입의 상위 타입들을 의미한다.

예를들어 <? super Integer> 라면 Integer 의 상위 타입인 NumberObject 를 허용한다.

부모 클래스 혹은 그 클래스가 구현하고 있는 인터페이스 타입들을 허용하는 것이다.

public class Main {
   public static void main(String[] args) {
      Stack<Number> numberStack = new Stack<>();
      Iterable<Integer> integers = Arrays.asList(3, 1, 4, 1, 5, 9);
      numberStack.pushAll(integers);

      Iterable<Double> doubles = Arrays.asList(3.1, 1.0, 4.0, 1.0, 5.0, 9.0);
      numberStack.pushAll(doubles);

      Collection<Object> objects = new ArrayList<>();
      numberStack.popAll(objects); 
   }
}

Object 클래스는 Number 의 상위 타입이므로 popAll 메서드의 사용이 가능해진다.

NumberObject 컬렉션에 넣어도 아무 문제가 없다.

ObjectNumber 의 상위 타입이기 때문이다.

이렇게 한정적 와일드 카드 타입 을 사용해 API 의 유연성을 높일 수 있다.

여기서 규칙을 찾을 수 있는데

프로듀서 (뭔가를 받아와서 쌓아넣는 곳, add 나 push 같은), 생산자 에서는 <? extends E> 를 사용하고,

컨슈머 (내가 가지고 있는 것을 꺼내 전달하는 곳) 에서는 <? super E> 를 사용한다.

프로듀서는 extends, 컨슈머는 super 의 앞글자만 따서 PECS 라고 부른다.

PECS: Producer-Extends, Consumer-Super



// T 생산자 매개변수에 와일드카드 타입 적용 (184쪽)
public class Chooser<T> {
   private final List<T> choiceList;
   private final Random rnd = new Random();

   // 코드 31-5 T 생산자 매개변수에 와일드카드 타입 적용 (184쪽)
   public Chooser(Collection<> choices) {
      choiceList = new ArrayList<>(choices);
   }

   public T choose() {
      return choiceList.get(rnd.nextInt(choiceList.size()));
   }
}

Chooser 클래스이다.

   public Chooser(Collection<T> choices) {
      choiceList = new ArrayList<>(choices);
   }

생성자에서 어떠한 Collection 을 받아서 랜덤한 값을 꺼내주는 기능을 한다.

어떠한 값을 가져와 추가하는 것이기 때문에 프로듀서 에 해당한다.

프로듀서 에서 위와 같이 T 라는 하나의 타입만 정의를 하면 선언한 그 타입만 사용해야한다.

public class Main {
   public static void main(String[] args) {
      List<Integer> intList = List.of(1, 2, 3, 4, 5, 6);
      Chooser<Number> chooser = new Chooser<>(intList);
      for (int i = 0; i < 10; i++) {
         Number choice = chooser.choose();
         System.out.println(choice);
      }
   }
}

위와 같이 Chooser<Number> 로 선언했기 때문에

List<Integer> 타입은 넘길 수 없다.

그러나 Number 의 하위 타입인 Integer 타입의 값을 할당해도 문제가 발생하지 않는다.

IntegerNumber 타입으로 사용하기 때문이다.

이 반대인 NumberInteger 타입으로 사용할 수 없다.

더 추상적인 타입을 구체화된 타입으로 사용할 수 없다.

   public Chooser(Collection<? extends T> choices) {
      choiceList = new ArrayList<>(choices);
   }

위와같이 프로듀서extends 키워드를 사용하면 아무런 무리없이

Nuumber 의 하위 타입인 Integer 타입의 List 를 받을 수 있다.



// 코드 30-2의 제네릭 union 메서드에 와일드카드 타입을 적용해 유연성을 높였다. (185-186쪽)
public class Union {
    public static <E> Set<E> union(Set<E> s1,
                                   Set<E> s2) {
        Set<E> result = new HashSet<>(s1);
        result.addAll(s2);
        return result;
    }
}

두 개의 Set 을 합쳐주는 Union 클래스이다.

public class Main {
   // 향상된 유연성을 확인해주는 맛보기 프로그램 (185쪽)
   public static void main(String[] args) {
      Set<Integer> integers = new HashSet<>();
      integers.add(1);
      integers.add(3);
      integers.add(5);

      Set<Double> doubles =  new HashSet<>();
      doubles.add(2.0);
      doubles.add(4.0);
      doubles.add(6.0);

      Set<Number> numbers = union(integers, doubles);

//      // 코드 31-6 자바 7까지는 명시적 타입 인수를 사용해야 한다. (186쪽)
//      Set<Number> numbers = Union.<Number>union(integers, doubles);

      System.out.println(numbers);
   }
}

Set<Integer>Set<Double>Set<Number> 으로 합쳐도 전혀 문제가 되지 않는다.

// 코드 30-2의 제네릭 union 메서드에 와일드카드 타입을 적용해 유연성을 높였다. (185-186쪽)
public class Union {
    public static <E> Set<E> union(Set<E> s1,
                                   Set<E> s2) {
        Set<E> result = new HashSet<>(s1);
        result.addAll(s2);
        return result;
    }
}

하지만 현재는 E 라는 하나의 타입만 사용하고 있기 때문에 불가능하다.

union 메서드는 무언가를 받아와 넣어주는 역할을 하기 때문에 프로듀서 이다.

// 코드 30-2의 제네릭 union 메서드에 와일드카드 타입을 적용해 유연성을 높였다. (185-186쪽)
public class Union {
    public static <E> Set<E> union(Set<? extends E> s1,
                                   Set<? extends E> s2) {
        Set<E> result = new HashSet<>(s1);
        result.addAll(s2);
        return result;
    }
}

위처럼 extends 를 사용한 한정적 와일드카드 타입 을 사용해 API 를 더 유연하게 사용할 수 있다.



// 재귀적 타입 한정을 이용해 상호 비교할 수 있음을 표현 (179쪽)
public class RecursiveTypeBound {
    // 코드 30-7 컬렉션에서 최댓값을 반환한다. - 재귀적 타입 한정 사용 (179쪽)
    public static <E extends Comparable<E>> E max(Collection<E> c) {
        if (c.isEmpty())
            throw new IllegalArgumentException("컬렉션이 비어 있습니다.");

        E result = null;
        for (E e : c)
            if (result == null || e.compareTo(result) > 0)
                result = Objects.requireNonNull(e);

        return result;
    }

    public static void main(String[] args) {
        List<String> argList = List.of("keesun", "whiteship");
        System.out.println(max(argList));
    }
}

RecursiveTypeBound 클래스는 가장 큰 값을 꺼내주는 기능을한다.

이때 재귀적 타입 한정 을 사용했다.

여기서 좀 더 유연하게 만들려면

public static <E extends Comparable<? super E>> E max(List<? extends E> list) {
      if (list.isEmpty())
      throw new IllegalArgumentException("빈 리스트");

      E result = null;
      for (E e : list)
      if (result == null || e.compareTo(result) > 0)
      result = e;

      return result;
      }

위와 같이 값을 받아오는 프로듀서 애서는 List<? extends E> 를 사용하고

Comparable 은 무언가 값을 꺼내 비교하는 소비자 이기 떄문에

Comparable<? super E> 를 사용했다.

우리가 사용하는 타입의 상위 타입만 Comparable 을 구현한 경우가 있기 때문이다.

public class Box<T extends Comparable<T>> implements Comparable<Box<T>> {

    protected T value;

    public Box(T value) {
        this.value = value;
    }

    public void change(T value) {
        this.value = value;
    }

    @SuppressWarnings("unchecked")
    @Override
    public int compareTo(Box anotherBox) {
        return this.value.compareTo((T)anotherBox.value);
    }

    @Override
    public String toString() {
        return "Box{" +
                "value=" + value +
                '}';
    }
}

Comparable 을 구현한 Box 클래스를 작성한다.


public class IntegerBox extends Box<Integer> {

    private final String message;

    public IntegerBox(int value, String message) {
        super(value);
        this.message = message;
    }

    @Override
    public String toString() {
        return "IntegerBox{" +
                "message='" + message + '\'' +
                ", value=" + value +
                '}';
    }
}

IntegerBox 클래스는 Box 클래스만 상속받고 있다.

IntegerBox 클래스는 직접적으로 Comparable 구현하고 있지는 않다.

public class Main {
   public static void main(String[] args) {
      List<IntegerBox> list = new ArrayList<>();
      list.add(new IntegerBox(10, "keesun"));
      list.add(new IntegerBox(2, "whiteship"));

      System.out.println(RecursiveTypeBound.max(list));
   }
}

이러한 경우에도 max 메서드가 잘 동작한다.

IntegerBox 클래스는 직접적으로 Comparable 구현하고 있지는 않지만

IntegerBoxsuper 타입인 Box 타입이

Comparable 구현했기 때문에 <E extends Comparable<? super E>> 타입에 맞는 타입이다.


와일드 카드 활용 팁

와일드 카드를 언제 어떻게 사용하면 좋은지와 주의사항을 알아보도록 하자.

와일드 카드는 ? 를 지칭한다.

? 하나만 사용하지는 않고 더 허용하고 싶은 매개변수를 넓혀줄 때

extendssuper 키워드와 같이 와일드 카드 를 사용한다.

? 하나만 선언했을 때는 불특정 타입 이 된다.

E 는 특정할 수 있는 타입이지만 ? 는 특정할 수 없는 임의의 타입이다.


// 와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 메서드 (189쪽)
public class Swap {

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

    // 와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 메서드
    private static <E> void swapHelper(List<E> list, int i, int j) {
        list.set(i, list.set(j, list.get(i)));
    }

    public static void main(String[] args) {
        // 첫 번째와 마지막 인수를 스왑한 후 결과 리스트를 출력한다.
        List<String> argList = Arrays.asList(args);
        swap(argList, 0, argList.size() - 1);
        System.out.println(argList);
    }
}

위의 클래스처럼 E 를 사용하는 경우에는 ? 로 고칠 수 있다.


// 와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 메서드 (189쪽)
public class Swap {

    public static void swap(List<?> list, int i, int j) {
        list.set(i, list.set(j, list.get(i))); // 컴파일 에러
    }

    // 와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 메서드
    private static <E> void swapHelper(List<E> list, int i, int j) {
        list.set(i, list.set(j, list.get(i)));
    }

    public static void main(String[] args) {
        // 첫 번째와 마지막 인수를 스왑한 후 결과 리스트를 출력한다.
        List<String> argList = Arrays.asList(args);
        swap(argList, 0, argList.size() - 1);
        System.out.println(argList);
    }
}

위와같이 ? 를 사용하게 되면 값을 넣을 수가 없어진다.

List<?> list 와 같이 비 한정적 타입 을 사용하면

list.set(j, list.get(i)) 에서도 비 한정적 타입 을 원한다.

심지어 list.get(i) 에서도 우리가 모르는 무언가의 타입이 나오게된다.

비 한정적 와일드카드 타입 은 타입을 모른다.

E 는 어떠한 특정한 타입을 안다.

? 타입은 오로지 Null 밖에 허용하지 않는다.

때문에 ? 타입 하나만 사용하는 걸 권장하지는 않는다.

? 를 사용했을 때 메서드 시그니쳐는 간소화시킬 수 있지만

값을 넣는 것 은 가능하지 않다.

대신 값을 꺼내는 것 은 가능하다.

? 타입은 Null 빼고는 아무것도 매치가 되지 않는다.

그래서 무언가를 꺼내기만 할 거라면 ? 하나만 사용해도 되지만

무언가를 넣는 작업이 있다면 ? 하나만 사용하면 안된다.

// 와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 메서드 (189쪽)
public class Swap {

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

무언가를 넣는 작업을 한다면 결국 E 타입을 정의해 사용할 수 밖에 없다.

반응형