[EffectiveJava] item28 - 배열보다는 리스트를 사용하라
16 Apr 2023배열과 제네릭 타입에는 중요한 차이가 두가지 있다. 첫 번째로 배열은 공변(covariant : 함께 변한다)이다. Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다.
반면, 제네릭은 불공변(invariant : 함께 변하지 않는다.)이다. 즉, 서로 다른 타입 Type1, Type2가 있을 때, List
배열과 제네릭
이것만 가지고는 배열보다는 리스트를 써야한다는 문제를 알 수가 없다. 다음의 예시를 살펴보도록 하자.
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다."; // ArrayStoreException을 던진다.
배열에 넣은 코드는 컴파일은 성공하지만 런타임에서 ArrayStoreException이 발생한다.
컬렉션을 사용한 코드를 살펴보자.
List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입이다.
ol.add("타입이 달라 넣을 수 없다.");
컬렉션을 사용한 코드는 컴파일 단계에서 에러가 발생하기 때문에 문제점을 바로 알아 챌 수 있다.
배열과 제네릭의 실체화
배열은 실체화(reify)된다. 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다. 그래서 위의 배열에 대한 예시에서 Long 배열에 String을 넣으려 하면 ArrayStoreException이 발생한 것이다. 반면, 제네릭은 타입 정보가 런타임에는 소거(erasure) 된다. 원소 타입을 컴파일타임에만 검사하며 런타임에는 알 수 조차 없다는 일이다.
소거(erasure)란 제네릭이 지원되기 전의 로 타입의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘으로, 자바 5가 제네릭으로 순조롭게 전환될수 있도록 해줬다.
이상의 주요 차이로 인해 배열과 제네릭은 잘 어우러 지지 못한다.
예를 들어 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 즉, 코드를 new List<E>[], new List<String>[], new E[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다.
제네릭 배열을 만들지 못하게 막은 이유는 무엇일까? 타입 안전하지 않기 때문이다. 이를 허용한다면 컴파일러가 자동 생성한 형변형 코드에서 런타임 ClassCastException이 발생할 수 있다.
E, List<E>, List<String> 같은 타입을 실체화 불가 타입(non-reifiable type)이라 한다. 쉽게 말해, 실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입이다. 소거 메커니즘 때문에 매개변수화 타입 가운데 실체화 될 수 있는 타입은 List<?>와 Map<?,?> 같은 비한정적 와일드카드 타입 뿐이다.
배열과 제네릭의 형변환
배열을 제네릭으로 만들 수 없어 귀찮을 때도 있다. 예컨대 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는게 보통은 불가능하다. 또한 제네릭 타입과 가변인수 메서드(varargs method)를 함께 쓰면 해석하기 어려운 경고 메시지를 받게된다.
이 문제는 @SafeVarargs 애너테이션을 대체할 수 있다.
또한 배열로 형변환할 때 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 대부분은 배열인 E[] 대신에 컬렉션인 List<E>를 사용하면 해결된다. 코드가 조금 복잡해지고 성능이 살짝 나빠질 수도 있지, 그 대신 타입 안정성과 상호운용성은 좋아진다.
예시를 한번 들어보자. 아래는 E[]을 사용한 Chooser 클래스이다. 생성자에 어떤 컬랙션을 넘기느냐에 따라 원소중 하나를 무작위로 선택해 반환하는 choose 메소드를 제공한다.
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 final T[] choiceArray;
public Chooser(Collection<T> choices) {
choiceArray = choices.toArray();
}
// choose 메서드는 그대로이다.
}
하지만 이 클래스를 컴파일 하면 다음의 오류 메시지가 출력된다.
Cooser.java:9: error: incompatible types: Object[] cannot be converted to T[]
choiceArray = choices.toArray();
^
where T is a type-variable:
T extends Object declared in class Chooser
Object 배열을 T 배열로 형변환 할 수 없기 때문에 에러가 난다. 그렇다면 형변환을 추가해주도록 하자.
choiceArray = (T[]) choices.toArray();
그렇다면 이번엔 경고가 뜬다.
Cooser.java:9: warning: [unchecked] unckecked cast
choiceArray = (T[]) choices.toArray();
^
required: T[], found: Object[] where T is a type-variable:
T extends Object declared in class Chooser
T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지이다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억하자!
이 경고는 @SuppressWarnings(“unchecked”)를 달아 해결 할 수 있지만, 애초에 경고의 원인을 제거하는 편이 훨씬 낫다.
비검사 형변환 경고를 제거하려면 배열 대신 리스트를 쓰면 된다. 다음 코드는 경고와 에러가 없어진 깔끔한 코드이다.
public class Chooser<T> {
private final List<T> choiceList;
public Chooser(Collection<T> choices) {
choiceList = new ArrayLisr<>(choices);
}
public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}
이번 버전은 코드양이 조금 늘었고 아마도 조금 더 느릴테지만, 런타임에 ClassCastException을 만날 일은 없으니 그만한 가치가 있다.