아이템 28 - 배열보다 리스트를 사용하라

728x90

배열게 제니릭 타입에는 두 가지 차이가 있다.

1. 배열은 공변(covariant)이다.

어려워 보이는 단어지만 뜻은 간단하다. Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위타입이 된다(공변, 즉 함께 변한다는 뜻이다). 반면, 제네릭은 불공변(invariant)이다. 즉, 서로 다른 타입 Type1과 Type2가 있을 때, List<Type1>은 List<Type2>의 하위 타입도 아니고 상위 타입도 아니다. 이것만 보면 제네릭에 문제가 있다고 생각할 수 있지만, 문제가 있는 쪽은 리스트가 아니라 오히려 배열 쪽이다. 다음은 문법상 허용되는 코드다.

Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다."; // ArrayStoreException을 던진다. 

하지만 다음 코드는 문법에 맞지 않는다.

List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입이다. 
ol.add("타입이 달라 넣을 수 없다.");

어느 쪽이든 Long 용 저장소에 String을 넣을 수는 없다. 다만 배열에서는 그 실수를 런타임에야 알게 되지만, 리스트를 사용하면 컴파일할 때 바로 알 수 있다. 당연하게도 우리는 컴파일 시에 알아채는 쪽을 선호할 것이다.

2. 배열은 실체화(reify)된다.

무슨 뜻인지 의아할 것이다. 이 의미는 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다. 그래서 앞서 첫번째 코드에서 보듯 Long 배열에 String을 넣으려 하면 ArrayStoreException이 발생한다. 반면, 앞서 이야기했듯 제네릭은 타입 정보가 런타임에는 소거(erasure)된다. 원소 타입을 컴파일타임에만 검사하면 런타임에는 알 수조차 없다는 뜻이다. 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘으로 자바 5가 제네릭으로 순조롭게 전환될 수 있도록 해줬다.

이같은 주요 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다. 예컨대 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 즉, 코드를 new List<E>[], new List<String>[], new E[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다.

제네릭 배열을 만들지 못하게 막은 이유는 무엇일까? 타입 안전하지 않기 때문이다. 이를 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCaseException이 발생할 수 있다. 런타임에 ClassCastException이 발생하는 일을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋나는 것이다.

E, List<E>. List<String> 같은 타입을 실체화 불가 타입(non-reifiable type)이라 한다. 쉽게 말해, 실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입이다. 소거 매커니즘 때문에 매개변수화 타입 가운데 실체화될 수 있는 타입은 List와 Map 같은 비한정적 와일드카드 타입뿐이다. 배열을 비한정적 와일드카드 타입으로 만들 수는 있지만, 유용하게 쓰일 일은 거의 없다.

배열은 제네릭으로 만들 수 없이 귀찮을 때도 있다. 예컨대 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는 게 보통은 불가능하다. 또한 제네릭 타입과 가변인수 메서드(varargs method)를 함께 쓰면 해석하기 어려운 경고 메시지를 받게 된다. 가변인수 메서드를 호출할 때마다 가변인수 매개변수를 담을 배열이 하나 만들어지는데, 이때 그 배열의 원소가 실체화 불가 타입이라면 경고가 발생하는 것이다. 이 문제는 @SafeVarargs 애너테이션으로 대처할 수 있다.

배열로 형변환할 때 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 대부분은 배열인 E[] 대신 컬렉션인 List<E>를 사용하면 해결된다. 코드가 조금 복잡해지고 성능이 살짝 나빠질 수도 있지만, 그 대신 타입 안정성과 상호운용성은 좋아진다.

생성자에서 컬렉션을 받는 Chooser 클래스를 예로 살펴보자. 이 클래스는 컬렉션 안의 원소 중 하나를 무작위로 선택해 반환하는 choose 메서드를 제공한다. 생성자에 어떤 컬렉션을 넘기느냐에 따라 이 클래스를 주사위판, 매직 8볼, 몬테카를로 시뮬레이션용 데이터 소스 등으로 사용할 수 있다. 다음은 제네릭을 쓰지 않고 구현한 가장 간단한 버전이다.

public class Chooser {
    private final Object[] choiceArray;

    public Chooser(Collection choices) {
        choiceArray = choices.toArray();
    }

    public Object choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceArray(rnd.nextInt[choiceArray.length)];
    }
}

이 클래스를 사용하려면 choose 메서드를 호출할 때마다 반환된 Object를 원하는 타입으로 형변환해야 한다. 혹시나 타입이 다른 원소가 들어있었다면 런타임에 형변환 오류가 날 것이다. 이 클래스를 제네릭으로 만들어보자.

public class Chooser<T> {
    private fianl T[] choiceArray;

    public Chooser(Collection<T> choices) {
        choiceArray = choice.toArray();
    }

    // choose 메서드는 그대로다.
}

이 클래스를 컴파일하면 다음의 오류 메시지가 출력될 것이다.

Chooser.java:9: error: incompatible type: Object[] cannot be converted to T[]
choiceArray = choices.toArray();

where T is a type-variable:
T extends Object declared in class Chooser

걱정할 것 없다. Object 배열을 T 배열로 형변환하면 된다.
chocieArray = (T[]) choices.toArray();

그런데 이렇게 변경했음에도 다른 경고가 뜬다.

Chooser.java:9: warning: [unchecked] unchecked cast
choiceArray = (T[]) choices.toArray();

required: T[], found: Object[]
where T is a type-variable:
T extends Object declared in class Chooser

T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억하자! 그렇다면 이 프로그램은 동작할까? 동작한다. 단지 컴파일러가 안전을 보장하지 못할 뿐이다. 코드를 작성하는 사람이 안전하다고 확신한다면 주석을 남기고 애노테이션을 달아 경고를 숨겨도 된다. 하지만 애초에 경고의 원인을 제거하는 편이 훨씬 낫다.

비검사 형변환 경고를 제거하려면 배열 대신 리스트를 쓰면 된다. 다음 Chooser는 오류나 경고 없이 컴파일된다.

public class Chooser<T> {
    private final List<T> choiceList;

    public Chooser(Collection<T> choices) {
        choiceList = new ArrayList<>(choices);
    }

    public T choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceList.get(rnd.nextInt(choiceList.size()));
    }
}

이번 버전은 코드양이 조금 늘었고 아마도 조금 더 느릴테지만, 런타임에 ClassCastException을 만날 일은 없으니 그만한 가치가 있다.

728x90