생산자·소비자 와일드카드
와일드카드의 읽기·쓰기 실패를 재현하고 제네릭 메서드와 PECS 기준으로 상한·하한을 선택합니다.
Box<Dog>와 Box<Animal>은 서로 대입되지 않습니다.
하지만 어떤 메서드는 Dog든 Cat이든 읽기만 하거나, 반대로 Animal 하위 값을 특정 상위 컨테이너에 쓰기만 합니다.
와일드카드는 제네릭 타입 정의를 바꾸지 않고 소비하는 메서드가 안전하게 허용할 타입 범위를 표현합니다.
생산자 와일드카드의 쓰기 제한
상한 와일드카드는 “정확한 타입은 모르지만 Animal의 어떤 하위 타입”입니다.
실제 객체가 Box<Cat>일 수도 있으므로 Dog를 쓰는 것은 안전하지 않습니다.
public final class ExtendsWildcardWriteFailure {
public static void main(String[] args) {
Box<Dog> dogs = new Box<>();
addDog(dogs);
}
private static void addDog(Box<? extends Animal> box) {
box.set(new Dog("dog"));
}
private static final class Box<T> {
private T value;
void set(T value) { this.value = value; }
}
private interface Animal { String name(); }
private record Dog(String name) implements Animal { }
}error: incompatible types: Dog cannot be converted to CAP#1CAP#1은 컴파일러가 캡처한 미지의 구체 타입입니다.
null을 제외한 특정 Animal을 안전하게 넣을 근거가 없습니다.
대신 get 결과는 최소 Animal이라는 사실이 보장되므로 읽기 생산자에 적합합니다.
소비자 와일드카드의 읽기 제한
하한 와일드카드는 Dog 또는 그 부모 타입의 박스를 받습니다.
어느 경우든 Dog를 저장하는 것은 안전합니다.
그러나 실제 박스가 Box<Object>일 수 있으므로 꺼낸 값이 Dog라는 보장은 없고 정적 반환 타입은 Object입니다.
public final class SuperWildcardReadFailure {
public static void main(String[] args) {
Box<Animal> animals = new Box<>();
Dog dog = readDog(animals);
System.out.println(dog.name());
}
private static Dog readDog(Box<? super Dog> box) {
box.set(new Dog("stored"));
return box.get();
}
private static final class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
private interface Animal { String name(); }
private record Dog(String name) implements Animal { }
}error: incompatible types: CAP#1 cannot be converted to Dog하한 와일드카드는 소비자 역할에 적합합니다.
Dog를 받아들이는 목적에는 충분하지만 구체 반환 관계까지 필요하면 타입 매개변수를 사용해야 합니다.
비제한 와일드카드
Box<?>는 Box<? extends Object>와 같은 허용 범위를 가지며 모든 Box를 받을 수 있습니다.
값의 toString, null 여부, 박스 자체의 공통 동작처럼 정확한 타입이 필요 없는 읽기에 적합합니다.
비매개변수화 타입과 달리 미지 타입이 존재한다는 사실을 보존하므로 임의 값을 넣지 못합니다.
import java.util.ArrayList;
import java.util.List;
public final class WildcardReadAndCopy {
public static void main(String[] args) {
List<Dog> source = List.of(new Dog("d1"), new Dog("d2"));
List<Animal> target = new ArrayList<>();
printAny(source);
copyDogs(source, target);
System.out.println("copied=" + target);
}
private static void printAny(List<?> values) {
System.out.println("size=" + values.size() + ",first=" + values.getFirst());
}
private static void copyDogs(List<? extends Dog> source, List<? super Dog> target) {
for (Dog dog : source) {
target.add(dog);
}
}
private interface Animal { String name(); }
private record Dog(String name) implements Animal { }
}size=2,first=Dog[name=d1]
copied=[Dog[name=d1], Dog[name=d2]]source는 Dog 생산자라 extends, target은 Dog 소비자라 super입니다.
PECS(Producer Extends, Consumer Super)는 이 방향을 기억하는 규칙입니다.
한 매개변수에서 읽고 쓰기를 모두 해야 하면 와일드카드보다 정확한 List<T>가 필요할 수 있습니다.
생산자라는 이름이 컬렉션의 불변성을 뜻하지는 않습니다. List<? extends Number>에서도 원소를 특정 Number로 새로 넣을 수는 없지만 remove·clear 같은 연산은 컴파일됩니다. 실제 수정 가능 여부는 전달한 구현의 계약입니다. 다음 프로그램은 별도 ArrayList와 List.of를 비교합니다.
예시
import java.util.ArrayList;
import java.util.List;
public final class WildcardMutationScope {
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>(List.of(1, 2));
List<? extends Number> producer = numbers;
Number first = producer.getFirst();
producer.remove(0);
System.out.println("first=" + first + ",remaining=" + numbers);
producer.clear();
System.out.println("after-clear=" + numbers.size());
List<?> fixed = List.of(1);
try {
fixed.clear();
} catch (UnsupportedOperationException error) {
System.out.println("fixed=unmodifiable");
}
}
}first=1,remaining=[2]
after-clear=0
fixed=unmodifiableremove와 clear가 통과한 타입 검사는 List.of의 수정 지원을 보장하지 않습니다. List의 수정 불가 계약은 이 두 판단을 나누어 설명합니다. 원래 copyDogs의 List.of source는 읽기만 하고 수정 대상은 별도 ArrayList입니다.
extends 입력의 Dog 저장과 super 입력의 Dog 반환은 각각 컴파일 실패한다. List<?> 관찰 예제는 구체 타입을 몰라도 원소를 Object 관점에서 읽어 출력한다.
와일드카드 · 허용 연산
와일드카드 안에서 허용되는 연산
extends의 미지 타입에 Dog를 쓰거나 super의 읽기를 Dog로 반환할 수 없습니다. List<?>의 실제 관찰은 Object 관점입니다.
| 메서드의 매개변수 | 알 수 있는 타입 관계 | 예제의 연산과 결과 |
|---|---|---|
Box<? extends Animal> | 정확한 Animal 하위 타입은 모름 | box.set(new Dog(...))컴파일 실패 Dog를 그 미지 타입에 저장할 수 없음 |
Box<? super Dog> | Dog 저장은 허용 꺼낸 값의 보장 타입은 Object | return box.get()Dog 반환에서 컴파일 실패 |
List<?> | 원소를 Object 관점에서 관찰 | getFirst()로 첫 원소를 출력size=2,first=Dog[name=d1] |
Box<? extends Animal>- 알 수 있는 타입 관계
- 정확한 Animal 하위 타입은 모름
- 예제의 연산과 결과
box.set(new Dog(...))컴파일 실패Dog를 그 미지 타입에 저장할 수 없음
Box<? super Dog>- 알 수 있는 타입 관계
- Dog 저장은 허용꺼낸 값의 보장 타입은 Object
- 예제의 연산과 결과
return box.get()Dog 반환에서 컴파일 실패
List<?>- 알 수 있는 타입 관계
- 원소를 Object 관점에서 관찰
- 예제의 연산과 결과
getFirst()로 첫 원소를 출력size=2,first=Dog[name=d1]
상한 와일드카드에도 null은 타입상 저장할 수 있습니다. 타입 검사가 허용하는 연산과 컬렉션 구현이 실제 수정을 지원하는지는 별개의 조건입니다.
와일드카드와 제네릭 메서드
단지 값을 출력하고 부모 기능만 호출하면 와일드카드 일반 메서드가 더 단순합니다.
입력 박스의 정확한 타입을 반환까지 보존해야 하면 제네릭 메서드의 T가 필요합니다.
public final class GenericMethodVersusWildcard {
public static void main(String[] args) {
Box<Dog> box = new Box<>(new Dog("dori", "A-7"));
Animal viewed = view(box);
Dog preserved = preserve(box);
System.out.println("view=" + viewed.name());
System.out.println("code=" + preserved.code());
}
private static Animal view(Box<? extends Animal> box) {
return box.get();
}
private static <T extends Animal> T preserve(Box<T> box) {
return box.get();
}
private record Box<T>(T get) { }
private interface Animal { String name(); }
private record Dog(String name, String code) implements Animal { }
}view=dori
code=A-7T는 입력과 출력의 동일 타입 관계를 명명합니다.
와일드카드 메서드는 그런 관계를 새로 정의하지 않고 이미 타입 인자가 정해진 Box들을 넓게 받아 부모 관점으로 사용합니다.
같은 Box<Dog>를 읽어도 view는 Animal을 반환하고 preserve는 입력의 T를 반환한다. main의 Dog preserved 참조는 code를 형변환 없이 호출한다.
반환 계약 · 같은 객체의 두 관점
같은 Box를 읽는 두 반환 계약
두 호출은 같은 dori를 읽습니다. view는 Animal 관점으로 반환하고 preserve는 입력의 T를 유지해 Dog의 code를 호출하게 합니다.
view(box)Box<? extends Animal> 입력
view(box)Box<? extends Animal> 입력- main에서 받는 타입
Animal viewed- 원문에서 사용하는 기능
viewed.name()view=dori
preserve(box)Box<T> 입력, T 반환
preserve(box)Box<T> 입력, T 반환- main에서 받는 타입
Dog preserved- 원문에서 사용하는 기능
preserved.code()code=A-7
두 호출은 같은 Dog dori를 읽습니다. 달라지는 것은 결과를 통해 컴파일러가 허용하는 멤버의 범위입니다.
타입 매개변수 이름 가림
제네릭 클래스 안에 <T> T method(T value)를 선언하면 메서드의 T가 클래스의 T와 별개이며 더 가까운 이름이 바깥 T를 가립니다.
서로 같은 타입처럼 보이지만 호출마다 전혀 다른 타입이 될 수 있습니다.
메서드 타입에는 R, U 같은 다른 이름을 사용하면 범위가 드러납니다.
예를 들어 class Converter<T> { <R> R convert(T source, Function<T,R> fn) }에서는 T가 인스턴스 입력 타입이고 R은 호출별 결과입니다.
같은 T 이름을 재사용하면 source와 result가 같은 타입이라는 잘못된 인상을 줍니다.
타입 변수 이름은 짧아도 관계 설명의 일부입니다.
와일드카드 선택표
- 정확한 타입을 몰라도 읽기만 한다:
List<?> - Animal 하위 값을 Animal로 생산한다:
List<? extends Animal> - Dog 값을 상위 컨테이너에 소비시킨다:
List<? super Dog> - 입력 타입을 반환에도 그대로 보존한다:
<T> T method(...) - 객체 상태 전체가 같은 T를 공유한다:
class Box<T> - 읽기와 쓰기를 모두 정확한 타입으로 한다:
List<T>
와일드카드를 지나치게 중첩하면 호출자가 CAP 진단을 이해하기 어렵습니다.
공개 API는 흔한 생산자·소비자 변형만 제공하고 복잡한 캡처는 내부 도우미 제네릭 메서드로 옮길 수 있습니다.
반환 타입에 ? extends를 노출하면 호출자가 다시 미지 타입을 다뤄야 하므로 가능한 한 안정된 인터페이스나 명명된 T로 반환합니다.
연습 문제
copy(List<? extends T> source, List<? super T> target)를 작성하고 List<ScoredPost>에서 List<Scored>로 복사하세요.
이 연습에서는 copy의 선언으로 source에서 읽을 타입과 target에 쓸 타입을 연결합니다. 점수 합산은 3장에서 배운 향상된 for문으로 확인하세요. 복사 후 target의 원소 수와 점수 합계를 먼저 예상해 보세요.
연습 입력은 ScoredPost(101L, 80), ScoredPost(102L, 95) 두 개이며 Scored는 score()를 제공합니다. target은 처음 비어 있는 수정 가능한 ArrayList<Scored>입니다. copy는 source 순서대로 원소를 target 끝에 추가하고 source를 바꾸지 않습니다. 중복 제거·초기화·새 ScoredPost 생성은 요구하지 않습니다.
독립 조건에서는 target에 점수 10인 기존 ScoredPost 하나가 있고 같은 두 source를 한 번 추가합니다. 그 뒤 count와 total은 각각 얼마인가요? 두 번째 copy를 다시 실행하면 첫 결과를 덮어쓸지도 함께 예상하세요.
해설 보기
import java.util.ArrayList;
import java.util.List;
public final class PecsCopyExercise {
public static void main(String[] args) {
List<ScoredPost> source = List.of(
new ScoredPost(101L, 80), new ScoredPost(102L, 95));
List<Scored> target = new ArrayList<>();
copy(source, target);
int total = 0;
for (Scored value : target) {
total += value.score();
}
System.out.println("count=" + target.size());
System.out.println("total=" + total);
}
private static <T> void copy(List<? extends T> source, List<? super T> target) {
for (T value : source) {
target.add(value);
}
}
private interface Scored { int score(); }
private record ScoredPost(long postId, int score) implements Scored { }
}count=2
total=175total은 0에서 시작해 첫 원소의 80점, 두 번째 원소의 95점을 더해 175가 됩니다. count가 2가 아니라면 copy가 source의 두 원소를 모두 추가했는지 확인하세요.
T는 source 원소와 target에 추가할 값의 관계를 묶습니다.
source는 생산하고 target은 소비하므로 PECS 방향과 일치합니다.
독립 조건에서는 기존 1개에 2개를 더해 count=3, total=10+80+95=185입니다. 같은 copy를 다시 하면 2개가 더 붙어 count=5, total=360입니다. 이 구현은 같은 입력을 다시 넣어도 한 번만 남기는 처리가 아닙니다.
copy는 객체 참조를 추가합니다. 새 리스트에 보관해도 각 ScoredPost를 새로 복제하지 않습니다. 원래 record의 long·int 값은 불변이지만, 다른 가변 원소 타입을 사용할 때에는 원소 상태를 공유할 수 있다는 조건을 따로 확인해야 합니다.
copy 안의 target은 List<? super T>이므로 읽은 값에 대해 보장되는 타입은 Object입니다. main에서 선언한 target의 타입은 여전히 List<Scored>이므로 위 반복문에서는 Scored로 읽어 score()를 호출할 수 있습니다.
복사 메서드와 호출자가 보는 타입
source의 두 참조를 target에 copy한 뒤 Scored로 읽어 175를 누산합니다. PECS의 공급/소비 역할을 유지하며 목록과 합계의 실제 전후를 보여 줄 수 있습니다.
copy의 source는 T를 생산하고 target은 T를 소비한다. 호출자의 target 변수는 List<Scored>여서 복사 후 Scored 점수를 합산할 수 있다.
PECS · 메서드와 호출자 관점
복사 메서드와 호출자가 보는 타입
copy 안에서 source는 T를 생산하고 target은 T를 받습니다. main의 target 선언은 여전히 List<Scored>여서 점수를 읽을 수 있습니다.
실선: source 순회 → target에 참조 추가 → 호출자가 Scored로 읽기와일드카드는 메서드의 사용 계약 · caller target 타입을 Object로 바꾸거나 원소를 복제하지 않음
실선: 순회·추가·읽기 연결원소 객체를 새로 복제하지 않음target의 선언 타입은 그대로
copy 안의 source
- 보이는 타입
List<? extends T>- 가능한 동작
for (T value : source)원소를 T로 읽음
copy 안의 target
- 보이는 타입
List<? super T>- 가능한 동작
target.add(value)T 값을 추가
main의 target
- 보이는 타입
List<Scored>- 가능한 동작
for (Scored value : target)value.score()로 합산count=2total=175
copy의 하한 와일드카드로 꺼낸 값은 Object 관점입니다. 호출자에게는 원래 List<Scored> 선언이 남아 있으므로 Scored로 읽을 수 있습니다.
이 예의 조건
- 객체 원소를 복제하거나 caller target 타입을 바꾸지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 5 | 101 |
| 6 | 80, 102 |
| 7 | 95 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 복사 메서드와 호출자가 보는 타입 복사 메서드와 호출자가 보는 타입 copy의 source는 T를 생산하고 target은 T를 소비한다. 호출자의 target 변수는 List<Scored>여서 복사 후 Scored 점수를 합산할 수 있다. PECS · 메서드와 호출자 관점 복사 메서드와 호출자가 보는 타입 copy 안에서 source는 T를 생산하고 target은 T를 받습니다. main의 target 선언은 여전히 List<Scored>여서 점수를 읽을 수 있습니다. copy 안의 source 보이는 타입 List<? extends T> 가능한 동작 for (T value : source) 원소를 T로 읽음 copy 안의 target 보이는 타입 List<? super T> 가능한 동작 target.add(value) T 값을 추가 main 의 target 보이는 타입 List<Scored> 가능한 동작 for (Scored value : target) value.score() 로 합산 count=2 total=175 copy 의 하한 와일드카드로 꺼낸 값은 Object 관점입니다. 호출자에게는 원래 List<Scored> 선언이 남아 있으므로 Scored로 읽을 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 읽기 준비 ScoredPost 두 개를 extends T로 읽습니다. |
|
|
| 참조 추가 source의 두 참조를 super T 대상 끝에 추가합니다. |
|
|
| 호출자 누산 Scored 계약으로 80과 95를 읽습니다. |
|
|
복사 메서드와 호출자가 보는 타입
source의 두 참조를 target에 copy한 뒤 Scored로 읽어 175를 누산합니다. PECS의 공급/소비 역할을 유지하며 목록과 합계의 실제 전후를 보여 줄 수 있습니다.
copy의 source는 T를 생산하고 target은 T를 소비한다. 호출자의 target 변수는 List<Scored>여서 복사 후 Scored 점수를 합산할 수 있다.
PECS · 메서드와 호출자 관점
복사 메서드와 호출자가 보는 타입
copy 안에서 source는 T를 생산하고 target은 T를 받습니다. main의 target 선언은 여전히 List<Scored>여서 점수를 읽을 수 있습니다.
실선: source 순회 → target에 참조 추가 → 호출자가 Scored로 읽기와일드카드는 메서드의 사용 계약 · caller target 타입을 Object로 바꾸거나 원소를 복제하지 않음
실선: 순회·추가·읽기 연결원소 객체를 새로 복제하지 않음target의 선언 타입은 그대로
copy 안의 source
- 보이는 타입
List<? extends T>- 가능한 동작
for (T value : source)원소를 T로 읽음
copy 안의 target
- 보이는 타입
List<? super T>- 가능한 동작
target.add(value)T 값을 추가
main의 target
- 보이는 타입
List<Scored>- 가능한 동작
for (Scored value : target)value.score()로 합산count=2total=175
copy의 하한 와일드카드로 꺼낸 값은 Object 관점입니다. 호출자에게는 원래 List<Scored> 선언이 남아 있으므로 Scored로 읽을 수 있습니다.
이 예의 조건
- 객체 원소를 복제하거나 caller target 타입을 바꾸지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 5 | 101 |
| 6 | 80, 102 |
| 7 | 95 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 복사 메서드와 호출자가 보는 타입 복사 메서드와 호출자가 보는 타입 copy의 source는 T를 생산하고 target은 T를 소비한다. 호출자의 target 변수는 List<Scored>여서 복사 후 Scored 점수를 합산할 수 있다. PECS · 메서드와 호출자 관점 복사 메서드와 호출자가 보는 타입 copy 안에서 source는 T를 생산하고 target은 T를 받습니다. main의 target 선언은 여전히 List<Scored>여서 점수를 읽을 수 있습니다. copy 안의 source 보이는 타입 List<? extends T> 가능한 동작 for (T value : source) 원소를 T로 읽음 copy 안의 target 보이는 타입 List<? super T> 가능한 동작 target.add(value) T 값을 추가 main 의 target 보이는 타입 List<Scored> 가능한 동작 for (Scored value : target) value.score() 로 합산 count=2 total=175 copy 의 하한 와일드카드로 꺼낸 값은 Object 관점입니다. 호출자에게는 원래 List<Scored> 선언이 남아 있으므로 Scored로 읽을 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 읽기 준비 ScoredPost 두 개를 extends T로 읽습니다. |
|
|
| 참조 추가 source의 두 참조를 super T 대상 끝에 추가합니다. |
|
|
| 호출자 누산 Scored 계약으로 80과 95를 읽습니다. |
|
|