배열과 제네릭 타입에는 두가지 큰 차이가 존재하는데, 이를 통해 왜 제네릭과 함께 사용할 때는 배열이 아닌 리스트를 사용해야 하는지에 대해 알아보자.
📚 공변(covariant)
함께 변한다는 것
배열은 공변이여서 Sub
가 Super
의 하위 타입이라면 Sub[]
또한 Super[]
의 하위 타입이다.
제네릭은 불공변(invariant)이기 때문에 서로 다른 타입 Type1
과 Type2
가 있을 때 List<Type1>
은 List<Type2>
의 하위 타입과 상위 타입 모두가 아니다.
따라서, 에러를 컴파일 시에 바로 알아차릴 수 있는 제네릭 타입이 더 선호된다.
배열을 공변으로 만든 이유는, 바로 다형성을 활용하기 위함이다.
다형성으로 인해, Long
이 Object
의 하위 타입이 되기 때문에 컴파일은 되지만 잘못된 타입을 넣는 다면 런타임에 오류가 나버린다.
만약 아래와 같이 배열이 불공변이었다면, 서로 다른 두 배열이 일치하는지 비교하는데에는 Object
배열만 가능하기 때문에 매 타입마다 메서드를 새로 생성해주어야 했을 것이다. 하지만 같은 타입으로 간주해 대입이 가능하다면, 하나의 메서드를 재사용할 수 있다.
boolean equalArrays (Object[] a1, Object[] a2);
제네릭을 불공변으로 만든 이유는, 바로 배열에서 문제였던 타입 안전성을 확보하기 위함이다. 아예 다른 타입으로 취급되기 때문에, 런타임이 아닌 컴파일 될때 실패한다.
위 코드에서는 List<Object>
로 되어 있으므로 컴파일러가 어느 객체가 들어올지 모르기 때문에 o1.add
에서 다른 타입을 넣어도 에러가 안났다. 하지만 아래와 같이 같은 타입의 제네릭 리스트라면, 다른 타입을 넣을 시 컴파일 타임에 명확한 에러가 나버린다. 이제 왜 제네릭이 타입 안전하다 하는지 이해가 가는가?
만약 아래와 같이 제네릭 또한 공변이었다면? dogs.get(0)
에서 런타임에 에러를 발생시킬 것이다.
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;
animals.add(new Cat());
Dog dog = dogs.get(0); // This should be safe, right?
참고로 다형성 또한 한정적 와일드카드 타입을 통해 실현될 수 있기 때문에 더더욱 공변으로 만들 필요가 없었을 것이다.
https://stackoverflow.com/questions/18666710/why-are-arrays-covariant-but-generics-are-invariant
🔖 실체화
런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다.
E
, List<E>
, List<String>
과 같은 타입은 실체 불가능화 타입(non-reifiable type)이라고 한다.🔖 소거(Erasure)
소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있는 메커니즘으로, 자바 5가 제네릭으로 순조롭게 전환될 수 있도록 해준다. 소거 메커니즘으로 인해List<?>
와 같은 비한정적 와일드카드 타입만 실체화 가능하다.
결론적으로, 이러한 차이로 인해 배열과 제네릭은 어우러지지 못한다.
제네릭 배열은, 타입 안전하지 않다는 이유로 만들지 못하게 막고 있다. 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException
이 발생할 수 있기 때문이다.
따라서 배열은 new List<E>[]
, new List<String>[]
, new E[]
처럼 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 사용한다 해도, 컴파일러가 Generic array creation
이라는 에러를 띄운다.
자바에서는 타입 안전성으로 인해, 둘을 같이 사용하게 허용하지 않고 컴파일 조차 되지 않게 하고 있다. 만약 둘을 같이 사용하게 허용했다면, 아래와 같은 에러가 발생하게 되기 때문이다.
List<String>[] stringLists = new List<String>[1];
List<Integer> intList = List.of(42);
Object[] objects = stringLists; // 배열은 공변이니 대입 가능
objects[0] = intList; // 제니릭은 실체화가 안되니, 대입 가능
String s = stringList[0].get(0); // 결과 에러 발생
위의 코드를 보면 List<String>
인스턴스만 담겠다고 선언한 stringLists
에 List<Integer>
인스턴스가 저장되어 있다. Object[]
로 변환해줌으로써 불공변의 컴파일 에러를 피해갔고, 런타임에는 타입 정보가 사라지니 저장하는 것이 가능해졌기 때문이다.
따라서 마지막 줄에서 원소를 꺼내려 할 때 String
변수에 Integer
타입 값을 저장하려 하여 런타임 상 오류인 ClassCastException
이 발생한다.
배열을 형변환할 때 제네릭 배열 생성 오류나, 비검사 형변환 경고가 뜨는 경우 대부분 배열인 E[]
대신 컬렉션인 List<E>
를 사용하면 해결된다.
성능은 나빠질 수 있지만, 타입 안전성과 상호 운용성이 좋아진다. 생성자에서 컬렉션을 받는 Chooser 클래스를 예로 들어보자.
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection<T> choices) {
choiceArray = choices.toArray();
}
public Object choose() { //컬렉션 안의 원소 중 하나를 무작위로 선택해 반환
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceArray.size()));
}
}
이 클래스를 사용하려면, choose()
메서드를 호출할 대마다 반환된 Object를 원하는 타입으로 형변환해야 하는 번거로움과 런타임 에러가 날 가능성이 존재한다.
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices) {
choiceList = (T[]) choiceArray.toArray(); //형변환
}
...
}
Object[] toArray();
하지만, 컴파일러는 T
가 무슨 타입인지 알 수 없으니 이 형변환이 런타임에도 안전할지 보장할 수 없다는 경고를 띄운다. 제네릭은 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없다는 것에 주의하자.
따라서 비검사 형변환 경고를 제거하려면 배열 대신 리스트를 사용하면 된다.
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()));
}
}