독서찰기(讀書札記)/이펙티브 자바
[아이템 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는 '지정한 위치'의 원소를 제거하는 기능을 수행한다.
- set.remove(i)의 시그니처는 remove(Object)다.
- 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)'고 한다.
- 이 조건을 충족하면 어느 다중정의 메서드를 호출할지가 매개변수들의 런타임 타입만으로 결정된다.