본문 바로가기

독서찰기(讀書札記)/이펙티브 자바

[아이템 33] 타입 안전 이종 컨테이너를 고려하라

[Why]

제네릭을 쓸 때 매개변수화되는 대상은 원소가 아닌 컨테이너 자신이다.

  • 따라서 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다.
  • 예컨대, Set에는 원소의 타입을 뜻하는 단 하나의 타입 매개변수만 있으면 되며, Map에는 키와 값을 뜻하는 2개만 필요한 식이다.

하지만 더 유연한 수단이 필요할 때도 종종 있다.

  • 예컨대 데이터베이스의 행(row)은 임의 개수의 열(column)을 가질 수 있는데, 모든 열을 타입 안전하게 이용한다면 좋을 것이다.
Map<KeyType, ValueType> map1 = new HashMap<>();
// ValueType이 한번 고정되면 ValueType만 넣어야 함

Map<KeyType, Object> map2 = new HashMap<>();
// Object에는 아무거나 들어갈 수 있으나 타입 안전하지 않음.
  • map2를 타입 안전하게 쓰고 싶다면?

 

[What]

타입 안전 이종 컨테이너 패턴이란?

컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하면 된다.

  • 이렇게 하면 제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해줄 것이다.
  • 이러한 설계 방식을 타입 안전 이종 컨테이너 패턴(type safe heterogeneous container pattern)이라 한다.
    컨테이너 안에 서로 다른 종(type)들이 들어있는데 타입 안전하게 쓸 수 있다는 뜻.
// 타입 안전 이종 컨테이너 패턴 - API
public class Favorites {
    public <T> void putFavorite(Class<T> type, T instance);
    public <T> T getFavorite(Class<T> type);
}
  • 위 코드는 타입별로 즐겨 찾는 인스턴스를 저장하고 검색할 수 있는 Favorites 클래스이다.
  • 각 타입의 Class 객체를 매개변수화한 키 역할로 사용하는데, 이 방식이 동작하는 이유는 class의 클래스가 제네릭이기 때문이다.
  • class 리터럴의 타입은 Class가 아닌 Class<T>다.
    e.g.) String.class의 타입은 Class<String>, Integer.class의 타입은 Class<Integer>이다.
  • 컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고받는 class 리터럴을 타입 토큰(type token)이라 한다.
public static void main(String[] args) {
    Favorites f = new Favorites();
    
    f.putFavorite(String.class, "Java");
    f.putFavorite(Integer.class, 0xcafebabe);
    f.putFavorite(Class.class, Favorites.class);
    
    String favoriteString = f.getFavorite(String.class);
    int favoriteInteger = f.getFavorite(Integer.class);
    Class<?> favoriteClass = f.getFavorite(Class.class);
    
    System.out.printf("%s %x %s%n", 
        favoriteString, favoriteInteger, favoriteClass.getName());
}

// Java cafebabe Favorites 출력

 

Favorites의 구현은 놀랍도록 간단한데, 다음 코드가 전부다.

// 타입 안전 이종 컨테이너 패턴 - 구현
public class Favorites {
    private Map<Class<?>, Object> favorites = new HashMap<>();
    
    public <T> void putFavorite(Class<T> type, T instance) {
        favorites.put(Objects.requireNonNull(type), instance);
    }
    
    public <T> T getFavorite(Class<T> type) {
        return type.cast(favorites.get(type));  // 런타임에는 Object로 들어가있으므로 동적 형변환
    }
}

// public T cast(Object obj) {
//     if (obj != null && !isInstance(obj))
//         throw new ClassCastException(cannotCastMsg(obj));
//     return (T) obj;
// }

Favorites가 사용하는 private 맵 변수인 favorites의 타입은 Map<Class<?>, Object>이다.

  • 비한정적 와일드카드 타입이라 이 맵 안에 아무것도 넣을 수 없다고 생각할 수 있지만, 사실은 그 반대다.
  • 와일드카드 타입이 중첩(nested)되었다는 점을 깨달아야 한다.
  • 맵이 아니라 키가 와일드카드 타입인 것이다.
  • 이는 모든 키가 서로 다른 매개변수화 타입일 수 있다는 뜻으로, 첫번째는 Class<String>, 두번째는 Class<Integer>일 수 있다.

그 다음으로 알아둘 점은 favorites 맵의 값 타입은 단순히 Object라는 것이다.

  • 이 맵은 키와 값 사이의 타입 관계를 보증하지 않는다는 말이다.
  • 자바의 타입 시스템에서는 이 관계를 명시할 방법이 없다.
    키와 값 사이의 '타입 링크(type linkage)' 정보가 버려지기 때문이다.
  • 하지만 우리는 이 관계가 성립함을 알고 있고, 즐겨찾기를 검색할 때 그 이점을 누리게 된다.

cast 메서드는 형변환 연산자의 동적 버전이다.

  • 이 메서드는 단순히 주어진 인수가 Class 객체가 알려주는 타입의 인스턴스인지를 검사한 다음, 맞다면 그 인수를 그대로 반환하고, 아니면 ClassCastException을 던진다.
  • 클라이언트 코드가 깔끔히 컴파일된다면, favorites 맵 안의 값은 해당 키의 타입과 항상 일치함을 알고 있다.
  • 그런데 cast 메서드를 굳이 왜 사용하는 것일까?
public class Class<T> {
    T cast(Object obj);
}
  • 위 코드에서 보듯 cast의 반환 타입은 Class 객체의 타입 매개변수와 같다.
  • 이것이 T로 비검사 형변환하는 손실 없이도 Favorites를 타입 안전하게 만드는 비결이다.
    return (T) favorites.get(type); 하지 않고 매개변수화된 타입으로 반환할 수 있음.

 

[How]

한정적 타입 토큰

Favorites가 사용하는 타입 토큰은 비한정적이다. 때로는 이 메서드들이 허용하는 타입을 제한하고 싶을 수 있는데, 한정적 타입 토큰을 활용하면 가능하다.

  • 한정적 타입 토큰이란 단순히 한정적 타입 매개변수(아이템29)나 한정적 와일드카드(아이템31)를 사용하여 표현 가능한 타입을 제한하는 타입 토큰이다.
  • 애너테이션 API(아이템39)는 한정적 타입 토큰을 적극적으로 사용한다.
public <T extends Annotation> T getAnnotation(Class<T> annotationType);
  • 위 메서드는 AnnotatedElement 인터페이스에 선언된 메서드로, 대상 요소에 달려 있는 애너테이션을 런타임에 읽어온다.

Class<?> 타입의 객체가 있고, 이를 한정적 타입 토큰을 받는 메서드에 넘기려면 어떻게 해야 할까?

  • Class<? extends Annotation>으로 형변환할 수도 있지만, 이 형변환은 비검사이므로 컴파일하면 경고가 뜰 것이다(아이템27).
  • Class 클래스가 이런 형변환을 수행해주는 인스턴스 메서드를 제공한다.
  • asSubclass 메서드로, 호출된 인스턴스 자신의 Class 객체를 인수가 명시한 클래스로 형변환한다.
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));
}

 

제약 두 가지

첫번째, 악의적인 클라이언트가 Class 객체를 로 타입으로 넘기면 Favorites 인스턴스의 타입 안전성이 쉽게 깨진다.

f.putFavorite((Class)Integer.class, "Integer의 인스턴스가 아닙니다.");
int favoriteInteger = f.getFavorite(Integer.class);
  • putFavorite을 호출할 때는 아무 불평이 없다가 getFavorite을 호출할 때 ClassCastException을 던진다.
  • HashSet, HashMap 등의 일반 컬렉션 구현체에도 똑같은 문제가 있다.
// 다음 코드가 컴파일도 되고 동작도 한다.
HashSet<Integer> set = new HashSet<>();
((HashSet)set).add("문자열입니다.");
  • Favorites가 타입 불변식을 어기는 일이 없도록 보장하려면 putFavorite 메서드에서 인수로 주어진 instance의 타입이 type으로 명시한 타입과 같은지 확인하면 된다.
public <T> void putFavorite(Class<T> type, T instance) {
    favorite.put(Objects.requireNonNull(type), type.cast(instance));
}
  • java.util.Collections의 checkedSet, checkedList, checkedMap 같은 메서드들이 이 방식을 적용한 컬렉션 래퍼들이다.
  • 이 래퍼들은 내부 컬렉션들을 실체화한다.
    런타임에 Coin을 Collection<Stamp>에 넣으려하면 ClassCastException을 던진다.

두번째, 실체화 불가 타입(아이템28)에는 사용할 수 없다.

  • 즐겨 찾는 String이나 String[]은 저장할 수 있어도 즐겨 찾는 List<String>은 저장할 수 없다.
  • List<String>용 Class 객체를 얻을 수 없기 때문에 컴파일되지 않는다.
    제네릭(<String>)은 런타임에 소거되기 때문이다.
    슈퍼 타입 토큰(super type token)으로 해결할 수 있지만 완벽하지는 않다.