Home Item28 (배열보다는 리스트를 사용하라)
Post
Cancel

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

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

왜 배열보다는 리스트를 사용하라는걸까?

리스트에는 사용할 수 있는 API가 많아서? 리스트는 가변 길이로 사용할 수 있어서?

결론부터 말하자면 이유는 리스트와 배열 간의 관계가 아닌 배열과 제네릭 타입에 중요한 차이들이 있기 때문이다.

그 차이들을 알아보자.

공변(covariant)과 불공변(invariant)

결론부터 말하면 배열은 공변이고, 제네릭은 불공변이다.

공변이란 함께 변한다는 뜻으로, Sub라는 클래스가 Super라는 클래스의 하위 클래스라고 가정할 때 Sub[]는 Super[]의 하위 타입이 된다.

반면 불공변은 같이 변하지 않는다는 뜻으로 만약 서로 다른 타입 타입1, 타입2가 있을 때 타입1 List는 타입2 List의 하위 타입도 아니고 상위 타입도 아니다.

정의만 봤을 때 공변이 더 유연하게 활용할 수 있는 여지가 있는 것 아닌가 싶다. 그러나 문제를 보자.

1
2
Object[] objectArr = new Long[1];
objectArr[0] = "문자열입니다.";
  • 위 코드는 문법상 허용되는 코드이다.
    • 컴파일 에러가 나지 않는다.
  • 그러나 런타임 시 ArrayStoreException이 발생한다.

반면 제네릭의 경우를 보자.

1
2
List<Object> objectArr = new ArrayList<Long>();
objectArr.add("문자열입니다.");
  • 컴파일 시 에러가 발생한다.
    • Object 리스트에 Long형 ArrayList를 할당하는 것부터 안된다.
    • The Long class wraps a value of the primitive type long in an object.

두 결과를 보면 배열이든 리스트든 Long용 저장소에 String을 넣을 수 없는 것은 마찬가지이다.

하지만 배열에서는 그 실수를 런타임에서 알 수 있다는 것과 리스트에서는 컴파일 시 바로 알아채는 것에서 차이가 있다.

컴파일 오류가 가장 좋은 오류이다.

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

제네릭은 타입 정보가 런타임에는 소거된다. 컴파일 시 타입을 검사했다면 런타임에는 알 수 없다는 뜻이다.

이러한 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있도록 하는 메커니즘이다.

반면 배열은 위 예시 코드에서도 보았다시피 런타임에도 자신의 원소 타입을 인지하고 확인한다.

이러한 차이로 배열과 제네릭은 잘 어우러지지 못한다. 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다.

1
2
3
4
new List<E>[]
new List<String>[]
new E[]
//전부 불가!

이렇게 제네릭 배열을 막아놓은 이유는 타입 안전성 때문이다.

이를 만약 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException이 발생할 수 있다. 런타임에 ClassCastException이 발생하는 것을 막겠다는 제네릭의 취지에 벗어나는 것이다.

코드 예시들을 보면서 제네릭 배열을 만약 허용했다면 무슨 일이 발생할 지 알아보자.

1
2
3
4
5
6
7
8
9
10
List<String>[] strLists = new ArrayList[1]; // 허용 O, 코테풀때 종종 쓰는데?
// 배열의 각 요소가 ArrayList<String> 객체를 참조할 뿐,
// List<String> 배열 자체를 생성하는 것이 아니기 때문에 제네릭 배열 생성을 간접적으로 지원하는 것.
// 반면 stringLists는 직접적으로 제네릭 배열을 생성하는 것. 타입 안정성때문에 허용 X

List<String>[] stringLists = new List<String>[1]; // (1) - 허용 X
List<Integer> intList = List.of(42); // (2)
Object[] objects = stringLists; // (3)
objects[0] = intList; // (4)
String s = stringLists[0].get(0); // (5)
  • 제네릭 배열을 생성하는 (1)이 허용된다고 가정해보자.
  • (2)는 원소가 하나인 Integer List가 생성된다.
  • (3)은 (1)에서 생성한 String List 배열을 Object 배열에 할당한다.
  • (4)는 (2)에서 생성한 Integer List의 인스턴스를 Object 배열의 첫 원소로 저장한다.
    • 제네릭은 소거되기 때문에 (2)의 Integer List가 List가 되고 (1)의 String List 배열은 List[]가 되기 때문에 가능하다.
    • String List만 저장하겠다고 했던 배열은 Integer List를 저장하고 있다.
  • (5)에서 제네릭 소거로 인한 (4)의 허용때문에 String 원소를 기대하지만 Integer 원소를 갖게되는 문제가 발생한다.
    • String - Integer 에서 발생하는 ClassCastException이 발생한다.

따라서 위와 같은 문제가 없으려면 (1)에서 제네릭 배열을 생성하게 두면 안된다.

1
E, List<E>, List<String>
  • 위와 같은 타입들을 실체화 불가 타입이라고 한다.
    • 실체화되지 않아 컴파일 타임보다 런타임에 타입 정보를 적게 가진다는 뜻이다.
    • 실체화될 수 있는 타입은 비한정적 와일드카드 타입 (?) 뿐이다.
      • 그러나 배열을 비한정적 와일드카드 타입으로 만들어 유용하게 쓰기는 어렵다.

배열을 제네릭으로 쓸 수 없어 귀찮아지는 경우들

제네릭 컬렉션에서는 보통 자신의 원소 타입을 담은 배열을 반환하는게 불가능하다.

1
2
3
4
5
List<String> stringList = new ArrayList<>(); 
stringList.add("Hello"); 

String[] arr = stringList.toArray(new String[stringList.size()]);
//불가능
  • 제네릭 타입은 런타임에 소거되기 때문에 toArray() 메서드가 어떤 타입을 반환할 지 알 수 없다.
    • 일반적으로 Object 형태로 반환되고, 이를 컴파일러가 강제로 String으로 캐스팅하려할 때 ClassCastException이 발생할 수 있어 막아둔 것이다.

물론 우회하는 방법도 있긴하다. 추후 아이템에서 설명한다.

그리고 또 제네릭 타입과 가변인수 메서드를 함께 쓰면 해석하기 어려운 경고 메시지를 받게 된다.

1
2
3
4
5
6
7
8
9
10
11
public static <T> void printElements(List<T> elements) { 
	for (T element : elements) { 
		System.out.print(element + " "); 
	} 
	System.out.println(); 
}

List<String> stringList = Arrays.asList("Hello", "World"); 
List<Integer> integerList = Arrays.asList(1, 2, 3);

printElements(stringList);

  • 가변인수 메서드를 호출할 때마다 가변인수 매개변수를 담을 배열이 하나 만들어진다.
    • 이 때, 해당 배열의 원소가 실체화 불가 타입이라면 경고가 발생하는 것이다.
    • 이 문제는 @SafeVarargs 애노테이션으로 대처할 수 있다.

보통 배열로 형변환 시 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우의 대부분은 배열인 E[] 대신 List를 사용하면 된다.

코드가 복잡해지고 성능이 나빠질 수 있지만 타입 안전성과 상호 운용성이 좋아진다.

1
2
3
4
5
6
7
8
9
10
11
12
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를 원하는 타입으로 형변환해야 한다.
    • 다른 타입의 원소가 들어있다면 형변환 오류를 발생시킬 것이다.

제네릭으로 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class Chooser<T> {
    private final T[] choiceArray;
    
    public Chooser(Collection<T> choices) {
        choiceArray = choice.toArray();
    }

	public Object choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceArray(rnd.nextInt[choiceArray.length)];
    }
}
  • 위에서 보았던 대로 제네릭 컬렉션에서는 보통 자신의 원소 타입을 담은 배열을 반환하는게 불가능하다.
    • 따라서 toArray()에서 오류가 발생한다.

소거때문에 무슨 타입인지 알 길이 없기 때문에 그렇다고 했다. 따라서 이런 경우 그냥 배열 대신 제네릭 컬렉션을 사용하면 문제는 해결된다.

1
2
3
4
5
6
7
8
9
10
11
12
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()));
    }
}
  • 컬렉션은 제네릭 타입을 지원해 런타임에도 요소의 타입 정보를 유지한다.
    • 따라서 타입 안전성을 보장하기 때문에 문제는 해결된다.

정리

배열은 공변이고 실체화된다. 반면 제네릭은 불공변이고 타입 정보가 소거된다.

이 차이 때문에 배열은 런타임에는 타입 안전하고 컴파일 타임에는 그렇지 않다. 제네릭은 그 반대이다.

따라서 배열과 제네릭은 섞어 쓰기 어렵다.

따라서 제네릭과 배열을 같이 사용하고 싶다면, 리스트를 사용해보자.

This post is licensed under CC BY 4.0 by the author.

Item27 (비검사 경고를 제거하라)

Item29 (이왕이면 제네릭 타입으로 만들라)