독서찰기(讀書札記)/이펙티브 자바

[아이템 52] 다중정의는 신중히 사용하라

NoodleMan 2022. 3. 15. 00:20

[Why]

public class CollectionClassifier {
    public static String classify(Set<?> s) {
        return "집합";
    }
    
    public static String classify(List<?> lst) {
        return "리스트";
    }
    
    public static String classify(Collection<?> c) {
        return "그 외";
    }
    
    public static void main(String[] args) {
        Collections<?>[] collections = {
            new HashSet<String>(),
            new ArrayList<BigInteger>(),
            new HashMap<String, String>().values()
        };
        
        for (Collection<?> c : collections) {
            System.out.println(classify(c));
        }
    }
}
  • "집합", "리스트", "그 외"를 차례로 출력할 것 같지만, 실제로 수행해보면 "그 외"만 세 번 연달아 출력한다.
  • 다중정의(overloading, 오버로딩)된 세 classify 중 어느 메서드를 호출할지가 컴파일타임에 정해지기 때문이다.
    • 컴파일타임에는 for문 안의 c는 항상 Collection<?> 타입이다.
    • 따라서 컴파일타임의 매개변수 타입을 기준으로 항상 세 번째 메서드인 classify(Collection<?>)만 호출하는 것이다.
  • 이처럼 직관과 어긋나는 이유는 재정의한 메서드는 동적으로 선택되고, 다중정의한 메서드는 정적으로 선택되기 때문이다.
class Wine {
    String name() { return "포도주"; }
}

class SparklingWine extends Wine {
    @Override String name() { return "발포성 포도주"; }
}

class Champagne extends SparklingWine {
    @Override String name() { return "샴페인"; }
}

public class Overriding {
    public static void main(String[] args) {
        List<Wine> wineList = List.of(
            new Wine(), new SparklingWine(), new Champagne()
        );
        
        for (Wine wine : wineList) {
            System.out.println(wine.name());
        }
    }
}
  • 예상한 것처럼 이 프로그램은 "포도주", "발포성 포도주", "샴페인"을 차례로 출력한다.

 

[When]

자바 5의 오토박싱이 도입되면서 기본 타입과 참조 타입의 근본적인 차이가 좁혀져 혼란스러운 상황이 생길 수 있다.

public class SetList {
    public static void main(String[] args) {
        Set<Integer> set = new TreeSet<>();
        List<Integer> list = new ArrayList<>();
        
        for (int i=-3; i<3; i++) {
            set.add(i);
            list.add(i);
        }
        
        for (int i=0; i<3; i++) {
            set.remove(i);
            list.remove(i);
        }
        System.out.println(set + " " + list);
    }
}
  • "[-3, -2, -1] [-3, -2, -1]"을 출력하리라 예상할 것이다. 하지만 "[-3, -2, -1] [-2, 0, 2]"를 출력한다.
    • set.remove(i)의 시그니처는 remove(Object)다.
      • 다중정의된 다른 메서드가 없으니 기대한 대로 동작한다.
    • list.remove(i)는 다중정의된 remove(int index)를 선택한다.
      • 그런데 이 remove는 '지정한 위치'의 원소를 제거하는 기능을 수행한다.
  • List<E> 인터페이스가 remove(Object)와 remove(int)를 다중정의했기 때문에 발생한 문제다.
    • 제네릭이 도입되기 전인 자바 4까지의 List에서는 Object와 int가 근본적으로 달라서 문제가 없었다.
    • 그런데 제네릭과 오토박싱이 등장하면서 두 메서드의 매개변수 타입이 더는 근본적으로 다르지 않게 되었다.
  • 해결법
    • list.remove의 인수를 Integer로 형변환하여 올바른 다중정의 메서드를 선택하게 하면 해결된다.
    • Integer.valueOf를 이용해 i를 Integer로 변환한 후 list.remove에 전달해도 된다.
for (int i=0; i<3; i++) {
    set.remove(i);
    list.remove((Integer) i);  // 혹은 remove(Integer.valueOf(i))
}

 

자바 8에서 도입한 람다와 메서드 참조 역시 다중정의 시의 혼란을 키웠다.

// 1번. Thread의 생성자 호출
new Thread(System.out::println).start();

// 2번. ExecutorService의 submit 메서드 호출
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);
  • 1번과 2번이 모습은 비슷하지만, 2번만 컴파일 오류가 난다.
  • 넘겨진 인수는 모두 System.out::println으로 똑같고, 양쪽 모두 Runnable을 받는 형제 메서드를 다중정의하고 있다.
  • 원인은 바로 submit 다중정의 메서드 중에는 Callable<T>를 받는 메서드도 있다는 데 있다.
    • 모든 println이 void를 반환하니, 반환값이 있는 Callable과 헷갈릴 리는 없다고 생각할지 모르겠다.
    • 합리적인 추론이지만, 다중정의 해소(resolution)는 이렇게 동작하지 않는다.
    • System.out::println은 부정확한 메서드 참조(inexact method reference)다.
    • 또한 암시적 타입 람다식(implicitly typed lambda expression)이나 부정확한 메서드 참조 같은 인수 표현식은 목표 타입이 선택되기 전에는 그 의미가 정해지지 않기 때문에 적용성 테스트(applicability test) 때 무시된다.
    • 이것이 문제의 원인이다. (컴파일러 제작자를 위한 설명이니 이해되지 않더라도 넘어가자)
  • 핵심은 다중정의된 메서드(혹은 생성자)들이 함수형 인터페이스를 인수로 받을 때, 비록 서로 다른 함수형 인터페이스라도 인수 위치가 같으면 혼란이 생긴다는 것이다.
    • 따라서 메서드를 다중정의할 때, 서로 다른 함수형 인터페이스라도 같은 위치의 인수로 받아서는 안 된다.
    • 컴파일할 때 명령줄 스위치로 -Xlint:overloads를 지정하면 이런 종류의 다중정의를 경고해줄 것이다.

 

[How]

모든 classify 메서드를 하나로 합친 후 instanceof로 명시적으로 검사하면 말끔히 해결된다.

public static String classify(Collection<?> c) {
    return c instanceof Set ? "집합" :
           c instanceof List ? "리스트" : "그 외";
}

 

다중정의가 혼동을 일으키는 상황을 피해야 한다.

  • 안전하고 보수적으로 가려면 매개변수 수가 같은 다중정의는 만들지 말자.
  • 가변인수(varargs)를 사용하는 메서드라면 다중정의를 아예 하지 말아야 한다. (아이템53)
  • 다중정의 대신 메서드 이름을 다르게 지어주면 된다.

생성자는 이름을 다르게 지을 수 없으니 두 번째 생성자부터는 무조건 다중정의가 된다.

  • 정적 팩터리를 쓰면 된다. (아이템1)
  • 생성자는 재정의할 수 없으니 다중정의와 재정의가 혼용될 여지는 없다.

그래도 여러 생성자가 같은 수의 매개변수를 받아야 하는 경우를 완전히 피할 수 없을 테니, 안전 대책을 알아보자.

  • 어느 것이 주어진 매개변수 집합을 처리할지가 명확히 구분된다면 헷갈릴 일은 없다.
  • 즉, 매개변수 중 하나 이상이 "근본적으로 다르다(radically different)"면 헷갈릴 일이 없다.
  • 근본적으로 다르다는 건 두 타입의(null이 아닌) 값을 서로 어느 쪽으로든 형변환할 수 없다는 뜻이다.
    • Object 외의 클래스 타입과 배열 타입은 근본적으로 다르다.
    • Serializable과 Cloneable 외의 인터페이스 타입과 배열 타입도 근본적으로 다르다.
    • 관련없는 클래스들끼리도 근본적으로 다르다.
    • 한편, String과 Throwable처럼 상위/하위 관계가 아닌 두 클래스는 '관련 없다(unrelated)'고 한다.
  • 이 조건을 충족하면 어느 다중정의 메서드를 호출할지가 매개변수들의 런타임 타입만으로 결정된다.