Java - 람다와 메서드 참조는 같다?

람다(Lambda)와 메서드 참조(Method Reference)는 Java 8에 도입된 기능이다.

람다(Lambda)를 활용하면 함수형 인터페이스 사용 시 익명 클래스를 직접 구현하지 않고도 간결한 문법을 통해 이를 활용할 수 있다.

메서드 참조(Method Reference)를 사용하면 간결한 람다를 더 짧은 표현식으로 활용할 수 있다.

그럼 람다와 메서드 참조 둘 모두 사용 가능한 상황이라면 메서드 참조를 사용하면 되는걸까?

이펙티브 자바(Effective Java)Item 43. 람다보다는 메서드 참조를 사용하라 에서는 둘을 비교하며 메서드 참조를 사용하도록 권장한다.

메서드 참조는 간단명료한 대안이 될 수 있다. 메서드 참조 쪽이 짧고 명확하다면 메서드 참조를 쓰고, 그렇지 않을 때만 람다를 사용하라.

[이펙티브 자바(Effective Java 3/E) p.261]

내용을 읽어보면 결국 둘은 같은 내용의 표현식에 대해 동일한 결과 값을 반환하고, 무엇을 사용하더라도 문제가 없을 것 같다.

필자는 그동안 코드 작성 시 가독성만을 고려하여 구분하지 않고 사용 했었는데, 한가지 문제가 되었던 케이스가 있어서 정리를 해보고자 한다.


문제상황

람다, 스트림과 함께 Java 8에 도입된 옵셔널(Optional)을 활용하면 null safe한 코드를 작성할 수 있다.

한 가지 예를 들어보자.

Optional.ofNullable(something)
	.map(Something::do))
	.orElseGet(() -> defaultValue);

이 코드는 something의 메서드 호출에 대한 null safety를 보장한다.

그런데 이런 코드를 작성하려면 Optional을 매번 선언해야 하는 번거로움이 생긴다.
반복되는 코드를 줄여보고자 아래와 같은 메서드를 정의했다고 가정해보자.

public static <T> T getOrDefault(Supplier<T> supplier, T defaultValue) {
    try {
        return Optional.ofNullable(supplier.get()).orElse(() -> defaultValue);
    } catch (NullPointerException e) {
        return defaultValue;
    }
}

이 메서드는 특정 값을 반환하는 함수 하나와 기본 값을 전달 받는다. 내부에서는 전달 받은 함수를 실행하는데, 만약 함수 실행 도중 NPE가 발생하면 기본 값을 반환한다.

이제 메서드를 사용해보자.
LocalDate today = LocalDate.now();
LocalDate localDate = getOrDefault(() -> localDateTime.toLocalDate(), today)

만약 localDateTime 변수에 값이 할당되어 있다면 localDateTime.toLocalDate() 결과를 반환 할 것이고, 값이 없다면 기본 값으로 지정한 today 가 반환될 것이다.

localDateTime 변수에 null을 할당하고 테스트 해보자.

@Test
void nullSafetyTest() {
	LocalDateTime localDateTime = null;
	LocalDate today = LocalDate.now();

	then(getOrDefault(() -> localDateTime.toLocalDate(), today)).isEqualTo(today);
}

Alt Image

localDateTime 변수가 null이되면 기본 값으로 지정한 today가 반환된다.

그런데 메서드의 인자로 람다 () -> localDateTime.toLocalTime() 보다는 localDateTime::toLocalTime 메서드 참조가 더 간결해 보인다.

// lambda
getOrDefault(() -> localDateTime.toLocalDate(), today)

// method reference
getOrDefault(localDateTime::toLocalDate, today)

람다를 메서드 참조로 변경한 뒤 실행해보자.

@Test
void nullMethodReference() {
	LocalDateTime localDateTime = null;
	LocalDate today = LocalDate.now();

	// method reference
	then(getOrDefault(localDateTime::toLocalDate, today)).isEqualTo(today);
}

Alt Image

호출 방식을 메서드 참조로 변경하면 기대 결과와는 다르게 NPE가 발생한다.

람다 방식과 어떤 차이에 의해 결과가 달라지는걸까?


메서드 참조와 람다의 런타임 평가

람다와 메서드 참조의 이러한 차이는 런타임 표현식(Expression) 평가 시점의 차이에 의해 발생한다.

JLS(Java Language Specification)을 보면 메서드 참조 관련하여 다음의 내용이 존재한다.

15.13.3. Run-Time Evaluation of Method References

Evaluation of a method reference expression is distinct from invocation of the method itself.

First, if the method reference expression begins with an ExpressionName or a Primary, this subexpression is evaluated.

If the subexpression evaluates to null, a NullPointerException is raised, and the method reference expression completes abruptly.

If the subexpression completes abruptly, the method reference expression completes abruptly for the same reason.


요약하면 메서드 참조에 대한 평가 수행 시 서브표현식(예제 코드의 localDateTime::toLocalDate 중 localDateTime에 해당 되는 부분)을 먼저 평가하고, null이면 NPE를 발생 시키고 종료한다는 것이다.

이제 동일 문서 내 람다에 대한 설명을 확인해보자.

15.27.4. Run-Time Evaluation of Lambda Expressions

At run time, evaluation of a lambda expression is similar to evaluation of a class instance creation expression, insofar as normal completion produces a reference to an object. Evaluation of a lambda expression is distinct from execution of the lambda body. ...


람다는 표현식의 평가 과정에서 함수형 인터페이스를 생성한다. 이미 생성된 인스턴가 있다면 해당 인스턴스를 사용하고, 없는 경우 새로 생성한다.
이 과정에서 body에 있는 localDateTime 이 null인지 아닌지는 평가되지 않는다.

결국 () -> localDateTime.toLocalDate()가 실제로 호출되는 시점에 인스턴스에 캡쳐되어 있는 변수들간 연산과 함께 평가가 수행되는 것이다.

그럼 아래 코드를 다시 보자.

public static <T> T getOrDefault(Supplier<T> supplier, T defaultValue) {
    try {
      // lazy execution
        return Optional.ofNullable(supplier.get()).orElse(() -> defaultValue);
    } catch (NullPointerException e) {
        return defaultValue;
    }
}

만약 람다를 사용하는 경우 supplier 함수가 실제 호출되는 시점에 연산이 수행되고, NPE를 catch하여 defaultValue로 지정한 값을 리턴한다.

이를 지연평가(Lazy Evaluation)라 한다.

아래 또 다른 코드를 보면서 한번 더 정리해보자.

String evaluation = "initial value";

@Test
void lazyEvaluationTest() {
	Supplier<String> methodReference = evaluation::toString;
	Supplier<String> lambda = () -> evaluation.toString();

	evaluation = "modified value";

	then(methodReference.get()).isEqualTo("initial value");
	then(lambda.get()).isEqualTo("modified value");
}

evaluation 변수에 initial value 를 할당하고, 메서드 참조 방식와 람다 각각을 Supplier 타입 변수에 저장했다.

테스트를 실행해보면 메서드 참조의 경우 최초 평가된 initial value를 저장하고 있고, 람다는 호출 시점의 값인 modified value 를 반환하는 것을 확인 할 수 있다.


References