본문 바로가기
Dev Books/Effective Java

[item 28] 배열보다는 리스트를 사용하라

by Thumper 2024. 5. 31.

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

배열과 제네릭 차이 1

배열

  • 배열은 공변이다. (함께 변한다는 뜻이다.)
  • Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다.

    제네릭

  • 불공변이다.
  • 즉, 서로 다른 타입 Type1과 Type2가 있을 때, List<Type1>List<Type2>의 하위 타입도 아니고 상위 타입도 아니다.

이것만 보면, 제네릭에 문제가 있다고 생각할 수도 있지만, 사실 문제가 있는 건 배열 쪽이다.

아래 코드를 보자.

배열 예시코드 : 런타임에 실패한다.

image

위 코드는 컴파일 단계에서는 문제가 없지만 런타임 단계에서 문제가 발생한다.


제네릭 예시코드 : 컴파일되지 않는다.

image
List를 사용하면, 컴파일 단계에서 문제를 알 수 있게 된다.

배열과 제네릭 차이 2

배열

  • 배열은 실체화(reify)된다.
  • 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다.
    그래서 위의 '배열 예시코드'에서 보듯 Long 배열에 String을 넣으려 하면 arrayStoreException이 발생한다.

    제네릭

  • 타입 정보가 런타임에는 소거(erasure)된다.
  • 원소 타입을 컴파일타임에만 검사하며 런타임에는 알수조차 없다는 뜻이다.
  • 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘이다.

제네릭 배열은 사용불가하다.

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

즉 코드를 new List[], new List[], new e[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다.


제네릭 배열을 만들지 못하게 막은 이유

타입 안전하기 않기 때문이다.
제네릭 배열을 허용하면, 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException이 발생할 수 있다.

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


제네릭 배열 생성을 허용하지 않는 이유 - 컴파일되지 않는다.

image

제네릭 배열을 생성하는 코드 (1)가 허용이 된다고 가정해보자.

  • (2)는 원소가 하나인 List를 생성한다.
  • (3)는 Object 배열에 할당한다. 배열은 공변이니 문제가 없다.
  • (4)는 (2)에서 생성한 List의 인스턴스를 Object 배열의 첫 원소로 저장한다.
    • 제네릭은 소거 방식으로 구현되어서 이 역시 성공한다.
    • 즉, 런타임에는 List<Integer> 타입은 List가 되고
      List<Integer>[]는 List[]가 된다.
  • (5)에서 문제가 발생한다.
    • (4)에서 String만 담기로 했는데 Integer가 들어가 있으니 ClassCastException이 발생한다.
    • 그리고 (5)는 이 배열의 처음 리스트에서 첫 원소를 꺼내려한다.
    • 컴파일러는 꺼낸 원소를 자동으로 String으로 형변환하는데, 이원소는 Integer이므로 런타임에 ClassCastException이 발생한다.

위와 같은 이유로 이런 일을 방지하기 위해서 컴파일 오류를 내야 한다.

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

E, List<E>, List<String> 같은 타입을 실체화 불가 타입이라고 한다.
실체화 되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입이다.

실체화될 수 있는 타입

소거 메커니즘으로 인해 매개변수화 타입 가운데 실체화될 수 있는 타입도 있다.
List<?>, Map<?, ?> 같은 비한정적 와일드카드만 가능하다.

배열의 불편함

배열을 제네릭으로 만들 수 없어 귀찮을 때도 있다.

  • 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는게 불가능하다.
  • 제네릭 타입과 가변인스 메서드(varargs method)를 함께 쓰면 해석하기 어려운 경고 메시지를 받게 된다.
  • 가변인수 메서드를 호출할 때마다 가변인수를 담는 배열이 만들어지는데,
    이때 그 배열의 원소가 실체화 불가 타입이면 경고가 뜬다. 이 문제는 @SafeVarargs로 해결한다.

배열 대신 리스트를 사용하자.

배열 단점

코드가 조금 복잡해지고 성능이 살짝 나빠질 수도 있다.

배열 장점

그 대신 타입 안정성과 상호운용성은 좋아진다.


제네릭을 사용하지 않은 코드 : Chooser 클래스

생성자에서 컬렉션을 받는 Chooser 클래스를 예로 살펴보자.

image

이 클래스는 컬렉션 안의 원소 중 하나를 무작위로 선택해 반환하는 choose 메서드를 제공한다.

생성자에 어떤 컬렉션을 넘기느냐에 따라 이 클래스를 주사위판, 매직 8볼, 몬테카를로 시뮬레이션용 데이터 소스 등으로 사용할 수 있다.

  • 제네릭을 쓰지 않고 구현한 가장 간단한 버전이다
  • 이 클래스를 사용하려면 choose 메서드를 호출할 때마다 반환된 Object를 원하는 타입으로 형변환해야 한다.
  • 혹시나 타입이 다른 원소가 들어 있었다면 런타임에 형변환 오류가 날 것이다.

아래와 같이, 잘못 변환하면 런타임 형변환 오류가 발생한다.
image


Chooser를 제네릭으로 만들기 위한 첫 시도 - 컴파일되지 않는다.

image

  • 제네릭을 추가하고, choose() 메서드는 그대로 유지했다.
  • 타입 컴파일 오류가 발생한다.

Chooser를 제네릭으로 만들기 위한 두번째 시도 - 경고가 뜬다.

image

  • Object 배열을 T 배열로 형변환했다.
  • T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보자알 수 없다는 경고 메시지를 띄운다.
  • 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없다.

image

실행했을 때 동작한다. 단지 컴파일러가 안전을 보장하지 못할 뿐이다.



리스트 기반으로 작성 - 타입 안정성 확보

위에서 발생한 비검사 형변환 경고를 제거하려면 배열 대신 리스트를 사용하면 된다.

image

  • 리스트로 바꾸게 되면 오류나 경고 없이 컴파일된다.
  • 이번 버전은 코드 양도 증가하고 살짝 느릴 수는 있다.
  • 하지만, 런타임에 ClassCastException을 만날 일은 없다.

☑️ 핵심정리

내용정리를 하기 전에 핵심 포인트를 짚어보자.

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

댓글