제네릭을 쓰다 보면 “아니 Integer가 Number의 하위 타입인데 왜 안 들어가?” 같은 상황이 종종 터진다. 그 핵심은 제네릭은 불공변(invariant) 이라는 점 때문이다. 이 문제를 해결하는 핵심 도구가 바로 한정적 와일드카드(extends / super) 다.
List<String>은 List<Object>의 하위 타입이 아니다.이 문제를 와일드카드로 해결할 수 있다.
public void pushAll(Iterable<E> src) {
for (E e : src) {
push(e);
}
}
겉보기엔 멀쩡하지만, 아래 코드는 컴파일 에러가 터진다.
StackWithGeneric<Number> stack = new StackWithGeneric<>();
List<Integer> integers = List.of(1, 2, 3);
stack.pushAll(integers); // ❌ 불공변 때문에 오류
public void pushAll(Iterable<? extends E> src) {
for (E e : src) push(e);
}
? extends E 덕에 E의 하위 타입도 받아들일 수 있게 된다.public void popAll(Collection<E> dst) { ... }
아래 코드는 오류가 난다:
Collection<Object> objects = new ArrayList<>();
stack.popAll(objects); // ❌ 역시 불공변
Number를 pop한 결과를 Object 컬렉션에 넣고 싶은데 불가능하다.
public void popAll(Collection<? super E> dst) {
while(size > 0)
dst.add(pop());
}
? super E 덕에 E의 상위 타입을 받을 수 있다.Producer → Extends
Consumer → Super
✔ 값을 “생산하는” 쪽은 extends
✔ 값을 “소비(담기)” 하는 쪽은 super
자바 제네릭 할 때는 그냥 무조건 떠올리면 되는 룰이다.
Comparable은 이렇게 생겼다:
public interface Comparable<T> {
int compareTo(T o);
}
compareTo의 매개변수는 "비교 기준을 소비"한다.
그래서 PECS 규칙상 super 를 써야 한다.
public static <E extends Comparable<E>> E max(List<E> list);
이건 E 스스로만 비교 가능할 때만 동작한다.
public static <E extends Comparable<? super E>> E max(List<E> list);
예: swap 메서드
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
가변인수(varargs)와 제네릭을 같이 쓰면 아주 달달해 보이지만… 사실 둘은 궁합이 굉장히 나쁜 조합이다.
가변인수는 배열을 내부적으로 만든 뒤 공개하는 구조라, 배열과 제네릭의 근본적인 불일치가 드러나기 때문이다.
이 아이템의 핵심은 단 하나다:
제네릭 가변인수는 안전하지 않지만, 위험한 두 행동만 하지 않으면 ‘조건부’로 써도 된다.
T... args 를 사용하면 자동으로 배열이 생성된다.List<String>...)이 들어가면public static void reifyExampleMethod(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList;
String s = stringLists[0].get(0); // ❌ ClassCastException
}
stringLists는 List<String>[] 로 추정되지만, 런타임에서는 그냥 Object[] 처럼 행동한다.
외부에서 잘못된 값을 끼워 넣으면 그대로 ClassCastException이 터진다.
➡ varargs 배열을 외부에 노출하거나, 다른 배열로 옮겨 담아 조작하는 것은 금지해야 한다.
예를 들어 List.of(), Arrays.asList() 등이 그렇다.
@SafeVarargs
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}
핵심: 안전한 방식으로 쓰기 때문이다.
위 두 가지를 지키면 @SafeVarargs 를 붙여 “이건 안전하다”라고 표시해도 좋다.
public static <T> T[] toArray(T... args) {
return args; // ❌ 그대로 반환하면 매우 위험
}
이 메서드는 varargs 배열을 그대로 노출하는데, 이때 반환 타입이 호출 시점에 잘못 추론될 수 있다.
이 메서드를 기반으로 한 pickTwo() 는 아래와 같이 완전한 폭탄이 된다.
public static <T> T[] pickTwo(T a, T b, T c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return toArray(a, b);
case 1: return toArray(a, c);
default: return toArray(b, c);
}
}
테스트:
String[] pickTwo = pickTwo("일", "이", "삼"); // ❌ runtime error
pickTwo()는 실제로는 Object[] 를 반환하지만,
컴파일러는 String[] 이라고 믿어버려
→ ClassCastException 발생!
게다가 원인이 toArray()에 있다는 점이 아주 먼 거리라서… 디버깅도 어렵다.
@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists) {
result.addAll(list);
}
return result;
}
이 메서드는 안전하다. 왜냐하면:
그래서 @SafeVarargs 를 달 수 있다.
💡 단, @SafeVarargs는 재정의 가능한 메서드(인스턴스 메서드)에는 달 수 없다.
하위 클래스가 위험한 방식으로 바꿔버리면 안전이 깨지기 때문.
static <T> List<T> flatten(List<List<? extends T>> lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists) {
result.addAll(list);
}
return result;
}
동일한 기능을 제공하면서 varargs 자체를 없애버린 버전.
➡ varargs를 꼭 쓸 필요가 없을 때는 List 버전이 더 명확하고 안전함.
public static <T> List<T> pickTwo2(T a, T b, T c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return List.of(a, b);
case 1: return List.of(a, c);
default: return List.of(b, c);
}
}
varargs 기반보다 훨씬 안전하고 자바의 표준 API를 활용해 더 깔끔해진다.
여러 타입의 데이터를 하나의 컨테이너에 넣되, 타입 안정성까지 보장하는 기법이다.
핵심 아이디어는 Class를 키로 사용하는 Map을 만드는 것.
static class Favorites {
private final 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 타입”이다.<T> void putFavorite(Class<T>, T) 덕분에type.cast() 덕분에 런타임에서도 타입 안전하게 꺼낼 수 있다.즉, 자료구조의 제네릭을 컨테이너에 하지 않고, 키(Class)에 한다 — 이게 패턴의 핵심임.
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 item33.Item33test$Favorites
타입별로 안전하게 저장·조회되는 걸 확인 가능.
f.putFavorite((Class) List.class, "난 리스트가 아닌데");
이런 식으로 raw type 캐스팅을 막을 방법은 없다.
→ 대신 컴파일 경고가 뜨고, 결국 런타임에 ClassCastException이 난다.
예: List<String>.class 같은 건 존재하지 않음.
그래서 복잡한 제네릭 타입은 기본 방식으로는 저장 불가.
“실체화 불가 타입 저장 문제”를 해결하려 등장한 기법.
예: 스프링의 ParameterizedTypeReference 같은 것.
f.putFavorite(new TypeRef<List<String>>(){}, pets);
List<String> list = f.getFavorite(new TypeRef<List<String>>(){});
익명 클래스를 활용해 실제 제네릭 타입 정보를 런타임까지 끌고 가는 방식.
특정 타입들만 키로 받도록 제한하고 싶을 때 사용.
대표적인 사례가 애너테이션 API임.
public <T extends Annotation> T getAnnotation(Class<T> annotationType);
annotationType은 “애너테이션 타입만 받을 수 있는” 한정적 타입 토큰Class.asSubclass() 사용
annotationType.asSubclass(Annotation.class);
변환 실패 시 ClassCastException.