220205 - 제네릭 타입 추론과 Comparator 관한 내용 정리

12630 단어 TILTIL

공부한 내용을 기록하는 겸 공유하는 글입니다. 잘못된 부분이 있다면 편하게 말씀해주세요 :)

발단

백엔드 디스코드에 공유되었던 질문을 통해 다음과 같은 코드를 보게 되었다.

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 타입을 반환하는 것으로 추론한다.

문제의 원인과 해결

코드 분석

  1. sort의 spec은 다음과 같다.
default void sort(Comparator<? super E> c)

여기서 E는 List에 담긴 객체의 타입이다. 즉, 객체 타입의 부모를 통한 비교 로직을 가진 Comparator를 가지고 정렬을 한다고 볼 수 있다.

  1. 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()를 사용하지 않는 방법.

Reference

자바 제네릭스(9) Java Generics: 타입추론(Type Inference)
Collection Framework2 - 정렬 메소드와 <T extends Compareble<? super T>>
Comparator.reversed() does not compile using lambda

좋은 웹페이지 즐겨찾기