공부한 내용을 기록하는 겸 공유하는 글입니다. 잘못된 부분이 있다면 편하게 말씀해주세요 :)
백엔드 디스코드에 공유되었던 질문을 통해 다음과 같은 코드를 보게 되었다.
Apple 타입의 인스턴스를 담은 List를 정렬하는 예제인데, Comparator
인터페이스의 comparing
을 통해 무게 순으로 정렬하는 코드이다.
comparing
은 내부적으로 function을 받으므로 람다식으로 Apple의 인스턴스 메소드인 getWeight라는 함수로 정렬을 하는 코드이다. 그런데 reversed
를 붙여서 내림차순으로 하려는 과정에서, 메서드 참조로 하면 잘 작동하는데 람다식 뒤에 reversed
를 붙이면 getWeight
라는 시그니처를 찾지 못하는 현상이 발생한다고 하였다.
그런데 또, 오름차순을 할 때는 comparing 내부의 람다식이 시그니처를 잘 찾고 있었다.
약간 헤매다 백엔드 멤버들이 답변해주는 걸 보고 타입 추론에 문제가 있다는 것을 느꼈다.
람다식 내부에 선언된 a의 타입을 Apple로 추론해야 되는데, reversed만 붙이면 Object 타입으로 추론을 하는 것이다.
이게 왜 그럴지에 대해서 개인적으로 궁금해져서 Comparator에 관해 조사도 해볼 겸 정리를 해보려고 한다.
기본적으로 자바 컴파일러는 제네릭 타입을 명시하지 않으면 Object 타입으로 간주한다고 한다.
IntelliJ의 변수 추출하기 기능(cmd+opt+v)을 통해 간단하게 어떤식으로 타입을 추출하는 지 알아보려고 한다.
아래와 같은 코드가 있다고 하자.
static <T> T pick(T a1, T a2) {
return a2;
}
public static void main(String[] args) {
pick("s", new ArrayList<String>());
pick("d", "s");
}
pick
의 정의에 따라 리턴 타입은 매개변수의 타입과 같은 제네릭으로 선언이 되어 있다.
그럼 pick
의 인자로 String, ArrayList를 넘길 때랑 String만 넘길때의 두 케이스를 만든 것이다.
각각 변수 추출을 해보면 어떻게 될까?
다음과 같이, 각각 Serializable, String으로 변환이 된다. 첫 줄이 Serializable 타입을 반환하는 이유는, String과 ArrayList가 모두 Serializable의 자손 클래스이기 때문이다.
이 논리대로면 Serializable이 아니라 모든 클래스의 공통 조상인 Object로 받아도 문제가 없다.
밑의 줄은 파라미터가 모두 String 이므로 String 타입을 반환하는 것으로 추론한다.
sort
의 spec은 다음과 같다.default void sort(Comparator<? super E> c)
여기서 E는 List에 담긴 객체의 타입이다. 즉, 객체 타입의 부모를 통한 비교 로직을 가진 Comparator를 가지고 정렬을 한다고 볼 수 있다.
comparing
의 spec은 다음과 같다. public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
Function<? super T, ? extends U> keyExtractor)
매우 복잡한 코드인데, 요점은 인자로는 Function을 받는다. (1 parameter -> 1 return 인 함수형 인터페이스). 이 메서드는 두 개의 제네릭 타입을 가지는데, T와 U이다.
그리고 U는 U extends Comparable<? super U>
라는 괴기한 선언이 되어있는데 이게 무슨 의미일지 생각해보자.
편의상 ? super U(U의 조상클래스)를 U로 생각해보면 U extends Comparable<U>
이런 식이다.
즉, U가 Comparable<U>
인터페이스를 구현해야 한다는 것이다.(제네릭에서는 인터페이스 구현도 extends로 적음에 유의하자)
이 함수형 인터페이스(Comparable)를 구현하게 되면 compareTo
라는 비교 로직을 가지고 있다는 뜻이다. 기본적으로 Integer, String등은 Comparable < Integer>, Comparable< String>을 구현하고 있기 때문에 이 제네릭 타입에 해당한다.
다시 comparing으로 돌아가서 리턴 타입을 보면 Compartor<T>이다. 즉, 비교하고자 하는 대상에 해당하는 Compartor를 리턴한다.
정리하면, comparing 내부에 람다식으로 어떤 객체를, 어떤 기준으로 비교할 건지를 적으면 ( 이 때, 기준에 해당하는 리턴 값의 타입은 compareTo를 구현한 클래스여야 한다.) 내부적으로 비교 로직이 완성된 Compartor가 반환되는 것이다. 이 비교 로직을 통해 List의 sort에서 정렬을 진행한다.
그렇다면, reversed()가 붙으면 왜 안될까? 사실 이건 아직 잘 모르겠다.
이미 람다식에서 a를 Apple로 추론했다면, Comparator는 Comparator가 되지 않을텐데..
그래서 스택오버플로우에 직접 질문도 처음 올려봤다.. 답변이 올지는 모르지만 개인적인 추측도 해봤지만 올바른 추측은 아닌 거 같다.
찾고 찾다가 결국 똑같은 질문을 한 글을 발견했다. 이 글에서도 정확한 이유는 모르지만, reversed()의 사용으로 인해 갑자기 정상적인 추론 로직이 깨진다. target type이 reversed()까지 전달되어야 하는데 그렇지 못하는 점이 타입 추론의 약점이라고 한다. 메소드 참조를 이용하는 등 타입 힌트를 제공하면 이 약점을 극복할 수 있지만, 타입을 생략하면 a를 Object로 본다는 것이다.
댓글에도, lambda를 포함한 제네릭 메서드가 리시버 포지션에서 호출되면 람다식 내의 변수의 타입을 추론할 수 없다고 한다.
Lambdas are divided into implicitly-typed (no manifest types for parameters) and explicitly-typed; method references are divided into exact (no overloads) and inexact. When a generic method call in a receiver position has lambda arguments, and the type parameters cannot be fully inferred from the other arguments, you need to provide either an explicit lambda, an exact method ref, a target type cast, or explicit type witnesses for the generic method call to provide the additional type information needed to proceed.
이를 해결한 코드는 여러 방법이 있다.
inventory.sort(Comparator.comparing((Apple a) -> a.getWeight()).reversed()); // 람다식 내부에 explicit type 선언
inventory.sort(Comparator.<Apple, Integer>comparing(a -> a.getWeight()).reversed()); // 제네릭 메소드인 comparing의 explicit type 선언
inventory.sort(Comparator.comparing(a -> ((Apple) a).getWeight()).reversed()); // Object a 를 Apple로 다운캐스팅
inventory.sort(Collections.reverseOrder(Comparator.comparing(a -> a.getWeight()))); // receiver인 reversed()를 사용하지 않는 방법.
자바 제네릭스(9) Java Generics: 타입추론(Type Inference)
Collection Framework2 - 정렬 메소드와 <T extends Compareble<? super T>>
Comparator.reversed() does not compile using lambda