Function 매개변수의 와일드카드와 타입 추론
Function<Animal, String>을 Function<Dog, String> 자리에 쓸 수 없는 이유와 함수를 받는 메서드에 ? super와 ? extends를 붙이는 규칙, 컴파일러가 타입 인수를 추론하는 방식과 와일드카드 캡처를 배웁니다.
어떤 동물이든 받아 설명 문자열을 만드는 함수 Function<Animal, String> describe가 있고, 개만 담긴 List<Dog>가 있다고 해 봅시다. 개도 동물이니 describe로 개를 설명하는 데는 아무 문제가 없어 보입니다. 그런데 다음 코드는 두 줄 모두 컴파일되지 않습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
public final class InvariantFunction {
interface Animal {
String name();
}
record Dog(String name) implements Animal {
}
static <T, R> List<R> map(List<T> source, Function<T, R> mapper) {
List<R> result = new ArrayList<>();
for (T value : source) {
result.add(mapper.apply(value));
}
return result;
}
public static void main(String[] args) {
List<Dog> dogs = List.of(new Dog("Bori"), new Dog("Maro"));
Function<Animal, String> describe = animal -> "Animal:" + animal.name();
Function<Dog, String> forDogs = describe;
System.out.println(map(dogs, describe));
}
}InvariantFunction.java:25: error: incompatible types: Function<Animal,String> cannot be converted to Function<Dog,String>
Function<Dog, String> forDogs = describe;
^
InvariantFunction.java:26: error: method map in class InvariantFunction cannot be applied to given types;
System.out.println(map(dogs, describe));
^
required: List<T>,Function<T,R>
found: List<Dog>,Function<Animal,String>
reason: inference variable T has incompatible equality constraints Animal,Dog
where T,R are type-variables:
T extends Object declared in method <T,R>map(List<T>,Function<T,R>)
R extends Object declared in method <T,R>map(List<T>,Function<T,R>)
2 errors이 글에서는 이 오류가 왜 나는지 이해하고, 함수를 받는 메서드의 시그니처에 ? super와 ? extends를 붙여 이런 함수를 안전하게 받는 방법을 익힙니다. 이어서 컴파일러가 제네릭 메서드의 타입 인수를 어떻게 정하는지, 그리고 List<?>처럼 이름 모를 타입을 다룰 때 쓰는 와일드카드 캡처까지 살펴봅니다. 와일드카드의 기본은 와일드카드와 PECS에서 다뤘으므로, 여기서는 함수형 인터페이스에 적용하는 데 집중합니다.
Function<Animal, String>이 Function<Dog, String>이 아닌 이유
첫 번째 오류는 Java 제네릭이 불공변(invariant)이기 때문에 생깁니다. 불공변이란 Dog가 Animal의 하위 타입이어도 Function<Dog, String>과 Function<Animal, String>은 서로 아무 관계가 없는 타입으로 본다는 뜻입니다. 타입 인수가 조금이라도 다르면 한쪽을 다른 쪽에 대입할 수 없습니다. 두 번째 오류도 같은 이유입니다. map의 T는 List<T> 쪽에서 보면 Dog여야 하고 Function<T, R> 쪽에서 보면 Animal이어야 하는데, 한 타입이 동시에 둘일 수는 없습니다.
Java가 이렇게 엄격한 데는 이유가 있습니다. List<Dog>를 List<Animal>로 볼 수 있다면 그 목록에 Cat을 넣는 코드도 컴파일될 것이고, 개만 들어 있어야 할 목록에 고양이가 섞입니다. 컴파일러는 타입 인수가 어떤 용도로 쓰이는지 모르므로, 모든 경우에 안전하도록 아예 대입을 막습니다.
그런데 함수는 사정이 조금 다릅니다. map은 함수에 Dog를 넣기만 하므로, Animal이라면 무엇이든 받는 함수를 써도 안전합니다. 나오는 쪽도 마찬가지여서, 결과를 CharSequence로 읽기만 한다면 String을 돌려주는 함수를 써도 안전합니다. 즉 함수의 입력은 더 넓은 타입을 받아도 되고, 출력은 더 좁은 타입을 돌려줘도 됩니다. 이 방향을 컴파일러에게 알려 주는 방법이 와일드카드입니다.
raw 타입으로 피하면 생기는 일
와일드카드를 쓰기 전에, 흔히 보는 잘못된 우회로부터 짚고 가겠습니다. 타입 인수를 아예 지운 raw 타입 Function을 쓰면 위와 같은 컴파일 오류는 사라집니다. 대신 컴파일러가 해 주던 타입 검사도 함께 사라집니다.
import java.util.List;
import java.util.function.Function;
public final class RawFunctionChain {
@SuppressWarnings({ "rawtypes", "unchecked" })
static Object applyAll(Object input, List<Function> functions) {
Object current = input;
for (Function function : functions) {
current = function.apply(current);
}
return current;
}
public static void main(String[] args) {
Function length = (Function<String, Integer>) String::length;
Function square = (Function<Integer, Integer>) value -> value * value;
System.out.println(applyAll("java", List.of(length, square)));
System.out.println(applyAll("java", List.of(square, length)));
}
}@SuppressWarnings는 raw 타입과 검사하지 않은 호출에 대한 컴파일러 경고를 끕니다. 그래서 이 코드는 경고 한 줄 없이 컴파일되고, 두 호출은 같은 함수 두 개를 순서만 바꿔 넘깁니다.
16
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
at RawFunctionChain.applyAll(RawFunctionChain.java:9)
at RawFunctionChain.main(RawFunctionChain.java:18)첫 줄은 "java"의 길이 4를 제곱한 16입니다. 길이 함수가 Integer를 돌려주고 제곱 함수가 Integer를 받으니 이 순서에서는 우연히 맞습니다. 하지만 둘째 줄처럼 순서만 바꿔도 컴파일은 그대로 통과하고, 실행하면 제곱 함수가 문자열 "java"를 Integer로 바꾸려다 ClassCastException을 던집니다. raw 타입 목록에는 함수마다 무엇을 받고 무엇을 돌려주는지에 대한 정보가 없으니, 잘못된 순서를 컴파일러가 알아챌 수 없습니다.
@SuppressWarnings도 경고를 보이지 않게 할 뿐 코드를 안전하게 만들지는 않습니다. raw 타입이 왜 위험한지는 제네릭 클래스와 타입 매개변수와 타입 소거와 런타임 한계에서 자세히 다룹니다.
List<Function<?, ?>>로 바꿔도 이 문제는 풀리지 않습니다. 원소마다 입력과 출력 타입이 다르므로 목록에서 꺼낸 함수를 차례로 호출하려면 결국 어딘가에서 형변환이 필요합니다. 서로 다른 타입의 함수를 이어야 한다면, 목록에 모아 두기보다 조합하는 시점에 andThen으로 하나의 Function<String, Integer>로 접어 두는 편이 낫습니다. 그러면 연결할 때마다 컴파일러가 앞 함수의 출력과 다음 함수의 입력을 비교합니다.
입력에는 ? super, 출력에는 ? extends
이제 올바른 방법을 보겠습니다. 다음 예제의 map은 앞의 InvariantFunction과 하는 일이 같지만 매개변수에 와일드카드를 붙였습니다. compose는 두 함수를 이어 붙여 하나의 함수를 만듭니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
public final class VarianceFunctionComposition {
interface Animal {
String name();
}
record Dog(String name) implements Animal {
}
record Cat(String name) implements Animal {
}
static <T, R> List<R> map(
List<? extends T> source,
Function<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>(source.size());
for (T value : source) {
result.add(mapper.apply(value));
}
return List.copyOf(result);
}
static <A, B, C> Function<A, C> compose(
Function<? super A, ? extends B> first,
Function<? super B, ? extends C> second) {
return value -> second.apply(first.apply(value));
}
public static void main(String[] args) {
List<Dog> dogs = List.of(new Dog("Bori"), new Dog("Maro"));
List<Animal> animals = List.of(new Cat("Nabi"), new Dog("Bori"));
Function<Animal, String> describe = animal -> animal.getClass().getSimpleName()
+ ":" + animal.name();
Function<CharSequence, Integer> length = CharSequence::length;
Function<Dog, Integer> descriptionLength = compose(describe, length);
System.out.println(map(dogs, describe));
System.out.println(map(animals, describe));
System.out.println(map(dogs, descriptionLength));
}
}세 번째 호출의 descriptionLength는 compose로 describe 뒤에 length를 이어 만든 Function<Dog, Integer>입니다. 그래서 셋째 줄에는 개마다 설명 문자열의 길이가 숫자로 찍힙니다.
[Dog:Bori, Dog:Maro]
[Cat:Nabi, Dog:Bori]
[8, 8]첫 줄에서 map(dogs, describe)가 이번에는 컴파일됩니다. Function<? super T, ? extends R>는 'T이거나 T의 상위 타입을 받고, R이거나 R의 하위 타입을 돌려주는 함수'라는 뜻입니다. 컴파일러는 dogs를 보고 T를 Dog로 정하는데, describe가 받는 Animal은 Dog의 상위 타입이므로 ? super Dog 조건을 만족합니다. 둘째 줄은 같은 describe를 List<Animal>에 쓴 경우로, 이때는 T가 Animal로 정해집니다. 실행 중 각 객체의 실제 클래스 이름이 getClass().getSimpleName()으로 찍혀 Cat과 Dog가 섞여 나옵니다.
source의 ? extends T는 목록 쪽을 넓혀 줍니다. map은 목록에서 원소를 꺼내 읽기만 하고 새 원소를 넣지 않으므로, T의 하위 타입 목록을 받아도 안전합니다. 그래서 T가 Animal로 정해지는 호출에서도 List<Dog>를 그대로 넘길 수 있습니다.
셋째 줄의 descriptionLength는 describe 다음에 length를 실행하는 함수입니다. 값 하나가 어떻게 흘러가는지 따라가 보면 와일드카드가 각 연결에서 무엇을 허용하는지 보입니다.
넓은 입력 함수와 구체적인 결과 타입을 반환하는 함수를 값의 이동 방향으로 읽는다
Dog Bori가 Animal 입력 함수로 Dog:Bori가 된 뒤 CharSequence 길이 함수에서 Integer 8이 됩니다. 고정 입력·현재 함수·출력의 값과 타입을 함께 확인하는 합성 예제입니다.
VarianceFunctionComposition의 descriptionLength 합성을 해석합니다.
값과 타입의 이동
넓은 입력 함수와 구체 결과 함수를 값의 이동 방향으로 읽는다
Dog → String → Integer로 값이 이동합니다. 각 함수는 전달되는 값보다 넓은 입력 타입을 받을 수 있습니다.
호출자 → describe
- 전달되는 타입
- Dog
- 원문에서 허용하는 연결
- Animal 입력으로 수용
describe → length
- 전달되는 타입
- String
- 원문에서 허용하는 연결
- CharSequence 입력으로 수용
length → 호출자
- 전달되는 타입
- Integer
- 원문에서 허용하는 연결
- Function<Dog, Integer>의 결과
이 클래스의 compose(first, second)는 first 뒤 second를 호출합니다. 인자로 받은 함수를 먼저 실행하는 JDK Function.compose(before)와 이름만으로 순서를 혼동하지 않습니다.
describe는 Animal을 받고 String을 돌려줍니다. length는 CharSequence를 받고 Integer를 돌려줍니다.
이 예의 조건
- 함수의 값 변환과 입력/출력 타입을 함께 유지한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 descriptionLength의 Bori 한 값이 Dog 입력에서 describe, String 결과, length, Integer 결과로 이동한다. Animal을 받는 describe는 Dog를 받고 CharSequence를 받는 length는 String을 받는다. 화살표는 값의 전달 순서이다. 모바일도 같은 5개 노드와 4개 관계이며 원 상세 타입 목록은 보조로 보존한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 descriptionLength의 Bori 한 값이 Dog 입력에서 describe, String 결과, length, Integer 결과로 이동한다. Animal을 받는 describe는 Dog를 받고 CharSequence를 받는 length는 String을 받는다. 화살표는 값의 전달 순서이다. 모바일도 같은 5개 노드와 4개 관계이며 원 상세 타입 목록은 보조로 보존한다. |
| 전체 읽기 정보 | 넓은 입력 함수와 구체적인 결과 타입을 반환하는 함수를 값의 이동 방향으로 읽는다 넓은 입력 함수와 구체적인 결과 타입을 반환하는 함수를 값의 이동 방향으로 읽는다 VarianceFunctionComposition의 descriptionLength 합성을 해석합니다. 값과 타입의 이동 넓은 입력 함수와 구체적인 결과 타입을 반환하는 함수를 값의 이동 방향으로 읽는다 Dog → String → Integer로 값이 이동합니다. 각 함수는 전달되는 값보다 넓은 입력 타입을 받을 수 있습니다. 호출자 → describe 전달되는 타입 Dog 원문에서 허용하는 연결 Animal 입력으로 수용 describe → length 전달되는 타입 String 원문에서 허용하는 연결 CharSequence 입력으로 수용 length → 호출자 전달되는 타입 Integer 원문에서 허용하는 연결 Function<Dog, Integer>의 결과 이 클래스의 compose(first, second)는 first 뒤 second를 호출합니다. 인자로 받은 함수를 먼저 실행하는 JDK Function.compose(before)와 이름만으로 순서를 혼동하지 않습니다. describe는 Animal을 받고 String을 돌려줍니다. length는 CharSequence를 받고 Integer를 돌려줍니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 Dog Bori를 합성 함수에 전달합니다. |
|
|
| 첫 변환 describe가 문자열을 반환합니다. |
|
|
| 둘째 변환 length가 문자열 길이를 반환합니다. |
|
|
넓은 입력 함수와 구체적인 결과 타입을 반환하는 함수를 값의 이동 방향으로 읽는다
Dog Bori가 Animal 입력 함수로 Dog:Bori가 된 뒤 CharSequence 길이 함수에서 Integer 8이 됩니다. 고정 입력·현재 함수·출력의 값과 타입을 함께 확인하는 합성 예제입니다.
VarianceFunctionComposition의 descriptionLength 합성을 해석합니다.
값과 타입의 이동
넓은 입력 함수와 구체 결과 함수를 값의 이동 방향으로 읽는다
Dog → String → Integer로 값이 이동합니다. 각 함수는 전달되는 값보다 넓은 입력 타입을 받을 수 있습니다.
호출자 → describe
- 전달되는 타입
- Dog
- 원문에서 허용하는 연결
- Animal 입력으로 수용
describe → length
- 전달되는 타입
- String
- 원문에서 허용하는 연결
- CharSequence 입력으로 수용
length → 호출자
- 전달되는 타입
- Integer
- 원문에서 허용하는 연결
- Function<Dog, Integer>의 결과
이 클래스의 compose(first, second)는 first 뒤 second를 호출합니다. 인자로 받은 함수를 먼저 실행하는 JDK Function.compose(before)와 이름만으로 순서를 혼동하지 않습니다.
describe는 Animal을 받고 String을 돌려줍니다. length는 CharSequence를 받고 Integer를 돌려줍니다.
이 예의 조건
- 함수의 값 변환과 입력/출력 타입을 함께 유지한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 descriptionLength의 Bori 한 값이 Dog 입력에서 describe, String 결과, length, Integer 결과로 이동한다. Animal을 받는 describe는 Dog를 받고 CharSequence를 받는 length는 String을 받는다. 화살표는 값의 전달 순서이다. 모바일도 같은 5개 노드와 4개 관계이며 원 상세 타입 목록은 보조로 보존한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 descriptionLength의 Bori 한 값이 Dog 입력에서 describe, String 결과, length, Integer 결과로 이동한다. Animal을 받는 describe는 Dog를 받고 CharSequence를 받는 length는 String을 받는다. 화살표는 값의 전달 순서이다. 모바일도 같은 5개 노드와 4개 관계이며 원 상세 타입 목록은 보조로 보존한다. |
| 전체 읽기 정보 | 넓은 입력 함수와 구체적인 결과 타입을 반환하는 함수를 값의 이동 방향으로 읽는다 넓은 입력 함수와 구체적인 결과 타입을 반환하는 함수를 값의 이동 방향으로 읽는다 VarianceFunctionComposition의 descriptionLength 합성을 해석합니다. 값과 타입의 이동 넓은 입력 함수와 구체적인 결과 타입을 반환하는 함수를 값의 이동 방향으로 읽는다 Dog → String → Integer로 값이 이동합니다. 각 함수는 전달되는 값보다 넓은 입력 타입을 받을 수 있습니다. 호출자 → describe 전달되는 타입 Dog 원문에서 허용하는 연결 Animal 입력으로 수용 describe → length 전달되는 타입 String 원문에서 허용하는 연결 CharSequence 입력으로 수용 length → 호출자 전달되는 타입 Integer 원문에서 허용하는 연결 Function<Dog, Integer>의 결과 이 클래스의 compose(first, second)는 first 뒤 second를 호출합니다. 인자로 받은 함수를 먼저 실행하는 JDK Function.compose(before)와 이름만으로 순서를 혼동하지 않습니다. describe는 Animal을 받고 String을 돌려줍니다. length는 CharSequence를 받고 Integer를 돌려줍니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 Dog Bori를 합성 함수에 전달합니다. |
|
|
| 첫 변환 describe가 문자열을 반환합니다. |
|
|
| 둘째 변환 length가 문자열 길이를 반환합니다. |
|
|
Dog("Bori")가 들어가면 Animal을 받는 describe가 이를 받아 String "Dog:Bori"를 돌려줍니다. 이 String은 CharSequence를 받는 length의 입력이 될 수 있고, length는 Dog:의 4글자와 Bori의 4글자를 더한 8을 돌려줍니다. Maro도 4글자라서 결과 목록은 [8, 8]입니다. 이때 compose의 타입 인수는 대입할 변수 Function<Dog, Integer>에서 A = Dog, C = Integer로 정해지고, 중간 타입 B는 describe의 출력과 length의 입력 사이에서 String으로 정해집니다. 두 함수 모두 실제로 전달되는 값보다 넓은 입력 타입을 가졌지만, 와일드카드 덕분에 그대로 이어 붙일 수 있었습니다.
이 규칙을 짧게 줄이면 '함수의 입력 타입에는 ? super, 출력 타입에는 ? extends'입니다. 와일드카드와 PECS의 '꺼내는 쪽(producer)은 extends, 넣는 쪽(consumer)은 super' 규칙을 함수에 적용한 것입니다. 함수의 입력은 우리가 값을 넣는 쪽이고 출력은 값을 꺼내는 쪽이기 때문에, 한 시그니처 안에 super와 extends가 함께 나타납니다.
JDK의 Function도 같은 규칙을 따릅니다. Function Javadoc에 있는 두 조합 메서드의 시그니처는 다음과 같습니다.
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after)
default <V> Function<V, R> compose(Function<? super V, ? extends T> before)andThen은 이 함수의 결과 R을 다음 함수에 넣으므로 다음 함수의 입력에 ? super R을 붙였고, compose는 앞 함수의 결과를 이 함수의 입력 T로 받으므로 앞 함수의 출력에 ? extends T를 붙였습니다. 주의할 것은 실행 순서입니다. describe.andThen(length)는 describe를 먼저 실행하고, length.compose(describe)도 인수로 받은 describe를 먼저 실행합니다. 이 글의 compose(first, second)는 이름은 JDK의 compose와 같지만 순서는 andThen과 같아서, first를 먼저 실행합니다. 이름만 보고 순서를 짐작하지 말고 시그니처와 문서를 확인해야 합니다.
와일드카드가 늘 필요한 것은 아닙니다. 클래스 안에서만 쓰는 비공개 도우미라면 List<T>와 Function<T, R>로 충분할 수 있습니다. 와일드카드는 시그니처를 읽기 어렵게 만들기도 하므로, 다른 사람이 만든 함수를 넘겨받는 공개 메서드처럼 유연함이 실제로 필요한 곳에 붙입니다.
더 깊이: JDK의 andThen으로는 Function<Dog, Integer>를 만들 수 없는 이유
앞 예제의 main에 다음 줄들을 추가해 보면 차이가 드러납니다. 여기서 쓰는 andThen과 compose는 JDK의 Function이 제공하는 default 메서드이고, 앞 예제에서 만든 정적 메서드 compose와는 다른 메서드입니다.
Function<Animal, Integer> viaAndThen = describe.andThen(length);
Function<Animal, Integer> viaCompose = length.compose(describe);
System.out.println(viaAndThen.apply(new Dog("Bori")) + " " + viaCompose.apply(new Dog("Bori")));
Function<Dog, Integer> forDogs = describe.andThen(length);처음 세 줄은 컴파일되고 8 8을 출력합니다. 하지만 마지막 줄은 컴파일되지 않습니다.
VarianceFunctionComposition.java:47: error: incompatible types: no instance(s) of type variable(s) V exist so that Function<Animal,V> conforms to Function<Dog,Integer>
Function<Dog, Integer> forDogs = describe.andThen(length);
^
where V,R,T are type-variables:
V extends Object declared in method <V>andThen(Function<? super R,? extends V>)
R extends Object declared in interface Function
T extends Object declared in interface Function
1 errorandThen은 인스턴스 메서드라서 결과의 입력 타입이 describe의 T, 즉 Animal로 이미 고정되어 있습니다. 그래서 결과는 Function<Animal, Integer>이고, 불공변 규칙 때문에 Function<Dog, Integer> 변수에 대입할 수 없습니다. 반면 이 글의 정적 메서드 compose는 입력 타입 A를 따로 가지고 있어서, 대입할 변수를 보고 A를 Dog로 정할 수 있었습니다. 실무에서는 Function<Animal, Integer>를 그대로 두고 받는 쪽 메서드가 Function<? super Dog, ...>로 선언되어 있으면 충분합니다. 함수를 만드는 쪽보다 받는 쪽에 와일드카드를 두는 것이 이 규칙의 핵심입니다.
컴파일러가 타입 인수를 추론하는 방식
제네릭 메서드를 호출할 때 VarianceFunctionComposition.<Dog, String>map(...)처럼 타입 인수를 적지 않아도 되는 것은 컴파일러가 추론하기 때문입니다. 컴파일러는 실제로 넘긴 인수, 람다나 메서드 참조의 본문, 그리고 결과를 대입할 변수의 타입에서 조건을 모아 T와 R을 정합니다. 다음 예제로 각 단서가 어떻게 쓰이는지 보겠습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
public final class GenericMethodInference {
static <I, O> List<O> transform(List<I> source, Function<? super I, ? extends O> mapper) {
List<O> result = new ArrayList<>();
for (I input : source) {
result.add(mapper.apply(input));
}
return List.copyOf(result);
}
static <T> T choose(boolean first, T left, T right) {
return first ? left : right;
}
record Code(String value) {
}
public static void main(String[] args) {
List<Code> codes = transform(List.of("A", "B"), Code::new);
List<CharSequence> labels = transform(codes, code -> "code=" + code.value());
Number inferred = choose(true, 10, 2.5);
Number explicit = GenericMethodInference.<Number>choose(false, 10, 2.5);
System.out.println(codes);
System.out.println(labels);
System.out.println(inferred.getClass().getSimpleName());
System.out.println(explicit);
}
}choose는 두 인수 가운데 하나를 그대로 돌려줍니다. 그래서 셋째 줄은 돌려받은 객체의 실제 클래스를, 넷째 줄은 그 값을 찍습니다.
[Code[value=A], Code[value=B]]
[code=A, code=B]
Integer
2.5첫 호출에서 I는 List.of("A", "B")의 원소 타입인 String으로, O는 결과를 담을 List<Code>에서 Code로 정해집니다. 생성자 참조 Code::new는 String을 받아 Code를 만드는 함수로 해석됩니다. 둘째 호출은 대입할 변수가 결과 타입을 정하는 예입니다. 람다는 String을 돌려주지만 변수가 List<CharSequence>이므로 O는 CharSequence가 되고, String은 ? extends CharSequence를 만족합니다. 출력에는 목록에 든 문자열이 그대로 찍힙니다.
choose(true, 10, 2.5)에서 10은 Integer로, 2.5는 Double로 박싱됩니다. T는 두 값을 모두 담을 수 있어야 하므로 컴파일러는 둘의 공통 상위 타입을 T로 고르고, 예제는 그 결과를 Number 변수에 대입했습니다. 그런데 셋째 줄에 Integer가 찍힙니다. 추론이 정하는 것은 컴파일 시점의 타입일 뿐, 실행 중인 객체는 여전히 first가 true라서 골라진 Integer 10이기 때문입니다.
explicit처럼 타입 인수를 직접 적을 때는 GenericMethodInference.<Number>choose(...)처럼 메서드 이름 앞에 클래스 이름(인스턴스 메서드라면 this 같은 참조)을 붙여야 합니다. 앞에 아무것도 없이 <Number>choose(...)만 쓰면 illegal start of expression이라는 문법 오류가 납니다. 이 호출에서는 first가 false이므로 넷째 줄에 2.5가 나옵니다.
조건이 서로 맞지 않으면 추론은 실패합니다. main 끝에 람다가 정수를 돌려주는데 결과를 List<String>에 담는 줄을 추가해 보겠습니다.
List<String> lengths = transform(codes, code -> code.value().length());GenericMethodInference.java:30: error: incompatible types: inference variable O has incompatible bounds
List<String> lengths = transform(codes, code -> code.value().length());
^
equality constraints: String
lower bounds: Integer
where O,I are type-variables:
O extends Object declared in method <I,O>transform(List<I>,Function<? super I,? extends O>)
I extends Object declared in method <I,O>transform(List<I>,Function<? super I,? extends O>)
1 error메시지는 O에 대한 두 조건이 충돌한다고 말합니다. 대입할 변수 List<String> 때문에 O는 정확히 String이어야 하고(equality constraints), 람다가 돌려주는 Integer가 ? extends O를 만족하려면 O는 Integer의 상위 타입이어야 합니다(lower bounds). 둘을 동시에 만족하는 타입은 없습니다.
호출식이 길어서 이런 메시지가 어디를 가리키는지 알기 어려울 때는, 람다를 먼저 타입을 적은 지역 변수에 담아 보면 좋습니다. Function<Code, String> lengthText = code -> code.value().length();처럼 쓰면 bad return type in lambda expression과 int cannot be converted to String이라는 메시지가 람다의 반환값 한 곳을 가리킵니다. 람다 매개변수에 (Code code) -> ...처럼 타입을 직접 적어 추론에 단서를 더할 수도 있지만, 필요 이상으로 구체적인 타입을 적으면 그 람다를 쓸 수 있는 곳이 좁아집니다. 어느 쪽이든 형변환으로 오류를 덮기 전에 메서드 시그니처의 타입 관계가 맞는지부터 확인합니다. 추론의 기초는 타입 상한과 메서드 추론에서 다뤘습니다.
와일드카드 캡처: 이름은 모르지만 하나로 정해진 타입
List<?>는 '아무 타입이나 섞어 담을 수 있는 목록'이 아니라 '원소 타입이 하나로 정해져 있지만 그 이름을 모르는 목록'입니다. 실제로는 List<String>일 수도 List<Integer>일 수도 있습니다. 그래서 다음처럼 앞의 두 원소를 맞바꾸는 간단한 코드도 컴파일되지 않습니다.
import java.util.List;
public final class WildcardCapture {
static void swapFirstTwo(List<?> list) {
Object first = list.get(0);
list.set(0, list.get(1));
list.set(1, first);
}
}WildcardCapture.java:6: error: incompatible types: Object cannot be converted to CAP#1
list.set(0, list.get(1));
^
where CAP#1 is a fresh type-variable:
CAP#1 extends Object from capture of ?
WildcardCapture.java:7: error: incompatible types: Object cannot be converted to CAP#1
list.set(1, first);
^
where CAP#1 is a fresh type-variable:
CAP#1 extends Object from capture of ?
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
2 errorsCAP#1은 컴파일러가 '이 목록의 이름 모를 원소 타입'에 임시로 붙인 이름입니다. 이렇게 와일드카드를 하나의 구체적인 미지 타입으로 바꾸는 것을 와일드카드 캡처(wildcard capture)라고 합니다. list.get(1)의 결과는 Object로만 알 수 있고, set에는 CAP#1 타입 값만 넣을 수 있습니다. 컴파일러 입장에서는 꺼낸 값이 같은 목록에서 나왔다는 사실을 연결할 방법이 없으므로 넣는 것을 막습니다.
해결 방법은 타입 매개변수가 있는 도우미 메서드에 목록을 넘기는 것입니다.
import java.util.ArrayList;
import java.util.List;
public final class WildcardCapture {
static void swapFirstTwo(List<?> list) {
swapHelper(list);
}
private static <T> void swapHelper(List<T> list) {
T first = list.get(0);
list.set(0, list.get(1));
list.set(1, first);
}
public static void main(String[] args) {
List<String> words = new ArrayList<>(List.of("lambda", "java"));
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3));
swapFirstTwo(words);
swapFirstTwo(numbers);
System.out.println(words);
System.out.println(numbers);
}
}두 목록은 원소 타입이 다르지만 같은 swapFirstTwo를 거칩니다. 출력에서는 두 목록 모두 앞의 두 원소만 자리가 바뀌었는지 확인하면 됩니다.
[java, lambda]
[2, 1, 3]swapHelper(list)를 호출하면 컴파일러는 캡처한 미지 타입을 T로 정합니다. 도우미 안에서는 그 타입에 T라는 이름이 생겼으므로 get이 T를 돌려주고 set이 T를 받아, 꺼낸 값을 같은 목록에 다시 넣는 일이 허용됩니다. 호출하는 쪽에는 단순한 List<?> 시그니처를 보여 주고, 타입을 이어 줘야 하는 작업은 비공개 도우미가 맡는 형태입니다. 반대로 서로 다른 List<?> 두 개는 각각 다른 미지 타입으로 캡처되므로, 한 목록에서 꺼낸 값을 다른 목록에 넣을 수 없습니다.
이 한계는 서로 다른 타입의 값과 함수를 한 컬렉션에 모을 때 드러납니다. 예를 들어 이벤트 타입마다 처리기를 등록하는 Map<Class<?>, List<Consumer<?>>>에서는 키의 ?와 값의 ?가 같은 타입이라는 사실을 컴파일러가 알 수 없습니다. 그래서 어딘가에서는 실행 중에 Class 객체로 검사하고 형변환해야 하는데, 그 형변환을 호출하는 곳마다 흩어 두지 말고 한 곳에 모아 두는 것이 좋습니다. 다음 연습 문제가 그런 구조입니다.
Class 객체로 검사하는 방법은 타입 소거 때문에 제네릭 타입에는 그대로 쓸 수 없다는 한계가 있습니다. List<String>.class 같은 표현은 없고 List.class는 raw 타입 Class<List>이므로, Class<List<String>> 토큰을 직접 얻을 수 없습니다(타입 소거와 런타임 한계).
정리
- Java 제네릭은 불공변이어서
Function<Animal, String>은Function<Dog, String>이 아닙니다. raw 타입으로 피하면 컴파일 오류 대신 실행 중ClassCastException을 얻게 됩니다. - 함수를 받는 메서드는 입력 타입에
? super, 출력 타입에? extends를 붙여Function<? super T, ? extends R>로 선언합니다. JDK의andThen과compose도 이 규칙을 따르며,andThen은 자기 자신을,compose는 인수로 받은 함수를 먼저 실행합니다. - 컴파일러는 인수, 람다 본문, 대입할 변수에서 조건을 모아 타입 인수를 추론합니다. 추론은 컴파일 시점의 타입만 정하고 실행 중인 객체를 바꾸지 않습니다.
List<?>의 원소 타입은 이름 모를 한 타입입니다. 타입 매개변수가 있는 도우미 메서드에 넘기면 그 타입을 캡처해 읽고 다시 넣는 작업을 할 수 있습니다.
연습 문제
서로 다른 타입의 이벤트를 하나의 라우터로 전달하는 EventRouter를 만드세요.
subscribe(Class<E> eventType, Consumer<? super E> handler)로 이벤트 타입별 처리기를 등록합니다.publish(Object event)는 이벤트의 실제 클래스와 정확히 같은 키로 등록된 처리기만 차례로 호출합니다. 등록된 처리기가 없는 이벤트는 조용히 무시합니다.- 확인되지 않은(unchecked) 형변환은 비공개 메서드 한 곳에만 둡니다.
해답 보기
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
public final class TypedEventRouterSolution {
static final class EventRouter {
private final Map<Class<?>, List<Consumer<?>>> handlers = new LinkedHashMap<>();
<E> void subscribe(Class<E> eventType, Consumer<? super E> handler) {
handlers.computeIfAbsent(eventType, ignored -> new ArrayList<>()).add(handler);
}
void publish(Object event) {
List<Consumer<?>> selected = handlers.getOrDefault(event.getClass(), List.of());
for (Consumer<?> handler : List.copyOf(selected)) {
invokeChecked(event.getClass(), handler, event);
}
}
private static <E> void invokeChecked(
Class<E> eventType,
Consumer<?> rawHandler,
Object event) {
E checkedEvent = eventType.cast(event);
@SuppressWarnings("unchecked")
Consumer<? super E> handler = (Consumer<? super E>) rawHandler;
handler.accept(checkedEvent);
}
}
record UserCreated(String id) {
}
record OrderPaid(long orderId) {
}
public static void main(String[] args) {
EventRouter router = new EventRouter();
List<String> audit = new ArrayList<>();
router.subscribe(UserCreated.class, event -> audit.add("user:" + event.id()));
router.subscribe(OrderPaid.class, event -> audit.add("order:" + event.orderId()));
router.publish(new UserCreated("U-1"));
router.publish(new OrderPaid(42));
router.publish("ignored");
System.out.println(audit);
}
}실행 결과:
[user:U-1, order:42]UserCreated("U-1")를 게시하면 event.getClass()가 UserCreated.class이므로 그 키에 등록된 처리기 목록을 골라 user:U-1을 기록합니다. OrderPaid(42)도 같은 방식으로 order:42를 남깁니다. 문자열 "ignored"는 String.class로 등록된 처리기가 없으므로 getOrDefault가 빈 목록을 돌려주고, 아무 일도 일어나지 않습니다.
처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다
게시 객체의 정확한 실행 클래스와 일치하는 등록된 Class<E> 키를 찾고 목록 복사·Class.cast 뒤 handler를 호출합니다. 키 선택과 호출 직전 타입 검사의 실제 순서가 독자에게 도움이 됩니다.
TypedEventRouterSolution의 등록·선택·호출 경계를 구분합니다.
등록에서 호출까지
처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다
등록 계약이 이벤트 클래스와 처리기의 타입 관계를 보존합니다. 게시할 때는 정확히 같은 실행 클래스의 목록을 선택합니다.
subscribe
- 원문의 검사 또는 선택
- Class<E>와 Consumer<? super E>를 함께 받음
- 안전성이 의존하는 조건
- 제네릭 등록 API의 타입 관계 유지
publish
- 원문의 검사 또는 선택
- event.getClass()와 정확히 같은 키만 선택
- 안전성이 의존하는 조건
- 부모 타입 처리기를 자동으로 찾지 않음
invokeChecked
- 원문의 검사 또는 선택
- 클래스 토큰으로 event를 cast한 뒤 처리기 호출
- 안전성이 의존하는 조건
- cast는 event를 검사하며 처리기 제네릭 타입을 검사하지 않음
Class.cast는 이벤트 객체를 검사합니다. 지워진 처리기의 제네릭 타입까지 실행 시 검사하는 장치는 아닙니다.
이 예의 조건
- 등록·게시 관계이며 두 actor를 무조건 하나의 실행 순서로 합치지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 등록은 클래스 토큰과 handler 타입 관계를 같은 API에서 받아 해당 Map 키의 목록에 보관한다. 게시한 event의 정확한 실행 클래스로 목록을 골라 복사하고 각 처리기에 Class.cast한 event를 전달한다. 화살표는 보관·입력·호출 관계이며 두 actor를 무조건 시간순으로 이어 붙이지 않는다. 등록되지 않은 정확한 키는 빈 목록이고 Class.cast가 처리기의 제네릭 타입까지 검사하지는 않는다. 모바일도 같은 6개 노드와 5개 관계이다. |
| 구분 | 내용 |
|---|---|
| 설명 | 등록은 클래스 토큰과 handler 타입 관계를 같은 API에서 받아 해당 Map 키의 목록에 보관한다. 게시한 event의 정확한 실행 클래스로 목록을 골라 복사하고 각 처리기에 Class.cast한 event를 전달한다. 화살표는 보관·입력·호출 관계이며 두 actor를 무조건 시간순으로 이어 붙이지 않는다. 등록되지 않은 정확한 키는 빈 목록이고 Class.cast가 처리기의 제네릭 타입까지 검사하지는 않는다. 모바일도 같은 6개 노드와 5개 관계이다. |
| 전체 읽기 정보 | 처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다 처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다 TypedEventRouterSolution의 등록·선택·호출 경계를 구분합니다. 등록에서 호출까지 처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다 등록 계약이 이벤트 클래스와 처리기의 타입 관계를 보존합니다. 게시할 때는 정확히 같은 실행 클래스의 목록을 선택합니다. subscribe 원문의 검사 또는 선택 Class<E>와 Consumer<? super E>를 함께 받음 안전성이 의존하는 조건 제네릭 등록 API의 타입 관계 유지 publish 원문의 검사 또는 선택 event.getClass()와 정확히 같은 키만 선택 안전성이 의존하는 조건 부모 타입 처리기를 자동으로 찾지 않음 invokeChecked 원문의 검사 또는 선택 클래스 토큰으로 event를 cast한 뒤 처리기 호출 안전성이 의존하는 조건 cast는 event를 검사하며 처리기 제네릭 타입을 검사하지 않음 Class.cast는 이벤트 객체를 검사합니다. 지워진 처리기의 제네릭 타입까지 실행 시 검사하는 장치는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 등록 계약 Class<E>와 handler의 관계를 받아 해당 Map 키 목록에 보관합니다. |
|
|
| 게시 선택 event의 정확한 실행 클래스로 목록을 찾아 복사합니다. |
|
|
| 검사 후 전달 Class.cast로 검사한 event를 handler.accept에 넘깁니다. |
|
|
처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다
게시 객체의 정확한 실행 클래스와 일치하는 등록된 Class<E> 키를 찾고 목록 복사·Class.cast 뒤 handler를 호출합니다. 키 선택과 호출 직전 타입 검사의 실제 순서가 독자에게 도움이 됩니다.
TypedEventRouterSolution의 등록·선택·호출 경계를 구분합니다.
등록에서 호출까지
처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다
등록 계약이 이벤트 클래스와 처리기의 타입 관계를 보존합니다. 게시할 때는 정확히 같은 실행 클래스의 목록을 선택합니다.
subscribe
- 원문의 검사 또는 선택
- Class<E>와 Consumer<? super E>를 함께 받음
- 안전성이 의존하는 조건
- 제네릭 등록 API의 타입 관계 유지
publish
- 원문의 검사 또는 선택
- event.getClass()와 정확히 같은 키만 선택
- 안전성이 의존하는 조건
- 부모 타입 처리기를 자동으로 찾지 않음
invokeChecked
- 원문의 검사 또는 선택
- 클래스 토큰으로 event를 cast한 뒤 처리기 호출
- 안전성이 의존하는 조건
- cast는 event를 검사하며 처리기 제네릭 타입을 검사하지 않음
Class.cast는 이벤트 객체를 검사합니다. 지워진 처리기의 제네릭 타입까지 실행 시 검사하는 장치는 아닙니다.
이 예의 조건
- 등록·게시 관계이며 두 actor를 무조건 하나의 실행 순서로 합치지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 등록은 클래스 토큰과 handler 타입 관계를 같은 API에서 받아 해당 Map 키의 목록에 보관한다. 게시한 event의 정확한 실행 클래스로 목록을 골라 복사하고 각 처리기에 Class.cast한 event를 전달한다. 화살표는 보관·입력·호출 관계이며 두 actor를 무조건 시간순으로 이어 붙이지 않는다. 등록되지 않은 정확한 키는 빈 목록이고 Class.cast가 처리기의 제네릭 타입까지 검사하지는 않는다. 모바일도 같은 6개 노드와 5개 관계이다. |
| 구분 | 내용 |
|---|---|
| 설명 | 등록은 클래스 토큰과 handler 타입 관계를 같은 API에서 받아 해당 Map 키의 목록에 보관한다. 게시한 event의 정확한 실행 클래스로 목록을 골라 복사하고 각 처리기에 Class.cast한 event를 전달한다. 화살표는 보관·입력·호출 관계이며 두 actor를 무조건 시간순으로 이어 붙이지 않는다. 등록되지 않은 정확한 키는 빈 목록이고 Class.cast가 처리기의 제네릭 타입까지 검사하지는 않는다. 모바일도 같은 6개 노드와 5개 관계이다. |
| 전체 읽기 정보 | 처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다 처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다 TypedEventRouterSolution의 등록·선택·호출 경계를 구분합니다. 등록에서 호출까지 처리기 타입의 근거는 등록 계약과 클래스별 보관 규칙이다 등록 계약이 이벤트 클래스와 처리기의 타입 관계를 보존합니다. 게시할 때는 정확히 같은 실행 클래스의 목록을 선택합니다. subscribe 원문의 검사 또는 선택 Class<E>와 Consumer<? super E>를 함께 받음 안전성이 의존하는 조건 제네릭 등록 API의 타입 관계 유지 publish 원문의 검사 또는 선택 event.getClass()와 정확히 같은 키만 선택 안전성이 의존하는 조건 부모 타입 처리기를 자동으로 찾지 않음 invokeChecked 원문의 검사 또는 선택 클래스 토큰으로 event를 cast한 뒤 처리기 호출 안전성이 의존하는 조건 cast는 event를 검사하며 처리기 제네릭 타입을 검사하지 않음 Class.cast는 이벤트 객체를 검사합니다. 지워진 처리기의 제네릭 타입까지 실행 시 검사하는 장치는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 등록 계약 Class<E>와 handler의 관계를 받아 해당 Map 키 목록에 보관합니다. |
|
|
| 게시 선택 event의 정확한 실행 클래스로 목록을 찾아 복사합니다. |
|
|
| 검사 후 전달 Class.cast로 검사한 event를 handler.accept에 넘깁니다. |
|
|
이 코드가 안전한 이유는 등록하는 방법이 subscribe 하나뿐이라는 데 있습니다. subscribe의 시그니처는 Class<E>의 E와 Consumer<? super E>의 E를 묶어 두므로, UserCreated.class 키 아래에는 UserCreated를 받을 수 있는 처리기만 들어갈 수 있습니다. 하지만 일단 Map<Class<?>, List<Consumer<?>>>에 저장하고 나면 컴파일러는 이 관계를 더는 알지 못합니다. 그래서 invokeChecked의 (Consumer<? super E>) 형변환은 컴파일러가 검사할 수 없는 확인되지 않은 형변환이고, 그 경고를 끄는 @SuppressWarnings("unchecked")도 이 메서드 한 곳에만 있습니다.
invokeChecked(event.getClass(), handler, event) 호출에는 앞에서 본 와일드카드 캡처가 쓰입니다. event의 정적 타입이 Object이므로 getClass()의 결과 타입은 Class<? extends Object>이고, 컴파일러는 이 미지 타입을 캡처해 E로 정합니다. 그 뒤 eventType.cast(event)가 실행 중에 이벤트 객체가 정말 그 클래스의 인스턴스인지 검사하고 E 타입으로 돌려줍니다. 다만 Class.cast가 검사하는 것은 이벤트 객체뿐입니다. 처리기의 제네릭 타입은 타입 소거로 실행 중에 남아 있지 않으므로, 처리기 쪽의 안전성은 전적으로 subscribe가 지켜 온 등록 규칙에 기대고 있습니다.
이 라우터에는 한계도 있습니다. 가장 큰 것은 키를 정확한 클래스로만 찾는다는 점입니다. 그래서 다음 코드처럼 Object.class에 처리기를 등록해도, 실제 클래스가 UserCreated나 String인 이벤트는 그 처리기에 전달되지 않습니다. 하위 클래스 이벤트를 상위 타입 처리기에도 보내려면 isAssignableFrom으로 키를 훑는 방식과 호출 순서 규칙을 따로 설계해야 합니다.
router.subscribe(Object.class, event -> audit.add("object:" + event));
router.publish(new UserCreated("U-2"));
router.publish("ignored");
System.out.println(audit); // [user:U-1, order:42, user:U-2]이 밖에도 publish(null)은 event.getClass()에서 NullPointerException을 던지고, 처리기 하나가 예외를 던지면 그 뒤의 처리기는 호출되지 않습니다.
목록을 List.copyOf로 복사해서 순회하는 것은, 처리기 안에서 같은 타입에 처리기를 새로 등록해도 순회 중인 목록이 바뀌지 않게 하려는 것입니다. 이것이 여러 스레드에서 동시에 등록하고 게시해도 안전하다는 뜻은 아닙니다. LinkedHashMap과 ArrayList에는 동기화가 없으므로, 여러 스레드에서 쓰려면 동시성 컬렉션이나 잠금이 따로 필요합니다.
다음 글
다음 글에서는 이 글에서 여러 번 등장한 '대입할 변수가 타입을 정한다'는 원리를 람다 쪽에서 살펴봅니다. 같은 람다가 문맥에 따라 Predicate가 되기도 하고 Function이 되기도 하는 이유와, 함수형 인터페이스를 받는 메서드를 오버로딩할 때 생기는 모호성을 다룹니다.