본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
31장 : 함수형 파이프라인

메서드 참조의 네 유형

정적·결합 인스턴스·비결합 인스턴스·생성자 참조를 대응 람다로 펼쳐 보고, 오버로드 선택·캡처·평가 시점·읽기 좋은 이름의 조건을 실제 함수형 타입과 함께 익힙니다.

메서드 참조는 이미 이름 있는 메서드를 함수형 값으로 연결하는 문법입니다. value -> Integer.parseInt(value)Integer::parseInt로 바꾸면 본문이 새 정책을 만들기보다 기존 메서드를 전달한다는 의도가 드러납니다. 하지만 참조만 보고 매개변수 목록이 보이지 않으므로 대상 함수형 메서드 시그니처와 함께 읽어야 합니다.

네 형태는 static 메서드, 특정 인스턴스의 메서드, 임의 인스턴스의 메서드, 생성자입니다. 결합 인스턴스 참조는 수신 객체를 이미 캡처합니다. 비결합 인스턴스 참조는 함수의 첫 매개변수가 수신 객체가 됩니다. 생성자 참조는 대상 함수 시그니처의 매개변수로 생성자 오버로드를 선택합니다.

과도한 메서드 참조

다음 코드는 메서드 참조를 많이 사용하지만 Objects::toStringnull 처리 오버로드 의미를 바로 알기 어렵고, 도우미 이름도 도메인 의도를 설명하지 않습니다. 메서드 참조는 줄을 줄이는 장식이 아니라 이미 좋은 메서드 이름이 있을 때 사용하는 연결 표기입니다.

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);
    }
}

람다 매개변수 이름이 중요한 설명을 제공한다면 그대로 두는 편이 낫습니다. (user, permission) -> policy.allows(user, permission)policy::allows보다 매개변수를 보여 주지만 후자도 메서드 이름이 충분히 명확할 수 있습니다. 팀의 독자와 주변 코드 문맥을 기준으로 선택합니다.

메서드 참조도 대상 타입 결정을 사용합니다. 메서드 참조만으로 타입이 결정되지 않으며 대입이나 인수의 함수형 인터페이스가 매개변수와 반환 타입을 제공합니다. 오버로드된 메서드는 함수 시그니처와 맞는 후보가 하나여야 합니다.

정적·바운드 참조

static 참조 Type::staticMethod에는 수신 객체가 없습니다. 결합된 참조 instance::method는 참조를 만들 때 인스턴스 식을 평가해 수신 객체를 캡처합니다. 이후 함수 호출에는 메서드의 명시 매개변수만 전달합니다.

아래 카탈로그는 네 형태와 대응 람다를 나란히 실행합니다. 결과가 같아도 캡처와 함수 시그니처 구조가 다릅니다.

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

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;
        Supplier<Label> constructorLambda = () -> new Label("empty");
        IntFunction<String[]> arrayConstructor = String[]::new;

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

prefixer::add를 만드는 순간 prefixer 식이 평가됩니다. 결합된 참조의 수신 객체 식은 람다 본문에서 지역 변수를 캡처하는 경우와 달리 그 변수에 사실상 final 제약을 요구하지 않습니다. 참조 생성 뒤 지역 변수에 다른 Prefixer를 대입해도 이미 만든 함수는 원래 수신 객체를 계속 사용합니다. 수신 객체가 null이면 결합된 참조 생성 시 NullPointerException이 발생할 수 있습니다. 비결합 참조는 호출할 때 첫 매개변수가 null이면 메서드 호출에서 실패합니다.

배열 생성자 참조는 length를 받아 새 배열을 만듭니다. 제네릭 컬렉션의 toArray(String[]::new)처럼 실행 시점 구성 요소 타입을 보존하는 데 쓰입니다.

생성자 참조

Type::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<?>> zeroArgument = new LinkedHashMap<>();

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

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

    public static void main(String[] args) {
        Supplier<StringBuilder> builder = StringBuilder::new;
        Function<String, StringBuilder> builderWithText = StringBuilder::new;
        BiFunction<Integer, Integer, Point> point = Point::new;
        Registry registry = new Registry();
        registry.register("buffer", StringBuilder::new);

        System.out.println(builder.get().append("empty"));
        System.out.println(builderWithText.apply("seed"));
        System.out.println(point.apply(3, 4));
        System.out.println(registry.create("buffer").getClass().getSimpleName());
    }
}

레지스트리는 이종 팩터리를 저장하므로 create 결과가 Object입니다. 호출자 타입까지 보존하려면 키에 Class 토큰을 결합하고 검증된 형변환을 레지스트리 내부에 격리합니다. 생성자 참조 문법만으로 의존성 주입 컨테이너가 타입-안전해지는 것은 아닙니다.

이름과 가독성

복잡한 인라인 람다를 메서드로 추출하고 참조로 연결하면 파이프라인에는 단계 이름만 남습니다. OrderRules::isPayable, OrderView::from처럼 입력과 산출물 의미가 메서드 이름과 타입에 드러나야 합니다. Util::process처럼 넓은 이름은 매개변수가 사라져 오히려 이해하기 어렵습니다.

검사 예외 호환성도 함수 시그니처가 결정합니다. IOException을 던지는 메서드는 Function 참조로 직접 사용할 수 없습니다. ThrowingFunction 같은 사용자 정의 대상이나 래퍼 메서드가 필요합니다. 접근 가능성과 static/인스턴스 구분도 컴파일 단계에서 확인됩니다.

연습 문제

static normalize, 특정 Prefixer 인스턴스의 add, 임의 Stringlength, Entry 생성자를 각각 람다와 메서드 참조로 만듭니다. 같은 입력에 같은 산출물인지 쌍별로 검사하고 참조 종류를 출력합니다.

해답 보기
src/ReferenceEquivalenceSolution.java
import java.util.List;
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;
        }
    }

    static <T, R> void verify(String kind, T input, Function<T, R> left, Function<T, R> right) {
        R first = left.apply(input);
        R second = right.apply(input);
        System.out.println(kind + " equal=" + first.equals(second) + " value=" + first);
    }

    public static void main(String[] args) {
        verify("static", " Java ", ReferenceEquivalenceSolution::normalize,
                value -> ReferenceEquivalenceSolution.normalize(value));
        Prefixer prefixer = new Prefixer("P-");
        verify("bound", "7", prefixer::add, value -> prefixer.add(value));

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

        BiFunction<String, Integer, Entry> constructorReference = Entry::new;
        BiFunction<String, Integer, Entry> constructorLambda = (key, value) -> new Entry(key, value);
        List<Entry> entries = List.of(
                constructorReference.apply("A", 1),
                constructorLambda.apply("B", 2));
        System.out.println("constructor=" + entries);
    }
}

해답은 관찰 가능한 산출물의 동치를 확인합니다. 람다와 참조의 구체 클래스나 동일성이 같다는 뜻은 아닙니다. 변환 후에는 캡처 시점과 예외, null 동작도 기존 계약과 같은지 검토합니다.