ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [이펙티브 자바] item 52 - 다중정의는 신중히 사용하라
    개발서적읽기/Effective Java - temp 2020. 8. 28. 21:54



    ■컴파일타임에 결정되는 다중정의 메서드


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


    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));
    }
    }

    "집합", "리스트", "그 외"를 차례로 출력할 것 같지만, 실제로 수행해보면 "그 외"만 세번 


    연달아 출력한다. 왜냐하면 다중정의된 세 classify 중 어느 메서드를 호출할지가 


    컴파일타임에 정해지기 때문이다. 컴파일타임에는 for 문 안의 c는 항상 Collection<?> 


    타입이다. 런타임에는 타입이 매번 달라지지만, 호출할 메서드를 선택하는 데는 영향을 


    주지 못한다. 따라서 컴파일타임의 매개변수 타입을 기준으로 항상 세 번째 메서드인


    classify(Collection<?>)만 호출하는 것이다.


    이처럼 직관과 어긋나는 이유는 재정의한 메서드는 동적으로 선택되고, 다중정의한 


    메서드는 정적으로 선택되기 때문이다. 메서드를 재정의했다면 해당 객체의 런타임 타입이


    어떤 메서드를 호출할지의 기준이 된다. 컴파일타임에 그 인스턴스의 타입이 무엇이었냐는


    상관없다. 다음 코드는 이러한 상황을 구체적으로 보여준다.


    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 클래스에 정의된 name 메서드는 하위 클래스인 SparklingWine과 Champagne에서


    재정의된다. 예상한 것처럼 이 프로그램은 "포도주", "발포성 포도주", "샴페인" 을


    차례로 출력한다. 




    ■다중정의 문제를 회피하는 방법 1 :: instanceof


    CollectionClassifier에서의 컴파일타임 다중정의 메서드 결정 문제는 모든 classify


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

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

    헷갈릴 여지가 있는 다중정의는 최대한 지양하자


    개발자에게는 재정의가 정상적으로 보이고, 다중정의가 예외적으로 보일 수 있다.


    즉, 재정의한 메서드는 개발자가 기대한 대로 동작하지만, CollectionClassifier 예에서 처럼


    다중정의한 메서드는 이러한 기대를 가볍게 무시한다. 헷갈릴 수 있는 코드는 작성하지


    않는 게 좋다. 특히나 공개 API라면 더욱 신경 써야 한다. 다중정의가 혼동을 일으키는


    상황을 최대한 피해야 한다. 정확히 어떻게 사용했을 때 다중정의가 혼란을 주느냐에 


    대해서는 논란의 여지가 있다. 안전하고 보수적으로 가려면 매개변수 수가 같은 다중정의는


    만들지 말자. 가변인수를 사용하는 메서드라면 다중정의를 아예 하지 말아야 한다. (item 53)


    다중정의하는 대신 메서드 이름을 다르게 지어주는 길은 항상 열려있다.




    ■다중정의 문제를 회피하는 방법 2 :: 메서드를 분리하여 정의하기


    ObjectOutPutStream 클래스의 경우를 살펴보자. 이 클래스의 write 메서드는 모든 기본 


    타입과 일부 참조 타입용 변형을 가지고 있다. 그런데 다중정의가 아닌, 모든 메서드에 다른


    이름을 지어주는 길을 택했다. writeBoolean(boolea), writeInt(int), writeLong(long) 처럼.


    이 방식의 장점은 read 메서드의 이름과 짝을 맞추기 좋다는 점이다. readBoolean(), 


    readInt(), readLong() 처럼. 실제로도 이렇게 되어 있다.




    ■다중정의를 회피하는 방법 3 :: 정적 팩터리 메서드 사용


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


    된다. 하지만 정적 팩터리라는 대안을 활용할 수 있는 경우가 많다. (item 1) 또한 생성자는


    재정의할 수 없으니 다중정의와 재정의가 혼용될 걱정은 하지 않아도 된다. 그래도 여러


    생성자가 같은 수의 매개변수를 받아야 하는 경우를 완전히 피해갈 수는 없으니, 그럴 때를


    대비해 안전 대책을 배워두면 도움이 될 것이다.




    ■다중정의를 회피하는 방법 4 :: 근본적으로 다른 매개변수 타입 사용


    매개변수가 같은 다중정의 메서드가 많더라도, 그중 어느 것이 주어진 매개변수 집합을 


    처리할지가 명확히 구분된다면 헷갈릴 일은 없을 것이다. 즉, 매개변수 중 하나 이상이


    "근본적으로 다르다"면 헷갈릴 일이 없다.


    근본적으로 다르다는 것은 null이 아닌 두 타입의 값을 서로 어느 쪽으로든 


    형변환할 수 없다는 뜻이다.


    이 조건만 충족하면 어느 다중정의 메서드를 호출할지가 매개변수들의 런타임 타입만으로


    결정된다. 따라서 컴파일타임 타입에는 영향을 받지 않게 되고, 혼란을 주는 주된 원인이 


    사라진다.




    ■다중정의를 방해하는 존재 1 :: 오토박싱(근본적으로 다른 타입을 방해)


    자바 4까지는 모든 기본 타입이 모든 참조 타입과 근본적으로 달랐지만, 자바 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까지의 정수를 정렬된 집합과 리스트에 각각 추가한 다음, 양쪽에


    똑같이 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로 변환한


    후 list.remove에 전달해도 된다.




    ■다중정의를 방해하는 존재 2 :: 람다와 메서드 참조


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

    new Thread(System.out::println).start();

    Executors.newCachedThreadPool().submit(System.out::println);

    2번만 컴파일 오류가 난다. 넘겨진 인수는 모두 System.out::println으로 똑같고, 양쪽 모두


    Runnable을 받는 형제 메서드를 다중정의하고 있다. 그런데 왜 한쪽만 실패할까?


    원인은 바로 submit 다중 정의 메서드 중에는 Callable<T>를 받는 메서드도 있다는 데 있다.

    <T> Future<T> submit(Callable<T> task);

    하지만 모든 println이 void를 반환하니, 반환값이 있는 Callable과 헷갈릴 리는 없다고 


    생각할지도 모른다. 합리적인 추론이지만, 다중정의 해소(resolution : 적절한 다중정의 


    메서드를 찾는 알고리즘)는 이렇게 동작하지 않는다. 만약 println이 다중정의 없이 단


    하나만 존재했다면 이 submit 메서드 호출이 제대로 컴파일됐을 것이다. 지금은 참조된


    메서드와 호출한 메서드 양쪽 다 다중정의되어, 다중정의 해소 알고리즘이 우리의 기대처럼


    동작하지 않는 상황이다.


    기술적으로 말하면 System.out::println은 부정확한 메서드 참조다.


    그리고 부정확한 메서드 참조같은 인수 표현식은 목표 타입이 선택되기 전에는 그 의미가


    정해지지 않기 때문에 적용성 테스트(??) 때 무시된다. 말이 어려운데, 핵심은 다중정의된


    메서드(or 생성자)들이 함수형 인터페이스를 인수로 받을 때, 비록 서로 다른 함수형 


    인터페이스라도 인수 위치가 같으면 혼란이 생긴다는 것이다. 따라서 메서드를 다중정의할


    때, 서로 다른 함수형 인터페이스라도 같은 위치의 인수로 받아서는 안 된다. 이 말은 서로


    다른 함수형 인터페이스라도 서로 근본적으로 다르지 않다는 뜻이다.


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


    경고해줄 것이다.


    이 부분이 잘 이해가 안가서, 곰곰히 생각을 한번 해봤다.


    <참조 메서드의 수 : 함수형 인터페이스로 다중정의한 메서드의 수> 로 봤을 때,


    n : 1 -> success


    n : m -> fail


    1 : m -> success 라고 정리해 볼 수 있다.


    일단 n : 1 은 왜 success가 나는지 잘 모르겠다. 컴파일 타임에는 일단 n개의 공통된


    참조 하나를 메서드쪽으로 전달하는 것이니 성공하는 것일까? 런타임 때 어떤 참조가


    선택될 지 결정되는건가? 어쨌든 도착지는 1개이니 컴파일 에러는 나지 않는 것 같다.


    1 : m은 왜 success일까? n : 1 과 마찬가지로 참조는 하나만 전달될 것이고,


    도착지는 m개이다. 이 때, m개의 함수형 인터페이스들은 서로 근본적으로 다르지 않다.


    그러면 맨 위의 첫 번째 코드와 마찬가지로 에러까지는 나지 않고 런타임때서야 m개의 


    함수형 인터페이스 중 하나가 선택될 것이다.


    그렇다면 n : m 은??????????? 마찬가지로 n개의 메서드의 공통된 참조 1개가 전달될 


    것이다. 그러면 결국 1 : m 과 모양새가 같아보이는데.... 그러면 이 경우도 success가


    나야 하는데.... 왜 fail이라고 하는거지??




    ■근본적으로 다른 타입들의 예


    Object 외의 클래스 타입과 배열 타입은 근본적으로 다르다. Serializable과 Cloneable 외의 


    인터페이스 타입과 배열 타입도 근본적으로 다르다. 한편, String과 Throwable처럼 


    상위/하위 관계가 아닌 두 클래스는 '관련 없다' 고 한다. 그리고 어떤 객체도 관련 없는


    두 클래스의 공통 인스턴스가 될 수 없기에, 관련 없는 클래스들끼리도 근본적으로 다르다.


    이 외에도 어떤 방향으로도 형변환할 수 없는 타입 쌍이 있지만, 어쨌든 앞에서 나열한


    간단한 예보다 복잡해지면 대부분 프로그래머는 어떤 다중정의 메서드가 선택될지를 


    구분하기 어려워할 것이다. 다중정의된 메서드 중 하나를 선택하는 규칙은 매우 복잡하며,


    자바가 버전업될수록 더 복잡해지고 있어, 이 모두를 이해하고 사용하는 프로그래머는


    극히 드물 것이다.




    ■다중정의를 주의하지 못한 사례


    이번 아이템에서 설명한 지침들을 지키지 못할 수도 있다. 이미 만들어진 클래스가 끼어들면


    특히 더 그렇다. 예를 들어 String은 자바 4 시절부터 ContentEquals(StringBuffer) 메서드를 


    가지고 있었다. 그런데 자바 5에서 StringBuffer, StringBuilder, String, CharBuffer 등의


    비슷한 부류의 타입을 위한 공통 인터페이스로 CharSequence가 등장하였고, 자연스럽게


    String에도 CharSequence를 받은 contentEquals가 다중정의되었다.


    그 결과 이번 아이템의 지침을 대놓고 어기는 모습이 되었다. 다행히 이 두 메서드는 


    같은 객체를 입력하면 완전히 같은 작업을 수행해주니 해로울 건 전혀 없다. 




    ■다중정의된 메서드가 완벽히 같은 로직을 수행할 때? :: foward


    어떤 다중정의 메서드가 불리는지 몰라도 기능이 똑같다면 아래와 같은 방법을 사용해 


    안전하게 메서드를 정의할 수 있다. 상대적으로 더 특수한 다중정의 메서드에서 덜 특수한


    (더 일반적인) 다중정의 메서드로 일을 넘겨버리는 것이다.

    public boolean contentEquals(StringBuffer sb) {
    return contentEquals((CharSequence) sb);
    }

    의문점이 있다.


    그냥 매개변수로 CharSequence가 있는 메서드 하나만 정의해두면 되지 않나?


    혹시 StringBuffer를 꼭 선언해야만 하는 상황인건가?(인터페이스를 추가로 구현)


    댓글

Designed by Tistory.