[EffectiveJava] item33 - 타입 안전 이종 컨테이너를 고려하라
30 Apr 2023제네릭은 Set\<E\>
, Map<K,V>
등의 컬렉션과 ThreadLocal<T>, AtomicReference<T> 등의 단일원소 컨테이너에도 흔히 쓰인다. 이런 모든 쓰임에서 매개변수화 되는 대상은 (원소가 아닌) 컨테이너 자신이다.
따라서 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다.
하지만 더 유연한 수단이 필요할때가 종종있다.
컨테이너 대신 키를 매개변수화한 다음 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하면 된다. 이렇게 하면 제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해줄 것이다.
이러한 설계 방식을 타입 안전 이종 컨테이너 패턴 (type safe heterogeneous container pattern) 이라 한다.
타입 안전 이종 컨테이너 패턴 (type safe heterogeneous container pattern)
해당 패턴의 간단한 예시를 들어보도록 하자.
Favorites 라는 클래스를 만들어 볼것이다. 해당 클래스는 타입별로 즐겨찾는 인스턴스를 저장하고 검색할 수 있는 클래스이다.
각타입의 Class 객체를 매개변수화한 키 역할로 사용하면 되는데, 이 방식은 class의 클래스가 제네릭이기 때문에 동작한다.
class 리터럴 타입은 Class가 아닌 Class
컴파일 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고받는 class 리터럴을 타입 토큰(type token)이라 한다.
해당 클래스는 아래와 같이 구성되어있다.
public class Favorites {
public <T> void putFavorite(Class<T> type, T instance);
public <T> T getFavorite(Class<T> type);
}
그리고 다음은 앞의 Favorites 클래스를 사용하는 예시이다.
public static void main(String[] args) {
Favorites f = new Favorites();
f.putFavorite(String.class, "Mho");
f.putFavorite(Integer.class, 0xcafebabe);
f.putFavorite(Class.class, Favorite.class);
String name = f.getFavorite(String.class);
int number = f.getFavorite(Integer.class);
Class<?> clazz = f.getFavorite(Class.class);
System.out.printf("%s %x %s%n", name, number, clazz);
}
해당 로직을 실행하면 아래와 같이 출력된다.
Mho cafebabe com.effectiveJava.item33.Favorite
Favorites 인스턴스는 타입 안전하다. String을 요청했을 때 Integer 타입을 반환하거나 하는 일이 없다. 또한 모든 키의 타입이 제각각이라, 일반적인 맵과 달리 여러가지 타입의 원소를 담을 수 있다.
따라서 Favorites는 타입 안전 이종(heterogeneous) 컨테이너라 할 만하다.
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));
}
}
Favorites 클래스가 사용하는 private 맵 변수인 favorites의 타입은 Map<Class<?>, Object> 이다. 비한정적 와일드카드 타입이라 이맵안에 아무것도 넣을 수 없다고 생각할수 있지만, 사실은 그반대다. 와일드카드 타입이 중첩(nested) 되어있기 때문에, 맵이 아니라 맵의 Key가 와일드카드 타입인 것이다.
이는 모든 키가 서로 다른 매개변수화 타입일 수 있다는 뜻으로, 다양한 타입을 지원할 수 있게 된다.
그 다음 favorites 맵의 값 타입은 단순히 Object라는 것이다. 이 말은, 키와 값 사이의 타입 관계를 보증하지 않는다는 말이다. 즉, 모든 값이 키로 명시한 타입임을 보증하지 않는다.
하지만 우리는 이관계가 성립함을 알 수 있다.
putFavorite 메서드를 살펴보자.
해당 구현은 주어진 Class 객체와 즐겨찾기 인스턴스를 favorites에 축해 관계를 지으면 끝이다. 말했듯이, 키와 값 사이의 ‘타입 링크(type linkage)’ 정보는 버려진다.
이번엔 getFavorite 메서드를 살펴보자.
해당 메서드에서는 주어진 Class 객체에 해당하는 값을 favorites 맵에서 꺼낸다. 해당 객체는 바로 반환해야할 객체는 맞지만 메서드의 반환타입인 T와 달리 favorites 맵에서 꺼내오는 값 타입은 Object이다.
따라서 type.cast()
메서드를 이용해 이 객체 참조를 Class객체가 가리키는 타입으로 동적 변환한다.
cast 메서드는 형변환 연산자의 동적 버전이다. 이 메서드는 단순이 주어진 인수가 Class 객체가 알려주는 타입인지 검사한 다음, 맞으면 그 인수를 그대로 반환하고 틀리면 ClassCastException을 반환한다.
그런데 cast 메서드가 단지 인수를 그대로 반환하기만 한다면 왜 사용하는 것일까? 그 이유는 cast 메서드의 시그니처가 Class 클래스가 제네릭이라는 이점을 완벽히 활용하기 때문이다.
아래는 cast의 코드이다.
public class Class<T> {
T cast(Object obj);
}
이것이 정확히 getFavorite 메서드에 필요한 기능으로, T로 비검사 형변환하는 손실 없이도 Favorites를 타입 안전하게 만드는 비결이다.
Favorites 클래스의 제약
하지만 해당 클래스에도 몇가지 제약이 있다.
첫번째로 악의적인 클라이언트가 Class객체를 로타입으로 넘기면 Favorites 인스턴스의 타입 안전성이 쉽게 깨진다.
f.putFavorite((Class)Integer.class, "Integer의 인스턴스가 아닙니다.");
int notInteger = f.getFavorite(Integer.class);
두번째로는 아래와 같은 HashSet<Integer>에 String을 넣는것과 같은 방법을 사용한다면 ClassCastException을 반환할 것이다.
HashSet<Integer> set = new HashSet();
((HashSet)set).add("문자열입니다.");
해당 제약을 보장하기위해서는 아래와 같이 수정할 수 있다. getFavorite와 동일하게 동적 형변환을 사용하면 된다.
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), type.cast(instance));
}
위의 같은 예시를 java.util.Collections
에서는 실제로 사용하고 있는데, checkedSet
, checkedList
, checkedMap
같은 메서드들이 바로 그 예시이다.
Favorites 클래스에는 또다른 제약이 있다.
실체화 불가 타입에는 사용할 수 없다는 것이다. String이나 String[]은 저장할 수 있어도 즐겨찾는 List<String>은 저장할 수 없다.
List<String>용 class 객체를 얻을 수 없기 때문이다.
List<String>나 List<Integer>와 같은 클래스는 동일한 List.class 라는 Class 객체를 공유하므로 List<String>와 같은 실체화 불가 타입은 사용할 수 없다.
한정적 타입 토큰
Favorites가 사용하는 타입 토큰은 비한정성이다. getFavorite 메서드와 putFavorite 메서드는 어떤 Class든 받아 드릴수 있기 때문이다.
만약 이 메서드들이 허용하는 타입을 제한해주고 싶다면 어떻게 해야할까?
한정적 타입 토큰을 활용하면 가능하다.
한정적 타입 토큰이란 단순히 한정적 타입 매개변수나 한정적 와일드카드를 사용하여 표현 가능한 타입을 제한하는 타입 토큰이다.
해당 예시로는 애너테이션 API가 있다.
public <T extends Annotation> T getAnnotation(Class<T> annotationType);
여기서 annotationType 인수는 애너테이션 타입을 뜻하는 한정적 타입 토큰이다. 따라서 이 메서드는 토큰으로 명시한 타입의 애너테이션이 대상요소에 달려 있다면 그 애너테이션을 반환하고 없다면 null을 반환한다.
즉, 애너테이션된 요소는 그 키가 애너테이션 타입인, 타입 안전 이종 컨테이너인 것이다.
Class<?> 타입의 객체가 있고, 이를 한정적 타입 토큰을 받는 메서드에 넘기려면 어떻게 해야 할까? 객체를 Class<? extends Annotation>으로 형변환할 수도 있지만, 이 형변환은 비검사이므로 컴파일하면 경고가 뜰것이다.
여기서 asSubClass
메서드라는 인스턴스 메서드를 사용할 수 있다.
asSubClass
메서드는 호출된 인스턴스 자신의 Class 객체를 인수가 명시한 클래스로 형변환 한다.(이 클래스가 인수로 명시한 클래스의 하위클래스로 변환한다.)
형변환에 성공하면 인수로 받은 클래스 객체를 반환하고, 실패하면 ClassCastException을 반환한다.
다음은 asSubClass
를 활용한 예시이다.
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));
}