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

김종준·2023년 6월 26일
0

이펙티브자바

목록 보기
28/63

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

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

이런 모든 쓰임에서 매개변수화되는 대상은 컨테이너 자신이다.

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

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

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

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

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

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

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

class 리터럴의 타입은 Class가 아닌 Class다.

예컨대 String.class의 타입은 Class이고 Integer.class의 타입은 Class인 식이다.

한편, 컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고받는 class 리터럴을 타입 토큰이라 한다.

그럼, 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));
    }
}

Map<Class<?>, Object> 를 보고 비한정적 와일드카드 타입이라 이 맵 안에 아무것도 넣을 수 없다고 생각할 수 있지만, 사실은 그 반대다.

와일드카드 타입이 중첩되었다는 점을 깨달아야 한다.

맵이 아니라 키가 와일드카드 타입인 것이다.

이는 모든 키가 서로 다른 매개변수화 타입일 수 있다는 뜻으로, 첫 번째는 Class , 두 번째는 Class 식으로 될 수 있다.

즉, 다양한 타입을 지원하는 것이다.

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

이는 맵은 키와 값 사이의 타입 관계를 보증하지 않는다는 말이다.

즉, 모든 값이 키로 명시한 타입임을 보증하지 않는다.

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

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

이 객체의 타입은 Object이나, 우리는 이를 T로 바꿔 반환해야 한다.

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

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

이 메서드는 단순히 주어진 인수가 Class 객체가 알려주는 타입의 인스턴스인지를 검사한 다음, 맞다면 그 인수를 그대로 반환하고, 아니면 ClassCastException을 던진다.

그런데 cast 메서드가 단지 인수를 그대로 반환하기만 한다면 굳이 왜 사용하는 것일까?

그 이유는 cast 메서드의 시그니처가 Class 클래스가 제네릭이라는 이점을 완벽히 활용하기 때문이다.

다음 코드에서 보듯 cast 반환 타입은 Class 객체의 타입 매개변수와 같다.

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

이것이 정확히 getFavorite 메서드에 필요한 기능으로 T로 비검사 형변환하는 손실 없이도 Favorites를 타입 안전하게 만드는 비결이다.

하지만 주의할 점도 있는 데 우선 악의적인 클라이언트가 Class 객체를 로 타입으로 넘기면 Favorites 인스턴스의 타입 안정성이 쉽게 깨진다.

하지만 이렇게 짜인 클라이언트 코드에서는 컴파일할 때 비검사 경고가 뜰 것이다.

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

이렇게 Favorites가 타입 불변식을 어기는 일이 없도록 보장하려면 putFavorite 메서드에서 인수로 주어진 instance의 타입이 type으로 명시한 타입과 같은지 확인하면 된다.

아래와 같이 그냥 동적 형변환을 쓰면 된다.

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

그리고 다른 한 문제는 실체화 불가 타입(E, List, List)에는 사용할 수 없다는 것이다.

다시 말해, 즐겨 찾는 String이나 String[]은 저장할 수 있어도 즐겨찾는 List은 저장할 수 없다는 것이다.

이는 List용 Class 객체를 얻을 수 없기 때문이다.

List.class라고 쓰면 문법 오류가 난다.

List와 List는 List.class라는 같은 Class 객체를 공유한다.

마지막으로 Favorites가 사용하는 타입 토큰은 비한정적이다.

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

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

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

아래는 한정적 타입 토큰의 예시다.

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

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

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

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

객체를 Class<? extends Annotation>으로 형변환할 수도 있지만, 이 형변환은 비검사이므로 컴파일하면 경고가 뜰 것이다.

하지만 asSubclass 메서드로, 호출된 인스턴스 자신의 Class 객체를 인수가 명시한 클래스로 형변환 할 수 있다.

형변환에 성공하면 인수로 받은 클래스 객체를 반환하고, 실패하면 ClassCastException을 던질 것이다.

0개의 댓글