[이펙티브 자바] 아이템 28. 배열보다는 리스트를 사용하라

June·2022년 3월 1일
0

[이펙티브자바]

목록 보기
26/72

학습 배경

    private WinningTicket makeWinningTicket() {
        int[] winningNumbers = InputView.requestWinningNumber();
        ...

배열을 꼭 반환해야하는 상황이 아니라면 컬렉션을 사용하는게 어떨까요?
이펙티브 자바 28. 배열보다 리스트를 사용하라

이 부분은 프리코스 때도 공통 피드백에 있던 내용이었다.

당시에는 큰 이유가 없어 잘 와닿지 않았던 것 같다. 아마 잘 구현된 안정적인 라이브러리를 사용하라는 의미로 받아들였던 것 같다.

공변과 불공변

배열은 공변(covariant)다. 공변성이란 자신이 상속받은 부모 객체로 타입을 변화시킬 수 있다라는 것을 뜻한다.

Sub라는 클래스가 Super라는 클래스의 하위클래스라고 할때, 배열 Sub[]Super[]의 하위타입이 된다.

Object[] objectArray = new Long[1];
objectArray[0] = "저장 안되는 문자열"; //ArrayStoreException을 던진다.

이것을 시도하면 런타임에 오류가 난다.

반면 제네릭은 불공변이다. List<Type1>List<Type2>의 하위 타입도 아니고 상위 타입도 아니다.

List<Object> o1 = new ArrayList<Long>(); // 호환되지 않는 타입
o1.add("저장 안되는 문자열");

리스트를 사용하면 애초에 컴파일 시에 오류가 난다. 컴파일은 가장 값이 싼 오류다.

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

배열은 런타임에도 자신의 원소 타입을 인지하고 확인한다. 하지만 제네릭은 런타임에는 소거된다. 컴파일시 확인하면 런타임에는 알 수 없는 것이다. 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘을 위해서 존재한다. (아이템 26)

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

그럼 제네릭 배열을 왜 막아놨을까? 타입 안전하지 않기 때문이다. 이를 허용하면 컴파일러가 자동 생성한 형변환 코드에서 런타입에 ClassCaseException이 발생할 수 있다. 그러면 런타임에 ClassCaseException이 발생하는 일을 막아주겟다는 제네릭의 취지에 어긋난다.

만약 제네릭 배열을 허용한다고 가정해보자.

List<String>[] stringLists = new List<String>[1]; // 1
List<Integer> intList = List.of(42);			  // 2
Objects[] 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가 되고, List<Integer>[] 인스턴스 타입은 List[]가 된다. 따라서 4도 문제 없다. 결국 List<String> 인스턴스만 담겠다고 선언한 stringListsList<Integer> 인스턴스가 저장되어있다. 그 원소를 String으로 캐스팅 하려하니 오류가 발생한다.

E, List<E>, List<String> 같은 타입을 실체화 불가 타입(non-relifiable type)이라 한다. 쉽게 말해, 실체화 되지 않아 컴파일타임보다 런타임에 타입 정보를 적게 가지는 것이다. 소거 메커니즘 때문에 매개변수화 타입 가운데 실체화될 수 있는 타입은 ListMap 같은 비한정적 와일드카드 타입뿐이다.

제네릭 컬레션에서 자신의 원소 타입을 담은 배열을 반환하는게 불가능하다 (아이템 33을 통해 우회할 수는 있다). 또 제네릭 타입과 가변인수 메서드를 함께 쓰면 해석하기 어려운 경고 메시지를 받게된다. 가변인수 메서드를 호출할 때마다 가변인수 매개변수를 담을 배열이 하나 만들어지는데, 이때 그 배열의 원소가 실체화 불가 타입이라면 경고가 발생하는 것이다.

배열로 형변환할 때 오류나 경고가 뜨는 경우 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 메서드를 호출할 때마다 반환된 Object를 원한는 타입으로 형변환해야 한다. 만약 다른 타입의 원소가 들어있었다면 런타임에 형변환 오류가 날 것이다. 지네릭으로 만들어보자.

public class Chooser<T> {
    private final T[] choiceArray;
    
    public Chooser(Collection<T> choices) {
        choiceArray = choice.toArray();
    }
}

위의 코드를 컴파일하면 오류 메시지가 출력된다.

Object 배열을 T 배열로 변환해도 또 다른 경고가 뜬다. 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()));
    }
}

이제 런타임에 ClassCastException을 만날 일이 없다.

추가 장점 (불변객체)

코드 리뷰를 받다가 추가적인 장점을 발견했다.

public static final LottoNumber[] LOTTO_NUMBER_CACHE = new LottoNumber[MAX_LOTTO_NUMBER + 1];

static {
    IntStream.rangeClosed(MIN_LOTTO_NUMBER, MAX_LOTTO_NUMBER)
            .forEach(number -> LOTTO_NUMBER_CACHE[number] = new LottoNumber(number));
}

LOTTO_NUMBER_CACHE 선언 + 값을 할당해놓고, 값을 수정하고있는데요. 가변객체로 보입니다. 이 부분을 불변객체로 선언하면 좋을 것 같아요~!

넵, 이전에 배열구조라면 LOTTO_NUMBER_CACHE[0] = null 이런 코드로 변경가능한 여지가 있습니다.

배열로 선언했을 경우 인덱스로 바로 접근해서 값을 변경해버리는 것을 막을 수 없다.
하지만 List를 사용하면 unmodifiableList를 반환해주면 된다.

    @Test
    @DisplayName("unmodifiable 배열 테스트")
    public void unmodifiableArray() {
        LottoNumber[] lottoNumberArray = new LottoNumber[3];
        lottoNumberArray[1] = LottoNumber.from(1);
        lottoNumberArray[2] = LottoNumber.from(2);

        lottoNumberArray[1] = null;

        for (int i = 1; i < 3; i++) {
            System.out.println(lottoNumberArray[i]);
        }
    }

실제로 null이 들어간걸 알 수 있다.

@Test
@DisplayName("unmodifiable 리스트 테스트")
public void unmodifiableList() {
    List<LottoNumber> lottoNumberList = IntStream.rangeClosed(1, 3).mapToObj(LottoNumber::from)
                .collect(Collectors.toUnmodifiableList());

    lottoNumberList.set(1, null);

    for (LottoNumber lottoNumber : lottoNumberList) {
        System.out.println(lottoNumber);
    }
}

unmodifiableList로 돌려주니 set을 사용하면 예외가 발생한다.

0개의 댓글