Effective Java

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

돌에 2022. 3. 9. 01:01

타입 안전 이종 컨테이너 패턴 (type safe heterogeneous container pattern)

 

제네릭은 Set<E>, Map<K, V> 등의 컬렉션과 ThreadLocal<T>, AtomicReference<T> 등의 단일원소 컨테이너에도 흔히 쓰인다.

이런 모든 쓰임에서 매개변수 화되는 대상은 (원소가 아닌) 컨테이너 자신이다.

따라서 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다.

 

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

 

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

이렇게 하면 제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해줄 것이다.

이러한 설계 방식을 타입 안전 이종 컨테이너 패턴이라 한다.

 

 

타입별로 즐겨 찾는 인스턴스를 저장하고 검색할 수 있는 Favorites 클래스를 생각해보자.

 

각 타입의 Class 객체를 매개변수화한 키 역할로 사용하면 되는데, 이 방식이 동작하는 이유는 class의 클래스가 제네릭 이기 때문이다. (class 리터 럴의 타입은 Class가 아닌 Class<T>다.)

 

타입 토큰(type token): 컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고받는 class 리터럴

 

API

public class Favorites {
	public <T> void putFavorite(Class<T> type, T instance);
	public <T> T getFavorite(Class<T> type);
}

 

클라이언트

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를 출력한다.

 

구현

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)되었다는 점을 깨달아야 한다.

 

맵이 아니라 키가 와일드카드 타입인 것이다. (다양한 타입을 지원 가능)

이는 모든 키가 서로 다른 매개변수화 타입 일 수 있다는 뜻으로,

첫 번째는 Class<String>, 두 번째는 Class<Integer> 식으로 될 수 있다. 

 

favorites 맵의 값 타입은 단순히 Object이므로 모든 값이 키로 명시한 타입임을 보증하지 않는다.

 

 

putFavorite 메서드는 주어진 Class 객체와 즐겨찾기 인스턴스를 favorites에 추가하면 된다.

이때, 키와 값 사이의 ‘타입 링크(type linkage)’ 정보는 버려진다.

즉, 그 값이 그 키 타입의 인스턴스라는 정보가 사라진다.

(getFavorite 메서드에서 이 관계를 되살릴 수 있으니 상관없다.)

 

 

getFavorite 메서드는 먼저 주어진 Class 객체에 해당하는 값을 favorites 맵에서 꺼낸다.

이 객체가 바로 반환해야 할 객체가 맞지만, 잘못된 컴파일타임 타입을 가지고 있다.

이 객체의 타입은 (favorites 맵의 값 타입인) Object이나, 이를 T로 바꿔 반환해야 한다.

따라서 getFavorite 구현은 Class의 cast 메서드를 사용해 이 객체 참조를 Class 객체가 가리키는 타입으로 동적 형변환한다.

 

public class Class<T> {
	T cast(Object obj);
}

 

→ 위 코드에서 보듯 cast의 반환 타입은 Class 객체의 타입 매개변수와 같기 때문에 Object가 아닌 T 타입을 반환한다.

 

 

지금의 Favorites 클래스에는 두 가지 제약이 존재한다.

1. Class 객체를 제네릭이 아닌 로 타입으로 넘기면 Favorites 인스턴스의 타입 안전성이 깨진다.

 

f.putFavorite((Class)Integer.class, "Integer의 인스턴스가 아닙니다.");
int favoriteInteger = f.getFavorite(Integer.class);

→ getFavorite을 호출할 때 ClassCastException을 던진다.

 

Favorites가 타입 불변식을 어기는 일이 없도록 보장하려면,

putFavorite 메서드에서 인수로 주어진 instance의 타입이 type으로 명시한 타입과 같은지 확인하면 된다.

 

public <T> void putFavorite(Class<T> type, T instance) {
	favorites.put(Objects.requireNonNull(type), type.cast(instance));
}

 

 

2. 실체화 불가 타입에는 사용할 수 없다.

 

String이나 String[]은 저장할 수 있어도 즐겨 찾는 List<String>은 저장할 수 없다.

List<String>용 Class 객체를 얻을 수 없기 때문에, List<String>을 저장하려는 코드는 컴파일되지 않을 것이다.

 

List<String>과 List<Integer>는 List.class라는 같은 Class 객체를 공유하기 때문에, 같은 타입의 객체 참조를 반환하게 된다.

→ 이 제약을 슈퍼 타입 토큰(super type token)으로 해결하려는 시도도 있다.

 

한정적 타입 토큰

: 단순히 한정적 타입 매개변수나 한정적 와일드카드를 사용하여 표현 가능한 타입을 제한하는 타입 토큰이다.

 

Favorites가 사용하는 타입 토큰은 비한정적이다.

즉, getFavorite과 put Favorite은 어떤 Class 객체든 받아들인다.

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

 

 

에너테이션 API는 한정적 타입 토큰을 적극적으로 사용한다.

예를 들어 다음은 AnnotatedElement 인터페이스에 선언된 메서드로, 대상 요소에 달려 있는 애너테이션을 런타임에 읽어 오는 기능을 한다.

이 메서드는 리플렉 션의 대상이 되는 타입들, 즉 클래스(java.lang.Class), 메서드(java.lang, reflect.Method), 필드(java.lang.reflect.Field) 같이 프로그램 요소를 표현하는 타입들에서 구현한다.

 

public <T extends Annotation>
	T getAnnotation(Class<T> annotationType);

 

annotationType 인수는 애너테이션 타입을 뜻하는 한정적 타입 토큰이다.

이 메서드는 토큰으로 명시한 타입의 애너테이션이 대상 요소에 달려 있다면 그 애너테이션을 반환하고, 없다면 null을 반환한다.

즉, 애너테이션된 요소는 그 키가 애너테이션 타입인, 타입 안전 이종 컨테이너인 것이다.

 

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

→ 컴파일 시점에는 타입을 알 수 없는 애너테이션을 asSubclass 메서드를 사용해 런타임에 읽어내는 예다.

 

 

컬렉션 API로 대표되는 일반적인 제네릭 형태에서는 한 컨테이너가 다룰 수 있는 타입 매개변수의 수가 고정되어 있다. 하지만 컨테이너 자체가 아닌 키를 타입 매개변수로 바꾸면 이런 제약이 없는 타입 안전 이종 컨테이너를 만들 수 있다. 타입 안전 이종 컨테이너는 Class를 키로 쓰며, 이런 식으로 쓰이는 Class 객체를 타입 토큰이라 한다. 또한, 직접 구현한 키 타입도 쓸 수 있다. 예컨대 데이터베이스의 행(컨테이너)을 표현한 DatabaseRow 타입에는 제네릭 타입인 Column<T>를 키로 사용할 수 있다.