BOOK 6 - 이펙티브 자바(5)

5장 제네릭

  • 제네릭은 클래스를 정의할 때 구체적인 타입을 적지않고 변수 형태로 선언함으로써 코드를 다양한 타입의 객체에 대해 재사용할 수 있게 해준다.
  • 제네릭 클래스에서는 타입을 변수로 표시하며, 이것을 타입 매개변수라고 한다.
    • 타입 매개변수는 객체 생성시에 프로그래머에 의해 결정된다.

raw type은 사용하지 말라

  • 클래스와 인터페이스 선언에 타입 매개변수가 쓰이면, 이를 제네릭 클래스 혹은 제네릭 인터페이스 라고 한다.
    • 예를 들어, List 인터페이스는 원소의 타입을 나타내는 타입 매개변수 E 를 받는다.
  • 제네릭 클래스와 제네릭 인터페이스를 통틀어 제네릭 타입이라 한다.

  • 각각의 제네릭 타입은 일련의 매개변수화 타입(parameterized type)을 정의한다.
    • List<String> 은 원소의 타입이 String인 리스트를 뜻하는 매개변수화 타입이다.
  • 제네릭 타입을 하나 정의하면 그에 딸린 raw type도 함께 정의된다.
  • raw type이란 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때를 말한다.
    • 로 타입은 타입 선언에서 제네릭 타입 정보가 전부 지워진 것처럼 동작한다.
    • 하위 호환성을 위해 존재한다.
private final Collection stamps = ...;
  • 이 코드는 실수로 Stamp 대신 Coin 을 넣어도 아무 오류 없이 컴파일되고 실행된다.
  • 컬렉션에서 다시 꺼내 캐스팅하기 전까지는 오류를 알아내지 못한다.
for (Iterator i = stamps.iterator(); i.hasNext()) {
  Stamp stamp = (Stamp) i.next();
  stamp.cancel();
}
  • 위 코드를 실행하면 ClassCastException 이 발생할 것이다.
private final Collection<Stamp> stamps = ...;
  • 이렇게 선언하면 컴파일러는 stamps 에는 Stamp 인스턴스만 넣어야 함을 인지하게 된다.
    • 컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가하여 절대 실패하지 않음을 보장한다.

임의 객체를 허용하는 매개변수화 타입

  • List 같은 로 타입은 사용해서는 안되나, List<Object> 처럼 임의 객체를 허용하는 매개변수화 타입은 괜찮다.
  • List 는 제네릭 타입에서 완전히 발을 뺀 것이고, List<Object> 는 모든 타입을 허용한다는 의사를 컴파일러에 명확하게 전달한 것이다.
    • 즉, List 를 매개변수로 받는 메서드에 List<String> 을 넘길 수 있지만, List<Object> 를 받는 메서드에는 넘길 수 없다.
    • List<String>List 의 하위 타입이지만, List<Object> 의 하위 타입은 아니다.
  • 따라서 List<Object> 같은 매개변수화 타입을 사용할 때와 달리 List 같은 로 타입을 사용하면 타입 안정성을 잃게 된다.
public static void main(String[] args) {
  List<String> strings = new ArrayList<>();
  unsafeAdd(strings, Integer.valueOf(42));
  String s = strings.get(0);
}

private static void unsafeAdd(List list, Object o) {
  list.add(o);
}
  • 위 코드는 컴파일은 되지만, 실행했을 때 strings.get(0) 의 결과를 형변환하려할 때 ClassCastException 을 던진다.
private static void unsafeAdd(List<Object> list, Object o) {
  list.add(o);
}
  • 위 코드로 수정 후 다시 컴파일하면 컴파일 오류가 발생한다.

비한정적 와일드카드 타입

  • 원소의 타입을 몰라도 되는 로 타입을 쓰고 싶을 수도 있다.
static int numElementsInCommon(Set s1, Set s2) {
  int result = 0;
  for (Object o1: s1) {
    if(s2.contains(o1))
      result++;
  }
  return result;
}
  • 위 메서드는 동작은 하지만 로 타입을 사용해 안전하지 않다.
  • 제네릭 타입을 쓰고싶지만 실제 타입 매개변수가 무엇인지 신경쓰고 싶지 않다면 ? 를 사용하자.
    • Set<?> 은 어떤 타입이라도 담을 수 있는 가장 범용적인 매개변수화 Set 타입이다.
static int numElementInCommon(Set<?> s1, Set<?> s2) { ... }
  • 로 타입 컬렉션에는 아무 원소나 넣을 수 있어 타입 불변식을 훼손하기 쉽다.
  • Collection<?> 에는 null 외의 어떤 원소도 넣을 수 없다.
  • 이러한 제약을 받아들일 수 없다면 제네릭 메서드나 한정적 와일드카드 타입을 사용하면 된다.

예외

  • class 리터럴에는 로 타입을 써야 한다.
    • 예를 들어, List.class , String[].class 는 허용되지만, List<String>.classList<?>.class 는 허용하지 않는다.
  • instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다.
    • 런타임에는 제네릭 타입 정보가 지워지기 때문이다.
    • 로 타입이든 비한정적 와일드카드 타입이든 instanceof 는 완전히 똑같이 동작하므로 로 타입으로 쓰는 편이 깔끔하다.
if (o instanceof Set) {
  Set<?> s = (Set<?>) o;
  ...
}
  • o 의 타입이 Set 임을 확인한 다음 와일드카드 타입인 Set<?> 으로 형변환해야 한다.

비검사 경고를 제거하라

  • 제네릭을 사용하기 시작하면 비검사 형변환 경고, 비검사 메서드 호출 경고, 비검사 매개변수화 가변인수 타입 경고, 비검사 변환 경고 등 수많은 컴파일러 경고를 마주하게 된다.
  • 이러한 모든 비검사 경고를 제거한다면 그 코드는 타입 안전성이 보장된다.
  • 경고를 제거할 수는 없지만 타입 안전하다고 확신할 수 있다면 @SuppressWarnings("unchecked") 어노테이션을 달아 경고를 숨기자.
    • 항상 가능한 한 좁은 범위에 적용하도록 한다.
    • 사용할 때는 항상 그 경고를 무시해도 안전한 이유를 주석으로 남겨야 한다.

배열보다는 리스트를 사용하라

  • 배열과 제네릭 타입에는 두가지 중요한 차이가 있다.

공변 (covariant)

배열

  • SubSuper 의 하위 타입이라면 배열 Sub[]Super[] 의 하위 타입이 된다.
    • 공변, 즉 함께 변한다는 뜻이다.
    • 공변 : 같은 타입으로 변환된다. (수학적으로는 벡터에서 같은 방향일 때를 의미)
      • 리스코프 치환 원칙처럼 타입 A가 타입 B의 하위 클래스일 때 타입 B로 치환될 수 있기 때문에 공변이라고 할 수 있다.
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다";
  • 위 코드는 문법상 허용되는 코드이다.
  • 그러나 실행했을 때 ArrayStoreException 을 던지게 된다.

제네릭

  • 반면 제네릭은 서로 다른 Type1Type2 에 대해서 List<Type1>List<Type2> 의 하위 타입도 상위 타입도 아니다.
List<Object> ol = new ArrayList<Long>();
ol.add("타입이 달라 넣을 수 없다");
  • 위 코드는 컴파일 타임에 컴파일 오류가 발생해 바로 오류를 알아챌 수 있다.

실체화 (reify)

  • 배열은 실체화된다.
  • 즉, 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다.
    • Long 배열에 String 을 넣으려하면 ArrayStoreException 이 발생한다.
  • 제네릭은 타입 정보가 런타임에는 소거된다.
    • 원소 타입을 컴파일타임에만 검사하며, 런타임에는 알 수조차 없다.
  • E , List<E> , List<String> 같은 타입을 실체화 불가 타입이라 한다.
    • 실체화되지 않아서 런타임에는 컴파일 타임보다 타입 정보를 적게 가지는 타입이다.

제네릭 배열

  • 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다.
    • 즉, new List<E>[] , new List<String>[] 식으로 작성하면 컴파일 시에 제네릭 배열 생성 오류가 발생한다.
  • 제네릭 배열을 만들지 못하게 막은 이유는 타입 안전하지 않기 때문이다.
    • 컴파일러가 자동 생성한 형변환 코드에서 ClassCastException 이 발생할 수 있다.
  • 제네릭 배열 생성이 가능하다고 가정해보자.
List<String>[] stringLists = new List<String>[1];			// 1
List<Integer> intList = List.of(42);									// 2
Object[] objects = stringLists;   										// 3
objects[0] = intList;																	// 4
String s = stringLists[0].get(0); 										// 5
  • 3에서 List<String> 배열을 Object 배열에 할당한다.
    • 배열은 공변이므로 문제없다.
  • 4에서 List<Integer> 인스턴스를 Object 배열의 첫 원소로 저장한다.
    • 제네릭은 소거 방식으로 구현되어서 역시 문제 없다.
    • 즉, 런타임에는 List<Integer> 인스턴스의 타입은 단순히 List 가 되고, List<Integer>[] 인스턴스의 타입은 List[] 가 된다.
  • 그러면 현재는 List<String> 인스턴스만 담겠다고 선언한 stringLists 배열에 List<Integer> 인스턴스가 저장되어 있다.
  • 5에서 배열의 처음 리스트의 첫 원소를 꺼낼 때 컴파일러는 자동으로 String 으로 형변환하는데, 이 원소는 Integer 이므로 ClassCastException 이 발생한다.

예시

public class Chooser {
  private final Object[] choiceArray;
  
  public Chooser(Collection choices) {
    choiceArray = choices.toArray();
  }
  
  public Object choose() {
    Random rnd = ThreadLocalRandom.current();
    return choiceArray[rnd.nextInt(choiceArray.length)];
  }
}
  • 이 클래스를 사용하려면 choose 메서드를 호출할 때마다 반환된 Object 를 원하는 타입으로 형변환해야 한다.
    • 만약 타입이 다른 원소가 들어있었다면 형변환 오류가 발생할 것이다.
  • 이 클래스를 제네릭으로 만들면 다음과 같이 된다.
public class Chooser<T> {
  private final T[] choiceArray;
  
  public Chooser(Collection<T> choices) {
    choiceArray = choices.toArray();
  }
  
  // choose 메서드는 동일
}
  • 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는게 보통은 불가능하다.
  • 이 클래스를 컴파일하면 컴파일 오류가 발생할 것이다.
    • Object 배열을 T 배열로 형변환하면 된다.
choiceArray = (T[]) choices.toArray();
  • 이렇게 바꾸면 컴파일 시에 경고가 뜬다. (비검사 형변환 경고)
    • T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 경고 메시지가 뜬다.
    • 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없기 때문이다.
  • 비검사 형변환 경고를 제거하려면 배열 대신 리스트를 사용하면 된다.
public class Chooser<T> {
  private final List<T> choiceList;
  
  public Chooser(Collection<T> choices) {
    choiceList = new ArrayList<>(choices);
  }
  
  public T choose() {
    Random rnd = ThreadLocalRandom.current();
    return choiceList.get(rnd.nextInt(choiceList.size()));
  }
}

정리

  • 배열은 공변이고 실체화 되는 반면, 제네릭은 불공변이고 타입 정보가 소거된다.
  • 따라서 배열은 런타임에는 타입 안전하지만, 컴파일 타임에는 그렇지 않다.
  • 제네릭은 컴파일 타임에는 타입 안전하지만, 런타임에는 그렇지 않다.
  • 그래서 둘을 섞어 쓰기란 쉽지 않다.
  • 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면 가장 먼저 배열을 리스트로 대체하는 방법을 적용해보자.

이왕이면 제네릭 타입으로 만들라

  • 이전의 Object 기반의 Stack 클래스는 원래 제네릭 타입이어야 마땅하다.
    • 클라이언트가 스택에서 꺼낸 객체를 형변환할 때 런타임 오류가 발생할 위험이 있다.

일반 클래스를 제네릭 클래스로 만들기

1. 클래스 선언에 타입 매개변수 추가

public class Stack<E> {
  private E[] elements;
  private int size = 0;
  private static final int DEFAULT_INITIAL_CAPACITY = 16;
  
  public Stack() {
  	elements = new E[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;
  }
}
  • 이 단계에서 대체로 하나 이상의 오류나 경고가 발생한다.
  • 여기서는 new E[DEFAULT_INITIAL_CAPACITY] 에서 오류가 발생한다.
    • E 와 같은 실체화 불가 타입으로는 배열을 만들 수 없기 때문이다.

2. 배열을 제네릭으로 변경

  • 제네릭은 배열로 만들 수 없다. 제네릭 배열 만들어야 한다면 다음 두가지 방법으로 우회할 수 있다.
  1. 제네릭 배열 생성 금지 제약 우회
  • Object 배열을 생성한 다음 제네릭 배열로 형변환한다.
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
  • 컴파일러는 오류 대신 경고를 내보낼 것이다.

    warning: [unchecked] unchecked cast
    found: Object[], required: E[]
    
  • 일반적으로 타입 안전하지 않다.

  • @SuppressWarnings 어노테이션으로 비검사 형변환 경고를 숨길 수 있다.

    • 단, 비검사 형변환이 타입 안정성을 해치치 않음을 확인한 후 숨겨야 한다.
    • 예제의 elements 는 private 필드에 저장되고, 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 전혀 없다.
    • push 메서드를 통해 배열에 저장되는 원소의 타입은 항상 E 다.
    • 따라서 이 비검사 형변환은 확실히 안전하다.
    @SuppressWarnings("unchecked")
    public Stack() {
      elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
    }
    
  1. elements 필드 타입을 E[] 에서 Object[] 로 바꾸기 (원소 반환시에 각 원소를 형변환)
  • 이렇게 하면 E result = elements[--size] 에서 컴파일 오류가 발생한다.
  • 배열이 반환한 원소를 E 로 형변환 하면 오류 대신 경고가 뜬다.
E result = (E) elements[--size];
  • E 는 실체화 불가 타입이므로 컴파일러는 런타임에 이뤄지는 형변환이 안전한지 증명할 방법이 없어 역시 비검사 형변환 경고를 발생시킨다.

  • 역시 형변환이 타입 안전함을 직접 증명하고 경고를 숨길 수 있다.

public E pop() {
  if (size == 0) throw new EmptyStackException();
  
  @SuppressWarnings("unchecked")
  E result = (E) elements[--size];
  
  elements[size] = null;
  return result;
}

제네릭 Stack 클래스 사용 예제

public static void main(String[] args) {
  Stack<String> stack = new Stack<>();
  for(String arg : args) {
    stack.push(arg);
  }
  
  while(!stack.isEmpty()) {
    System.out.println(stack.pop().toUpperCase());
  }
}
  • 스택에서 꺼낸 원소에서 toUpperCase 를 호출할 때 명시적 형변환을 수행하지 않는다.

타입 매개변수 제약

  • 대다수의 제네릭 타입은 타입 매개변수에 아무런 제약을 두지 않는다.
    • Stack<Object> , Stack<int[]> , Stack<List<String>> 등 어떤 참조 타입으로도 스택을 만들 수 있다.
  • 단, 기본 타입은 사용할 수 없다.
    • 박싱된 기본 타입을 사용해야 한다.
  • 타입 매개변수에 제약을 두는 제네릭 타입도 있다.
class DelayQueue<E extends Delayed> implements BlockingQueue<E>
  • 타입 매개변수로 Delayed 의 하위 타입만 받는다는 의미이다.
    • DelayQueue 자신과 이를 사용하는 클라이언트는 DelayQueue 원소에서 형변환 없이 바로 Delayed 클래스의 메서드를 호출할 수 있다.
    • 이러한 타입 매개변수 E 를 한정적 타입 매개변수라고 한다.

이왕이면 제네릭 메서드로 만들어라

  • 매개변수화 타입을 받는 정적 유틸리티 메서드는 보통 제네릭이다.

  • 다음 로 타입을 사용한 메서드를 제네릭 메서드로 바꿔보자

public static Set union(Set s1, Set s2) {
  Set result = new HashSet(s1);
  result.addAll(s2);
  return result;
}
  • 위 코드는 컴파일은 되지만 로 타입으로 인해 두 개의 경고가 발생한다.
  • 경고를 없애려면 이 메서드를 타입 안전하게 만들어야 한다.

제네릭 메서드로 변경

  • 메서의 입력 파라미터와 반환 값의 원소 타입을 타입 매개변수로 명시하고, 메서드 안에서도 이 타입 매개변수를 사용하도록 수정한다.
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
  Set<E> result = new HashSet<>(s1);
  result.addAll(s2);
  return result;
}
  • 타입 매개변수 목록(<E>) 은 메서드의 제한자와 반환타입 사이에 온다.

제네릭 메서드를 활용하는 코드

public static void main(String[] args) {
  Set<String> guys = Set.of("톰", "딕", "해리");
  Set<String> stooges = Set.of("래리", "모에", "컬리");
  Set<String> aflCio = union(guys, stooges);
  System.out.println(aflCio);
}
  • 직접 형변환하지 않아도 오류나 경고 없이 컴파일된다.

제네릭 싱글턴 팩터리

  • 때때로 불변 객체를 여러 타입으로 활용할 수 있게 만들어야 할 때가 있다.
  • 제네릭은 런타임에 타입 정보가 소거되므로 하나의 객체를 어떤 타입으로든 매개변수화할 수 있다.
  • 요청한 타입 매개변수에 맞게 그 객체의 타입을 바꿔주는 정적 팩터리를 만들어야 한다.

항등함수 예제

  • 입력 값을 수정없이 그대로 반환하는 함수
public static UnaryOperator<Object> IDENTITY_FN = (t) -> t;

@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
  return (UnaryOperator<T>) IDENTITY_FN;
}
  • IDENTITY_FNUnaryOperator<T> 로 형변환하면 비검사 형변환 경고가 발생한다.
  • 하지만 입력 값을 그대로 반환하는 항등함수 특성상 UnaryOperator<T> 를 사용해도 타입 안전하다는 사실을 알고 있기 때문에 경고를 숨길 수 있다.
제네릭 싱글턴 사용 예제
public static void main(String[] args) {
  String[] strings = {"A", "B", "C"};
  UnaryOperator<String> sameString = identityFunction();
  for (String s : strings) {
    System.out.println(sameString.apply(s));
  }
}

재귀적 타입 한정

  • 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정할 수 있다.
  • 재귀적 타입 한정은 주로 Comparable 인터페이스와 함께 쓰인다.
  • Comparable<T> 의 타입 매개변수 T는 Comparable<T> 를 구현한 타입이 비교할 수 있는 원소의 타입을 정의한다.
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;
}
  • 타입 한정인 <E extends Comparable<E>> 는 모든 타입 E 는 자신과 비교할 수 있다는 의미이다.
    • 즉, 컬렉션에 담긴 모든 원소가 상호 비교될 수 있다는 뜻이다.

한정적 와일드카드를 사용해 API 유연성을 높이라

  • 매개변수화 타입은 불공변이다.
  • 때로는 불공변 방식보다 유연한 무언가가 필요하다.
  • 런타임시에 여러개의 타입을 처리하는데, 그 범위를 내가 타입 안정성을 확보할 수 있는 선까지 제한하기 위해서 한정적 와일드카드를 사용한다.
    • 예를 들어, 이 메서드 내에서 Type 타입으로만 원소를 활용함을 보장하면, Type 의 하위 타입까지 받아 처리할 수 있어 <? extends Type> 으로 선언할 수 있다.

producer - extends

  • 앞서 본 Stack 클래스에 pushAll 메서드를 추가해야 한다고 해보자.
public void pushAll(Iterable<E> src) {
  for (E e : src) {
    push(e);
  }
}
  • 이 메서드는 컴파일되지만 완벽히 작동하지는 않는다.
  • src 의 원소 타입이 스택의 원소 타입과 일치하면 잘 동작하지만, 다음과 같은 경우에는 오류가 발생한다.
Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ...;
numberStack.pushAll(integers);
// 결과 : error: incompatible types: Iterable<Integer> cannot be converted to Iterable<Number>
  • 이런 경우 한정적 와일드카드 타입이라는 매개변수화 타입을 통해 해결할 수 있다.

  • pushAll 의 매개변수 타입은 “E의 Iterable” 이 아니라 “E의 하위 타입의 Iterable” 이어야 한다.

    • Iterable<? extends E> 가 이 뜻을 의미한다.
public void pushAll(Iterable<? extends E> src) {
  for (E e : src) {
    push(e);
  }
}
  • 이렇게 수정하면 Stack과 클라이언트 코드 모두 타입 안전하게 컴파일된다.

consumer - super

  • Stack 클래스에 popAll 메서드를 추가해보자.
public void popAll(Collection<E> dst) {
  while(!isEmpty()) {
    dst.add(pop());
  }
}
  • 이 코드 역시 컬렉션의 원소 타입이 스택의 원소 타입과 동일하면 문제없이 동작한다.
  • 그러나 다음 상황에서는 오류가 발생한다.
Stack<Number> numberStack = new Stack<>();
Collection<Object> objects = ...;
numberStack.popAll(objects);
  • 이 코드를 컴파일하면 ‘Collection<Object>Collection<Number> 의 하위 타입이 아니다’ 라는 오류가 발생한다.
  • popAll 의 매개변수 타입이 “E의 Collection” 이 아니라 “E의 상위 타입의 Collection” 이어야 한다.
    • Collection<? super E> 가 이 뜻을 의미한다.
public void popAll(Collection<? super E> dst) {
  while(!isEmpty()) {
    dst.add(pop());
  }
}
  • 이처럼 유연성을 극대화하려면 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용해야 한다.

PECS 공식 : producer - extends , consumer - super

  • 매개변수화 타입 T가 생산자라면 <? extends T> 를 사용하고,
  • 소비자라면 <? super T> 를 사용한다.
  • Stack 클래스 예에서
    • pushAll 의 매개변수 src 는 Stack이 사용할 E 인스턴스를 생산하므로 생산자이다.
    • popAll 의 매개변수 dst 는 Stack으로부터 E 인스턴스를 소비하므로 소비자이다.
  • 앞서 봤던 예제들에 이 공식을 적용하여 와일드카드 타입을 적용할 수 있다.
public Chooser(Collection<? extends T> choices)
  • Chooser 생성자에 넘겨지는 choices 컬렉션은 T 타입의 값을 생산하기만 하니 T 를 확장하는 와일드카드 타입을 사용해 선언했다.
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2)
  • s1s2 는 모두 생산자이니 역시 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);

타입 매개변수에 와일드카드 적용

  • 앞에 설명한 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)
  • 와일드카드 타입을 두 번 적용했다.
  • 입력 매개변수 list 는 생산자이므로 List<? extends E> 로 수정했다.
  • 타입 매개변수의 경우, 원래 선언에서는 EComparable<E> 를 확장한다고 정의했다.
    • Comparable<E> 는 E 인스턴스를 소비한다.
    • 따라서 매개변수화 타입 Comparable<E>Comparable<? super E> 로 수정했다.
  • ComparableComparator 는 언제나 소비자이므로, Comparable<? super E>Comparator<? super E> 를 사용하는 편이 낫다.
    • Comparable 이나 Comparator직접 구현하지 않고, 직접 구현한 다른 타입을 확장한 타입을 지원하기 위해 와일드카드가 필요하다.

타입 매개변수와 와일드카드

  • 타입 매개변수와 와일드카드에는 공통되는 부분이 있어서 메서드를 정의할 때 어느 것을 사용해도 괜찮을 때가 많다.
  • 기본 규칙은 메서드 선언에 타입 매개변수가 한번만 나오면 와일드카드로 대체하는 것이다.

비한정적 타입 매개변수 사용

public static <E> void swap(List<E> list, int i, int j);

비한정적 와일드카드 사용

public static void swap(List<?> list, int i, int j);
  • 이 코드는 메서드 구현시 컴파일되지 않는다는 문제가 있다.
public static void swap(List<?> list, int i, int j) {
  list.set(i, list.set(j, list.get(i)));
}
  • list 의 타입은 List<?> 인데, List<?> 에는 null 이외에 어떤 값도 넣을 수 없다는 문제가 있다.
  • 와일드카드 타입의 실제 타입을 알려주는 private 도우미 메서드를 따로 작성하여 활용하면 이를 해결할 수 있다.
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)));
}
  • 내부에서 제네릭 메서드를 활용하여 외부에서는 유연한 와일드카드 기반의 API 선언을 유지할 수 있다.

제네릭과 가변인수를 함께 쓸 때는 신중하라

  • 가변인수는 메서드에 넘기는 인수의 개수를 클라이언트가 조절할 수 있게 해준다.
  • 가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 자동으로 하나 만들어진다.
  • 거의 모든 제네릭과 매개변수화 타입은 실체화되지 않으므로, 메서드를 선언할 때 실체화 불가 타입으로 varargs 매개변수를 선언하면 컴파일러가 경고를 보낸다.
warning: [unchecked] Possible heap pollution from parameterized varargs type List<String>
  • 매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생한다.
static void dangerous(List<String>... stringLists) {
  List<Integer> intList = List.of(42);
  Object[] objects = stringLists;
  objects[0] = intList;									// 힙 오염 발생
  String s = stringLists[0].get(0);			// ClassCastException 발생
}
  • 위 코드를 실행하면 마지막 줄에서 컴파일러가 생성한 형변환이 실패하여 ClassCastException 이 발생한다.
  • 이처럼 타입 안정성이 깨지기 때문에 제네릭 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않다.

@SafeVarargs 로 경고 숨기기

  • 제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 메서드는 실제로 실무에서 매우 유용하다.
  • Arrays.asList(T... a) , Collections.addAll(Collection<? super T> c, T... elements) 가 대표적이다.
  • @SafeVarargs 어노테이션을 사용하여 메서드 작성자가 그 메서드가 타입 안전함을 보장할 수 있다.
    • 단, 메서드가 타입 안전함을 확신한 경우에만 사용해야 한다.

메서드의 타입 안전 여부 확인

  • 가변인수 메서드를 호출할 때 varargs 매개변수를 담는 제네릭 배열이 만들어진다.
  • 메서드가 이 배열에 아무것도 저장하지 않고, 그 배열의 참조가 밖으로 노출되지 않는다면 타입 안전하다.
    • 즉, varargs 매개변수 배열이 호출자로부터 그 메서드로 순수하게 인수들을 전달하는 일만 한다면 그 메서드는 안전하다.
static <T> T[] toArray(T... args) {
  return args;
}
  • 위 코드는 가변인수로 넘어온 매개변수들을 배열에 담아 반환한다.
  • 매개변수 배열의 참조를 노출하므로 타입 안전하지 않다.
    • 매개변수 배열에 다른 메서드가 접근하도록 허용하기 때문이다.

안전한 제네릭 가변인수 메서드 예제

@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
  List<T> result = new ArrayList<>();
  for (List<? extends T> list : lists) {
    result.addAll(list);
  }
  return result;
}
  • 타입 안전하지 않은 varargs 메서드는 절대 작성해서는 안되며, 타입 안전한 모든 제네릭 varargs 메서드에는 @SafeVarargs 어노테이션을 달아 사용자를 헷갈리게 하는 컴파일러 경고를 없애야 한다.

  • 굳이 @SafeVarargs 를 사용하지 않고, varargs 매개변수를 List 매개변수로 바꿔서 선언할 수도 있다.

static <T> List<T> flatten(List<List<? extends T>> lists) {
  List<T> result = new ArrayList<>();
  for (List<? extends T> list : lists) {
    result.addAll(list);
  }
  return result;
}
  • 정적 팩터리 메서드인 List.of 를 활용하면 위 메서드에 임의 개수의 인수를 넘길 수 있다.
    • List.of@SafeVarargs 어노테이션이 선언되어있기 때문에 가능하다.
audience = flatten(List.of(friends, romans, countrymen));
  • @SafeVarargs 어노테이션을 직접 달지 않아도 되며, 실수로 안전하다고 판단할 걱정도 없다.

타입 안전 이종 컨테이너를 고려하라

  • 제네릭은 Set<E> , Map<K,V> 등의 컬렉션, ThreadLocal<T> , AtomicReference<T> 등의 단일원소 컨테이너에서 흔히 쓰인다.
    • 여기서 매개변수화 되는 대상은 원소가 아닌 컨테이너 자신이다.
    • 따라서 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다.
  • 더 유연한 수단이 필요할 때도 있다.
    • 컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하면 된다.
    • 제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해준다.
    • 이런 방식을 타입 안전 이종 컨테이너 패턴이라고 한다.

타입 안전 이종 컨테이너 예제 - Favorites 클래스

public class Favorites {
  public <T> void putFavorites(Class<T> type, T instance);
  public <T> T getFavorites(Class<T> type);
}
  • 타입별로 즐겨찾는 인스턴스를 저장하고, 검색할 수 있는 클래스이다.
  • 각 타입의 Class 객체를 매개변수화한 키 역할로 사용한다.
    • class 의 클래스가 제네릭이기 때문에 동작할 수 있다.
    • 즉, class 리터럴의 타입은 Class 가 아닌 Class<T> 이다.
  • 컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고 받는 class 리터럴을 타입 토큰이라 한다.

Favorites 구현

public class Favorites {
  private Map<Class<?>, Object> favorites = new HashMap<>();
  
  public <T> void putFavorites(Class<T> type, T instance) {
    favorites.put(Objects.requireNonNull(type), instance);
  }
  
  public <T> T getFavorites(Class<T> type) {
    return type.cast(favorites.get(type));
  }
}
  • favorites 타입이 Map<Class<?>, Object> 이다.
    • 비한정적 와일드카드 타입이어서 맵 안에 null 외에 어떤 원소도 넣을 수 없을거라 판단할 수 있지만, 맵이 아니라 키가 와일드 카드 타입이기 때문에 그렇지 않다.
      • 모든 키가 서로 다른 매개변수화 타입일 수 있다는 의미이다.
  • favorites 맵의 값 타입이 Object 이다.
    • 즉, 맵이 키와 값 사이의 타입 관계를 보증하지 않는다.
    • 하지만 우리는 이 관계가 성립함을 알고 있고, getFavorites 에서 이 관계를 되살릴 수 있다.
getFavorites
  • 주어진 Class 객체에 해당하는 값을 favorites 맵에서 꺼낸다.
  • 이 객체의 타입은 Object 이지만, 이를 T 로 바꿔 반환해야 한다.
  • 따라서 Classcast 메서드를 사용해 객체 참조를 Class 객체가 가리키는 타입으로 동적 형변환한다.
    • cast 메서드는 주어진 인수가 Class 객체가 알려주는 타입의 인스턴스인지 검사한 후, 맞다면 그 인수를 반환하고, 아니면 ClassCastException 을 던진다.
  • cast 메서드의 반환 타입이 Class 객체의 타입 매개변수와 같기 때문에, T 로 비검사 형변환하는 손실 없이도 Favorites 를 타입 안전하게 만들 수 있다.

Favorites 사용 예제

public static void main(String[] args) {
  Favorites f = new Favorites();
  
  f.putFavorites(String.class, "Java");
  f.putFavorites(Integer.class, 0xcafebabe);
  f.putFavorites(Class.class, Favorites.class);
  
  String favoriteString = f.getFavorites(String.class);
  int favoriteInteger = f.getFavorites(Integer.class);
  Class<?> favoriteClass = f.getFavorites(Class.class);
}

Favorites 클래스 제약 사항

로 타입을 넘기는 클라이언트
  • 악의적인 클라이언트가 Class 객체를 제네릭이 아닌 로 타입으로 넘기면 Favorites 인스턴스의 타입 안전성이 쉽게 깨진다.
  • 하지만 로 타입을 넘기는 클라이언트 코드는 컴파일 시에 비검사 경고가 발생할 것이다.
  • Favorites 가 타입 불변식을 어기는 일이 없도록 보장하려면 putFavorites 에서 인수로 주어진 instance 타입이 type 으로 명시한 타입과 같은지 확인하면 된다.
public <T> void putFavorites(Class<T> type, T instance) {
  favorites.put(Objects.requireNonNull(type), type.cast(instance));
}
  • 동적 형변환으로 런타임에 타입 안정성을 확보했다.
  • 제네릭과 로 타입을 섞어 사용하는 어플리케이션에서 클라이언트 코드가 잘못된 타입의 원소를 넣지 못하게 추적하는데 도움을 준다.
실체화 불가 타입
  • 실체화 불가 타입은 Favorites 를 사용할 수 없다.
  • 즉, 즐겨찾는 String 이나 String[] 은 저장할 수 있지만, 즐겨 찾는 List<String> 은 저장할 수 없다.
    • List<String>Class 객체를 얻을 수 없기 때문이다.
  • List<String>List<Integer>List.class 라는 같은 Class 객체를 공유하므로, 이를 허용한다면 둘 다 똑같은 타입의 객체 참조를 반환해 Favorites 내부는 아수라장이 될 것이다.

슈퍼 타입 토큰

  • TypeRef 같은 클래스로 감싸고, 이 안에 실제 객체의 타입 정보를 저장해놓는다.
  • 런타임에는 제네릭 타입 정보는 소거되므로, 타입 정보가 필요할 때 안에 저장된 객체 타입 정보를 가져온다.
  • Jackson, Spring 에서 사용

한정적 타입 토큰

  • 메서드가 허용하는 타입을 제한하고 싶을 수 있는데, 한정적 타입 토큰을 활용하면 가능하다.

Annotation API

  • Annotation API는 한정적 타입 토큰을 적극적으로 사용하는 예이다.
public <T extends Annotation> T getAnnotation(Class<T> annotationType);
  • annotationType 인수는 어노테이션 타입을 뜻하는 한정적 타입 토큰이다.
  • 위 메서드는 토큰으로 명시한 타입의 어노테이션이 대상 요소에 달려있다면 그 어노테이션을 반환하고, 없다면 null을 반환한다.
  • 이 메서드는 리플렉션의 대상이 되는 타입들, 즉, Class<T> , Method , Field 같이 프로그램 요소를 표현하는 타입들에서 구현한다.

asSubClass

  • Class<?> 타입의 객체를 한정적 타입 토큰을 받는 메서드에 넘기려면 형변환을 해야 한다.
  • 객체를 직접 Class<? extends Annotation> 과 같은 타입으로 형변환할 수도 있겠지만, 이는 비검사 형변환 경고가 발생할 것이다.
  • Class 클래스가 이런 형변환을 안전하게 해주는 인스턴스 메서드 asSubClass 를 제공한다.
  • 호출된 인스턴스 자신의 Class 객체를 인수가 명시한 클래스로 형변환한다.
    • 이 클래스가 인수로 명시한 클래스의 하위클래스일 때 형변환되어 객체를 반환하고, 그렇지 않으면 ClassCastException 을 던진다.
static Annotation getAnnotation(AnnotatedElement element, String annotationTypeName) {
  Class<?> annotationType = null;
  try {
    annotationType = Class.forName(annotationTypeName);
  } catch (Exception ex) {
    throw new IllegalArgumentException(ex);
  }
  
  return element.getAnnotation(annotationType.asSubClass(Annotation.class));
}