함수형 인터페이스 계약
함수형 인터페이스의 단일 추상 메서드 규칙과 상속·기본·정적 메서드의 영향을 구분하고 타입 안전한 변환 계약을 조합합니다.
람다가 대입될 수 있는 타입은 추상 메서드가 정확히 하나인 함수형 인터페이스입니다.
이를 SAM, 즉 단일 추상 메서드 계약이라고 부릅니다.
메서드 이름은 자유롭고 매개변수와 return 타입이 함수 시그니처를 만듭니다.
@FunctionalInterface는 필수가 아니지만 향후 누군가 두 번째 추상 메서드를 추가할 때 컴파일 오류로 계약 파괴를 알려 줍니다.
단일 책임이라는 설계 원칙과 SAM은 같은 말이 아닙니다.
추상 메서드가 하나여도 입력과 출력에 Object만 쓰고 여러 모드 플래그를 받으면 의미가 넓을 수 있습니다.
반대로 기본 메서드가 여러 개 있어도 핵심 추상 행동 하나를 안전하게 조합하도록 돕는다면 좋은 함수형 API가 될 수 있습니다.
Object 함수의 타입 손실
다음 인터페이스는 SAM이므로 람다를 받을 수 있지만 Object -> Object 계약이 실제 의미를 숨깁니다.
호출자가 형변환을 반복하고 잘못된 타입 조합도 컴파일됩니다.
모드 문자열까지 더해지면 하나의 함수가 파싱, 서식화, 검증 역할을 모두 떠맡습니다.
import java.util.List;
public final class RawObjectAction {
@FunctionalInterface
interface Action {
Object apply(Object value);
}
public static void main(String[] args) {
Action length = value -> ((String) value).length();
Action doubleNumber = value -> ((Integer) value) * 2;
List<Action> actions = List.of(length, doubleNumber);
Object first = actions.get(0).apply("lambda");
Object second = actions.get(1).apply(21);
System.out.println(first + " " + second);
}
}제네릭 매개변수 T와 결과 R을 분리하면 컴파일러가 함수 연결을 검사합니다.
Transformer<String, Integer>는 String을 받아 Integer를 돌려준다는 계약입니다.
Transformer<Integer, Money>를 다음 단계로 연결할 수 있지만 Transformer<Path, User>를 잘못 연결하면 컴파일되지 않습니다.
기본형을 제네릭 타입 인수로 쓸 수 없으므로 Integer 같은 래퍼가 필요합니다.
대량 숫자 처리에서 박싱 비용이 중요하면 IntFunction, ToIntFunction 같은 기본형 특화를 선택합니다.
먼저 의미 있는 제네릭 계약을 세우고 측정된 성능 핵심 경로에서 특화를 검토합니다.
추상 메서드 판정
인터페이스가 boolean equals(Object)를 다시 선언해도 Object의 public 메서드와 일치하므로 SAM 계산에서 제외됩니다.
기본값과 static 메서드도 추상 메서드가 아닙니다.
아래 예제는 하나의 convert 추상 메서드와 조합용 기본값, 팩터리 static, 설명용 Object 재선언을 함께 둡니다.
import java.util.Objects;
public final class FunctionalInterfaceRules {
@FunctionalInterface
interface Converter<T, R> {
R convert(T value);
@Override
boolean equals(Object other);
default <V> Converter<T, V> andThen(Converter<? super R, ? extends V> next) {
Objects.requireNonNull(next);
return value -> next.convert(convert(value));
}
static <T> Converter<T, T> identity() {
return value -> value;
}
}
record UserId(long value) {
}
public static void main(String[] args) {
Converter<String, String> trim = String::trim;
Converter<String, Long> parse = Long::parseLong;
Converter<Long, UserId> wrap = UserId::new;
Converter<String, UserId> userId = trim.andThen(parse).andThen(wrap);
System.out.println(userId.convert(" 42 "));
System.out.println(Converter.<String>identity().convert("same"));
}
}andThen의 와일드카드는 현재 결과를 소비할 수 있는 더 넓은 입력과, 원하는 결과 하위 타입을 허용합니다.
제네릭 변성은 뒤 문서에서 더 깊게 다룹니다.
여기서는 기본 메서드가 핵심부 함수 시그니처를 바꾸지 않으면서 함수 조합을 제공한다는 점을 확인합니다.
인터페이스가 부모 둘에서 동일 시그니처의 추상 메서드를 상속하면 하나의 함수 시그니처로 합쳐질 수 있습니다. 반대로 이름이나 매개변수가 다른 추상 메서드가 둘이면 함수형이 아닙니다. 반환 타입은 대체 가능해야 합니다. 복잡한 상속 계산에 기대기보다 공개 함수형 인터페이스는 작은 자체 선언으로 유지하는 편이 설명하기 쉽습니다.
타입 매개변수의 역할
T, R, U는 관례지만 API가 복잡하면 INPUT, OUTPUT처럼 의미 있는 이름도 가능합니다.
같은 T를 매개변수와 return에 사용하면 입력과 출력 타입이 반드시 같다는 제약을 겁니다.
변환기인데 UnaryOperator<T>를 잘못 선택하면 String을 Integer로 바꾸는 함수를 표현할 수 없습니다.
다음 예제는 제네릭 Transformer<I, O>와 mapAll()을 사용합니다.
함수형 인터페이스와 알고리즘 양쪽의 타입 매개변수가 독립적으로 추론됩니다.
결과는 새 목록이며 원본을 수정하지 않습니다.
import java.util.ArrayList;
import java.util.List;
public final class GenericTransformer {
@FunctionalInterface
interface Transformer<I, O> {
O transform(I input);
}
static <I, O> List<O> mapAll(List<I> inputs, Transformer<? super I, ? extends O> transformer) {
List<O> outputs = new ArrayList<>(inputs.size());
for (I input : inputs) {
outputs.add(transformer.transform(input));
}
return List.copyOf(outputs);
}
sealed interface Token permits Word, NumberToken {
}
record Word(String value) implements Token {
}
record NumberToken(int value) implements Token {
}
public static void main(String[] args) {
Transformer<CharSequence, Word> word = value -> new Word(value.toString().trim());
List<Token> words = mapAll(List.of(" java ", " lambda "), word);
List<Token> numbers = mapAll(List.of("3", "7"), value -> new NumberToken(Integer.parseInt(value)));
System.out.println(words);
System.out.println(numbers);
}
}mapAll()이 Transformer<I, O>만 받도록 써도 기본 사례는 동작합니다.
? super I와 ? extends O는 호출자가 이미 가진 넓은 소비자와 구체 결과 함수를 재사용할 수 있게 합니다.
와일드카드를 무조건 추가하면 시그니처 읽기가 어려워질 수 있으므로 실제 변성 요구가 있는 API 경계에 사용합니다.
@FunctionalInterface 계약
애노테이션 없이도 SAM이면 람다 대상이 됩니다. 그래도 공개 API에 애노테이션을 붙이면 “두 번째 추상 행동을 추가하지 말라”는 의도를 컴파일러와 독자에게 전달합니다. 새 기능은 기본 메서드, 별도 협력자, 새로운 인터페이스 가운데 의미에 맞는 위치로 갑니다.
함수형 인터페이스의 이름은 실행 의미를 드러내야 합니다.
Processor와 Handler만 반복하면 매개변수가 무엇을 소비하고 실패를 어떻게 전달하는지 알기 어렵습니다.
표준 Function, Predicate로 충분하면 표준 타입을 사용하고, 검사 예외나 도메인 메타데이터처럼 다른 계약이 필요할 때 사용자 정의 인터페이스를 만듭니다.
연습 문제
CheckedMapper<I, O>에 O map(I input)과 기본값 andThen을 정의합니다.
null 입력과 null 결과는 허용하지 않고, 파이프라인 어느 단계에서 발생했는지 포함한 예외를 던집니다.
String 정규화, 정수 변환, 양수 검증을 연결해 실행합니다.
해답 보기
import java.util.Objects;
public final class ValidatedMapperSolution {
@FunctionalInterface
interface CheckedMapper<I, O> {
O map(I input);
default <N> CheckedMapper<I, N> andThen(
String stage,
CheckedMapper<? super O, ? extends N> next) {
Objects.requireNonNull(stage);
Objects.requireNonNull(next);
return input -> {
O intermediate = Objects.requireNonNull(map(input), "null before " + stage);
try {
return Objects.requireNonNull(next.map(intermediate), "null from " + stage);
} catch (RuntimeException exception) {
throw new PipelineException(stage, exception);
}
};
}
}
static final class PipelineException extends RuntimeException {
PipelineException(String stage, Throwable cause) {
super("pipeline failed at " + stage + ": " + cause.getMessage(), cause);
}
}
public static void main(String[] args) {
CheckedMapper<String, String> normalize = value -> value.trim();
CheckedMapper<String, Integer> parse = Integer::parseInt;
CheckedMapper<Integer, Integer> positive = value -> {
if (value <= 0) {
throw new IllegalArgumentException("value is not positive");
}
return value;
};
CheckedMapper<String, Integer> pipeline = normalize
.andThen("parse", parse)
.andThen("positive", positive);
System.out.println(pipeline.map(" 12 "));
try {
pipeline.map(" -3 ");
} catch (PipelineException exception) {
System.out.println(exception.getMessage());
}
}
}해답은 제네릭 함수 시그니처를 유지하면서 각 기본 메서드 조합 단계에 진단 이름을 넣습니다. 모든 예외를 같은 타입으로 감싸는 정책이 적절한지는 사용처에 따라 다르며, 이미 도메인 타입인 예외를 보존할 매퍼가 필요할 수도 있습니다.