DEV ℧ Developer Diary

[EffectiveJava] item52 - 다중정의(Overloading)는 신중히 사용하라

다중정의(Overloading)란 매개변수를 다르게 한다면 메소드의 이름을 같게하여 정의하는것을 말한다.

다중정의(Overloading)

먼저 예시를 살펴보자.

public class CollectionClassifier {
  public static String classify(Set<?> s) {
    return "집합";
  }

  public static String classify(List<?> list) {
    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<?> 타입이다. 런타임에는 타입이 매번 달라지지만, 호출할 메서드를 선택하는 데는 영향을 주지 못한다.

이러한 이유는 재정의(Override)한 메서드는 동적으로 선택되고 다중정의(Overloading)한 메서드는 정적으로 선택되기 때문이다.

재정의(Override)

재정의(Override)란 상위 클래스가 정의한 것과 똑같은 시그니처 메서드를 하위 클래스에서 다시 정의한 것을 말한다.

한번 다음의 예시를 살펴보자.

public class OverrideExample {

    public static class Wine {
        String name() { return "포도주"; }
    }
    public static class SparklingWine extends Wine {
        @Override String name() { return "발포성 포도주"; }
    }

    public static  class Champagne extends SparklingWine {
        @Override String name() { return "샹페인"; }
    }

    public static void main(String[] args) {
        List<Wine> wineList = List.of(
                new Wine(), new SparklingWine(), new Champagne()
        );

        for (Wine w : wineList)
            System.out.println(w.name());
    }
}

Wine 클래스에 정의된 name 메서드는 하의 클래스인 SparklingWine, Champagne에서 재정의 된다. 예상대로 이 코드는 “포도주”, “발포성 포도주”, “샴페인”을 차례로 출력한다.

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

재정의와 다중정의의 차이

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

아까의 CollectionClassifier의 예시를 수정한다면. instancof로 명시적으로 검사하면 말끔히 해결된다.

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

재정의한 메서드는 프로그래머가 기대한 대로 동작하지만 CollectionClassifier의 예시처럼 다중정의한 메서드는 이러한 기대를 가볍게 무시한다. 헷갈릴 수 있는 코드는 장성하지 않는것이 좋다.

다중정의의 문제점

공개 API라면 더 신경을 써야한다. API 사용자가 매개변수를 넘기면서 어떤 다중정의 메서드가 호출될지를 모른다면 프로그램이 오동작하기 쉽다. 다중정의가 혼동을 일으키는 상황을 피해야 한다.

다중정의의 안전한 사용법

정확히 어떻게 사용했을 때 다중정의가 혼란을 주느냐에 대해서는 논란의 여지가 있지만, 안전하고 보수적으로 가려면 매개변수 수가 같은 다중정의는 만들지 않는것이 좋다.

이 규칙만 잘 따르면 어떤 다중정의 메서드가 호출될지 헷갈일 일은 없을 것이다. 다중정의하는 대신 메서드 이름을 다르게 지어주는 길도 항상 열려 있으니 말이다.

ObjectOutputStream 의 write 메서드

예시로 ObjectOutputStream 클래스를 살펴보도록 하자. 이 클래스의 write 메서드는 모든 기본 타입과 일부 참조 타입용 변형을 가지고 있다. 그런데 다중정의가 아닌, 모든 메서드에 다른 이름을 지어주는 길을 택했다. writeBoolean(boolean), writeInt(int), writeLong(long) 같은 식이다.

이 방식이 다중정의보다 나은 점은 read 메서드의 이름과 짝을 맞추기 좋다는 것이다. 예를 들면 readBoolean(), readInt(), readLong()과 같은 식이다. 실제로도 ObjectOutputStream 클래스의 read는 이렇게 되어 있다.

생성자의 다중정의

한편, 생성자는 이름을 다르게 지을 수 없으니 두 번째 생성자부터는 무조건 다중정의가 된다. 하지만 정적 팩터리라는 대안을 활용할 수 있는 경우가 많다.

또한 생성자는 재정의할 수 없으니 다중정의와 재정의가 혼용될 걱정은 안해도 된다.

매개변수 수가 같은 다중정의

매개변수 수가 같은 다중정의 메서드가 많더라도, 그중 어느것이 주어진 매개변수 중 하나 이상이 “근본적으로 다르다면(radically different)” 헷갈일 일이 없다.

근본적으로 다르다는 건 두 타입의 (null이 아닌) 값을 서로 어느쪽 으로든 형변환할 수 없다는 뜻이다.

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

오토박싱으로 인한 다중정의의 혼란

자바 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);
            set.remove(i);
        }
    }
}

-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로 형변환하여 올바른 다중정의 메서드를 선택하면 해결된다.

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

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

다중정의시 주의를 기울여야할 근거로는 충분하다.

람다로 인한 다중정의의 혼란

자바 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를 받는 메서드도 있다는 데 있다. 하지만 모든 println이 void를 반환하니, 반환값이 있는 Callable과 헷갈릴 리는 없다고 생각한다.

하지만 다중정의 해소(resolution; 적절한 다중정의 메서드를 찾는 알고리즘)는 이렇게 동작하지 않는다. 만약 println이 다중정의 없이 단 하나만 존재했다면, 컴파일 오류는 발생하지 않았을 것이다.

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