대상 타입과 오버로드
람다 자체에는 고정 클래스 타입이 없다는 점을 출발로 대입·인수·형변환 문맥이 함수 시그니처를 정하는 방식과 오버로드 모호성, 표준 함수형 타입 선택을 다룹니다.
value -> value.isBlank()만 보고 컴파일러는 값 타입과 함수형 인터페이스를 확정할 수 없습니다.
Predicate<String> 변수에 대입하면 String을 받아 boolean을 반환하는 테스트 구현이 됩니다.
Function<String, Boolean>에 대입하면 비슷한 본문이어도 apply 구현입니다.
람다는 대상 타입을 필요로 하는 식이며 대입, 메서드 인수, 반환, 형변환 문맥이 목표 타입을 제공합니다.
이 성질은 간결한 API를 만들지만 오버로드가 여러 함수형 인터페이스를 받을 때 모호성을 낳습니다.
매개변수와 return 모양이 같은 사용자 정의 인터페이스 둘을 같은 메서드 이름으로 오버로드하면 람다 본문만으로 어느 쪽인지 결정할 수 없습니다.
호출자 형변환으로 해결할 수 있어도 API 이름을 분리하는 편이 지속적으로 읽기 쉽습니다.
오버로드와 강제 형변환
아래 클래스는 같은 register 이름에 두 함수형 인터페이스를 받습니다.
호출자는 람다를 형변환해 선택합니다.
컴파일은 되지만 새 오버로드가 추가될수록 호출부가 구현 타입을 더 알아야 합니다.
두 등록의 생명 주기와 의미가 다르다면 메서드 이름에서 차이를 보여 주는 편이 낫습니다.
public final class CastSelectedOverload {
@FunctionalInterface
interface TextRule {
boolean test(String value);
}
@FunctionalInterface
interface TextCheck {
boolean check(String value);
}
static String register(TextRule rule) {
return "rule=" + rule.test("java");
}
static String register(TextCheck check) {
return "check=" + check.check("java");
}
public static void main(String[] args) {
System.out.println(register((TextRule) value -> value.length() >= 4));
System.out.println(register((TextCheck) value -> value.startsWith("j")));
}
}컴파일러 진단이 람다 매개변수를 Object로 본다는 식으로 나타나면 대상 타입이 소실된 지점을 찾습니다.
원시 타입, 와일드카드가 지나치게 넓은 메서드, 오버로드된 제네릭 호출이 원인일 수 있습니다.
람다 내부에 형변환을 추가하기보다 타입 지정 지역 변수로 목표 인터페이스를 명시해 제약을 단계별로 확인합니다.
return 문맥도 대상을 제공합니다.
static Predicate<String> rule() { return text -> !text.isBlank(); }에서는 메서드 return 타입이 함수 시그니처를 정합니다.
조건식의 양쪽 람다가 같은 목표 타입을 공유할 수도 있습니다.
대상 타입과 호출 API
다음 프로그램은 같은 의미의 람다를 Predicate, Function, Callable에 대입합니다.
매개변수와 결과 모양은 비슷해도 메서드 이름, 박싱, 검사 예외 선언이 다릅니다.
대상 타입은 단지 레이블이 아니라 호출 계약입니다.
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Function;
import java.util.function.Predicate;
public final class TargetTypingProbe {
static Predicate<String> choose(boolean acceptBlank) {
return acceptBlank
? value -> true
: value -> !value.isBlank();
}
public static void main(String[] args) throws Exception {
Predicate<String> predicate = value -> value.length() >= 4;
Function<String, Boolean> boxedFunction = value -> value.length() >= 4;
Callable<Boolean> delayed = () -> "java".length() >= 4;
System.out.println(predicate.test("java"));
System.out.println(boxedFunction.apply("go"));
System.out.println(delayed.call());
System.out.println(List.of("", "ok").stream().filter(choose(false)).toList());
}
}Predicate는 기본형 boolean을 반환하므로 Function<String, Boolean>보다 의도가 분명하고 박싱을 피합니다.
Callable은 매개변수가 없고 검사 예외를 선언하며 결과가 있습니다.
Runnable은 매개변수와 결과가 모두 없습니다.
“람다를 받을 수 있다”는 공통점보다 호출자가 무엇을 제공하고 어떤 결과와 실패를 기대하는지로 타입을 고릅니다.
메서드 참조도 대상 타입 결정을 사용합니다.
String::isBlank은 수신 객체를 첫 매개변수로 받는 Predicate<String>이 될 수 있습니다.
String::length는 ToIntFunction<String>에 맞습니다.
같은 참조가 오버로드된 메서드를 가리키면 대상 함수 시그니처가 정확한 시그니처 선택을 돕습니다.
콜백 이름 분리
검증 규칙과 비동기 작업이 우연히 같은 매개변수 모양을 갖더라도 생명 주기와 실패 정책은 다릅니다.
addRule(Predicate<T>), submit(Callable<R>), onEvent(Consumer<E>)처럼 동사를 분리하면 호출자가 형변환하지 않고 목적을 읽습니다.
다음 예제는 각 메서드가 표준 인터페이스의 계약을 그대로 사용합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import java.util.function.Predicate;
public final class OverloadResolutionDesign {
static final class Workflow<T> {
private final List<Predicate<T>> rules = new ArrayList<>();
private final List<Consumer<T>> observers = new ArrayList<>();
void addRule(Predicate<T> rule) {
rules.add(rule);
}
void onAccepted(Consumer<T> observer) {
observers.add(observer);
}
boolean accept(T value) {
if (!rules.stream().allMatch(rule -> rule.test(value))) {
return false;
}
observers.forEach(observer -> observer.accept(value));
return true;
}
}
static <R> R submit(Callable<R> task) throws Exception {
return task.call();
}
public static void main(String[] args) throws Exception {
Workflow<String> workflow = new Workflow<>();
List<String> audit = new ArrayList<>();
workflow.addRule(value -> !value.isBlank());
workflow.addRule(value -> value.length() <= 8);
workflow.onAccepted(value -> audit.add("accepted:" + value));
System.out.println(workflow.accept("lambda"));
System.out.println(workflow.accept(""));
System.out.println(audit);
System.out.println(submit(() -> "done"));
}
}allMatch는 첫 false에서 멈추므로 뒤 규칙의 부수 효과에 의존하지 않습니다.
관찰자는 검증이 모두 성공한 뒤 실행합니다.
콜백 종류를 분리하면서 실행 순서까지 API가 명확하게 소유합니다.
표준 함수형 인터페이스
JDK는 Function, Consumer, Supplier, Predicate, UnaryOperator, BinaryOperator, Runnable, Callable과 기본형 특화를 제공합니다.
사용자 정의 타입을 만들기 전에 매개변수 개수, 결과 유무, 검사 예외, 기본형 여부를 표로 정리하면 선택이 쉬워집니다.
도메인별 이름과 추가 메타데이터가 계약의 핵심이면 사용자 정의 인터페이스가 더 적합합니다.
표준 인터페이스를 사용하면 Stream, Optional, Comparator 팩터리 같은 API와 바로 조합할 수 있습니다.
반대로 UserValidator라는 이름이 감사 코드나 규칙 ID를 함께 가져야 한다면 Predicate<User>만으로는 부족합니다.
둘 중 하나를 절대 원칙으로 두지 않고 계약의 정보량을 기준으로 선택합니다.
연습 문제
문자열 길이 기준을 받아 Predicate, ToIntFunction, Consumer를 각각 반환하는 이름 있는 팩터리를 작성합니다.
호출자에서 var만 쓰지 말고 목표 타입을 선언해 호출 메서드를 확인합니다.
잘못된 기준값은 팩터리 생성 시 거절합니다.
해답 보기
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.ToIntFunction;
public final class TargetTypeFactorySolution {
static Predicate<String> minimumLength(int limit) {
requirePositive(limit);
return value -> value != null && value.length() >= limit;
}
static ToIntFunction<String> distanceFrom(int limit) {
requirePositive(limit);
return value -> value.length() - limit;
}
static Consumer<String> auditInto(List<String> sink, int limit) {
requirePositive(limit);
return value -> sink.add(value + ":" + (value.length() >= limit));
}
private static void requirePositive(int limit) {
if (limit <= 0) {
throw new IllegalArgumentException("limit must be positive");
}
}
public static void main(String[] args) {
List<String> audit = new ArrayList<>();
Predicate<String> accepted = minimumLength(4);
ToIntFunction<String> distance = distanceFrom(4);
Consumer<String> recorder = auditInto(audit, 4);
List<String> values = List.of("go", "java", "lambda");
values.forEach(recorder);
System.out.println(values.stream().filter(accepted).toList());
System.out.println(values.stream().mapToInt(distance).boxed().toList());
System.out.println(audit);
}
}해답은 세 팩터리의 반환 타입이 각 람다 대상을 확정합니다. 호출자의 타입 지정 변수는 교육용으로 명시했으며, 문맥이 충분한 실제 호출에서는 바로 메서드 인수로 전달해도 같은 대상 타입 결정이 작동합니다.