본문 바로가기

메서드 참조의 네 가지 형태

정적 메서드, 특정 객체의 메서드, 임의 객체의 메서드, 생성자를 가리키는 네 가지 메서드 참조를 람다와 비교하고, 대상 타입과 평가 시점이 참조의 뜻을 어떻게 정하는지 익힙니다.

람다를 쓰다 보면 받은 인수를 다른 메서드에 그대로 넘기기만 하는 람다를 자주 만납니다. text -> Integer.parseInt(text)는 받은 문자열을 Integer.parseInt에 넘기는 일 말고는 아무것도 하지 않습니다. 이럴 때 Integer::parseInt라고 쓰면 '이 함수는 곧 그 메서드'라는 뜻을 더 짧게 전할 수 있습니다. 이 문법을 메서드 참조(method reference)라고 하며, 앞의 글들에서도 String::length나 User::id처럼 이미 여러 번 썼습니다.

메서드 참조를 읽을 때 주의할 점은 매개변수 목록이 코드에 보이지 않는다는 것입니다. Integer::parseInt만 봐서는 인수가 몇 개인지 알 수 없고, 그 정보는 참조를 받는 함수형 인터페이스, 즉 대상 타입에서 옵니다. 이 글에서는 메서드 참조의 네 가지 형태를 람다와 나란히 비교한 뒤, 대상 타입이 참조의 뜻을 어떻게 정하는지와 참조가 언제 평가되는지를 차례로 살펴봅니다.

네 가지 형태를 람다와 나란히 보기

메서드 참조는 :: 앞에 무엇을 쓰고 뒤에 무엇을 쓰느냐에 따라 네 가지로 나뉩니다. 각 형태와 같은 뜻의 람다를 표로 정리하면 다음과 같습니다.

형태메서드 참조같은 뜻의 람다함수를 호출할 때 받는 값
정적 메서드Integer::parseInttext -> Integer.parseInt(text)파싱할 문자열
특정 객체의 메서드prefixer::addvalue -> prefixer.add(value)붙일 문자열(수신 객체는 이미 정해짐)
임의 객체의 메서드String::trimreceiver -> receiver.trim()수신 객체가 될 문자열
생성자Label::newtext -> new Label(text)생성자에 넘길 문자열

정적 메서드 참조는 클래스::메서드 모양이고 수신 객체가 없습니다. 함수가 받은 인수가 그대로 메서드의 인수가 됩니다. 인스턴스 메서드를 가리키는 두 형태는 :: 앞에 무엇을 쓰느냐로 갈립니다. prefixer::add처럼 객체를 가리키는 식을 쓰면, 메서드를 호출받을 객체인 수신 객체(receiver)가 참조를 만들 때 정해집니다. 이것을 특정 객체의 메서드 참조, 줄여서 바운드 참조(bound method reference)라고 부릅니다.

String::trim처럼 :: 앞에 타입 이름을 쓰면 수신 객체가 아직 정해지지 않았다는 뜻입니다. 이때는 함수를 호출할 때 받는 첫 번째 인수가 수신 객체가 되며, 이것을 임의 객체의 메서드 참조, 줄여서 언바운드 참조(unbound method reference)라고 부릅니다. 마지막으로 클래스::new는 생성자를 가리키고, 같은 모양의 타입[]::new는 배열을 만드는 함수가 됩니다. 다음 프로그램은 각 형태를 같은 뜻의 람다와 함께 만들고, 둘에 같은 입력을 넣어 결과를 비교합니다.

src/MethodReferenceCatalog.java
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.IntFunction;

public final class MethodReferenceCatalog {
    record Label(String text, int width) {
        Label(String text) {
            this(text, text.length());
        }
    }

    static final class Prefixer {
        private final String prefix;

        Prefixer(String prefix) {
            this.prefix = prefix;
        }

        String add(String value) {
            return prefix + value;
        }
    }

    public static void main(String[] args) {
        Function<String, Integer> staticReference = Integer::parseInt;
        Function<String, Integer> staticLambda = text -> Integer.parseInt(text);

        Prefixer prefixer = new Prefixer("id:");
        Function<String, String> boundReference = prefixer::add;
        Function<String, String> boundLambda = value -> prefixer.add(value);

        Function<String, String> unboundReference = String::trim;
        Function<String, String> unboundLambda = receiver -> receiver.trim();
        BiFunction<String, Integer, String> unboundWithArgument = String::repeat;

        Function<String, Label> constructorReference = Label::new;
        Function<String, Label> constructorLambda = text -> new Label(text);
        IntFunction<String[]> arrayConstructor = String[]::new;

        System.out.println(staticReference.apply("12") + " " + staticLambda.apply("12"));
        System.out.println(boundReference.apply("A") + " " + boundLambda.apply("A"));
        System.out.println("[" + unboundReference.apply(" java ") + "] [" + unboundLambda.apply(" java ") + "]");
        System.out.println(unboundWithArgument.apply("go", 3));
        System.out.println(constructorReference.apply("method") + " " + constructorLambda.apply("method"));
        System.out.println("arrayLength=" + arrayConstructor.apply(4).length);
    }
}

대부분의 println은 메서드 참조의 결과와 같은 뜻의 람다의 결과를 공백으로 이어 한 줄에 찍습니다. 넷째 줄과 마지막 줄만 짝이 되는 람다 없이 메서드 참조 하나의 결과를 찍습니다.

12 12
id:A id:A
[java] [java]
gogogo
Label[text=method, width=6] Label[text=method, width=6]
arrayLength=4

값이 두 개인 줄은 같은 입력을 메서드 참조와 람다에 넣은 결과이고, 두 값이 모두 같습니다. 세 번째 줄은 앞뒤 공백이 사라졌는지 보이도록 결과를 대괄호로 감쌌습니다. 네 번째 줄의 String::repeat는 인수가 있는 인스턴스 메서드의 언바운드 참조입니다. BiFunction<String, Integer, String>이 받는 첫 번째 인수 "go"가 수신 객체가 되고, 두 번째 인수 3이 repeat의 인수로 넘어가 "go".repeat(3)이 실행됩니다. 이 규칙은 다음 글에서 자세히 다룹니다.

다섯 번째 줄에서 Label에는 생성자가 Label(String, int)와 Label(String) 두 개 있지만, 대상 타입이 문자열 하나를 받는 Function<String, Label>이므로 인수가 하나인 생성자가 선택됐습니다. 마지막 줄의 String[]::new는 길이를 받아 그 길이의 새 배열을 만드는 IntFunction<String[]>입니다. 아래 도표는 두 인스턴스 메서드 참조가 수신 객체를 언제 받는지 비교하고, 다섯 표기가 호출될 때 각각 무엇을 받는지 정리합니다.

메서드 참조가 수신자와 인수를 받는 방식

메서드 참조가 수신자와 인수를 받는 방식

메서드 참조의 호출 계약

메서드 참조가 수신자와 인수를 받는 방식

대상 타입과 함께 참조를 읽습니다. 결합 참조는 수신자를 미리 고정하고, 비결합 참조는 첫 호출 인수로 받습니다.

생성 때 고정한 수신자와 호출 때 전달한 수신자를 비교한다왼쪽 결합 참조는 생성 시 Prefixer id:를 보관한 뒤 호출 문자열 A로 id:A를 얻는다. 오른쪽 String::trim은 정의 시 특정 문자열을 보관하지 않고 호출 때 받은 문자열의 trim으로 java를 얻는다. 두 독립 사슬은 참조 생성·입력·호출 결과의 관계이며 한 사슬에서 다른 사슬로 실행이 이어지는 것이 아니다. 모바일도 같은7노드5간선이다.결합: 생성 때Prefixer id:같은 수신자를함수에 보관호출 입력 A수신자 추가 없음add 결과 id:A비결합: 정의String::trim호출 수신 문자열" java "trim 결과 java
생성 때 고정한 수신자와 호출 때 전달한 수신자를 비교한다왼쪽 결합 참조는 생성 시 Prefixer id:를 보관한 뒤 호출 문자열 A로 id:A를 얻는다. 오른쪽 String::trim은 정의 시 특정 문자열을 보관하지 않고 호출 때 받은 문자열의 trim으로 java를 얻는다. 두 독립 사슬은 참조 생성·입력·호출 결과의 관계이며 한 사슬에서 다른 사슬로 실행이 이어지는 것이 아니다. 모바일도 같은7노드5간선이다.결합: 생성 때Prefixer id:같은 수신자를함수에 보관호출 입력 A수신자 추가 없음add 결과 id:A비결합: 정의String::trim호출 수신 문자열" java "trim 결과 java
메서드 참조가 수신자와 인수를 받는 방식
형태원문의 연결식호출 때 필요한 값
정적 메서드Integer::parseInt파싱할 문자열
결합 인스턴스prefixer::add문자열 · 수신자는 미리 고정
비결합 인스턴스String::trim첫 인수가 수신 문자열
객체 생성Label::newLabel에 전달할 문자열
배열 생성String[]::new새 배열의 길이
정적 메서드
원문의 연결식
Integer::parseInt
호출 때 필요한 값
파싱할 문자열
결합 인스턴스
원문의 연결식
prefixer::add
호출 때 필요한 값
문자열 · 수신자는 미리 고정
비결합 인스턴스
원문의 연결식
String::trim
호출 때 필요한 값
첫 인수가 수신 문자열
객체 생성
원문의 연결식
Label::new
호출 때 필요한 값
Label에 전달할 문자열
배열 생성
원문의 연결식
String[]::new
호출 때 필요한 값
새 배열의 길이

객체 생성과 배열 생성도 대상 함수 시그니처의 인수를 사용합니다. 문자열·배열 길이는 콜백을 호출할 때 전달합니다.

대상 타입이 참조의 뜻을 정한다

메서드 참조는 혼자서는 타입이 없습니다. 람다와 마찬가지로 대입문이나 메서드 인수처럼 함수형 인터페이스를 기대하는 자리에 놓여야 하고, 그 인터페이스의 추상 메서드가 매개변수와 반환 타입을 알려 줍니다. 그래서 var parse = Integer::parseInt;처럼 대상 타입을 주지 않으면 cannot infer type for local variable parse 오류와 함께 (method reference needs an explicit target-type)라는 설명이 나옵니다. 대상 타입이 정해지는 방식은 30장의 대상 타입 설명에서 람다를 기준으로 다뤘고, 메서드 참조에도 같은 규칙이 적용됩니다.

같은 참조라도 대상 타입에 따라 다른 메서드를 가리킬 수 있다는 점이 중요합니다. 예를 들어 StringBuilder::new는 Supplier<StringBuilder>에 대입하면 인수 없는 생성자를, Function<String, StringBuilder>에 대입하면 문자열을 받는 생성자를 뜻합니다. 반대로 대상 타입만으로 메서드를 하나로 정할 수 없으면 컴파일 오류가 납니다. Integer를 받아 String을 돌려주는 함수로 Integer::toString을 쓰는 다음 코드는 컴파일되지 않고, 코드 아래와 같은 오류가 납니다.

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

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

Integer에는 정적 메서드 toString(int)와 인스턴스 메서드 toString()이 모두 있습니다. Function<Integer, String>의 apply는 Integer 하나를 받으므로, 그 값을 정적 메서드의 인수로 넘기는 해석 Integer.toString(value)와 수신 객체로 쓰는 해석 value.toString()이 둘 다 성립합니다. 컴파일러는 둘 중 하나를 고를 근거가 없으니 모호하다고 알립니다. 이럴 때는 의도가 하나로 정해지게 고치면 됩니다.

Function<Integer, String> instanceCall = Object::toString;
IntFunction<String> staticCall = Integer::toString;
Function<Integer, String> lambda = value -> Integer.toString(value);

세 함수 모두 42를 넣으면 "42"를 돌려줍니다. Object에는 정적 toString이 없으므로 Object::toString은 인스턴스 메서드로만 해석됩니다. IntFunction<String>은 기본형 int를 받는데 기본형은 수신 객체가 될 수 없으므로, 정적 메서드 toString(int)만 맞습니다. 마지막 줄처럼 람다로 호출을 직접 적으면 해석의 여지가 아예 없습니다.

바운드 참조는 만들 때 수신 객체를 정한다

바운드 참조와 그에 대응하는 람다는 같은 결과를 내지만, 무엇을 언제 평가하느냐가 다릅니다. 메서드 참조 식을 평가하는 일과 그 메서드를 호출하는 일은 별개입니다. current::add라는 식을 실행하는 순간 current가 가리키는 객체를 읽어 수신 객체로 저장하고, add 자체는 나중에 함수를 호출할 때 실행됩니다. 반면 람다 value -> current.add(value)는 호출될 때마다 본문에서 current를 읽습니다.

src/BoundReceiverTiming.java
import java.util.function.Function;

public final class BoundReceiverTiming {
    static final class Prefixer {
        private final String prefix;

        Prefixer(String prefix) {
            this.prefix = prefix;
        }

        String add(String value) {
            return prefix + value;
        }
    }

    public static void main(String[] args) {
        Prefixer current = new Prefixer("A:");
        Function<String, String> saved = current::add;
        current = new Prefixer("B:");

        System.out.println(saved.apply("7"));
        System.out.println(current.add("7"));
    }
}

saved는 current에 새 객체를 대입하기 전에 만들어집니다. 두 줄은 같은 인수 7을 넣었을 때 saved와 지금의 current가 각각 어느 접두사를 붙이는지 보여 줍니다.

A:7
B:7

saved를 만든 뒤 current에 새 Prefixer를 대입했지만, saved는 만들 때 붙잡은 A: 객체를 계속 씁니다. 바운드 참조는 변수를 기억하는 것이 아니라 그 순간 변수가 가리키던 객체를 기억하기 때문입니다. 그래서 current가 사실상 final이 아닌데도 컴파일이 됩니다. 같은 자리를 람다 value -> current.add(value)로 바꾸면 앞 글에서 본 local variables referenced from a lambda expression must be final or effectively final 오류가 납니다.

평가 시점의 차이는 null에서 더 분명하게 드러납니다. 다음 프로그램은 수신 객체가 null일 때 바운드 참조, 람다, 언바운드 참조가 각각 어느 단계에서 실패하는지 확인합니다.

src/NullTiming.java
import java.util.function.Function;

public final class NullTiming {
    static final class Prefixer {
        private final String prefix;

        Prefixer(String prefix) {
            this.prefix = prefix;
        }

        String add(String value) {
            return prefix + value;
        }
    }

    public static void main(String[] args) {
        Prefixer missing = null;
        try {
            Function<String, String> bound = missing::add;
            System.out.println("바운드 참조 생성 성공");
        } catch (NullPointerException e) {
            System.out.println("바운드 참조: 만들 때 NullPointerException");
        }

        Function<String, String> lambda = value -> missing.add(value);
        System.out.println("람다 생성 성공");
        try {
            lambda.apply("7");
        } catch (NullPointerException e) {
            System.out.println("람다: 호출할 때 NullPointerException");
        }

        Function<String, String> trim = String::trim;
        System.out.println("언바운드 참조 생성 성공");
        try {
            trim.apply(null);
        } catch (NullPointerException e) {
            System.out.println("언바운드 참조: 호출할 때 NullPointerException");
        }
    }
}

각 단계는 try 안에서 실행되고, NullPointerException이 나면 어느 단계였는지 알리는 줄을, 나지 않으면 성공 줄을 찍습니다. 그래서 출력만 보고도 실패가 만들 때 났는지 호출할 때 났는지를 구별할 수 있습니다.

바운드 참조: 만들 때 NullPointerException
람다 생성 성공
람다: 호출할 때 NullPointerException
언바운드 참조 생성 성공
언바운드 참조: 호출할 때 NullPointerException

바운드 참조는 missing::add를 평가하는 순간 수신 객체가 null인지 확인하므로, 함수를 만드는 줄에서 바로 예외가 납니다. 람다는 호출되기 전까지 missing을 쓰지 않으므로 만들 때는 아무 문제가 없다가 apply에서 실패합니다. 언바운드 참조 String::trim은 만들 때 붙잡는 수신 객체가 없어서 역시 만들 때는 성공하고, null이 첫 번째 인수, 즉 수신 객체로 들어온 apply(null)에서 실패합니다. JLS 15.13.3은 메서드 참조 식을 평가할 때 :: 앞의 식을 먼저 평가하고, 그 값이 null이면 NullPointerException을 던진다고 정해 두었습니다.

이 차이는 실무에서도 쓸모가 있습니다. 바운드 참조는 잘못된 수신 객체를 함수를 만드는 시점에 바로 알려 주는 반면, 같은 코드를 람다로 쓰면 그 함수가 한참 뒤에 호출될 때에야 문제가 드러납니다. 콜백을 등록해 두고 나중에 호출하는 구조라면 이 시간 차이만큼 원인을 찾기가 어려워집니다.

생성자 참조와 배열 생성자 참조

클래스::new는 생성자를 함수형 인터페이스의 구현으로 쓰게 해 줍니다. 어떤 생성자가 선택되는지는 앞에서 본 것처럼 대상 타입의 매개변수가 정합니다. 인수가 없는 Supplier에는 인수 없는 생성자가, 인수가 하나인 Function에는 인수 하나짜리 생성자가, 인수가 둘인 BiFunction에는 인수 둘짜리 생성자가 맞습니다. 다음 예제는 이 세 경우와 함께, 생성자 참조를 '객체를 만드는 방법'으로 등록해 두는 간단한 레지스트리를 보여 줍니다.

src/ConstructorReferenceFactory.java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;

public final class ConstructorReferenceFactory {
    record Point(int x, int y) {
    }

    static final class Registry {
        private final Map<String, Supplier<?>> factories = new LinkedHashMap<>();

        void register(String key, Supplier<?> factory) {
            if (factories.putIfAbsent(key, factory) != null) {
                throw new IllegalArgumentException("duplicate factory: " + key);
            }
        }

        Object create(String key) {
            Supplier<?> factory = factories.get(key);
            if (factory == null) {
                throw new IllegalArgumentException("unknown factory: " + key);
            }
            return factory.get();
        }
    }

    public static void main(String[] args) {
        Supplier<StringBuilder> empty = StringBuilder::new;
        Function<String, StringBuilder> seeded = StringBuilder::new;
        BiFunction<Integer, Integer, Point> point = Point::new;

        Registry registry = new Registry();
        registry.register("buffer", StringBuilder::new);

        System.out.println("[" + empty.get() + "]");
        System.out.println(seeded.apply("seed").append("!"));
        System.out.println(point.apply(3, 4));
        System.out.println(registry.create("buffer").getClass().getSimpleName());
    }
}

처음 세 줄은 empty, seeded, point가 만든 객체를 찍은 것입니다. Registry.create는 Object를 돌려주므로, 마지막 줄은 만들어진 객체의 실제 클래스 이름을 찍어 확인합니다.

[]
seed!
Point[x=3, y=4]
StringBuilder

첫 줄은 인수 없는 생성자로 만든 빈 StringBuilder를 대괄호로 감싸 출력한 것이고, 두 번째 줄은 문자열 "seed"를 받는 생성자로 만든 버퍼에 !를 덧붙인 결과입니다. 세 번째 줄의 Point::new는 레코드의 정식 생성자(canonical constructor)인 Point(int x, int y)를 가리키며, BiFunction<Integer, Integer, Point>가 받은 두 Integer는 언박싱되어 int 매개변수로 넘어갑니다.

레지스트리에 등록한 StringBuilder::new는 등록하는 순간에는 아무것도 만들지 않습니다. 생성자는 create("buffer")가 factory.get()을 호출할 때 비로소 실행되고, 마지막 줄은 그렇게 만들어진 객체의 클래스 이름입니다. 메서드 참조를 만드는 것과 호출하는 것이 별개라는 규칙은 생성자 참조에도 그대로 적용됩니다. 다만 이 레지스트리는 서로 다른 타입의 팩터리를 한 맵에 담기 때문에 create가 Object를 돌려줄 수밖에 없고, 호출하는 쪽에서 형변환을 해야 합니다. 생성자 참조를 쓴다고 이런 구조가 저절로 타입 안전해지지는 않으며, 타입을 지키려면 문자열 대신 Class<T> 객체를 키로 쓰고 꺼낼 때 type.cast(...)로 검사하는 식의 설계가 따로 필요합니다.

생성 과정에서 입력을 검증하거나, 캐시해 둔 객체를 돌려주거나, 입력에 따라 다른 하위 타입을 골라야 한다면 생성자 참조보다 이름 있는 정적 팩터리 메서드의 참조가 알맞습니다. 생성자는 언제나 그 클래스의 새 객체를 만들지만, Integer::valueOf처럼 팩터리 메서드를 가리키면 그런 결정을 메서드 안에 담을 수 있습니다. 실제로 Integer.valueOf는 -128부터 127까지의 값에 대해 미리 만들어 둔 객체를 재사용합니다.

배열 생성자 참조 String[]::new는 길이를 받아 새 배열을 돌려주는 IntFunction<String[]>입니다. 가장 흔히 쓰이는 곳은 컬렉션을 배열로 바꾸는 toArray입니다.

src/ToArrayGenerator.java
import java.util.Arrays;
import java.util.List;

public final class ToArrayGenerator {
    public static void main(String[] args) {
        List<String> names = List.of("kim", "lee");
        Object[] plain = names.toArray();
        String[] typed = names.toArray(String[]::new);
        System.out.println(plain.getClass().getSimpleName() + " " + Arrays.toString(plain));
        System.out.println(typed.getClass().getSimpleName() + " " + Arrays.toString(typed));
    }
}

배열 객체에 getClass().getSimpleName()을 부르면 Object[], String[]처럼 원소 타입 뒤에 대괄호를 붙인 이름이 나옵니다. 그래서 각 줄의 앞부분을 보면 실제로 어떤 타입의 배열이 만들어졌는지 알 수 있습니다.

Object[] [kim, lee]
String[] [kim, lee]

인수 없는 toArray()는 원소가 모두 문자열이어도 Object[]를 돌려줍니다. 제네릭 타입 정보는 실행 시점에 지워지기 때문에 목록 객체는 자신이 String을 담는다는 사실을 모릅니다. Java 11에 추가된 toArray(IntFunction)에 String[]::new를 넘기면, 목록이 이 함수로 String[] 배열을 얻어 원소를 담아 돌려줍니다. Collection의 기본 구현은 이 함수를 길이 0으로 호출해 배열의 타입만 알아낸 뒤, 그 타입으로 알맞은 크기의 배열을 새로 만듭니다.

메서드 참조가 오히려 읽기 어려울 때

메서드 참조는 이미 좋은 이름을 가진 메서드가 있을 때 그 이름으로 동작을 설명하는 표기입니다. 줄을 줄이려고 모든 람다를 참조로 바꾸면 오히려 읽기 어려워질 수 있습니다. 다음 코드를 보세요.

bad/ReferenceForEveryOperation.java
import java.util.List;
import java.util.Objects;
import java.util.function.Function;

public final class ReferenceForEveryOperation {
    static String x(String value) {
        return value.trim();
    }

    public static void main(String[] args) {
        Function<String, String> first = ReferenceForEveryOperation::x;
        Function<Object, String> second = Objects::toString;
        List<String> result = List.of(" a ", " b ").stream()
                .map(first)
                .map(second)
                .toList();
        System.out.println(result);
    }
}

first와 second라는 이름만 봐서는 두 단계가 무슨 일을 하는지 알 수 없습니다. 출력을 보기 전에 각 문자열이 어떻게 바뀔지 먼저 짐작해 보세요.

[a, b]

결과는 앞뒤 공백을 지운 것뿐이지만, 코드만 보고 그 사실을 알기는 어렵습니다. x라는 이름은 무슨 일을 하는지 알려 주지 않아서 메서드 본문을 찾아가야 합니다. Objects::toString은 대상 타입이 인수 하나짜리이므로 Objects.toString(Object)가 선택되는데, 이 메서드는 null을 받으면 예외 대신 문자열 "null"을 돌려줍니다. 독자는 어떤 오버로드가 선택됐는지와 그 메서드가 null을 어떻게 다루는지까지 알아야 이 줄을 이해할 수 있는데, 정작 문자열을 다시 문자열로 바꾸는 이 단계는 하는 일이 없습니다. 같은 일은 .map(String::trim) 한 단계로 충분합니다.

반대로 람다의 매개변수 이름이 중요한 설명을 담고 있다면 람다로 두는 편이 낫습니다. (user, permission) -> policy.allows(user, permission)은 두 인수가 무엇인지 보여 주지만, policy::allows는 더 짧은 대신 그 정보를 대상 타입에 맡깁니다. 메서드 이름만으로 의미가 충분히 드러난다면 참조를, 인수의 역할을 보여 줘야 한다면 람다를 고르면 됩니다. 어느 쪽이 나은지는 함께 일하는 사람들과 주변 코드의 문맥에 따라 정합니다.

복잡한 람다를 이름 있는 메서드로 뽑아내고 참조로 연결하는 것도 같은 원리입니다. orders.stream().filter(OrderRules::isPayable).map(OrderView::from)처럼 쓰면 파이프라인에는 단계의 이름만 남아 흐름이 한눈에 보입니다. 이때는 메서드 이름과 타입이 입력과 결과의 뜻을 드러내야 합니다. Util::process처럼 범위가 넓은 이름은 매개변수까지 사라진 탓에 람다보다 더 알기 어렵습니다.

검사 예외를 던지는 메서드는 그대로 참조할 수 없다

메서드 참조가 대상 타입에 맞는지 판단할 때는 매개변수와 반환 타입뿐 아니라 던지는 예외도 따집니다. Files.readString(Path)는 검사 예외(checked exception)인 IOException을 던지는데, Function의 apply는 검사 예외를 던진다고 선언하지 않았습니다. 그래서 다음 코드를 컴파일하면 코드 아래와 같은 오류가 납니다.

bad/CheckedReference.java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Function;

public final class CheckedReference {
    public static void main(String[] args) {
        Function<Path, String> read = Files::readString;
        System.out.println(read.apply(Path.of("memo.txt")));
    }
}
CheckedReference.java:7: error: incompatible thrown types IOException in functional expression
        Function<Path, String> read = Files::readString;
                                      ^
1 error

람다로 써도 마찬가지입니다. IOException을 던지는 메서드를 함수로 넘기려면 검사 예외를 선언한 함수형 인터페이스를 직접 만들거나, 예외를 잡아 비검사 예외로 바꿔 던지는 감싸기 메서드를 거쳐야 합니다. 두 방법은 30장의 검사 예외 설명에서 다뤘습니다. 접근할 수 없는 메서드를 참조하거나 정적 메서드와 인스턴스 메서드를 잘못 짚은 경우도 모두 이처럼 컴파일할 때 걸러집니다.

정리

  • 메서드 참조는 인수를 그대로 넘기기만 하는 람다를 메서드 이름으로 바꿔 쓰는 문법입니다. 정적 메서드, 특정 객체의 메서드(바운드), 임의 객체의 메서드(언바운드), 생성자의 네 가지 형태가 있습니다.
  • 메서드 참조는 혼자서는 타입이 없고, 대상 타입의 매개변수와 반환 타입이 어떤 메서드나 생성자를 가리키는지 정합니다. 후보가 둘 이상 맞으면 모호하다는 컴파일 오류가 납니다.
  • 바운드 참조는 참조를 만들 때 :: 앞의 식을 평가해 수신 객체를 붙잡습니다. 그래서 변수를 나중에 바꿔도 영향을 받지 않고, 수신 객체가 null이면 만드는 순간 실패합니다.
  • 언바운드 참조는 함수의 첫 번째 인수를 수신 객체로 씁니다. 생성자 참조와 배열 생성자 참조는 호출될 때 비로소 객체를 만듭니다.
  • 메서드 이름이 동작을 잘 설명할 때 참조를 쓰고, 매개변수 이름이 중요한 정보를 담고 있다면 람다로 둡니다.

연습 문제

1. 네 형태를 람다와 비교하기

정적 메서드 normalize, 특정 Prefixer 인스턴스의 add, 임의 String의 length, 레코드 Entry(String key, int value)의 생성자를 각각 람다와 메서드 참조로 만드세요. 각 쌍에 같은 입력을 넣어 결과를 출력하고, 생성자 쌍은 ("A", 1)로 만든 두 Entry를 equals와 ==로도 비교해 보세요.

해답 보기
src/ReferenceEquivalenceSolution.java
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.ToIntFunction;

public final class ReferenceEquivalenceSolution {
    record Entry(String key, int value) {
    }

    static String normalize(String value) {
        return value.trim().toLowerCase();
    }

    static final class Prefixer {
        private final String prefix;

        Prefixer(String prefix) {
            this.prefix = prefix;
        }

        String add(String value) {
            return prefix + value;
        }
    }

    public static void main(String[] args) {
        Function<String, String> staticReference = ReferenceEquivalenceSolution::normalize;
        Function<String, String> staticLambda = value -> ReferenceEquivalenceSolution.normalize(value);
        System.out.println("static: " + staticReference.apply(" Java ") + " " + staticLambda.apply(" Java "));

        Prefixer prefixer = new Prefixer("P-");
        Function<String, String> boundReference = prefixer::add;
        Function<String, String> boundLambda = value -> prefixer.add(value);
        System.out.println("bound: " + boundReference.apply("7") + " " + boundLambda.apply("7"));

        ToIntFunction<String> unboundReference = String::length;
        ToIntFunction<String> unboundLambda = receiver -> receiver.length();
        System.out.println("unbound: " + unboundReference.applyAsInt("lambda") + " "
                + unboundLambda.applyAsInt("lambda"));

        BiFunction<String, Integer, Entry> constructorReference = Entry::new;
        BiFunction<String, Integer, Entry> constructorLambda = (key, value) -> new Entry(key, value);
        Entry fromReference = constructorReference.apply("A", 1);
        Entry fromLambda = constructorLambda.apply("A", 1);
        System.out.println("constructor: " + fromReference + " " + fromLambda);
        System.out.println("equals=" + fromReference.equals(fromLambda) + " same=" + (fromReference == fromLambda));
    }
}

실행 결과:

static: java java
bound: P-7 P-7
unbound: 6 6
constructor: Entry[key=A, value=1] Entry[key=A, value=1]
equals=true same=false

앞의 네 줄은 각 쌍이 같은 입력에 같은 결과를 낸다는 것을 보여 줍니다. String::length는 int를 돌려주므로 박싱을 피하려고 Function 대신 ToIntFunction을 썼습니다. 마지막 줄에서 equals가 true인 것은 레코드의 equals가 구성 요소의 값을 비교하기 때문이고, ==가 false인 것은 두 함수가 각각 new로 별개의 객체를 만들었기 때문입니다.

이 비교는 함수가 만든 결과를 비교한 것이지, 메서드 참조와 람다가 같은 함수 객체라는 뜻은 아닙니다. 자바는 메서드 참조나 람다를 평가할 때 새 객체를 만들지 기존 객체를 재사용할지 정해 두지 않았으므로, 함수 객체끼리 ==로 비교하는 코드에 기대면 안 됩니다. 람다를 참조로 바꾼 뒤에는 결과뿐 아니라 앞에서 본 평가 시점과 null 처리도 그대로인지 확인하세요.

2. 람다를 메서드 참조로 바꾸기

다음 람다를 메서드 참조로 바꿔 보세요. 바꿀 수 없거나 바꾸지 않는 편이 나은 것은 그 이유를 설명하세요.

  1. Predicate<String> empty = s -> s.isEmpty();
  2. Consumer<String> print = s -> System.out.println(s);
  3. Supplier<List<String>> make = () -> new ArrayList<>();
  4. Function<String, String> lower = s -> s.toLowerCase(Locale.ROOT);
  5. Function<Integer, String> label = n -> Integer.toString(n);
풀이 확인
  1. String::isEmpty입니다. 함수가 받는 문자열이 수신 객체가 되는 언바운드 참조입니다.
  2. System.out::println입니다. System.out이라는 특정 PrintStream 객체를 수신 객체로 붙잡는 바운드 참조이므로, 참조를 만든 뒤 System.setOut으로 표준 출력을 바꿔도 이 함수는 만들 때의 스트림에 계속 출력합니다. 람다는 호출할 때마다 System.out을 다시 읽으므로 바뀐 스트림에 출력합니다.
  3. ArrayList::new입니다. 대상 타입이 인수 없는 Supplier이므로 인수 없는 생성자가 선택됩니다.
  4. 바꿀 수 없습니다. Locale.ROOT는 함수가 받는 인수가 아니라 람다 본문에서 정한 값이고, 메서드 참조는 함수가 받은 인수만 메서드에 넘길 수 있습니다. 이런 경우는 람다가 맞는 표기입니다.
  5. Integer::toString으로 바꾸면 본문에서 본 것처럼 정적 toString(int)와 인스턴스 toString()이 모두 맞아 컴파일 오류가 납니다. 람다로 두거나 Object::toString을 씁니다.

3. 참조를 만드는 시점과 호출하는 시점

다음 상황에서 무슨 일이 일어나는지 예상해 보세요. 참조를 만드는 순간과 함수를 호출하는 순간을 구분하는 것이 요점입니다.

  1. Function<String, Label> make = Label::new;를 실행한 직후, Label의 생성자는 실행됐나요?
  2. IntFunction<String[]> arrays = String[]::new;에 apply(0)과 apply(-1)을 차례로 호출하면 어떻게 되나요?
풀이 확인
  1. 실행되지 않았습니다. 생성자 참조를 만드는 것은 '이 생성자를 호출하는 함수'를 만드는 일일 뿐이고, 생성자는 make.apply("hi")처럼 함수를 호출할 때마다 실행됩니다. 생성자 안에 출력문을 넣어 보면 참조를 저장한 시점에는 아무것도 출력되지 않고, apply를 호출할 때 출력되는 것을 확인할 수 있습니다.
  2. apply(0)은 길이가 0인 빈 String[]을 돌려줍니다. apply(-1)은 new String[-1]을 실행하는 것과 같아서 java.lang.NegativeArraySizeException: -1이 납니다. 이 예외도 참조를 만들 때가 아니라 호출할 때 발생합니다.

다음 글

언바운드 메서드 참조 활용에서는 네 형태 가운데 가장 헷갈리기 쉬운 언바운드 참조를 더 자세히 봅니다. String::substring처럼 인수가 있는 메서드에서 수신 객체와 인수가 어떤 순서로 짝지어지는지, 정렬과 축약, null 처리에서 언바운드 참조를 안전하게 쓰는 방법을 다룹니다.