용어 정리 제대로 안하고 뒤로 넘어가다가 죽는 줄 알았다.
📌 타입 안전 이종 컨테이너
타입 안전 이종 컨테이너가 영어로 type safe heterogeneous container인데, heterogeneous가 "여러 다른 종류들로 이뤄진"이라는 뜻이다.
처음에 이종이라길래 '이중' 내지 '두 가지 종류' 정도로 이해하고 읽다가 이해하는 데 한참 걸렸다. (이래서 원문으로 읽어야..)
container는 값을 저장할 수 있는 객체를 말한다.
즉, 타입 안전 이종 컨테이너란 여러 다른 종류들로 이루어진 값을 저장하는 타입 안전한 객체를 의미한다.
📌 사용 목적
- 학번을 저장하는 Set<Integer>
- Integer 타입 값만을 저장할 수 있다.
- 이름과 나이를 저장하는 Map<String, Integer>
- String 타입 key와 Integer 타입 value만 저장할 수 있다.
- 데이터 베이스의 타입 종류를 저장하는 Set
- 모든 Column을 type-safe하게 이용하기 위해서 타입 안전 이종 컨테이너 패턴을 사용하다.
📌 타입 안전 이종 컨테이너 패턴 (type safe heterogeneous container pattern)
컬렉션 API로 대표되는 일반적인 제네릭 형태에서는 한 컨테이너가 다룰 수 있는 타입 매개변수 수가 고정되어 있다.
일반적인 경우(컬렉션, 단일원소 컨테이너)에선 매개변수화되는 대상은 컨테이너 자신이다.
그러나 이보다 유연한 수단이 필요한 경우 사용한다.
- 컨테이너 대신 키를 타입 매개변수화 한다.
- 컨테이너에서 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공한다.
- 제네릭 타입 시스템 값의 타입이 키와 같음을 보장해준다.
각 타입의 Class 객체를 매개변수화한 키 역할로 사용하는 방식이 동작하는 이유는 다음과 같다.
- Class의 리터럴 타입은 Class가 아닌 Class<T> 이다.
- String.class는 Class<String>
- Integer.class는 Class<Integer>
- 컴파일 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고 받는 class 리터럴을 타입 토큰(type token)이라 한다.
- 타입 안전 이종 컨테이너는 Class를 키로 쓰며, 이렇게 쓰이는 Class 객체를 타입 토큰이라고 한다. (직접 구현한 키 타입도 가능하다.)
API
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));
}
}
Client
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());
}
위의 Favorites는 타입 안전 이종 컨테이너라고 할 수 있다.
- Favorites는 타입 안전하다. String을 요청했는데 Integer를 반환하는 일은 절대 없다.
- 모든 키의 타입이 제각각이라, 여러 가지 타입의 원소를 담을 수 있다.
이 예제에서는 미묘한 일들이 일어나고 있다. 하나하나 살펴보자.
1️⃣ Map<Class<?>, Object>
private Map<Class<?>, Object> favorites = new HashMap<>();
비한정적 와일드 카드 타입이라 맵 안에 아무것도 넣을 수 없다고 생각할 수 있지만, 그 반대다.
맵이 아니라 키가 와일드 타입이다. 이는 모든 키가 서로 다른 매개변수화 타입일 수 있다는 뜻이다.
그리고 맵의 값 타입은 단순히 Object이므로, 모든 값이 키로 명시한 타입임을 보증하지 않는다.
사실 자바 타입 시스템에서는 이 관계를 명시할 방법이 없지만, 우리는 이 관계가 성립함을 알고 있다.
이 한 줄 때문에 멘탈 나가서 한참을 연구했다. 차근차근 분석해보자.
- Class<?>
- Class<?>는 비한정적 와일드 카드 타입이다.
- Class는 자바에서 클래스를 나타내는 타입이다.
- <?>는 와일드 카드를 의미하며, 모든 타입을 허용한다는 뜻이다.
- 즉, Class<?>는 모든 종류의 클래스를 나타낼 수 있는 타입이다.
- Map<Class<?>, Object>
- Map은 키와 값으로 이루어진 자료구조다.
- Class<?>는 키의 타입으로 사용되었다. 모든 종류의 클래스를 받을 수 있음을 의미한다.
- Object는 값의 타입으로 사용되었다. 모든 종류의 객체를 받을 수 있음을 의미한다.
- 따라서 Map<Class<?>, Object>는 모든 종류의 클래스를 키로 갖고, 모든 종류의 객체를 값으로 가지는 맵을 나타낸다.
2️⃣ putFavorite()
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), instance);
}
주어지 Class 객체와 인스턴스를 추가하여 관계를 맺고 있으며, 키와 값 사이의 타입 링크(type linkage) 정보는 버려진다.
즉, 그 값이 해당 키 타입의 인스턴스라는 정보가 사라진다. (getFavorite에서 되살릴 수 있다.)
3️⃣ getFavorite()
public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
우선, 주어진 Class 객체에 해당하는 값을 favorites 맵에서 꺼낸다.
이 객체가 반환해야할 타입 객체는 맞으나, 잘못된 컴파일타입 타입(Object)를 가지고 있어 T 타입으로 바꿔야 한다.
Class의 cast() 메서드를 사용해 객체가 가리키는 타입으로 동적 형변환하여 가져올 수 있다.
cast 메서드의 시그니처가 Class 클래스가 제네릭이라는 이점을 완벽히 활용하고 있다.
cast의 반환 타입은 Class 객체의 타입 매개변수와 동일하다.
따라서 Favorites를 T로 비검사 형변환하지 않고도 type-safe하게 만들 수 있다.
public final class Class<T> {
...
@SuppressWarnings("unchecked")
public T cast(Object obj) {
if (obj != null && !isInstance(obj))
throw new ClassCastException(cannotCastMsg(obj));
return (T) obj;
}
....
}
📌 제약 1
악의적인 클라이언트가 Class 객체를 제네릭이 아닌 로타입으로 넘기면 안전성이 깨진다.
favorites.put((Class) Integer.class, "Invalid Type");
int value = favorites.getFavorite(Integer.class); //ClassCastException 발생
여기서 컴파일은 가능하지만 비검사 경고가 발생하고, 런타임시 ClassCastException 예가 발생한다.
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), type.cast(instance));
}
Favorites가 타입 불변식을 어기는 일이 없도록 보장하기 위해서는 type.cast(instance)로 instance의 타입이 type으로 명시한 타입과 같은지 확인해보면 된다.
Collections.checkedList, Collections.checkedSet, Collections.checkedMap은 이런 방법을 적용한 컬렉션 래퍼들이다.
- 이 정적 팩터리들은 컬렉션과 함께 1개 혹은 2개의 Class 객체를 받는다.
- 이 메서드들은 모두 제네릭이므로, Class 객체와 컬렉션의 컴파일타임 타입이 같음을 보장한다.
- 또한, 내부 컬렉션들을 실체화 한다. (런타임에 Coin을 Collection<Stamp>에 넣으려하면 형변환 예외 발생)
- 제네릭과 로타입을 섞어 사용하는 코드가 컬렉션에 잘못된 타입의 원소를 넣지 못하게 추적하는 데 도움을 준다.
📌 제약 2
실체화 불가 타입에는 사용할 수 없다.
String, String[]은 저장할 수 있지만, List<String>은 저장할 수 없다.
왜냐 하면, List<String>과 List<Integer> 모두 List.class라는 객체를 공유하기 때문이다.
이 두 번째 제약을 슈퍼 타입 토큰으로 해결하려는 시도도 있다.
하지만 이 방법도 완벽하진 않으니 주의해야 하며, 책에서는 "완벽히 만족스러운 우회로는 없다"고 명시해두었다.
📌 한정적 타입 토큰
Favorite은 어느 Class 객체든 받아들이나, 타입을 제한하고 싶을 때 사용한다.
이 방법은 어노테이션 API(Item 39)가 한정적 타입 토큰을 적극적으로 활용한다.
package java.lang.annotation;
public interface Annotation {
boolean equals(Object obj);
int hashCode();
String toString();
Class<? extends Annotation> annotationType();
}
다음 메서드는 대상 요소에 달려 있는 어노테이션을 런타임에 읽어오는 기능을 한다.
리플렉션의 대상이 되는 타입들, 즉 클래스(java.lang.Class<T>), 메서드(java.lang.reflect.Method), 필드(java.lang.reflect.Field) 같이 프로그램 요소를 표현하는 타입들에서 구현한다.
public <T extends Annotation> T getAnnotation(Class<T> annotationType);
annotationType 인수는 어노테이션 타입을 뜻하는 한정적 타입 토큰에 해당한다.
이 메서드는 토큰으로 명시한 타입의 어노테이션이 대상 요소에 달려 있다면 그 어노테이션을 반환하고, 없다면 null을 반환한다.
즉, 어노테이션된 요소는 그 키가 어노테이션 타입인, 타입 안전 이종 컨테이너인 것이다.
여기서 Class<?>와 같이 비한정적 와일드카드 타입을 한정적 타입 토큰을 받는 메서드에 전달할 때,
객체를 Class<? extends Annotation>으로 형변환할 수는 있으나, 비검사 경고 문구가 뜰 것이다.
Class에서는 이러한 동적 형변환을 안전하게 수행해주는 asSubclass 메서드를 제공한다.
public <U> Class<? extends U> asSubclass(Class<U> clazz) {
if (clazz.isAssignableFrom(this))
return (Class<? extends U>) this;
else
throw new ClassCastException(this.toString());
}
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));
}
위 예제는 컴파일 시점에서 타입을 알 수 없는 어노테이션을 asSubclass 메서드를 사용해 런타임에 읽어내는 예다.