본문 바로가기

람다의 대상 타입과 오버로딩

람다의 타입이 대입·메서드 인수·return·형변환 문맥에서 정해지는 원리와, 함수형 인터페이스를 받는 메서드를 오버로딩할 때 생기는 모호성을 피하는 방법, 상황에 맞는 함수형 인터페이스를 고르는 기준을 배웁니다.

람다 value -> value.isBlank()는 무슨 타입일까요? 앞의 글들에서는 늘 Predicate<String> 같은 변수에 대입했기 때문에 이 질문을 할 일이 없었습니다. 그런데 대입할 곳의 타입을 알려 주지 않으면 컴파일러도 답하지 못합니다.

bad/NoTarget.java
public final class NoTarget {
    public static void main(String[] args) {
        var blank = (String value) -> value.isBlank();
        Object task = () -> System.out.println("run");
    }
}
NoTarget.java:3: error: cannot infer type for local variable blank
        var blank = (String value) -> value.isBlank();
            ^
  (lambda expression needs an explicit target-type)
NoTarget.java:4: error: incompatible types: Object is not a functional interface
        Object task = () -> System.out.println("run");
                      ^
2 errors

첫 번째 오류는 매개변수 타입까지 적어 줬는데도 납니다. String을 받아 boolean을 돌려주는 함수형 인터페이스는 Predicate<String>도 있고 Function<String, Boolean>도 있으며, 직접 만든 인터페이스도 얼마든지 있을 수 있습니다. 람다만 보고는 그중 무엇을 만들어야 할지 정할 수 없으므로, 컴파일러는 '람다에는 명시적인 대상 타입이 필요하다(lambda expression needs an explicit target-type)'고 말합니다. 두 번째 오류는 대상 타입이 있긴 하지만 Object가 함수형 인터페이스가 아니어서 납니다.

이처럼 람다는 자기 혼자서는 타입이 없고, 놓인 자리가 기대하는 타입을 받아서 그 타입의 객체가 됩니다. 이 기대 타입을 대상 타입(target type)이라고 합니다. 이 글에서는 어떤 자리가 대상 타입을 정해 주는지, 같은 람다가 대상 타입에 따라 어떻게 달라지는지, 그리고 함수형 인터페이스를 받는 메서드를 오버로딩할 때 대상 타입이 왜 문제를 일으키는지를 살펴봅니다.

같은 람다가 문맥에 따라 다른 타입이 됩니다

다음 프로그램은 거의 같은 람다를 Predicate, Function, Callable에 대입하고, 메서드의 return 문에서도 람다를 씁니다. 본문이 같아도 대상 타입이 다르면 무엇이 달라지는지 확인해 보겠습니다.

src/TargetTypingProbe.java
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());
    }
}

넷째 줄의 choose는 조건 연산자의 두 갈래에 람다를 두었는데, 메서드의 반환 타입 Predicate<String>이 두 람다의 대상 타입이 됩니다. false를 넘겼으므로 빈 문자열을 거르는 쪽 람다가 골라집니다.

true
false
true
[ok]

predicate와 boxedFunction의 람다는 글자 하나 다르지 않지만, 하나는 Predicate<String>의 test를, 다른 하나는 Function<String, Boolean>의 apply를 구현합니다. 그래서 호출할 때도 각각 test와 apply를 불러야 하고, 결과 타입도 다릅니다. test는 기본형 boolean을 돌려주지만 apply는 Boolean 객체를 돌려주므로 값을 매번 박싱합니다. 참과 거짓을 판단하는 함수라면 의도도 분명하고 박싱도 없는 Predicate가 맞는 선택입니다. 첫 줄은 "java"의 길이가 4 이상이라 true이고, 둘째 줄은 "go"의 길이가 2라서 false입니다.

Callable<Boolean>은 매개변수가 없는 call()을 구현하므로 람다도 ()로 시작합니다. call()은 throws Exception을 선언하고 있어서, 이를 호출하는 main도 throws Exception을 선언했습니다. 셋째 줄의 true가 그 결과입니다.

choose 메서드에는 대입문이 없지만 return 문이 대상 타입을 정합니다. 반환 타입이 Predicate<String>이므로 반환하는 람다는 Predicate<String>이 됩니다. 조건 연산자 ?:가 이런 자리에 놓이면 두 결과식이 모두 같은 대상 타입을 받으므로, value -> true와 value -> !value.isBlank() 둘 다 Predicate<String>입니다. choose(false)는 공백이 아닌 문자열만 통과시키는 조건을 돌려주므로, filter를 거친 넷째 줄에는 "ok"만 남습니다.

대상 타입을 정해 주는 자리는 JLS가 정한 대입 문맥, 호출 문맥, 형변환 문맥 세 가지입니다. 변수에 대입할 때(선언과 함께 초기화하는 것 포함)와 return 문, 배열 초기화 식의 원소, 다른 람다가 돌려주는 값이 대입 문맥입니다. 메서드 인수로 넘길 때는 호출 문맥이고, (Predicate<String>) value -> ... 같은 형변환은 형변환 문맥입니다. 조건 연산자와 switch 식은 그 자체가 대입 문맥이나 호출 문맥에 있을 때 결과식에 같은 대상 타입을 전해 줍니다. 람다는 이 밖의 자리, 예를 들어 앞의 var 선언처럼 타입을 정해 줄 수 없는 곳에는 쓸 수 없습니다.

람다 본문은 대상 타입의 함수 타입에 맞아야 합니다

대상 타입이 정해지면 컴파일러는 그 인터페이스의 추상 메서드, 즉 함수 타입에 맞춰 람다를 검사합니다. 매개변수 개수가 맞는지, 본문이 돌려주는 값이 반환 타입과 맞는지를 봅니다. 예를 들어 Predicate<String>에 길이를 돌려주는 람다를 대입하면 반환값이 맞지 않는다는 오류가 납니다.

bad/BadReturn.java
import java.util.function.Predicate;

public final class BadReturn {
    public static void main(String[] args) {
        Predicate<String> longEnough = value -> value.length();
    }
}
BadReturn.java:5: error: incompatible types: bad return type in lambda expression
        Predicate<String> longEnough = value -> value.length();
                                                            ^
    int cannot be converted to boolean
1 error

던질 수 있는 검사 예외도 함수 타입의 일부입니다. 파일을 읽는 Files.readString은 검사 예외인 IOException을 던질 수 있는데, 같은 람다를 Callable에 대입하면 컴파일되고 Supplier에 대입하면 오류가 납니다.

bad/CheckedTarget.java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Callable;
import java.util.function.Supplier;

public final class CheckedTarget {
    public static void main(String[] args) {
        Path path = Path.of("notes.txt");
        Callable<String> viaCallable = () -> Files.readString(path);
        Supplier<String> viaSupplier = () -> Files.readString(path);
    }
}
CheckedTarget.java:10: error: unreported exception IOException; must be caught or declared to be thrown
        Supplier<String> viaSupplier = () -> Files.readString(path);
                                                             ^
1 error

Callable.call()은 throws Exception을 선언하므로 그 구현인 람다도 IOException을 던질 수 있습니다. 반면 Supplier.get()에는 throws 절이 없으니, 람다 안에서 검사 예외를 처리하지 않으면 컴파일되지 않습니다. 두 인터페이스는 '입력 없이 값을 하나 돌려준다'는 모양은 같지만, 실패를 어떻게 전달하는지가 다릅니다. 검사 예외를 다루는 방법은 람다 문법을 다룬 글에서 자세히 봤습니다.

오버로딩된 메서드에 람다를 넘길 때

대상 타입이 메서드 인수에서 정해진다는 것은, 같은 이름의 메서드가 여러 개 있으면 컴파일러가 먼저 그중 하나를 골라야 람다의 타입도 정해진다는 뜻입니다. 메서드 오버로딩에서는 인수의 타입을 보고 메서드를 골랐지만, 람다에는 미리 정해진 타입이 없습니다. 그래서 함수형 인터페이스를 받는 메서드를 오버로딩하면 문제가 생길 수 있습니다.

다음 두 인터페이스는 이름만 다를 뿐 모두 String을 받아 boolean을 돌려줍니다. 이 둘을 받는 register를 같은 이름으로 오버로딩하고 람다를 넘겨 보겠습니다.

bad/AmbiguousOverload.java
public final class AmbiguousOverload {
    @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(value -> value.length() >= 4));
    }
}
AmbiguousOverload.java:21: error: reference to register is ambiguous
        System.out.println(register(value -> value.length() >= 4));
                           ^
  both method register(TextRule) in AmbiguousOverload and method register(TextCheck) in AmbiguousOverload match
1 error

람다 value -> value.length() >= 4는 TextRule.test의 구현도 될 수 있고 TextCheck.check의 구현도 될 수 있습니다. 두 후보 중 어느 쪽이 더 알맞다고 할 근거가 없으므로, 컴파일러는 'register 호출이 모호하다'는 오류를 냅니다. 호출하는 쪽에서 람다를 형변환하면 형변환이 대상 타입을 정해 주므로 이 오류를 피할 수 있습니다.

bad/CastSelectedOverload.java
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")));
    }
}

형변환한 타입에 따라 각 호출이 어느 register로 갈지가 정해집니다. 출력 앞부분의 rule=과 check=를 보면 선택된 메서드를 확인할 수 있습니다.

rule=true
check=true

동작은 하지만 좋은 해결은 아닙니다. 호출하는 사람이 매번 인터페이스 이름을 알고 적어야 하고, 오버로딩이 하나 늘어날 때마다 기존 호출이 모호해질 수 있습니다. 컴파일러도 이런 선언을 위험하다고 봅니다. javac -Xlint:overloads로 컴파일하면 람다를 넘기는 호출이 하나도 없어도 선언 자체에 경고를 냅니다.

CastSelectedOverload.java:16: warning: [overloads] register(TextCheck) in CastSelectedOverload is potentially ambiguous with register(TextRule) in CastSelectedOverload
    static String register(TextCheck check) {
                  ^
1 warning

모든 오버로딩이 모호한 것은 아닙니다. 함수형 인터페이스의 모양이 다르면 컴파일러는 람다의 모양을 보고 후보를 걸러 냅니다.

src/ShapeOverload.java
import java.util.function.Supplier;

public final class ShapeOverload {
    static void run(Runnable task) {
        System.out.println("Runnable");
        task.run();
    }

    static void run(Supplier<String> supplier) {
        System.out.println("Supplier: " + supplier.get());
    }

    public static void main(String[] args) {
        run(() -> System.out.println("hello"));
        run(() -> "hello");
    }
}

두 run은 출력의 첫 단어로 자기가 불렸음을 알립니다. Runnable 버전은 이름을 찍은 뒤 받은 작업을 실행하므로 두 줄을, Supplier 버전은 한 줄을 남깁니다.

Runnable
hello
Supplier: hello

Runnable.run()과 Supplier.get()은 둘 다 매개변수가 없지만, 하나는 값을 돌려주지 않고 다른 하나는 돌려줍니다. 첫 번째 람다의 본문 System.out.println("hello")는 돌려줄 값이 없는 void 메서드 호출이라서 Supplier<String>의 구현이 될 수 없으므로 Runnable 버전만 남습니다. 두 번째 람다의 본문 "hello"는 값일 뿐 문장으로 쓸 수 없는 식이라서 Runnable의 구현이 될 수 없고, Supplier 버전만 남습니다. 이처럼 매개변수 개수나 반환값 유무가 다르면 오버로딩해도 모호하지 않습니다. 모호성이 생기는 것은 앞의 TextRule과 TextCheck처럼 모양이 같은 인터페이스를 같은 이름으로 받을 때입니다.

JDK가 Comparator.comparingInt, Stream.mapToInt처럼 기본형 특화 버전에 별도의 이름을 붙여 둔 덕분에 이런 모호성이 생기지 않습니다. 같은 이름으로 오버로딩했다면 어떻게 되는지 직접 만들어 보면 알 수 있습니다. 아래 comparing은 Function을 받는 버전과 ToIntFunction을 받는 버전을 같은 이름으로 선언한 것입니다.

bad/ComparingOverload.java
import java.util.Comparator;
import java.util.function.Function;
import java.util.function.ToIntFunction;

public final class ComparingOverload {
    static <T, U extends Comparable<? super U>> Comparator<T> comparing(Function<? super T, ? extends U> key) {
        return Comparator.comparing(key);
    }

    static <T> Comparator<T> comparing(ToIntFunction<? super T> key) {
        return Comparator.comparingInt(key);
    }

    public static void main(String[] args) {
        Comparator<String> byLength = comparing(text -> text.length());
    }
}
ComparingOverload.java:15: error: reference to comparing is ambiguous
        Comparator<String> byLength = comparing(text -> text.length());
                                      ^
  both method <T#1,U>comparing(Function<? super T#1,? extends U>) in ComparingOverload and method <T#2>comparing(ToIntFunction<? super T#2>) in ComparingOverload match
  where T#1,U,T#2 are type-variables:
    T#1 extends Object declared in method <T#1,U>comparing(Function<? super T#1,? extends U>)
    U extends Comparable<? super U> declared in method <T#1,U>comparing(Function<? super T#1,? extends U>)
    T#2 extends Object declared in method <T#2>comparing(ToIntFunction<? super T#2>)
1 error

text -> text.length()는 길이를 Integer로 박싱해 돌려주는 Function으로도, int를 그대로 돌려주는 ToIntFunction으로도 볼 수 있어서 모호합니다. 실제 JDK에서는 Comparator.comparing(text -> text.length())와 Comparator.comparingInt(text -> text.length())가 서로 다른 이름이라 이런 문제가 없습니다.

더 깊이: 두 후보가 모두 맞을 때 컴파일러가 고르는 경우

앞의 TextRule과 TextCheck는 모양이 완전히 같아서 모호했습니다. 그런데 모양이 다른 두 인터페이스에 같은 람다가 모두 맞는 경우도 있습니다. 이때는 컴파일러가 둘 중 더 구체적인(more specific) 메서드를 고를 수 있습니다. ExecutorService.submit이 Runnable을 받는 버전과 Callable<T>를 받는 버전으로 오버로딩되어 있는 것과 같은 구조를 만들어 보겠습니다.

src/SubmitOverload.java
import java.util.concurrent.Callable;

public final class SubmitOverload {
    static void submit(Runnable task) {
        System.out.println("Runnable");
    }

    static <T> void submit(Callable<T> task) {
        System.out.println("Callable");
    }

    static String work() {
        return "done";
    }

    public static void main(String[] args) {
        submit(() -> System.out.println("hi"));
        submit(() -> "done");
        submit(() -> work());
    }
}

두 submit은 받은 작업을 실행하지 않고, 어느 쪽이 선택됐는지만 찍습니다. 그래서 첫 번째 람다의 hi는 출력되지 않습니다.

Runnable
Callable
Callable

처음 두 호출은 앞의 ShapeOverload와 같은 이유로 후보가 하나만 남습니다. 셋째 호출의 본문 work()는 메서드 호출이라 결과를 버리는 문장으로도 쓸 수 있고 값으로도 쓸 수 있으므로, Runnable과 Callable 모두에 맞습니다. 이때 컴파일러는 값을 돌려주는 Callable 쪽을 더 구체적이라고 보고 선택합니다. JLS §15.12.2.5의 규칙에 따르면 매개변수 타입이 명시된 람다(매개변수가 없는 람다도 여기에 속합니다)에 대해서는 반환 타입이 void인 인터페이스가 덜 구체적이기 때문입니다. 실무에서 executor.submit(() -> work())가 Future에 결과를 담아 주는 것도 이 규칙 덕분입니다.

형변환 대신 메서드 이름으로 구분하기

모양이 같은 함수형 인터페이스를 받아야 한다면, 가장 확실한 방법은 메서드 이름을 다르게 짓는 것입니다. 이름이 다르면 오버로딩 자체가 없으니 모호할 일이 없고, 호출하는 쪽은 형변환 없이 이름만 보고 각 콜백이 무슨 역할인지 알 수 있습니다. 다음 예제의 Workflow는 값을 검사하는 규칙과, 검사를 통과했을 때 실행할 동작을 서로 다른 메서드로 등록받습니다.

src/OverloadResolutionDesign.java
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"));
    }
}

마지막 줄의 submit은 Callable을 받아 바로 call()하는 정적 메서드입니다. 이 이름의 메서드가 하나뿐이므로 형변환 없이 람다를 넘길 수 있습니다.

true
false
[accepted:lambda]
done

"lambda"는 공백이 아니고 길이 6이 8 이하이므로 두 규칙을 모두 통과하고, 등록된 동작이 audit에 accepted:lambda를 기록한 뒤 true를 돌려줍니다. ""는 첫 규칙에서 걸립니다. allMatch는 false가 나오는 순간 나머지 원소를 확인하지 않고 멈추는 단락(short-circuit) 연산이므로, 둘째 규칙은 실행되지 않고 동작도 호출되지 않습니다. 그래서 셋째 줄의 audit에는 기록이 하나뿐입니다.

addRule과 onAccepted는 받는 람다가 비슷해 보여도 하는 일이 다릅니다. 하나는 참과 거짓을 판단하고, 다른 하나는 판단이 끝난 뒤 실행됩니다. 메서드 이름이 이 차이를 드러내고, 규칙을 먼저 검사한 다음 동작을 실행한다는 순서는 accept 메서드가 정합니다. 마지막 줄의 submit은 이름 때문에 작업을 다른 스레드에 넘기는 것처럼 보이지만, 이 예제에서는 task.call()을 그 자리에서 바로 호출하는 단순한 메서드입니다.

메서드 참조도 대상 타입을 따릅니다

메서드 참조도 람다처럼 그 자체로는 타입이 없고 대상 타입에 맞춰 해석됩니다. 같은 String::length가 대상 타입에 따라 int를 돌려주는 함수가 되기도 하고 Integer를 돌려주는 함수가 되기도 합니다.

src/MethodRefTargets.java
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.ToIntFunction;

public final class MethodRefTargets {
    public static void main(String[] args) {
        Predicate<String> blank = String::isBlank;
        ToIntFunction<String> length = String::length;
        Function<String, Integer> boxedLength = String::length;
        System.out.println(blank.test("  "));
        System.out.println(length.applyAsInt("java"));
        System.out.println(boxedLength.apply("lambda"));
    }
}

세 참조는 모두 String의 메서드를 가리키지만, 대상 타입이 다르므로 호출할 때 쓰는 메서드도 test, applyAsInt, apply로 각각 다릅니다. 세 줄은 차례로 공백 두 칸, java, lambda를 넘긴 결과입니다.

true
4
6

String::isBlank와 String::length는 특정 문자열이 아니라 '받은 문자열의' 메서드를 부르는 참조입니다. 그래서 대상 타입의 첫 번째 매개변수가 메서드를 호출할 객체가 됩니다. blank.test(" ")는 " ".isBlank()를 호출해 true를, length.applyAsInt("java")는 4를 돌려줍니다. ToIntFunction은 int를 그대로 돌려주고, Function<String, Integer>는 같은 길이를 Integer로 박싱해 돌려줍니다.

참조하는 메서드가 오버로딩되어 있으면, 대상 타입의 함수 타입이 어느 메서드를 쓸지 정하는 단서가 됩니다. 단서가 두 메서드에 똑같이 맞으면 람다 때와 마찬가지로 모호하다는 오류가 납니다. 대표적인 예가 Integer::toString입니다.

bad/MethodRefAmbiguous.java
import java.util.function.Function;

public final class MethodRefAmbiguous {
    public static void main(String[] args) {
        Function<Integer, String> text = Integer::toString;
    }
}
MethodRefAmbiguous.java:5: error: incompatible types: invalid method reference
        Function<Integer, String> text = Integer::toString;
                                         ^
    reference to toString is ambiguous
      both method toString(int) in Integer and method toString() in Integer match
1 error

Function<Integer, String>은 Integer 하나를 받습니다. 이 값을 정적 메서드 Integer.toString(int)의 인수로 넘길 수도 있고, 이 값 자체에 인스턴스 메서드 toString()을 호출할 수도 있어서 둘 다 맞습니다. 이럴 때는 number -> Integer.toString(number)처럼 람다로 의도를 직접 적습니다. 메서드 참조의 네 가지 형태와 수신 객체 규칙은 메서드 참조의 네 가지 형태와 언바운드 메서드 참조 활용에서 자세히 다룹니다.

어떤 함수형 인터페이스를 고를까

대상 타입은 단순한 이름표가 아닙니다. 어떤 인터페이스를 고르느냐에 따라 호출할 메서드 이름, 결과 타입, 박싱 여부, 던질 수 있는 예외가 함께 정해집니다. 그래서 '람다를 받을 수 있다'는 공통점보다, 호출하는 쪽이 무엇을 넘기고 무엇을 돌려받으며 실패를 어떻게 알게 되는지를 기준으로 고릅니다. 자주 쓰는 인터페이스를 이 기준으로 정리하면 다음과 같습니다.

인터페이스패키지추상 메서드입력결과검사 예외
Runnablejava.langvoid run()없음없음던질 수 없음
Callable<V>java.util.concurrentV call()없음V던질 수 있음(throws Exception)
Supplier<T>java.util.functionT get()없음T던질 수 없음
Consumer<T>java.util.functionvoid accept(T)T없음던질 수 없음
Function<T, R>java.util.functionR apply(T)TR던질 수 없음
Predicate<T>java.util.functionboolean test(T)Tboolean던질 수 없음
UnaryOperator<T>java.util.functionT apply(T)TT던질 수 없음
BinaryOperator<T>java.util.functionT apply(T, T)T 두 개T던질 수 없음

UnaryOperator<T>는 Function<T, T>를, BinaryOperator<T>는 BiFunction<T, T, T>를 상속한 특수한 경우입니다. 표의 '던질 수 없음'은 검사 예외를 말하며, RuntimeException 계열의 비검사 예외는 어느 람다에서든 던질 수 있습니다.

모양이 맞는 표준 인터페이스가 있다면 대개 그것을 씁니다. Stream.filter는 Predicate를, Stream.map과 Optional.map은 Function을, Comparator.comparing은 Function을 받으므로, 표준 인터페이스로 만든 함수는 이런 API에 그대로 넘길 수 있습니다. 반대로 함수와 함께 다녀야 할 정보가 있으면 표준 인터페이스로는 부족합니다. 예를 들어 회원 검증 규칙마다 오류 코드와 메시지를 보여 줘야 한다면, Predicate<User>만으로는 어떤 규칙이 실패했는지 알릴 방법이 없으므로 규칙 이름을 함께 가진 타입을 따로 만듭니다. 표준 인터페이스의 종류와 조합 메서드는 표준 함수형 인터페이스와 함수 조합에서, 인수가 둘 이상이거나 기본형을 다루는 인터페이스는 BiFunction과 기본형 함수형 인터페이스에서 이어서 다룹니다.

정리

  • 람다와 메서드 참조는 혼자서는 타입이 없고, 대입문, 메서드 인수, return 문, 형변환이 정해 주는 대상 타입의 객체가 됩니다. 조건 연산자는 그 자리의 대상 타입을 두 결과식에 전해 줍니다.
  • 대상 타입이 정해지면 컴파일러는 람다의 매개변수 개수, 반환값, 던질 수 있는 검사 예외를 그 인터페이스의 추상 메서드와 비교합니다.
  • 모양이 같은 함수형 인터페이스를 같은 이름의 메서드로 오버로딩하면 람다를 넘기는 호출이 모호해집니다. 형변환으로 피할 수는 있지만, 메서드 이름을 다르게 짓는 것이 낫습니다.
  • 함수형 인터페이스는 '람다를 받을 수 있는가'가 아니라 입력, 결과, 실패 전달 방식을 기준으로 고릅니다.

연습 문제

문자열 길이 기준 limit을 받아 서로 다른 함수형 인터페이스를 돌려주는 팩터리 메서드 세 개를 만드세요.

  1. minimumLength(int limit)은 길이가 limit 이상인지 판단하는 Predicate<String>을 돌려줍니다. null은 기준을 통과하지 못한 것으로 봅니다.
  2. distanceFrom(int limit)은 길이에서 limit을 뺀 값을 돌려주는 ToIntFunction<String>을 돌려줍니다.
  3. auditInto(List<String> sink, int limit)은 받은 문자열과 기준 통과 여부를 sink에 기록하는 Consumer<String>을 돌려줍니다.

세 팩터리 모두 limit이 양수가 아니면 함수를 만들기 전에 거절해야 합니다. 호출하는 쪽에서는 var 대신 대상 타입을 직접 적어, 각 함수에서 어떤 메서드를 호출하게 되는지 확인해 보세요.

해답 보기
src/TargetTypeFactorySolution.java
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);
    }
}

실행 결과:

[java, lambda]
[-2, 0, 2]
[go:false, java:true, lambda:true]

세 팩터리 안의 람다는 return 문에서 대상 타입을 받습니다. 그래서 거의 같은 길이 비교라도 minimumLength의 람다는 Predicate.test의 구현이 되어 boolean을, distanceFrom의 람다는 ToIntFunction.applyAsInt의 구현이 되어 int를, auditInto의 람다는 Consumer.accept의 구현이 되어 반환값 없이 sink에 기록만 합니다.

팩터리의 반환 타입이 호출 메서드와 결과를 정한다

TargetTypeFactorySolution의 세 반환 계약을 비교합니다.

대상 타입의 호출 계약

팩터리의 반환 타입이 호출 메서드와 결과를 정한다

팩터리의 반환 타입이 test·applyAsInt·accept 중 무엇을 호출하고 어떤 결과를 받는지 정합니다.

양수 기준을 받은 세 팩터리는 서로 다른 호출을 반환한다양수 limit을 생성 때 검사하고 각각 Predicate.test의 boolean, ToIntFunction.applyAsInt의 int, Consumer.accept의 sink 부수 효과 계약을 반환한다. 화살표는 대안 팩터리 선택과 그 반환 콜백 호출을 잇는 관계이며 세 콜백을 순서대로 반드시 실행한다는 의미가 아니다. minimumLength만 null 문자열을 false로 처리한다. 모바일은 같은 7노드6관계를 세 선택 가지로 재배치한다.팩터리 입력양수 limit=4minimumLengthPredicate<String>distanceFromToIntFunctionauditIntoConsumer<String>test(value)booleanapplyAsInt(value)intaccept(value)sink 수정·반환 없음
양수 기준을 받은 세 팩터리는 서로 다른 호출을 반환한다양수 limit을 생성 때 검사하고 각각 Predicate.test의 boolean, ToIntFunction.applyAsInt의 int, Consumer.accept의 sink 부수 효과 계약을 반환한다. 화살표는 대안 팩터리 선택과 그 반환 콜백 호출을 잇는 관계이며 세 콜백을 순서대로 반드시 실행한다는 의미가 아니다. minimumLength만 null 문자열을 false로 처리한다. 모바일은 같은 7노드6관계를 세 선택 가지로 재배치한다.팩터리 입력양수 limit=4minimumLengthPredicate<String>test(value)booleandistanceFromToIntFunctionapplyAsInt(value)intauditIntoConsumer<String>accept(value)sink 수정·반환 없음

minimumLength

반환 타입과 호출
Predicate<String> · test
호출할 때 하는 일
길이 충족 여부를 boolean으로 반환

distanceFrom

반환 타입과 호출
ToIntFunction<String> · applyAsInt
호출할 때 하는 일
길이에서 limit을 뺀 int 반환

auditInto

반환 타입과 호출
Consumer<String> · accept
호출할 때 하는 일
외부 sink에 문자열 추가 · 반환값 없음

세 팩터리는 limit을 받을 때 양수인지 검사합니다. 문자열 검사와 목록 기록은 반환된 콜백을 호출할 때 수행됩니다.

기준 limit은 생성할 때 양수인지 검사합니다. minimumLength만 문자열 null을 명시적으로 처리합니다.

limit이 4일 때 go, java, lambda의 길이는 2, 4, 6입니다. 먼저 values.forEach(recorder)가 세 문자열을 모두 audit에 기록해 둡니다. 그다음 filter(accepted)는 test가 true인 java와 lambda만 남기고, mapToInt(distance)는 각 길이에서 4를 뺀 -2, 0, 2를 만듭니다. 마지막 줄은 미리 기록해 둔 audit입니다. 같은 입력을 쓰더라도 선택, 계산, 기록은 서로 다른 메서드를 호출하고 서로 다른 결과를 냅니다.

호출하는 쪽에서 변수의 타입을 적은 것은 연습을 위해서입니다. 팩터리의 반환 타입이 이미 함수형 인터페이스이므로, 실제 코드에서는 values.stream().filter(minimumLength(4))처럼 결과를 바로 넘겨도 됩니다. 다만 limit 검사는 팩터리를 호출하는 순간에, 문자열 검사는 돌려받은 함수를 호출할 때마다 일어난다는 시점 차이는 알고 있어야 합니다.

또 null을 직접 처리하는 것은 minimumLength뿐입니다. 나머지 두 함수는 value.length()를 바로 부르므로 null이 들어오면 NullPointerException이 납니다. auditInto는 바깥 목록을 수정하는 함수이므로, 그 목록을 누가 소유하고 어느 스레드에서 호출하는지도 함께 정해야 합니다.

세 팩터리의 limit을 모두 6으로 바꾸면 어떻게 될까요? 실제로 실행하면 선택 결과는 [lambda], 거리는 [-4, -2, 0], 기록은 [go:false, java:false, lambda:true]가 됩니다. minimumLength(0)은 함수를 만들기도 전에 requirePositive에서 IllegalArgumentException을 던집니다. 그리고 minimumLength(6).test(null)은 false를 돌려주지만 distanceFrom(6).applyAsInt(null)은 NullPointerException을 던집니다. 함수를 돌려받았다고 해서 그 함수가 모든 입력을 안전하게 처리한다는 뜻은 아닙니다.

다음 글

다음 글에서는 표에서 본 표준 함수형 인터페이스를 하나씩 살펴보고, andThen, compose, and, or 같은 조합 메서드가 실제로 어떤 순서로 실행되는지 확인합니다. 이 글에서 람다 하나가 어떤 타입이 되는지를 봤다면, 다음 글에서는 그렇게 만든 함수들을 서로 이어 붙이는 방법을 봅니다.