[Effective Java]다중정의는 신중히 사용하라

메소드, 네 번째 아이템

Posted by SungBeom on April 29, 2020 · 8 mins read

메소드에서의 다중정의

다음은 컬렉션을 집합, 리스트, 그 외로 구분하고자 만든 프로그램이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 컬렉션 분류기 - 오류! 이 프로그램은 무엇을 출력할까?
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) {
        Collection<?>[] collections = {
            new HashSet<String>(),
            new ArrayList<BigInteger>(),
            new HashMap<StringString>().values()
    };
 
    for (Collection<?> c : collections)
        System.out.println(classify(c));
    }
}
cs

"집합", "리스트", "그 외"를 차례로 출력할 것 같지만, 실제로 수행해보면 "그 외"만 세 번 연달아 출력한다. 다중정의(overloading, 오버로딩)된 세 classify 중 어느 메소드를 호출할지가 컴파일타임에 정해지기 때문이다. 컴파일타임에는 for 문 안 c는 항상 Collection<?> 타입이라(런타임에는 타입이 매번 달라진다) 매개변수 타입을 기준으로 항상 세 번째 메소드인 classify(Collection<?>)만 호출하는 것이다.

이처럼 직관과 어긋나는 이유는 재정의한 메소드는 동적으로 선택되고, 다중정의한 메소드는 정적으로 선택되기 때문이다. 메소드를 재정의했다면 해당 객체의 런타임 타입이 어떤 메소드를 호출할지의 기준이 된다. 컴파일타임에 그 인스턴스의 타입이 무엇이었냐는 상관없이 '하위 클래스의 인스턴스'에서 재정의한 메소드를 호출하면 해당 메소드가 실행된다. 다음 코드는 이러한 상황을 구체적으로 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 재정의된 메소드 호출 매커니즘 - 이 프로그램은 무엇을 출력할까?
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 satic void main(String[] args) {
        List<Wine> wineList = List.of(
            new Wine(), new SparklingWine(), new Chanpagne());
 
        for (Wine wine : wineList)
            System.out.println(wine.name());
    }
}
cs

Wine 클래스에 정의된 name 메소드는 하위 클래스인 SparklingWine과 Champagne에서 재정의되기 때문에, 이 프로그램은 "포도주", "발포성 포도주", "샴페인"을 차례로 출력한다. 컴파일타임 타입이 모두 Wine인 것에 무관하게 항상 '가장 하위에서 정의한' 재정의 메소드가 실행되는 것이다.

매개변수의 런타임 타입에 기초해 적절한 다중정의 메소드로 자동 분배하기 위해서는 CollectionClassifier의 모든 classify 메소드를 하나로 합친 후 instanceof로 명시적으로 검사하면 말끔히 해결된다.

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

프로그래머에게는 재정의가 정상적인 동작 방식이고, 다중정의가 예외적인 동작으로 보일 것이다. 즉, 재정의한 메소드는 프로그래머가 기대한 대로 동작하지만, CollectionClassifier 예에서처럼 다중정의한 메소드는 이러한 기대를 가볍게 무시한다. 헷갈릴 수 있는 코드는 작성하지 않는 게 좋으며, 특히나 공개 API라면 더욱 신경 써야 한다. 그러니 다중정의가 혼동을 일으키는 상황을 피해야 한다.

안전하고 보수적으로 가려면 매개변수 수가 같은 다중정의는 만들지 말자. 가변인수(varargs)를 사용하는 메소드라면 다중정의를 아예 하지 말아야 한다. 이 규칙만 잘 따르면 어떤 다중정의 메소드가 호출될지 헷갈릴 일은 없을 것이다. 다중정의하는 대신 메소드 이름을 다르게 지어주는 길도 항상 열려 있으니 말이다.

생성자에서의 다중정의

한편, 생성자는 이름을 다르게 지을 수 없으니 두 번째 생성자부터는 무조건 다중정의가 된다. 하지만 정적 팩토리라는 대안을 활용할 수 있는 경우가 많다. 또한 생성자는 재정의할 수 없으니 다중정의와 재정의가 혼용될 걱정은 넣어둬도 된다. 그래도 여러 생성자가 같은 수의 매개변수를 받아야 하는 경우를 완전히 피해갈 수는 없을 테니, 그럴 때를 대비해 안전 대책을 배워두면 도움이 될 것이다.

매개변수 수가 같은 다중정의 메소드가 많더라도, 그중 어느 것이 주어진 매개변수 집합을 처리할지가 명확히 구분된다면 헷갈릴 일은 없을 것이다. 즉, 매개변수 중 하나 이상이 "근본적으로 다르다(radically different, 두 타입의 null이 아닌 값을 서로 어느 쪽으로든 형변환할 수 없다)"면 헷갈릴 일이 없다. 이 조건만 충족하면 어느 다중정의 메소드를 호출할지가 컴파일타임 타입에는 영향을 받지 않고, 매개변수들의 런타임 타입만으로 결정된다.

다중정의의 문제점

자바 4까지는 모든 기본 타입이 모든 참조 타입과 근본적으로 달랐지만, 자바 5에서 오토박싱이 도입되면서 문제가 생겼다. 예컨대 list.remove 메소드는 다중정의된 remove(int index)를 선택하게 될 수 있다. 이 문제는 list.remove의 인수를 Integer로 형변환하여 올바른 다중정의 메소드를 선택하게 하면 해결된다. 제네릭과 오토박싱을 더한 결과 List 인터페이스가 취약해졌기 때문에, 다중정의 시 주의를 기울여야 한다.

여기서 끝이 아닌 자바 8에서 도입한 람다와 메소드 참조 역시 다중정의 시의 혼란을 키웠다. 예를 들어 Runnable과 Callable<T>를 받는 다중정의된 메소드인 submit에 System.out::println을 인수로 넘기면 컴파일 오류가 난다. 참조된 메소드(println)와 호출한 메소드(submit) 양쪽 다 다중정의되어, 다중정의 해소(resolution, 적절한 다중정의 메소드를 찾는 알고리즘)이 우리의 기대처럼 동작하지 않는 상황이다.

기술적으로 말하면 System.out::println은 부정확한 메소드 참조(inexact method reference)다. 또한 암시적 람다식(implicitly typed lambda expression)이나 부정확한 메소드 참조 같은 인수 표현식은 목표 타입이 선택되기 전에는 그 의미가 정해지지 않기 때문에 적용성 테스트(applicability test) 때 무시된다는 것이 문제의 원인이다. 핵심은 다중정의된 메소드(혹은 생성자)들이 함수형 인터페이스를 인수로 받을 때, 비록 서로 다른 함수형 인터페이스라도 인수 위치가 같으면 혼란이 생긴다는 것이다. 따라서 메소드를 다중정의할 때, 서로 다른 함수형 인터페이스라도 같은 위치의 인수로 받아서는 안 된다.


핵심 정리

프로그래밍 언어가 다중정의를 허용한다고 해서 다중정의를 꼭 활용하란 뜻은 아니다. 일반적으로 매개변수 수가 같을 때는 다중정의를 피하는 게 좋다. 상황에 따라 특히 생성자라면 이 조언을 따르기가 불가능할 수 있다. 그럴 때는 헷갈릴 만한 매개변수는 형변환하여 정확한 다중정의 메소드가 선택되도록 해야 한다. 이것이 불가능하면, 예컨대 기존 클래스를 수정해 새로운 인터페이스를 구현해야 할 때는 같은 객체를 입력받는 다중정의 메소드들이 모두 동일하게 동작하도록 만들어야 한다. 그렇지 못하면 프로그래머들은 다중정의된 메소드나 생성자를 효과적으로 사용하지 못할 것이고, 의도대로 동작하지 않는 이유를 이해하지도 못할 것이다.