ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 제네릭 - 아이템 28. 배열보다는 리스트를 사용하라
    Study/Effective Java 2023. 1. 4. 14:55

    배열보다는 리스트를 사용하라

    배열과 제네릭의 차이

    1. 공변과 불공변

    배열과 제네릭 타입에는 중요한 두 가지 차이가 있다. 첫 번째, 배열은 공변이다. 예를 들어, SubSuper의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다. 즉, 같이 변한다는 의미이다.

    반면, 제네릭은 불공변이다. 즉, 서로 다른 타입 Type1Type2가 있을 때, List<Type1>List<Type2>의 하위 타입도 아니고 상위 타입도 아니다.

     

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

    Long용 저장소에 String을 넣을 수는 없다. 배열에서는 이러한 문제점을 런타임에야 알게 된다.

     

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

    하지만, 리스트를 사용하면 컴파일할 때 문법적 오류를 통해 바로 알 수 있다. 여러분도 물론 컴파일 시에 알아채는 쪽을 선호할 것이다.

     

    2. 실체화

    두 번째 주요 차이로는, 배열은 실체화된다. 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인질하고 확인한다. 그래서 Long 배열에 String을 넣으려 하면 ArrayStoreException이 발생한다.

    반면, 제네릭은 타입 정보가 런타임에는 소거된다. 원소 타입을 컴파일 타임에만 검사하며, 런타임에는 알 수조차 없다는 뜻이다.

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

     

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

     

    List<String>[] stringLists = new List<String>[];    // 1
    List<Integer> intList = List.of(42);                // 2
    Object[] objects = stringList;                      // 3
    objects[0] = intList;                               // 4
    String s = stringLists[0].get(0);                   // 5

    만약 위 코드와 같이, 제네릭이 배열을 허용한다고 가정해 보겠다.

    1과 같이 제네릭 배열이 허용된다고 가정해 보겠다. 3은 1에서 생성한 List<String> 배열을 Object 배열에 할당한다. 배열은 공변이기 때문에 문제가 없다.

    4는 2에서 생성한 List<Integer> 인스턴스를 Object 배열의 첫 번째 원소로 저장한다. 제네릭은 소거 방식으로 구현되어서 런타임에 List<Integer> 인스턴스 타입은 단순히 List가 된다. 따라서, 4도 문제없다.

    결국, List<Integer> 인스턴스만 담겠다고 선언한 stringLists에 List<Integer> 인스턴스가 저장되었다. 따라서,  5에서 해당 원소를 String으로 캐스팅하려 하니 오류가 발생한다.

     

    위와 같은 이유 때문에, 제네릭 배열을 만들지 못하도록 제한을 한 것이다.

     

    List<E>의 사용

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

     

    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() 메서드를 제공한다. 위 클래스를 사용하기 위해서는 choose() 메서드를 호출할 때마다 Object를 원하는 타입으로 형변환해야 한다. 혹시나 타입이 다른 원소가 들어 있었다면 런타임에 형변환 오류가 날 것이다.

     

    public class Chooser<T> {
        private final T[] choiceArray;
        
        public Chooser(Collection<T> choices) {
            choiceArray = choices.toArray();
        }
        
        public Object choose() {
            Random rnd = ThreadLocalRandom.current();
            return choiceArray[rnd.nextInt(choiceArray.length)];
        }
    }

    위 클래스는 앞서 설명한 클래스를 제네릭으로 만들어 본 것이다. 

     

     

    제네릭으로 변경한 클래스를 컴파일하면 위와 같은 오류 메시지가 출력된다.

     

    choiceArray = (T[]) choices.toArray();

    위 오류를 수정하기 위해서, Object 배열을 T 배열로 형변환하였다.

    그런데, 이번에는 경고가 뜰 것이다. T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억해야 한다.

     

    public class Chooser<T> {
        private final List<T> choiceList;
        
        public Chooser(Collection<T> choices) {
            choiceList = new ArrayList<>(choices);
        }
        
        public Object choose() {
            Random rnd = ThreadLocalRandom.current();
            return choiceList.get(rnd.nextInt(choiceList.size()));
        }
    }

    위 코드는 비검사 형변환 경고를 제거하기 위해서 배열 대신 리스트를 사용한 코드이다. 위 코드는 오류가 경고 없이 컴파일된다. 비록, 위 코드는 이전 코드에 비해서 조금 더 느릴 테지만, 런타임에 ClassCastException을 만날 일이 없으니 그만한 가치가 있다.

     

    최종 정리

    배열과 제네릭에는 매우 다른 타입 규칙이 적용된다. 배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소진된다. 그 결과 배열은 런타임에는 타입 안전하지만 컴파일타임에는 그렇지 않다. 제네릭은 반대다. 그래서 둘을 섞어 쓰기란 쉽지 않다. 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해보는 것이 좋다.


    출처

     이펙티브 자바 Effective Java 3/E. 조슈아 블로크 저자(글) · 개앞맵시(이복연) 번역

     

    728x90

    댓글

Designed by Tistory.