Posts [이펙티브자바] 아이템52-다중정의는 신중히 사용하라
Post
Cancel

[이펙티브자바] 아이템52-다중정의는 신중히 사용하라

다중정의된 메서드는 컴파일 타임에 실행이 결정된다.

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

스크린샷 2022-11-03 오전 1 24 11

“집합”, “리스트”, “그 외”를 차례로 출력할 것 같지만, 실제로 수행해보면 “그 외”만 세 번 연달아 출력한다. 이유가 뭘까? 다중정의(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 "샴페인"; }
}

// 재정의된 메서드 호출 메커니즘 (313쪽, 코드 52-2의 일부)
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());
    }
}

예상대로 이 프로그램은 “포도주”, “발포성 포도주”, “삼폐인”을 차례로 출력한다.

for 문에서의 컴파일타임 타입이 모두 Wine인 것에 무관하게 항상 ‘가장 하위에서 정의한’ 재정의 메서드가 실행되는 것이다.

한편, 다중정의된 메서드 사이에서는 객체의 런타임 타입은 전혀 중요치 않다. 선택은 컴파일타임에, 오직 매개변수의 컴파일타임 타입에 의해 이뤄진다.

코드52-1 ColelctionClassifier 예제 코드의 원래 의도는 매개변수의 런타임 타입에 기초해 적절한 다중정의 메서드로 자동 분배하는 것이었다.

만약 이를 개선하고자 한다면 아래와 같이 모든 classify 메서드를 하나로 합친 후 instanceof 로 명시적으로 검사하면 말끔히 해결된다.

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

헤깔릴 수 있는 다중정의 코드는 다중정의하지 말자.

특히나 공개 API라면 더욱 신경 써야 한다. API사용자가 매개변수를 넘기면서 어떤 다중정의 메서드가 호출될지를 모른다면 프로그램이 오동작하기 쉽다. 런타임에 이상하게 행동할 것이며 API 사용자들은 문제를 진단하느라 긴 시간을 허비할 것이다. 그러니 다중정의가 혼동을 일으키는 상황을 피해야 한다.

정확히 어떻게 사용했을 때 다중정의가 혼란을 주느냐에 대해선 논란의 여지가 있다.

안전하고 보수적으로 가려면 매개변수 수가 같은 다중정의는 만들지 말자.

가변 인수(varargs)를 사용하는 메서드라면 다중정의를 아예하지 말아야한다. 이 규칙만 잘 따르면 어떤 다중정의 메서드가 호출될지 헷갈릴 일은 없을 것이다. 다중정의하는 대신 메서드 이름을 다르게 지어주는길도 항상 열려 있으니 특별히 따르기 어려운 규칙은 아니다.

ObjectOutputStream과 ObjectInputStream

ObjectOutputStream 클래스의 write메서드는 모든 기본 타입과 일부 참조 타입용 변형을 가지고 있다. 그런데 다중정의가 아닌, 모든 메서드에 다른 이름을 지어주는 길을 택했다. writeBoolean(boolean), writeInt(int), writeLong(long) 같은 식이다. 이 방식이 다중정의보다 나은 또 다른 점은 read메서드의 이름과 짝을 맞추기 좋다는 것이다. 실제로 ObjectInputStream 클래스의 read메서드는 readBoolean(), readInt(), readLong() 같은식으로 짝을 맞추어 이루어져있다.

생성자와 다중정의

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

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

예컨대, ArrayList에선 int를 받는 생성자와 Collection을 받는 생성자가 있는데, 어떤 상황에서든 두 생성자 중 어느 것이 호출될지 헷갈릴 일은 없을 것이다.

자바5에서 도입된 오토박싱과 다중정의를 주의하자.

자바4까진 모든 기본타입이 모든 참조 타입과 근본적으로 달랐지만, 자바 5에서 오토박싱이 도입되면서 평화롭던 시대가 막을 내렸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 이 프로그램은 무엇을 출력할까? (315-316쪽)
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);
        }
        Set<String > a=  new HashSet<>();

        System.out.println(set + " " + list);
    }
}

위 프로그램은 -3부터 2까지의 정수를 Set과 List에 각각 추가 후, 양쪽에 똑같이 remove 메서드를 세 번 호출한다. 그러면 이 프로그램은 음이 아닌 값, 즉 0, 1, 2를 제거 후 "[-3, -2, -1], [-3, -2, -1]"을 출력하리라 예상할 것이다.

하지만 실제로는 집합에서 음이 아닌 값을 제거하고, 리스트에선 홀수를 제거 후 "[-3, -2, -1], [-2, 0, 2]"를 출력한다. 이 문제를 자세히 들여다보자.

set.remove(i)의 시그니처는 remove(Object)이다. 다중정의된 다른 메서드가 없으니 기대한 대로 동작하여 집합에서 0 이상의 수를 제거한다.

한편, list.remove(i)는 다중정의된 remove(int index)를 선택한다. 그런데 이 remove는 ‘지정한 위치’의 원소를 제거하는 기능을 수행한다. 그래서 위와 같은 결과가 발생한 것이다.

이 문제는 list.remove의 인수를 Integer로 형변환하여 올바른 다중정의 메서드를 선택하게 하면 해결된다. 혹은 Integer.valueOf를 이용해 i를 Integer로 변환후 remove 메서드에 전달해도 된다.

1
2
3
4
for (int i = 0; i< 3; i++) {
    set.remove(i);
    list.remove((Integer) i); // 혹은 remove(Integer.valueOf(i))
}

이 예가 혼란스러웠던 이유는 List<E> 인터페이스가 remove(Object)와 remove(int)를 다중정의했기 때문이다. 제네릭이 도입되기 전인 자바4까지의 List에선 Object와 int가 근본적으로 달라서 문제가 없었다. 그런데 제네릭과 오토박싱이 등장하면서 두 메서드의 매개변수 타입이 더는 근본적으로 다르지 않게 되었다.

정리하자면, 자바 언어에 제네릭과 오토박싱을 더한 결과 List 인터페이스가 취약해졌다. 다행히 같은 피해를 입은 API는 거의 없지만, 다중정의시 주의를 기울여야 할 근거로는 충분하다.

자바8에서 도입된 람다&메서드 참조와 다중정의

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

1
2
3
4
5
6
// 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과 헷갈릴리는 없다고 생각할 지도 모른다.

만약 println이 다중정의 없이 단 하나만 존재했다면 이 submit 메서드 호출이 제대로 컴파일 됐을 거라는 사실이다. 지금은 참조된 메서드(println)와 호출한 메서드(submit) 양쪽 다 다중정의되어, 다중정의 해소 알고리즘이 우리의 기대처럼 동작하지 않는 상황이다.

기술적으로 말하면 System.out::println은 부정확한 메서드 참조다. 또한 “암시적 타입 람다식”이나 부정확한 메서드 참조 같은 인수 표현식은 목표타입이 선택되기 정네는 그 의미가 정해지지 않기 때문에 적용성 테스트때 무시된다. 이것이 문제의 원인이다. 컴파일러 제작자를 위한 설명이니 무슨 말인지 이해되지 않더라도 그냥 넘어가자. 핵심은 다중정의된 메서드(혹은 생성자)들이 함수형 인터페이스를 인수로 받을 때, 비록 서로 다른 함수형 인터페이스라도 인수 위치가 같으면 혼란이 생긴다는 것이다. 따라서 메서드를 다중정의할 때, 서로 다른 함수형 인터페이스라도 같은 위치의 인수로 받아서는 안된다. 이 말은 서로 다른 함수형 인터페이스라도 서로 근본적으로 다르지 않다는 뜻이다.

Note: 컴파일할 때 명렬줄 스위치로 -Xlint:overloads 를 지정하면 이런 종류의 다중정의를 경고해줄 것이다.

어떤 다중정의 메서드가 불리는지 몰라도 기능이 똑같다면 신경 쓸게 없다.

이번 아이템에서 설명한 지침들을 어기고 싶을 때도 있을 것이다. 이미 만들어진 클래스가 끼어들면 특히 더 그렇다. 예를 들어 String은 자바4 시절부터 contentEquals(StringBuffer) 메서드를 가지고 있었다.

그런데 자바5에서 StringBuffer, StringBuilder, String, CharBuffer 등의 비슷한 부류의 타입을 위한 공통 인터페이스로 CharSequence가 등장하였고, 자연스럽게도 String에도 CharSequence를 받은 contentEquals가 다중정의되어 있다.

그 결과 이번 아이템의 지침을 대놓고 어기는 모습이 되었다. 다행히 이 두 메서드는 같은 객체를 입력하면 완전히 같은 작업을 수행해주니 해로울 건 전혀 없다. 이처럼 어떤 다중정의 메서드가 불리는지 몰라도 기능이 똑같다면 신경 쓸게 없다. 이렇게 하는 가장 일반적인 방법은 상대적으로 더 특수한 다중정의 메서드에서 덜 특수한(덜 일반적인) 다중정의 메서드로 일을 넘겨버리는(forward) 것이다.

1
2
3
4
// 코드52-3 인수를 포워드하여 두 메서드가 동일한 일을 하도록 보장한다.
public boolean contentEquals(StringBuffer sb) {
    return contentEquals((CharSequence) sb);
}

자바 라이브러리는 이번 아이템의 정신을 지켜내려 애쓰고 있지만, 실패한 클래스도 몇 개 있다. 예컨대 String 클래스의 valueOf(char[])valueOf(Object)는 같은 객체를 건네더라도 전혀 다른 일을 수행한다. 이렇게 해야 할 이유가 없었음에도, 혼란을 불러올 수 있는 잘못된 사례로 남게 되었다.

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

This post is licensed under CC BY 4.0 by the author.

[이펙티브자바] 아이템53-가변인수는 신중히 사용하라

[이펙티브자바] 아이템58-전통적인 for문보다는 foreach문을 사용하라