3 분 소요

자바 8부터는 함수형 프로그래밍 방식을 지원하며 람다 식과 메서드 참조를 사용할 수 있습니다.
Comparator 인터페이스를 람다 식으로 구현하며 발생했던 문제에 대해 정리해봤습니다.

예제

class Member {
    private String name;
    private int age;

    public Member(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

public class MemberSortingTest {

    List<Member> members = new ArrayList<>();

    @Before
    public void setUp() {
        members.add(new Member("A", 20));
        members.add(new Member("B", 10));
        members.add(new Member("C", 30));
    }
}

예제는 간단하게 이름과 나이가 있는 Member 클래스를 정의하고, 이를 List에 담아서 정렬하는 테스트입니다.

Comparator를 람다 식으로 사용했을 때

@Test
public void comparingWithLambda() {
    List<Member> sortedList = members.stream()
            .sorted(Comparator.comparing(member -> member.getAge()))
            .collect(Collectors.toList());

    assertThat(sortedList.get(0).getName()).isEqualTo("B"); // true
    assertThat(sortedList.get(2).getName()).isEqualTo("C"); // true
}

Member는 Comparable 인터페이스를 구현하지 않았기 때문에, List의 sort 메서드를 사용하면 컴파일 에러가 발생합니다.
하지만 자바 8부터 지원되는 default 메서드와 함수형 인터페이스의 힘으로 Comparator를 이용하여 쉽게 정렬이 가능합니다.
위의 테스트는 나이를 기준으로 오름차순 정렬이 되며 테스트가 통과된 것을 확인할 수 있습니다.

Comparator를 메서드 참조로 사용했을 때

@Test
public void comparingWithMethodReference() {
    List<Member> sortedList = members.stream()
            .sorted(Comparator.comparing(Member::getAge))
            .collect(Collectors.toList());

    assertThat(sortedList.get(0).getName()).isEqualTo("B");
    assertThat(sortedList.get(2).getName()).isEqualTo("C");
}

자바 8에서는 메서드 참조를 제공하는데 람다 식에서 단 하나의 메서드를 호출하는 경우 불필요한 매개변수를 제거하고 간결하게 표현할 수 있도록 해줍니다.
결국 람다 식과 같은 기능을 하기 때문에 위의 테스트도 통과합니다.

Comparator의 comparing()과 reverse()를 호출했을 때

@Test
public void comparingAndReverse() {
    List<Member> lambda = members.stream()
            .sorted(Comparator.comparing(member -> member.getAge()).reversed()) // 컴파일 에러
            .collect(Collectors.toList());

    List<Member> methodReference = members.stream()
            .sorted(Comparator.comparing(Member::getAge).reversed())
            .collect(Collectors.toList());
}

위와 같은 코드를 작성했을 때 메서드 참조를 이용한 정렬 후 역정렬은 아무런 이상이 없지만 람다 식을 이용한 경우에는 컴파일 에러가 발생합니다.
Object 클래스의 getAge 메서드를 호출할 수 없다는 Cannot resolve method 'getAge' in 'Object' 메시지를 보여줍니다.

reversed()의 반환 타입을 확인해 보면

Object Member

위처럼 제네릭 타입이 Member가 아닌 Object로 추론되어 있는데 자바 컴파일러가 타입 추론을 하는 부분에서 문제가 발생한 것을 알 수 있습니다.

자바가 타입 추론을 하는 방법

Type inference is a Java compiler’s ability to look at each method invocation and corresponding declaration to determine the type argument (or arguments) that make the invocation applicable. The inference algorithm determines the types of the arguments and, if available, the type that the result is being assigned, or returned. Finally, the inference algorithm tries to find the most specific type that works with all of the arguments. [The Java™ Tutorials]

자바 튜토리얼에서 타입 추론에 대한 설명을 살펴보면, 컴파일러가 메서드 호출과 선언을 보고 타입 인자를 추론합니다.
추론 알고리즘은 인자의 타입과 반환되는 타입을 결정할 때 가능한 구체적인 타입을 찾으려고 합니다.
쉽게 말해서 메서드가 호출되는 컨텍스트를 기반으로 타입 추론을 시도하는데요. 다시 예제를 살펴보겠습니다.

List<Member> sortedList = members.stream()
        .filter(member -> member.getAge() > 20)
        .sorted(
                Comparator.comparing((member) -> member.getAge()) // 1
                        .reversed())                              // 2
        .collect(Collectors.toList());

우선 1번의 Comparator.comparing을 수행할 때 member 타입을 정해주지 않았습니다.
Stream이 Member 타입의 요소를 가지고 있기 때문에 컴파일러는 이를 기반으로 타입을 구체적으로 추론할 수 있고 컴파일 에러도 발생하지 않습니다.
이어서 2번처럼 reversed를 호출하면서 문제가 발생하는데요.

차근차근 살펴보면

  1. comparing() 호출 시 인자로 사용된 member의 타입이 명시되지 않아 Comparator<Object>를 반환
  2. 1번 수행 후 reversed를 호출하지 않았다면 sorted() 메서드의 인자로 Comparator<Object>가 전달되고 이는 Stream<Member> 타입의 스트림에서 중간 연산을 수행하기 때문에 Member 타입으로 추론 가능
  3. comparing 메서드 체이닝으로 reversed() 호출 시 comparing 메서드의 반환 타입이 Comparator<Object>이기 때문에 Comparator<Object>로 추론
  4. Object에는 getAge 메서드가 없기 때문에 컴파일 에러가 발생

여기서 알 수 있는 것은 자바 컴파일러가 타입 추론을 할 때 메서드 호출 시 인자로 사용된 타입을 기반으로 타입을 추론한다는 것입니다.
자바 튜토리얼의 설명처럼 호출되는 컨텍스트 기반으로 타입을 추론하며 컴파일러는 참조 타입의 최상위 타입인 Object로 추론을 했기 때문에 getAge 메서드를 찾을 수 없었던 것입니다.

메서드 참조를 사용했을 때는 왜 컴파일 에러가 발생하지 않았는지도 쉽게 연상이 되는데요.
메서드 참조는 클래스::메서드 형태로 사용되며 이미 명시적으로 타입을 지정해주었기 때문입니다.

해결 방법

@Test
public void comparingAndReverse() {
    List<Member> sortedList = members.stream()
            .sorted(Comparator.comparing((Member member) -> member.getAge()).reversed())
            .collect(Collectors.toList());

    assertThat(sortedList.get(2).getName()).isEqualTo("B"); // true
    assertThat(sortedList.get(0).getName()).isEqualTo("C"); // true
}

해결은 아주 쉬운데요, 컴파일러가 타입을 쉽게 파악할 수 있도록 람다 식의 매개변수에 타입을 지정해 주면 됩니다.
람다 식에서 타입을 명시적으로 지정해주면 컴파일러는 해당 타입을 기반으로 타입 추론을 하기 때문에 문제가 발생하지 않습니다.

추가

스택오버플로에 저와 같은 문제를 질문한 스레드가 있었는데 스레드의 댓글에는 이를 위한 추가 설명이 있었습니다.

answer

댓글을 번역해보자면 람다 식은 매개변수 타입이 지정된 명시적인 경우와 지정되지 않은 암시적인 경우로 나뉘고, 메서드 참조는 오버로딩이 없는 정확한 경우와 오버로딩이 있는 부정확한 경우로 나뉩니다.
제네릭 메서드를 호출할 때 람다 인수가 있는 경우에 타입 매개변수가 다른 인수에서 추론되지 않는 경우가 발생할 수 있습니다.
이런 경우에 명시적으로 타입을 제공해 주면 컴파일러가 타입을 추론할 수 있습니다.

참고
https://stackoverflow.com/questions/25172595/comparator-reversed-does-not-compile-using-lambda
https://docs.oracle.com/javase/tutorial/java/generics/genTypeInference.html

댓글남기기