이펙티브 자바 아이템 33 - 타입 안전 이종 컨테이너를 고려하라 - 핵심 정리 / 완벽 공략
아이템 33 - 타입 안전 이종 컨테이너를 고려하라 - 핵심정리 / 완벽 공략
이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.
타입 토큰을 사용한 타입 안전 이종 컨테이너
컨테이너란 다른 어떤 오브젝트를 넣을 수 있는 Set
, Map
을 의미한다.
우리가 여태 사용한 제네릭은 오직 한 가지 타입 만 넣을 수 있는 컨테이너를 만드는 것이었다.
public class Favorites<T> {
List<T> value;
public static void main(String[] args) {
Favorites<String> names = new Favorites<>();
names.value.add("TEST");
}
}
경우에 따라서는 이종(동족이 아닌, 서로 다른타입) 컨테이너 가 필요할 수 있다.
예를들어 데이터베이스의 각 컬럼들을 저장할 떄 해당하는 타입의 value 만 넣을 수 있도록하는 작업이 있다.
public class Favorites {
private Map<Class, Object> map = new HashMap<>();
public void put(Class clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), value);
}
public Object get(Class clazz) {
return this.map.get(clazz);
}
public static void main(String[] args) {
Favorites favorites = new Favorites();
favorites.put(String.class, 2); // 타입 안정성이 꺠진다
favorites.put(Integer.class, "kee");
}
}
Map<Class, Object>
를 사용해 값을 정의할 수도 있지만 타입 안정성 이 보장되자도 않고
제네릭 을 제대로 활용하지 않게된다.
public class Favorites {
private Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), value);
}
public <T> T get(Class<T> clazz) {
return clazz.cast(this.map.get(clazz));
}
public static void main(String[] args) {
Favorites favorites = new Favorites();
favorites.put(String.class, "keesun");
favorites.put(Integer.class, 2);
}
}
이러한 경우에는 컨테이너에 타입을 선언하는게 아니라 Map
안에 들어가는 Key
에 선언을 해야한다.
Class
라는 클래스를 보면 제네릭 타입 이다.
// Class 내부
public final class Class<T> implements java.io.Serializable,
GenericDeclaration,
Type,
AnnotatedElement,
TypeDescriptor.OfField<Class<?>>,
Constable {
private static final int ANNOTATION = 0x00002000;
private static final int ENUM = 0x00004000;
private static final int SYNTHETIC = 0x00001000;
private static native void registerNatives();
static {
registerNatives();
}
//*******
}
String.class
는 Class<String>
과 같은 것이다.
String.class
는 클래스 리터럴 , 타입 토큰 이라고 부른다.
런타임, 컴파일 타임에 해당하는 클래스의 타입을 파악할 수 있는 정보이다.
임의의 클래스를 Key
로 받을 것이기 때문에 비한정적 와일드 카드 를 사용해
Map<Class<?>, Object>
로 선언한다.
public class Favorites {
private Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), value);
}
public <T> T get(Class<T> clazz) {
return clazz.cast(this.map.get(clazz));
}
public static void main(String[] args) {
Favorites favorites = new Favorites();
favorites.put(String.class, "keesun");
favorites.put(Integer.class, 2);
}
}
private Map<Class<?>, Object> map = new HashMap<>();
만 선언해서는 String
키에 숫자가 들어가는 것을 막을 수 없으므로
public <T> void put(Class<T> clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
}
public <T> T get(Class<T> clazz) {
return clazz.cast(this.map.get(clazz));
}
메서드 수준에서도 제네릭 타입을 정의해야한다.
public static void main(String[] args) {
Favorites favorites = new Favorites();
favorites.put(String.class, 22); // 에러 발생
favorites.put(Integer.class, "KEEE"); // 에러 발생
}
이렇게 작성하면 문자열 키에 숫자를 전달하는 걸 막을 수 있다.
값을 꺼내올 때도 클래스 리터럴 에 해당하는 정보로 꺼내올 수 있다.
clazz.cast()
를 사용하면 해당 타입으로 형변환이 가능한지 검사하고 형변환을 하기 때문에 경고 메세지가 발생하지 않는다.
여기까지가 기본적인 타입 안전 이종 컨테이너 이다.
public class Favorites {
private Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), value);
}
public <T> T get(Class<T> clazz) {
return clazz.cast(this.map.get(clazz));
}
public static void main(String[] args) {
Favorites favorites = new Favorites();
favorites.put((Class)String.class, 123123);
favorites.put(String.class, "keesun");
favorites.put(Integer.class, 2);
}
}
위의 코드처럼 favorites.put((Class)String.class, 123123)
String.class
를 Class
로 형변환해 강제적으로 로타입 으로 전달하게 되면 아무 타입이다 넣을 수 있다.
Class
타입으로 값이 전달되면 제네릭의 소거 방식에 의해서 Object
가 되기 때문에 아무 값이나 넘길 수 있게된다.
이런 상황에서 값을 꺼내려고 할 때 에러가 발생한다.
public <T> void put(Class<T> clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
}
이 상황을 막고싶다면 값을 넣을 때 clazz.cast()
를 통해
해당 클래스의 타입으로 캐스팅이 되는 value
값을 검사한다.
단점으로는 근본적으로 클라이언트가 악의적으로 로 타입 으로 넘기는 것을 막을수는 없다는 것이다.
public class Favorites {
private Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
}
public <T> T get(Class<T> clazz) {
return clazz.cast(this.map.get(clazz));
}
public static void main(String[] args) {
Favorites favorites = new Favorites();
favorites.put(String.class, "keesun");
favorites.put(Integer.class, 2);
favorites.put(List.class, List.of(1, 2, 3));
favorites.put(List.class, List.of("a", "b", "c"));
List list = favorites.get(List.class);
list.forEach(System.out::println);
}
}
List
를 전달할 때 List
라는 Key
값이 중복되기 때문에 값이 덮어씌어지게 된다.
public class Favorites {
private Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
}
public <T> T get(Class<T> clazz) {
return clazz.cast(this.map.get(clazz));
}
public static void main(String[] args) {
Favorites favorites = new Favorites();
favorites.put(String.class, "keesun");
favorites.put(Integer.class, 2);
favorites.put(List<Integer>.class, List.of(1, 2, 3)); // 에러 발생
favorites.put(List<String>.class, List.of("a", "b", "c"));
List list = favorites.get(List.class);
list.forEach(System.out::println);
}
}
favorites.put(List<Integer>.class, List.of(1, 2, 3));
처럼 타입을 구분하려고 하지만 에러가 발생한다.
List<Integer>.class
는 문법이 허용하지 않는다.
List<Integer>.class
처럼 타입을 가지고 있는 클래스 리터럴 은 존재하지 않는다.
때문에 제네릭을 가지고 있는 클래스를 구분할 수 있는 방법이 없다.
그러나 누군가 슈퍼 타입 토큰 을 이용해 구분할 수 있는 방법을 고안했다.
슈퍼 타입 토큰
컴파일 타임 이나 런 타임 에 타입 정보를 알아내기 위해 메서드에 전달하는 클래스 리터럴 을 타입 토큰 이라고 한다.
타입 토큰 의 단점은 제네릭 타입 에 대한 클래스 리터럴 을 구할 수 없다는 것이다.
이러한 단점을 극복하기 위한 방법으로 슈퍼 타입 토큰 이 나왔다.
슈퍼 타입 토큰 을 이해하기 위해서는 타입을 알아내는 방법부터 알아야한다.
public class GenericTypeInfer {
static class Super<T> {
T value;
}
public static void main(String[] args) throws NoSuchFieldException {
Super<String> stringSuper = new Super<>();
System.out.println(stringSuper.getClass().getDeclaredField("value").getType());
}
}
Super<T>
는 일반적인 제네릭 컨테이너 역할을 하는 클래스이다.
stringSuper.getClass().getDeclaredField("value").getType()
를 사용해 해당 클래스가 가지고 있는 필드의 타입을 꺼내보면
Object
타입이 나온다.
제네릭에서 사용하는 소거 방식 때문이다.
그래서 타입을 알고싶어도 런 타임에 타입을 알수가 없다.
public class GenericTypeInfer {
static class Super<T> {
T value;
}
static class Sub extends Super<String> {
}
public static void main(String[] args) throws NoSuchFieldException {
Super<String> stringSuper = new Super<>();
System.out.println(stringSuper.getClass().getDeclaredField("value").getType());
}
}
그러나 상속 을 받은 경우에는 타입 정보가 남아있게된다.
static class Sub extends Super<String> {
}
Sub
클래스는 Super<String>
을 상속받았다.
public class GenericTypeInfer {
static class Super<T> {
T value;
}
static class Sub extends Super<String> {
}
public static void main(String[] args) throws NoSuchFieldException {
Super<String> stringSuper = new Super<>();
System.out.println(stringSuper.getClass().getDeclaredField("value").getType());
Sub sub = new Sub();
Type type = sub.getClass().getGenericSuperclass();
ParameterizedType pType = (ParameterizedType) type;
Type actualTypeArgument = pType.getActualTypeArguments()[0];
System.out.println(actualTypeArgument);
}
}
.getClass().getGenericSuperclass()
를 사용하고 ParameterizedType
로 타입 변환을 해주면
파라미터화된 타입 을 알 수 있다.
ParameterizedType
타입으로 변환을 해야 getActualTypeArguments()
메서드를 사용할 수 있다.
getActualTypeArguments()
는 타입 배열을 반환하는데 Super<T, K, B>
처럼 제네릭 타입을 여러개 지정할 수 있기 때문이다.
반환받은 배열의 첫번째 값을 출력해보면 Super<String>
의 파라미터 타입인 String
을 가져올 수 있다.
상속을 사용하지 않고 제네릭을 사용했을 때는 타입을 알아낼 방법이 없지만
상속 을 사용했을 때는 해당하는 인스턴스의 타입으로부터 제네릭 타입을 알아낼 수 있다.
public class GenericTypeInfer {
static class Super<T> {
T value;
}
public static void main(String[] args) throws NoSuchFieldException {
Super<String> stringSuper = new Super<>();
System.out.println(stringSuper.getClass().getDeclaredField("value").getType());
Type type = (new Super<String>(){}).getClass().getGenericSuperclass();
ParameterizedType pType = (ParameterizedType) type;
Type actualTypeArgument = pType.getActualTypeArguments()[0];
System.out.println(actualTypeArgument);
}
}
Type type = (new Super<String>(){}).getClass().getGenericSuperclass();
처럼 클래스 정의도 필요없이
익명 내부 클래스로 선언해 바로 제네릭타 입을 알아낼 수 있다.
익명 내부 클래스는 내부 클래스이자 클래스 정의임과 동시에 해당하는 클래스의 인스턴스 이기 때문이다.
이렇게 알아낸 타입으로 타입 이종 컨테이너 를 구현하는 것이다.
public abstract class TypeRef<T> {
private final Type type;
protected TypeRef() {
ParameterizedType superclass = (ParameterizedType) getClass().getGenericSuperclass();
type = superclass.getActualTypeArguments()[0];
}
@Override
public boolean equals(Object o) {
return o instanceof TypeRef && ((TypeRef)o).type.equals(type);
}
@Override
public int hashCode() {
return type.hashCode();
}
public Type getType() {
return type;
}
}
TypeRef
라는 추상 클래스를 만든다.
TypeRef
을 상속받은 클래스를 통해 제네릭 타입을 추론하는 것이다.
public class Favorites2 {
private final Map<TypeRef<?>, Object> favorites = new HashMap<>();
public <T> void put(TypeRef<T> typeRef, T thing) {
favorites.put(typeRef, thing);
}
@SuppressWarnings("unchecked")
public <T> T get(TypeRef<T> typeRref) {
return (T)(favorites.get(typeRref));
}
public static void main(String[] args) {
Favorites2 f = new Favorites2();
f.put(new TypeRef<List<String>>() {}, List.of("a", "b", "c"));
f.put(new TypeRef<List<Integer>>() {}, List.of(1, 2, 3));
f.get(new TypeRef<List<String>>() {}).forEach(System.out::println);
f.get(new TypeRef<List<Integer>>() {}).forEach(System.out::println);
}
}
TypeRef
를 Map
에서 사용한다.
이전 버전처럼 Class
의 cast()
메서드를 사용할 수 없다.
TypeRef
의 getType()
의 클래스가 Type
이지 우리가 원하는 T
의 타입이 아니기 때문이다.
@SuppressWarnings("unchecked")
를 통해 경고를 무시했지만 잘못된 경우가 발생할 수 있다.
public static void main(String[] args) {
Favorites2 f = new Favorites2();
f.put(new TypeRef<List<String>>() {}, List.of("a", "b", "c"));
f.put(new TypeRef<List<Integer>>() {}, List.of(1, 2, 3));
f.get(new TypeRef<List<String>>() {}).forEach(System.out::println);
f.get(new TypeRef<List<Integer>>() {}).forEach(System.out::println);
}
위처럼 <List<Integer>
와 List<String>
을 구분하여 사용할 수 있게된다.
favorites.put(List<Integer>.class, List.of(1, 2, 3)); // 에러 발생
favorites.put(List<String>.class, List.of("a", "b", "c"));
여전히 위처럼 사용할 수는 없지만 그래도 <List<Integer>
와 List<String>
을 구분하여 Key
로 사용할 수 있게되었다.
public static void main(String[] args) {
Favorites2 f = new Favorites2();
f.put(new TypeRef<List<String>>() {}, List.of("a", "b", "c"));
f.put(new TypeRef<List<Integer>>() {}, List.of(1, 2, 3));
f.get(new TypeRef<List<String>>() {}).forEach(System.out::println);
f.get(new TypeRef<List<Integer>>() {}).forEach(System.out::println);
}
위의 코드에서는 new TypeRef<List<String>>() {}
처럼 구체적으로 타입을 명시해서 사용했기 때문에 안전하게 사용할 수 있다.
public static void main(String[] args) {
Favorites2 f = new Favorites2();
TypeRef<List<String>> stringTypeRef = new TypeRef<>() {};
System.out.println(stringTypeRef.getType());
TypeRef<List<Integer>> integerTypeRef = new TypeRef<>() {};
System.out.println(integerTypeRef.getType());
f.put(stringTypeRef, List.of("a", "b", "c"));
f.put(integerTypeRef, List.of(1, 2, 3));
f.get(stringTypeRef).forEach(System.out::println);
f.get(integerTypeRef).forEach(System.out::println);
}
실제로 둘의 타입을 꺼내서 확인해보면 다른 타입이 출력된다.
class Oops {
static Favorites2 f = new Favorites2();
static <T> List<T> favoriteList() {
TypeRef<List<T>> ref = new TypeRef<>() {};
System.out.println(ref.getType());
List<T> result = f.get(ref);
if (result == null) {
result = new ArrayList<T>();
f.put(ref, result);
}
return result;
}
public static void main(String[] args) {
List<String> ls = favoriteList();
List<Integer> li = favoriteList();
li.add(1);
for (String s : ls) System.out.println(s);
}
}
위의 코드에서 보면 favoriteList
를 호출해 List
를 전달받는다.
이 때 TypeRef<List<T>> ref = new TypeRef<>() {};
를 정의하는데
마치 List<String>
와 List<Integer>
를 각각 구분한 List
를 받을 것 같지만 그렇지 않다.
static <T> List<T> favoriteList() {
TypeRef<List<T>> ref = new TypeRef<>() {};
System.out.println(ref.getType()); // List<T>
List<T> result = f.get(ref);
if (result == null) {
result = new ArrayList<T>();
f.put(ref, result);
}
return result;
}
System.out.println(ref.getType())
를 통해 출력해보면 List<T>
로 같은 타입이 나온다.
이렇게 같은 타입이 나온다면 List<String>
를 요구했을 때 만든 List<String>
가
List<Integer>
에서도 반환되게 된다.
public abstract class TypeRef<T> {
private final Type type;
protected TypeRef() {
ParameterizedType superclass = (ParameterizedType) getClass().getGenericSuperclass();
type = superclass.getActualTypeArguments()[0];
}
@Override
public boolean equals(Object o) {
return o instanceof TypeRef && ((TypeRef)o).type.equals(type);
}
@Override
public int hashCode() {
return type.hashCode();
}
public Type getType() {
return type;
}
}
이유는 TypeRef
클래스에서 재정의한 equals
와 hashCode
를 통해 같은 타입으로 판별되기 때문이다.
때문에 슈퍼 타입 토큰 역시 완벽한 해법이 아니고 구멍이 생길 수 있다.
대신 추상화된 클래스와 익명 클래스를 통해 제네릭 타입의 타입을 알아낼 수 있고,
이렇게 알아낸 타입으로 Map
을 사용하면 List<String>
, List<Integer>
로 Key
를 구분해서 사용할 수 있다.
한정적 타입 토큰
public class Favorites {
private Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> clazz, T value) {
this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
}
public <T> T get(Class<T> clazz) {
return clazz.cast(this.map.get(clazz));
}
public static void main(String[] args) {
Favorites favorites = new Favorites();
favorites.put(String.class, "keesun");
favorites.put(Integer.class, 2);
List list = favorites.get(List.class);
list.forEach(System.out::println);
}
}
지금까지 이종 컨테이너 에서는 ?
를 통해 비한정적 와일드 카드 타입 을 사용했다.
그러나 특정한 타입 이하로만 한정적으로 타입 토큰 을 사용할 수도 있다.
public class PrintAnnotation {
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));
}
}
AnnotatedElement
는 타입이 제한되어있는 타입 토큰 을 사용하는 대표적인 예이다.
AnnotatedElement
는 애노테이션을 가지고 있는 그 어떠한 모든것을 말한다.
@Retention(RetentionPolicy.RUNTIME)
public @interface FindMe {
}
FindMe
애노테이션을 만든다.
@FindMe
public class MyService {
}
FindMe
애노테이션을 붙인 MyService
를 정의한다.
// 코드 33-5 asSubclass를 사용해 한정적 타입 토큰을 안전하게 형변환한다. (204쪽)
public class PrintAnnotation {
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));
}
// 명시한 클래스의 명시한 애너테이션을 출력하는 테스트 프로그램
public static void main(String[] args) throws Exception {
System.out.println(getAnnotation(MyService.class, FindMe.class.getName()));
}
}
AnnotatedElement
타입에 애노테이션을 붙인 일반적인 클래스를 전달하고
AnnotatedElement
에서 제공하는 getAnnotation()
을 사용하면 제한되어 있는 타입 토큰을 받을 수 있다.
// getAnnotation 메서드
<T extends Annotation> T getAnnotation(Class<T> annotationClass);
내부를 보면 <T extends Annotation>
를 사용해 타입이 제한 되어 있는 걸 확인할 수 있다.
죽, 특정한 애노테이션 종류를 말한다.
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));
}
getAnnotation
메서드에서 Class<?> annotationType
처럼 비한정적 타입 토큰 을 사용한다.
비한정적 타입 토큰 은 어떤 타입인지는 모르지만 annotationType = Class.forName(annotationTypeName);
전달받은 annotationTypeName
의 이름을 사용하는 애노테이션이 분명히 있다고 가정한다.
Class<?> annotationType
의 타입 토큰 은 애노테이션의 서브 타입 일 것이다.
asSubclass()
는 파라미터로 전달하는 타입의 하위타입으로 변환해주는 메서드이다.