제네릭 함수와 변성
제네릭 함수의 입력·출력 타입 조합에서 무공변성이 만드는 제약을 확인하고 하한 입력·상한 출력, 타입 추론, 와일드카드 캡처로 안전한 재사용 범위를 넓힙니다.
Function<Dog, Animal>과 Function<Animal, Dog>은 타입 인수 관계만 보고 서로 대입할 수 없습니다.
Java 제네릭은 기본적으로 무공변입니다.
하지만 알고리즘이 함수에 Dog를 넣기만 한다면 Animal을 받을 수 있는 함수도 안전합니다.
알고리즘이 함수의 결과를 Animal로 읽기만 한다면 Dog를 반환하는 함수도 안전합니다.
이 방향을 와일드카드로 표현합니다.
함수형 매개변수에서 입력은 소비되고 출력은 생산됩니다.
Function<? super T, ? extends R>은 T 또는 그 부모를 받을 수 있고 R 또는 그 자식을 반환할 수 있다는 뜻입니다.
흔히 PECS라 부르는 생산자 extends, 소비자 super가 한 함수 시그니처 안에 동시에 나타납니다.
비매개변수화 Function과 형변환
다음 파이프라인은 원시 타입을 사용해 컴파일러 검사를 포기합니다.
String 길이 함수 뒤에 Integer 제곱 함수는 우연히 맞지만, 순서를 바꾸거나 다른 함수를 넣어도 컴파일될 수 있고 ClassCastException이 실행 중 발생합니다.
@SuppressWarnings는 안전성을 만드는 것이 아니라 경고를 숨길 뿐입니다.
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)));
}
}타입 지정 파이프라인은 각 연결점의 중간 타입을 컴파일러가 알아야 합니다.
길이가 String에서 Integer로, 제곱이 Integer에서 Integer로 이어진다는 정보가 있으면 잘못된 순서를 컴파일 단계에서 막습니다.
이종 함수 목록은 원소마다 타입이 다르므로 단순 List<Function<?, ?>>로 저장한 뒤 안전하게 연속 호출할 수 없습니다.
조합할 때 타입 정보를 보존한 하나의 Function으로 접는 편이 낫습니다.
와일드카드는 값을 넣을 수 있는 범위를 제한합니다.
List<? extends Animal>에서는 Animal을 읽을 수 있지만 어떤 구체 하위 타입 목록인지 모르므로 null 외에 새 Animal을 넣을 수 없습니다.
List<? super Dog>에는 Dog를 넣을 수 있지만 읽을 때는 Object로만 보장됩니다.
함수 매개변수에서도 같은 원리가 적용됩니다.
소비자 super·생산자 extends
아래 map()은 Dog 목록을 받으면서 Animal 전체를 처리하는 설명 함수를 재사용하고, String 하위 타입을 생산하는 함수도 받을 수 있습니다.
compose()는 첫 함수의 결과가 다음 함수의 입력으로 안전하게 전달되도록 와일드카드 방향을 정합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
public final class VarianceFunctionComposition {
sealed interface Animal permits Dog, Cat {
String name();
}
record Dog(String name, int tricks) 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", 4), new Dog("Maro", 2));
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(dogs, descriptionLength));
System.out.println(map(List.<Animal>of(new Cat("Nabi")), describe));
}
}map(List<? extends T>, ...)의 원본 와일드카드는 호출자가 Dog 목록을 Animal 관점으로 전달할 수 있게 합니다.
메서드 본문은 원소를 T로 읽을 뿐 원본에 새 값을 넣지 않습니다.
지나치게 유연한 시그니처가 항상 필요한 것은 아닙니다.
비공개 도우미는 단순 List<T>로 충분할 수 있고, 재사용 경계에서 실제 호출자 요구가 나타날 때 변성을 확장합니다.
Function.andThen()과 compose()의 JDK 시그니처도 같은 방향을 따릅니다.
타입 오류가 길게 보일 때는 중간 함수를 타입 지정 지역 변수로 나눠 컴파일러가 어느 연결에서 실패했는지 확인합니다.
타입 추론
제네릭 메서드를 호출하면 컴파일러는 실제 인수, 람다 본문, 대입 대상에서 타입 제약을 모아 T와 R을 추론합니다.
문맥이 충분하지 않거나 서로 모순되면 명시적 타입 인수나 타입 지정 변수가 필요합니다.
형변환으로 강제하기 전에 API의 매개변수 관계가 올바른지 봅니다.
다음 예제에서 transform()의 I는 원본 원소로, O는 람다 결과와 대입 대상으로 정해집니다.
choose()는 두 후보의 공통 상위 타입을 찾습니다.
구체 타입이 필요한 경우 GenericMethodInference.<Number>choose()처럼 의도를 밝힐 수 있습니다.
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);
}
}람다 매개변수에 명시적 타입을 쓰면 추론에 제약을 추가합니다. 이는 오류를 설명하거나 오버로드된 메서드를 선택할 때 유용하지만, 구현 세부 타입을 너무 구체적으로 적으면 재사용 범위를 좁힐 수 있습니다. 목표 타입을 먼저 변수로 선언하는 방식이 복잡한 호출식 안의 형변환보다 읽기 좋습니다.
와일드카드 캡처
List<?>의 원소 타입은 “아무 타입”이 아니라 “하나이지만 현재 이름을 모르는 타입”입니다.
도우미 메서드에 넘기면 컴파일러가 그 타입을 캡처해 같은 원소를 읽고 다시 넣는 연산을 안전하게 허용할 수 있습니다.
서로 다른 List<?> 두 개는 캡처가 다르므로 임의로 값을 이동할 수 없습니다.
함수 레지스트리처럼 이종 타입을 한 컬렉션에 담으면 어느 지점에서는 실행 시점 타입 토큰과 검증된 형변환이 필요할 수 있습니다.
형변환을 호출자 전체에 흩뜨리지 말고 레지스트리 내부 한 곳에서 Class<T> 검사를 거쳐 격리합니다.
타입 소거 때문에 Class<List<String>>를 직접 얻을 수 없다는 한계도 기억합니다.
연습 문제
서로 다른 이벤트 타입을 하나의 라우터에 등록합니다.
subscribe(Class<E>, Consumer<? super E>)와 publish(E)를 제공하고, 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);
}
}해답은 등록 API에서 이벤트 타입과 처리기 타입을 컴파일-시간으로 묶고, 게시 때 정확한 실행 시점 클래스로 같은 버킷만 선택합니다. 하위 클래스 이벤트를 부모 처리기에도 전달하려면 대입 가능성 순회와 중복 순서 정책을 별도로 설계해야 합니다.