[교재 EffectiveJava] 아이템 52. 다중정의는 신중히 사용하라

반응형
728x90
반응형

다중정의

package com.java.effective.item52;

import java.math.BigInteger;
import java.util.*;

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

mian 메서드의 결과를 예상해보자.

예상 : "집합", "리스트", "그 외"
실제 결과 : "그 외", "그 외", "그 외"

이유가 뭘까? classify 메서드는 다중정의 되어있다. 위의 결과가 나온 이유는 다중정의(overloading)된 총 3개의 classify 중 어느 메서드를 호출해야할지가 컴파일 타임에 정해지기 때문이다. 컴파일 타임에는 for문 안의 c는 항상 Collection<?> 타입이다. 런타임에는 타입이 매번 달라지지만, 호출할 메서드를 선택하는 데는 영향을 주지 못한다.

 

그로 인해, 아래 classify(Collection<?>) 메서드만 계속 호출된다.

public static String classify(Collection<?> c) {
    return "그 외";
}

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

 

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

 

 

메서드 재정의

재정의한 메서드는 동적으로 선택되고, 다중정의한 메서드는 정적으로 선택된다. 메서드를 재정의했다면 해당 객체의 런타임 타입이 어떤 메서드를 호출할지의 기준이 된다. 

* 메서드 재정의
상위 클래스가 정의한 것과 똑같은 시그니처의 메서드를 하위 클래스에서 다시 정의하는 것
재정의된 메서드 호출 메커니즘
package com.java.effective.item52;

import java.util.List;

class Wine {
    String name() {
        return "포도주";
    }
}

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

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

public class OverridingTest {
    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인 것에 무관하게 항상 '가장 하위에서 정의한' 재정의 메서드가 실행되는 것이다. 

 

 

다양한 메서드 이름으로 다중정의 사용 지양

프로그래머에게는 재정의가 정상적인 동작 방식이고, 다중 정의가 예외적인 동작으로 보일 것이다. 다중정의한 메서드는 우리의 예상을 빗나갔다. 헷갈릴 수 있는 코드는 작성하지 않는게 좋다. 특히나 공개 public API라면 더더욱 신경 써야 한다. 다중정의가 혼동을 일으키는 상황을 피해야한다. 

 

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

 

예제

ObjectOutputStream 클래스를 살펴보자. 이 클래스이 write 메서드는 모든 기본 타입과 일부 참조 타입용 변형을 가지고있다. 다중 정의가 아닌, 모든 메서드에 다른 이름을 지어주는 길을 택했다.

 

ObjectOutputStream.java
....
    public void writeBoolean(boolean val) throws IOException {
        bout.writeBoolean(val);
    }

    /**
     * Writes an 8 bit byte.
     *
     * @param   val the byte value to be written
     * @throws  IOException if I/O errors occur while writing to the underlying
     *          stream
     */
    public void writeByte(int val) throws IOException  {
        bout.writeByte(val);
    }

    /**
     * Writes a 16 bit short.
     *
     * @param   val the short value to be written
     * @throws  IOException if I/O errors occur while writing to the underlying
     *          stream
     */
    public void writeShort(int val)  throws IOException {
        bout.writeShort(val);
    }

    /**
     * Writes a 16 bit char.
     *
     * @param   val the char value to be written
     * @throws  IOException if I/O errors occur while writing to the underlying
     *          stream
     */
    public void writeChar(int val)  throws IOException {
        bout.writeChar(val);
    }

    /**
     * Writes a 32 bit int.
     *
     * @param   val the integer value to be written
     * @throws  IOException if I/O errors occur while writing to the underlying
     *          stream
     */
    public void writeInt(int val)  throws IOException {
        bout.writeInt(val);
    }

    /**
     * Writes a 64 bit long.
     *
     * @param   val the long value to be written
     * @throws  IOException if I/O errors occur while writing to the underlying
     *          stream
     */
    public void writeLong(long val)  throws IOException {
        bout.writeLong(val);
    }

    /**
     * Writes a 32 bit float.
     *
     * @param   val the float value to be written
     * @throws  IOException if I/O errors occur while writing to the underlying
     *          stream
     */
    public void writeFloat(float val) throws IOException {
        bout.writeFloat(val);
    }
....

굳이 다중정의를 사용하지 않아도, 이렇게 메서드 이름을 다르게 좀더 나은 코드로 가져갈 수 있다.

 

 

생성자 다중정의

참고로 생성자는 이름을 다르게 지을 수 없으니 두번째 생성자부터는 무조건 다중 정의가 된다. 하지만 정적 팩터리라는 대안을 활용할 수 있는 경우가 많다. 또한 생성자는 재정의할 수 없으니 다중정의와 재정의가 혼용될 걱정은 안해도 된다. 

 

매개변수 수가 같은 다중정의 메서드가 많더라도, 그 중 어느것이 주어진 매개변수의 집합을 처리할지가 명확히 구분되면 헷갈일 일이 없다. 

매개변수 중 하나 이상이 근본적으로 다르다.
-> 두 타입의 (null이 아닌) 값을 서로 어느쪽으로든 형변환이 불가능하다.

이 조건을 충족하면 어느 다중정의 메서드를 호출할지가 매개변수들의 런타임 타입만으로 결정된다. 따라서 컴파일 타임 타입에는 영향을 받지 않게 되고, 혼란을 주는 주된 원인이 사라진다. 예를들어 위 첫번째 예제코드에서 List, Set 등은 모두 Coolection의 하위 클래스이다. 만일 int를 받는 생성자와 Collection을 받는 생성자가 있었다면 두 생성자중 어느 것이 호출될지 헷갈리지 않았을 것이다.

 

 

오토박싱

package com.java.effective.item52;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

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

}

위 코드의 결과를 예상해보자.

예상 : set : [-3, -2, -1] / list : [-3, -2, -1]
실제결과 : set : [-3, -2, -1] / list : [-2, 0, 2]

실제 결과에서는 set 집합은 음이 아닌 값이 제거되었고, list 는 홀수를 제거하였다. 

 

set.remove(i)

remove(Object)로, 다중정의된 다른 메서드가 없으니 기대한대로 동작하여 집합에서 0 이상의 수들을 제거한다.

 

list.remove(i)

remove(int index)로, 지정한 위치의 원소를 제거한다. 리스트의 0번째, 1번째, 2번째 원소가 제거되어 실제 결과가 예상과 다르게 나온 것이다.

 

Integer 형변환 추가
for (int i = 0; i < 3; i++) {
    set.remove(i);
    list.remove((Integer) i);
}

이렇게 하면 예상된 결과 "set : [-3, -2, -1] / list : [-3, -2, -1]"가 나오게된다. 형변환을 하면 remove(Object)를 호출하여 올바른 다중정의 메서드를 선택한다. 

 

 

람다와 메서드 참조의 혼란

// 1번. Thread의 생성자 호출
new Thread(System.out::println).start();

// 2번. ExecutorService의 submit 메서드 호출
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);

2번은 컴파일 오류가 발생한다. submit 다중 정의 메서드 중에는 Callable<T> 를 받는 메서드도 있고, void를 반환하는 println 의 경우 정상적으로 작동할거라고 예상했겠지만, println도 다중정의 되어있는 메서드기 때문에 우리의 예상대로 동작하지 않는다. 

 

println의 다중정의 PrintStream.java
...
    public void println() {
        newLine();
    }

    /**
     * Prints a boolean and then terminate the line.  This method behaves as
     * though it invokes {@link #print(boolean)} and then
     * {@link #println()}.
     *
     * @param x  The {@code boolean} to be printed
     */
    public void println(boolean x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

    /**
     * Prints a character and then terminate the line.  This method behaves as
     * though it invokes {@link #print(char)} and then
     * {@link #println()}.
     *
     * @param x  The {@code char} to be printed.
     */
    public void println(char x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
...

System.out::println은 부정확한 메서드 참조다. "암시적 타입 람다식"이나 부정확한 메서드 참조 같은 인수 표현식은 목표 타입이 선택되기 전에는 그 의미가 정해지지 않기 때문에 적용성 테스트 때 무시된다. 다중정의된 메서드 혹은 생성자들이 함수형 인터페이스를 인수로 받을때, 비록 서로 다른 함수형 인터페이스라도 인수 위치가 같으면 혼란이 생긴다.

 

따라서 메서드를 다중정의할 때, 서로 다른 함수형 인터페이스라도 같은 위치의 인수로 받아서는 안된다. 즉, 서로 다른 함수형 인터페이스라도 서로 근본적으로 다르지 않아 컴파일러의 경고가 발생할 것이다.

 

 

forward 방법

다중정의된 메서드 중 하나를 선택하는 규칙은 매우 복잡하며, "근본적으로 다르다"라는 두 클래스의 경우도 위 println 처럼 예외가 있을 수 있으니 구분하기가 매우 어렵다. 다중정의 메서드의 기능이 동일하다면 신경쓸게 없다. 

 

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

인수를 포워드하여 기존의 메서드 contentEquals를 호출하면 된다. 

 

 

 

 

반응형

Designed by JB FACTORY