Use List not Array.

jiho·2021년 5월 27일
0

EffectiveJava

목록 보기
11/12

Effective Java Item 28

이번 주제는 Java언어에 국한 되지않고 정말 중요한 내용이 많이 있습니다
개발을 하다가 한번쯤은 고민해볼만한 주제인 Array와 List 둘 중 무엇을 써야할까입니다.

언제 List를 써야하고 언제 Array를 왜 사용해야할 지 정리해보겠습니다.

배열(array)와 제네릭 타입에는 중요한 차이가 두 가지 있습니다.

Convariant vs Invariant

배열은 공변(covariant)이다.

어려워보이지만 뜻은 간단합니다. Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열Super[]의 하위 타입이 되는 것을 의미합니다.

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

배열이 공변이라는 특징때문에 런타임에 실패할 가능성이 있습니다.

Object[] objectArray = new Long[1];
objectArray[0] = "Problem"; // ArrayStoreExcpetion 발생

위와 같이 컴파일 단계에서는 문법상 허용은 되지만 런타임에 Exception을 발생시킵니다.

Generic을 활용할 경우, 컴파일 시기에 해당 문제를 파악할 수 있습니다.

List<Object> ol = new ArrayList<Long>(); // 호환되지않는 타입입니다.
ol.add("problem");

이러한 배열이 공변이라는 특징은 버그를 발생시킬 여지가 충분해보입니다. 항상 컴파일 시에 문제를 알아차리는 것을 선호해야합니다.

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

여기서 조금 내용이 복잡해집니다. 하지만 이해해야만하는 내용들입니다.

배열은 실체화(reify)됩니다. 무슨 뜻인지 풀어 설명하면 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인합니다. 위 공변을 설명할 때 런타임에 잘못된 타입을 할당할 때 ArrayStoreException이 발생한 것도 할당 중 타입을 넣을 수 있는지 확인하기때문입니다.

당연한 내용같지만 제네릭은 내용이 달라집니다.

제네릭은 타입 정보가 런타임에 소거됩니다.

원소 타입을 컴파일타임에만 검사하며 런타임에는 알수조차 없다는 뜻입니다. 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타임을 함께 사용할 수 있게 해주는 메커니즘입니다. (이 메커니즘은 자바5에서 제네릭으로 순조롭게 전환할 수 있도록 해줬습니다.)

위 두 가지 주요한 차이 공변(corvariant)와 런타임 시에 실체화(reify) 때문에 배열과 제네릭은 잘 어우러지지못합니다.

그래서 배열은 제네릭타입, 매개변수화타입, 타입 매개변수로 사용할 수 없습니다. 즉, 코드를 new List<E>[], new List<String>[], new E[]식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킵니다.

제네릭 배열을 만들지 못하게 막은 이유는 타입이 안전하지 않기 때문입니다.

이를 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException이 발생할 수 있습니다.

제네릭을 사용하는 장점을 모두 잃어버리게됩니다.

예제를 보면서 다시 정리해보겠습니다.

List<String>[] stringLists = new List<String>[];
List<Integer> intList = List.of(42);
Object[] objects = stringLits;
objects[0] = intList;
String s = stringLists[0].get(0);

stringLists변수에 할당한 것처럼 제네릭 배열이 허용된다고 가정해봅시다.
배열의 공변이라는 특징 때문에 Object 배열에 stringLists이라는 배열을 넣는 것에는 이상이 없습니다. 그리고 런타임시 List의 generic 정보(Integer가 원소라는)는 소거되어 List 타입인intListList[]의 첫 원소에 넣는 것도 가능해집니다.(generic의 정보는 런타임에 소거된다는 특징에 의해)

이제부터 문제가 발생합니다. String 변수만 담겠다고 선언된 stringLists 배열에는 지금 List<Intger>인스턴스가 저장돼있습니다. 그래서 코드를 작성한 의도와는 달리 마지막 stringLists[0].get(0)에는 integer타입이 반환되게 됩니다. 그래서 ClassCastException이 발생하게 됩니다.

이런 일을 막으려면 제네릭 배열자체를 생성하지 못하도록 해야합니다.

그래서E, List<E>, List<String>같은 타입을 실체화 불가 타입(non-reifiable type) 이라고 합니다.

불편한 점

위와 같이 어울리지않는 특징들 때문에 배열과 제네릭은 혼용해서 같이 쓰기가 어렵다

제네릭 컬렉션에서는 자신의 원소를 담은 배열을 반환하는게 보통 불가능하다.라는 내용이 책에 있는데 아마 Java SE의 toArray라는 method를 지원하지않을 때 작성된게 아닐까 추측해봅니다.


List<Integer> numberList = new ArrayList<>();
numberList.add(1);
numberList.add(2);
numberList.add(3);

Integer[] numberArray = numberList.toArray(new Integer[0]);

이 문제는 대부분의 개발자들이 겪는 문제일 것이다. 특정 API이 제네릭 컬렉션이 아닌 배열타입을 원한다면 제네릭을 배열로 바꾸고 싶은데 생각보다 쉽지않다. 특히 코딩 테스트 플랫폼들이 문제의 결과를 배열로 돌려달라고 하지만 알고리즘에 Collection 타입을 상속받은 클래스들을 써야 코드가 훨씬 짧아져서 번거로운 변환 코드가 필요했었다.

Array 대신 List<E>를 사용하자.

배열로 형변환할 때 제네릭 배열 생성 오류나 Unchecked 형변환 경고가 뜨는 경우 대부분은 배열인 E[]대신 컬렉션인 List<E>를 사용하면 해결된다. (Primitive Type일 경우 Boxing Type을 이용하면 된다.)

단, 코드가 조금 복잡해지고 성능이 살짝 나빠질 수가 있다. 대신 타입 안정성과 상호 운용성이 좋아진다.

생성자에서 컬렉션을 받고 랜덤으로 특정 원소를 반환하는 Chooser class를 예로 Array 대신 Collection<T>를 적용해보겠습니다.

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 = (T[]) choices.toArray(); // toArray는 Object[]를 반환하기 때문에 강제 형변환을 해줘야한다.
    }
    public T choose() {
    	Random rnd = ThreadLocalRandom.current();
        return choiceArray[rnd.nextInt(choiceArray.length)];
    }
}

위와 같은 코드르 컴파일 할 경우, (T[]) choice.toArray()에서 uncheck cast 경고가 발생한다. T가 무슨타입인지 컴파일러는 알 수 없으니 런타임에 안정한지 보장할 수 없다는 메세지다.

제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억하자!

프로그램은 잘 동작한다. 단지 컴파일러가 안전을 보장하지 못할 뿐입니다. 해당 경고를 @SuppressWarnings("unchecked") 어노테이션을 통해 제거하고 주석으로 남기겨서 처리하면 된다.

하지만 애초에 경고가 나지 않도록하는 방법이 있다면 적용하는 것이 좋다.
비검사 형변환 경고를 제거하려면 배열대신 List를 쓰면 다 해결된다.

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

코드가 조금 늘었고 조금 느리지만 런타임에 CastException를 발생할 위험을 제거했으니 충분히 가치가 있다.

정리

이번 글에서 배열과 제네릭 리스트를 비교해봤습니다. 배열과 리스트(제네릭)에 적용되는 전혀 다른 규칙인 공변, 실체화에 대해 알게되었고 제네릭의 타입 정보는 런타임에서만 유지된다는 사실은 굉장히 디버깅에 좋은 힌트를 주는 정보입니다.

배열과 제네릭을 섞어서 쓰는 코드는 지양하도록하고 타입 안정성과 상호운용성을 위해서 제네릭이 적용된Collection<T>을 사용하는 것을 습관화해야겠습니다.

profile
Scratch, Under the hood, Initial version analysis

0개의 댓글