본문 바로가기

Reading/Effective Java

[Effective-Java] Item 52. 다중정의는 신중히 사용하라

다중정의(오버로딩)와 메소드 재정의(오버라이딩) 결정 시점


다음 코드를 보자.

"집합","리스트","그  외"를 출력할 것 같지만 "그 외"를 세 번 출력한다.

 

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<String, String>().values()
        };
        for (Collection<?> c : collections)
            System.out.println(classify(c));
    }
}

 

컴파일타임에 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());
    }
}

 

다중정의와 달리 메소드 재정의는 메소드를 재정의한 다음 하위클래스의 인스턴스에서 그 메소드를 호출하면 재정의한 메소드가 실행되기 때문에 해당 객체의 런타임 타입이 어떤 메소드를 호출할지의 기준이된다.

따라서 예상한 대로 "포도주", "발포성 포도주", "샴페인"을 차례로 출력하고, 컴파일 타임에 모두 Wine인 것과 무관하게 항상 가장 하위에서 정의한 재정의 메소드가 실행되는 것이다.

 

 

 

즉, 다중정의(오버로딩)메소드 중 어느 메소드를 호출할지는 매개변수의 컴파일타임 타입에 의해 정적으로 정해지고 재정의(오버라이딩) 메소드는 런타임에 동적으로 선택된다.

 

다중정의의 예제 코드는 매개변수의 런타임 타입에 기초해 적절한 다중정의 메소드로 자동 분배하는 것이었으니, 코드를 개선한다면

삼항연산자와 instanceof 메소드를 통해서 런타임에도 알 수 있게 코드를 수정할 수 있다.

 

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

 

다중정의는 기대한대로 동작하지 않을 수 있어서, 헷갈릴 수 있는 코드는 작성하지 않는 게 좋다. 특히 공개 API라면 사용자가 매개변수를 넘기며 어떤 메소드가 호출될지 모른다면 프로그램이 오동작하기 쉽다. 그리고 런타임에 이상하게 동작하면 문제 진단 시간을 소요시킨다.

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

 

 

 

다중정의의 혼란을 피하는 방법


가능한 매개변수 수가 같은 다중정의는 만들지 말자. 다중정의 대신 메소드 이름을 다르게 지어주는 방법도 있다.

특히 가변 인수를 사용하는 메소드라면 다중정의하지 말아야한다[아이템53]. 매개변수 개수가 다르면 어떤 메소드가 호출될지 직관적일 것.

ObjectOutputStream 클래스가 다중정의 대신 메소드 이름을 다르게 지어준 클래스다.

 

ObjectOutputStream 클래스의 메소드 이름 예시

 

이 클래스의 write 메소드는 모든 기본 타입과 일부 참조 타입용 변형을 가지고 모든 메소드에 다른 이름을 지어주는 것을 선택했다.

그리고 ObjectInputStream 클래스도 똑같은 방식을 선택했다.

 

ObjectInputStream 클래스의 메소드 이름 예시

따라서 ObjectInputStream 클래스의 read 메소드의 이름과 짝을 맞추기 좋다.

 

생성자는 이름이 없어 다중정의를 피할 수 없지만 정적팩토리라는 대안이 있다.

생성자는 재정의할 수 없으니 다중정의와 혼용될 걱정도 없지만, 여러 생성자가 같은 수의 매개변수를 받아야 하는 경우도 있을 것이고, 그 내용을 이 다음 주제에서 확인하자.

 

매개변수 수가 같은 다중정의 메소드가 많더라도 처리가 명확히 구분된다면 헷갈리지 않는다.

즉, 매개변수 중 하나 이상이 근본적으로 다르면 헷갈릴 일이 없다. 근본적으로 다르다는 것은 두 타입이 null이 아닌 값을 서로 어느 쪽으로든 형변환할 수 없다는 것이다. 이 조건이 충족되면 어느 다중 정의 메소드를 호출할지가 매개변수들의 런타임 타입만으로 결정된다.

따라서 컴파일타임 타입에는 영향을 받지 않게 되고 혼란을 주는 원인이 사라진다. ArrayList 생성자를 예시로 보자.

 

ArrayList의 생성자

 

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

 

 

다중정의가 가져온 혼란


List의 remove 다중정의

자바 5에서 오토박싱이 도입되면서 문제가 생겼다. 다음 예시를 보자

 

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의 정수를 집합과 리스트에 추가하고 똑같이 remove 메소드를 호출하면 "[-3, -2, -1] [-2, 0, 2]가 출력된다.

값을 제거하길 기대했지만 list의 경우 인덱스 0,1,2가 삭제됐다. 리스트의 remove가 인덱스만 삭제하게 설계되었기 때문일까?

 

List의 remove 다중정의

 

List의 remove가 다중적의 되었기 때문이다. 이 문제는 list.remove의 인수를 Integer로 형변환하면 해결된다.

혹은 Integer.valueOf를 이용해 i를 Integer로 변환해도 된다. 어느 방식을 쓰든 형변환을 하면 정상적으로 [-3, -2, -1]을 출력한다.

 

제네릭이 도입되기 전인 자바 4까지 List에서 Object와 int가 근본적으로 다르니 문제가 없었지만 제네릭과 오토박싱이 등장하면서 두 메소드의 매개변수 타입이 더는 근본적으로 다르기 않게 됐고, 자바 언어에 제네릭과 오토박싱을 더한 결과 List 인터페이스는 취약해졌다. 

 

 

람다와 메소드 참조

 

// 1번. Thread의 생성자 호출
new Thread(System.out::println).start();
// 2번. ExecutorService의 submit 메소드 호출
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);

 

1번과 2번은 비슷하지만 2번만 컴파일 오류가 발생한다. 양쪽 모두 Runnable을 받는 메소드를 다중정의하고 있는데, 왜 2번만?

 

 

2번의 경우 Callable<T>를 받는 메소드도 있기 때문이다. 

Callable<T>는 인수 없이 특정 타입을 리턴하는 거고 Runnable은 인수 없이 리턴도 없는 것이다. [참고]

모든 println은 void를 반환하니 Callable을 반환할 리 없다고 생각했다. 그리고 인수가 들어갈 상황이 아닌데

컴파일타임에 println이 모호하고, 내가 사용하려는 println()과 println(boolean) 사이에서 모호하다고 나온다. 

 

하지만 다중정의 해소, 즉 다중정의 메소드를 찾는 알고리즘은 이렇게 동작하지 않는다.

만약 println이 다중정의 없이 단 하나만 존재했다면 이 submit 메소드 호출이 제대로 컴파일 됐을 것이다.

 

public class Ovld {
    static void hello(){ }
    static void hello(int a){ }
    public static void main(String[] args) {
        ExecutorService exec = Executors.newCachedThreadPool();
        exec.submit(Ovld::hello);
    }
}

 

당장 위 코드만해도 "Reference to 'hello' is ambiguous, both 'hello()' and 'hello(int)' match" 라는 컴파일 에러를 뱉는다.

이 때 다중 정의된 hello(int a)를 제거했을 때 정상적으로 컴파일이 되었다.

 

부정확한 메소드 참조(inexact method reference)나 암시적 타입 람다식(implicitly typed lambda expression)은 목표 타입이 선택되기 전에는 그 의미가 정해지지 않기 떄문에 적용성 테스트(applicability test)때 무시된다는 것이 원인이다. [JLS 15.12.2]

System.out::println은 부정확한 메소드 참조다.  [JLS 15.13.1]

암시적 타입 람다식은 (x, y) -> ... 같이 타입이 명시적이지 않은 것이고 명시적 타입은 (int x, int y) -> ... 이렇게 둘 수 있을 것이다.[참고]

 

견해:

출처: jls-15.13.1

 

15.13.1의 주 내용은 타입으로 결정될 수 있는 함수들을 모두 찾고 n개의 매개변수를 가지는 메소드들의 세트들을 찾아서 15.12.2.1로 이동한다. 그리고 적용성 테스트를 시작한다. 대개 여기서는 이 클래스가 해당 메소드를 호출할 수 있는지의 적격성을 확인하는 등의 작업을 하고, 여기서 다중정의된 메소드들은 존재의 여부만을 판단하는 것으로 보여, 이 1단계는 통과를 하고 최종적으로 15.13.1의 3단계인 15.12.2~15.12.2.5의 적용성 테스트로 이동한다. [ JLS 15.12.2.1 ]

 

출처: jls-15.12.2.2

적용 가능성이 있다고 판단되려면 다음 중 어떠한 것도 포함되지 않아야하는데 2,3 번째줄에 따라 암시적 타입 람다식과 부정확한 메소드 참조가 있다. 가장 하단의 설명을 보면 암시적으로 입력된 람다 식 또는 부정확한 메소드 참조는 충분히 모호하고, 오버로딩이 해결이 완료될 때까지 무시된다고 나온다. 즉 15.12.2~15.12.5를 거치지 않고 가능하지 않다고 무시되는 것이다.

 

 

15.13.1의 최종 단계에서 15.12.2~15.12.5를 2번 검색하는데, 매 검색마다 결과가 무시되니 컴파일 타임에 정의할 수 없게 되는 것이다.

 

자, 여기서 알 수 있는 점은 부정확한 메소드 참조는 목표 타입이 선택되기 전이라서 의미가 정해지지 않았다는 것만 알 수 있다.

그런데 ExecutorService의 submit 메소드는 Callable<? extends Object>와 Runnable을 파라미터로 받는다.

이 때, submit 메소드가 함수형 인터페이스를 매개변수로 받는 게 하나일 경우에도 컴파일 에러가 발생하지 않을 것이다.

 

"부정확한 메소드 참조는 의미를 모른다" + "매개변수에 들어갈 함수형 인터페이스의 종류가 2개가 있다" 

= 다중정의 메소드중에 어떤 것을 호출해야하는 지 알 수 없다는 것으로 이어진다.

 

Callable이 될 수 없다!

 

맞다. System.out::println은 Runnable이고 Callable일 수 없다. 하지만 그건 Callable로 결정이났을 때 알 수 있는거다.

Callable이 틀렸다는 것을 아는건 tempSubmit이 한 개만 정의되어 있어서 모호한 반환에 Callable을 씌울 수 없다는 것을 안거다.

 

결정을 못한 경우

 

지금은 다중정의 메소드 중에 어떤 메소드를 써야할 지 결정을 못했기 때문에 위와 같은 컴파일 오류가 발생하는 것이다.

그렇다면 다중정의라도, 함수형 인터페이스가 Runnable 하나만 있다면 컴파일 오류가 생기지 않는걸까??

 

함수형 인터페이스 매개변수가 runnable 하나인 경우

 

맞다. 모호하더라도 Runnable로 형변환 할 수 있다는 사실을 알고있고, 자신의 자리가 맞기 때문에 컴파일 오류가 발생하지 않는다!

 

문제의 원인을 이제 알게됐고, 핵심은 다중정의된 메소드(혹은 생성자)들이 함수형 인터페이스를 인수로 받을 때,

비록 서로 다른 함수형 인터페이스라도 인수 위치가 같으면 혼란이 생긴다는 것이다.

 

따라서 메소드를 다중정의할 때, 서로 다른 함수형 인터페이스라도 같은 위치의 인수를 받아서는 안 된다.

위에서 들었던 예시와 같이 서로 다른 함수형 인터페이스라도 서로 근본적으로 다르자 않다는 뜻이다.

컴파일할 때 명령줄 스위치로 "-Xlint:overloads"를 지정하면 이런 종류의 다중정의를 경고해줄 수 있다!

 

그러면 함수형 인터페이스가 다중정의된 메소드에 System.out.println 등의 모호한 메소드 참조를 넣으려면 어떻게 해야할까?

 

 

컴파일러가 인지할 수 있게 형변환만 해주면 끝!!!!!!!!

 

 

다중정의 자체의 난해

근본적으로 Object외 클래스 타입과 배열 타입은 다르고, Serializable과 Cloneable외 인터페이스 타입과 배열타입도 다르다. String과 Throwable처럼 상/하위 관계가 아닌 두 클래스는 관련없다고 하고, 관련 없는 두 클래스의 공통 인스턴스는 없어 근본적으로 다른 것이다.

이 외에도 어떤 방향으로도 형변환 할 수 없는 타입 쌍도 있다.( String.valueOf()나 Integer.parseInt(), 혹은 강제 형변환 조차도 안되는)

 

위 간단한 예보다 복잡해지면 어떤 다중정의 메소드가 선택될지 구분하기 어려워지고, 이 규칙은 자바 버전이 올라갈수록 더 복잡해진다.

이번 주제의 지침을 어기고 싶은 경우도 있을텐데 이미 만들어진 클래스가 끼어들면 특히 더 그렇다. 

 

예를들어 String은 자바 4부터 contentEquals(StringBuffer) 메소드를 가지고 있었는데 자바 5부터 String, StringBuffer, StringBuilder, CharBuffer 등의 비슷한 부류의 타입을 위한 공통 인터페이스로 CharSequence가 등장했고 자연스레 String에도 CharSequence를 받은 contentEquals가 다중정의되었다.

 

 

다행히 이 두 메소드는 같은 객체를 입력하면 완전히 같은 작업을 수행해준다. 이처럼 어떤 다중정의 메소드가 불리는지 몰라도 기능이 똑같으면 신경 쓸 게 없고, 이렇게 하는 가장 일반적인 방법은 더 특수한 다중정의 메소드에서 덜 특수한(더 일반적인) 다중정의 메소드로 일을 넘겨버리는(forward) 것이다.

 

자바 라이브러리에도 다중정의를 신중히 사용하지 못해 실패한 클래스도 있다. String의 valueOf를 보자

 

 

valueOf(Object)와 valueOf(char[])는 같은 객체를 건네도 전혀 다른 일을 수행한다. 이렇게 해야 할 이유도 없고, 혼란을 불러올 수 있는 잘못된 사례로 남게되었다. 다중정의된 valueOf는 이 외에도 더 많은 매개변수가 (int, long, boolean 등) 존재한다.

위에서 예시로 나타낸 ObjectOutputStream이 매개변수가 1개일 때 각각 다른 이름을 줬던 것과 상반된 결과다.

 

 

정리


  • 다중정의를 허용한다고 해서 다중정의를 꼭 활용할 필요는 없다. 
  • 일반적으로 매개변수 수가 같을 때는 다중 정의를 피하는게 좋다
  • 생성자라면 불가능할 수 있으니 헷갈릴만한 매개변수는 형변환해서 정확한 다중정의 메소드가 선택되게 해야하고 이것 또한 불가능하다면, 같은 객체를 입력받는 다중정의 메소드들이 모두 동일하게 동작하게 만들어야한다.
  • 무시한다면 사용자는 의도대로 동작하지 않는 이유를 모르고, 다중정의 메소드나 생성자를 효과적으로 사용하지 못할 것이다.

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.