일상/레벨업 독서

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

Gamii 2022. 4. 4. 23:44
728x90

핵심 정리

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

 

배열 제네릭
공변 불공변
실체화 타입 정보 소거
런타임 - 타입 안전 런타임 - 불안전
컴파일타임 - 불안전 컴파일타임 - 안전

 

배열과 제네릭 타입의 차이

배열은 공변(covariant)이다.

Sub가 Super의 하위 타입이라면 배열 Sub[ ]는 배열 Super[ ]의 하위 타입이 된다(공변, 즉 함께 변한다는 뜻이다).
반면, 제네릭은 불공변(invariant)이다. 즉, 서로 다른 타입 Type1과 Type2가 있을 때, List<Type1>은 List<Type2>의 하위 타입도 아니고 상위 타입도 아니다.



공변이 문제가 되는 이유
아래 두 코드 모두에서 Long용 저장소에 String을 넣을 수 없다. 
배열은 이를 런타임에 알게 되지만, 리스트는 컴파일 때 바로 알 수 있다.

/*
*  1. 문법상 허용은 되지만 런타임에 실패한다.
*  배열은 공변이므로 Long배열은 Object배열의 하위 타입으로 인식되어 문제없이 컴파일이 된다.
*  그 후 런타임에 오류가 발생하게 된다.(배열을 사용하면 안되는 이유)
*/
Object[] objectArray = new Long[1]; //컴파일이 된다.
objectArray[0] = "타입이 달라 넣을 수 없다."; //런타임에 ArrayStoreException을 던진다. 


/*
*  2. 컴파일 오류를 일으킨다.
*  List<Object>와 List<Long>은 서로 다른 타입으로 인식한다.
*  처음부터 컴파일 오류가 발생하기 때문에 리스트를 사용해야한다.
*/
List<Object> objectList = new ArrayList<Long>(); //컴파일이 안된다. 호환이 되지 않는다.
objectList.add("타입이 달라 넣을 수 없다.");

 

*참고*

질문 )  타입 안정성이 보장되지 않는 공변의 특성을 배열에 넣은 이유는 무엇일까?

답 )  배열의 다형성의 이점을 살리기 위해서다.

public class Arrays {
	private static final int MIN_ARRAY_SORT_GRAN = 8192;
    private static final int INSERTIONSORT_THRESHOLD = 7;
    
	private Arrays() {
    }
    
    //배열에 들어있는 원소 타입과는 상관없이 단순히 두 원소의 위치만 바꿔주면 되는 메서드
    private static void swap(Object[ ] x, int a, int b) {
    	Object t = x[a];
        x[a] = x[b];
        x[b] = t;
    }
    
    ... //생략
}

배열을 공변으로 만들게 되면 각 타입마다 오버로딩으로 모든 swap 메서드를 만들어 줄 필요가 없어진다.

형변환 과정에서 발생할 수 있는 오류를 어느정도 감안하더라도 다형성으로 얻을 수 있는 이점이 크다고 생각 됬을 것이다. 자바에서 제네릭이 도입되고 와일드카드 등의 기능을 통해 다형성까지 챙길 수 있게 되었고, 이제는 배열보다는 리스트를 사용하여 컴파일러가 타입 안정성을 보장할 수 있도록 코드를 만드는 것이 옳게 되었다.

 

 

배열은 실체화(reify)된다.
//배열은 런타임에 타입이 실체화되기 때문에
//objects는 런타임에 String[]가 된다.
Object[] objects = new String[1];


//제네릭 타입은 런타임에 소거
// 컴파일 타임시
ArrayList<String> stringArr = new ArrayList<String>();
ArrayList<Integer> integerArr = new ArrayList<Integer>();

// 런타임시 제네릭 타입은 런타임에 소거되므로 구분이 불가능하다.
// 타입이 소거된 ArrayList만 남게 된다.
ArrayList stringArr = new ArrayList();
ArrayList integerArr = new ArrayList();

배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다. Long배열에 String을 넣으려 하면 ArrayStoreException이 발생한다. 제네릭은 타입 정보가 런타임에는 소거(erasure)된다. 원소 타입을 컴파일타임에만 검사하며 런타임에는 알수조차 없다는 뜻이다.

 

 

제네릭 배열을 만들지 못하게 막은 이유는 무엇일까?

 

타입 안전하지 않기 때문이다.

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

런타임에 ClassCastException이 발생하는 일을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋나는 것이다.

//제네릭 배열 생성. 런타임시 제네릭 타입은 소거되므로 List[]가 된다.
List<String>[] stringLists = new List<String>[1]; // (1) 

//타입 소거로 인해 런타임시 List가 된다.
List<Integer> intList = List.of(42);              // (2)

//배열은 공변성을 가지므로 Object[]는 List[]가 될 수 있다.
Object[] objects = stringLists;                   // (3)

//intList또한 List이므로 배열의 요소가 될 수 있다.
objects[0] = intList;                             // (4)

//String 타입을 가져야 하지만 Integer이므로 예외가 발생한다.
String s = stringLists[0].get(0);                 // (5)

 

(5)는 이 배열의 처음 리스트에서 첫 원소를 꺼내려 하는데 컴파일러는 꺼낸 원소를 자동으로 String으로
형변환 하는데, 이 원소는 Integer이니 런타임에 ClassCastException이 발생한다.

이를 막기 위해서 제네릭 배열 생성을 막도록 (1)에서 컴파일 오류를 내야 한다.

 

 

실체화 불가 타입(non-reifiable type)

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

 

*참고*

비한정적 와일드카드란?

 

  • 와일드카드 문자인  ? 만 사용할 때 비한정적 와일드카드라고 하며, 알려지지 않은 타입의 리스트라고 한다.
  • List<String>, List<Integer> 등 모든 타입이 List<?>의 하위 타입이기 때문에 어떤 타입의 List라도 타입을 보존할 수 있다.
  • add()가 막혀있어 null만 넣을 수 있다. 그래서 이상한 타입의 element가 리스트에 추가 되는 것을 막아준다.(안정성)
  • List<? extends Number> 이런 형태로 범위를 한정지을 수 있다.