1. raw 타입은 사용하지 말라
raw 타입이란 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때를 말한다
예컨대 List<E>의 raw 타입은 매개변수가 없는 제네릭 타입인 List다
raw 타입은 타입 선언에서 제네릭 타입 정보가 전부 지워진 것처럼 동작하는데, 제네릭이 도래하기 전 코드와 호환되도록 하기 위한 궁여지책이라 할 수 있다
제네릭을 지원하기 전에는 컬렉션을 다음과 같이 선언했다
// Stamp 인스턴스만 취급한다
private final Collection stamps = ...;
// 실수로 동전을 넣는다
stamps.add(new Coin(...)); // "unchecked call" 경고
이 코드를 사용하면 실수로 도장(Stamp) 대신 동전(Coin)을 넣어도 아무 오류 없이 컴파일되고 실행된다
컬렉션에서 이 동전을 다시 꺼내기 전까지는 오류를 알아채지 못한다
for(Iterator i=stamps.iterator(); i.hasNext();) {
Stamp stamp = (Stamp) i.next(); // ClassCastException을 던진다
stamp.cancel();
}
컴파일러 단계에서 에러를 발견하도록 코드를 작성해야 안전하다
private final Collection<Stamp> stamps = ...;
또한 엉뚱한 타입의 인스턴스를 넣으려 하면 컴파일 오류가 발생하며 무엇이 잘못됏는지 정확히 알려준다
raw 타입을 쓰면 제네릭이 안겨주는 안전성과 표현력을 모두 잃게 된다
List 같은 raw 타입은 사용해서는 안 되나?
List<Object> 처럼 임의 객체를 허용하는 매개변수화 타입은 괜찮다
List는 제네릭 타입에서 완전히 발을 뺀 것이고, List<Object>는 모든 타입을 허용한다는 의사를 컴파일러에 명확히 전달한 것이다
매개변수로 List를 받는 메서드에 List<String>을 넘길 수 있지만, List<Object>를 받는 메서드에는 넘길 수 없다
이는 제네릭의 하위 타입 규칙 때문이다
즉, List<String>은 raw 타입인 List의 하위 타입이지만, List<Object>의 하위 타입은 아니다
결국 List<Ojbect> 같은 매개변수화 타입을 사용할 때와 달리 List 같은 raw 타입을 사용하면 타입 안전성을 잃게 된다
원소의 타입을 몰라도 되는 raw 타입을 쓰고 싶어질 수 있다
제네릭 타입을 쓰고 싶지만 실제 타입 매개변수가 무엇인지 신경 쓰고 싶지 않다면 '?'를 사용하자
예를 들어 Set<E>의 비한정적 와일드카드 타입은 Set<?>다
static int numElementsInCommon(Set<?> s1, Set<?> s2) { ... }
그렇다면 비한정적 와일드카드 타입인 Set<?>와 raw 타입인 Set의 차이는 무엇일까?
와일드카드 타입은 안전하고 raw 타입은 안전하지 않다는 차이가 있다
raw 타입 컬렉션에는 아무 원소나 넣을 수 있으니 타입 불변식을 훼손하기 쉽다
예외적으로 raw 타입을 써도 되는 경우가 2가지 있다
첫 번째는 class 리터럴에는 raw 타입을 써야 한다
자바 명세는 class 리터럴에 매개변수화 타입을 사용하지 못하게 했다 (배열과 기본 타입은 허용한다)
예를 들어 List.class, String[].class, int.class는 허용하고 List<String>.class와 List<?>.class 는 허용하지 않는다
두 번째 예외는 instanceof 연산자를 사용할 때이다
런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다
그리고 raw 타입이든 비한정적 와일드카드 타입이든 instanceof는 완전히 똑같이 동작한다
비한정적 와일드카드 타입의 '<?>'는 아무런 역할 없이 코드만 지저분하게 만드므로 raw 타입을 쓰는 것이 깔끔하다
다음 코드는 제네릭 타입에 instanceof를 사용하는 올바른 예다
if (o instanceof Set) { // raw 타입
Set<?> s = (Set<?>) o; // 와일드카드 타입
}
o의 타입이 Set임을 확인한 다음 와일드카드 타입인 Set<?>로 형변환해야 한다 (raw 타입인 Set이 아니다)
이는 검사 형변환(checked cast)이므로 컴파일러 경고가 뜨지 않는다
- 핵심 정리
raw 타입을 사용하면 런타임에 예외가 일어날 수 있으니 사용하면 안 된다
raw 타입은 제네릭이 도입되기 이전 코드와의 호환성을 위해 제공될 뿐이다
빠르게 훑어보자면, Set<Object>는 어떤 타입의 객체도 저장할 수 있는 매개변수화 타입이고, Set<?>는 모종의 타입 객체만 저장할 수 있는 와일드카드 타입이다
그리고 이들의 raw 타입인 Set은 제네릭 타입 시스템에 속하지 않는다
Set<Object>와 Set<?>는 안전하지만, raw 타입인 Set은 안전하지 않다
2. 비검사 경고를 제거하라
제네릭을 사용하기 시작하면 수많은 컴파일러 경고를 보게 될 것이다
비검사 형변환 경고, 비검사 메서드 호출 경고, 비검사 매개변수화 가변인수 타입 경고, 비검사 변환 경고 등
제네릭에 익숙해질수록 마주치는 경고 수는 줄겠지만 새로 작성한 코드가 한 번에 깨끗하게 컴파일되리라 기대하지는 말자
대부분의 비검사 경고는 쉽게 제거할 수 있다
// unchecked conversion 경고가 발생한다
Set<Lark> exaltation = new HashSet();
// 다이아몬드 연산자로 해결
Set<Lark> exaltation = new HashSet<>();
다이아몬드 연산자(<>)를 사용하면 컴파일러가 올바른 실제 타입 매개변수(예제의 경우 Lark)를 추론해 준다
제거하기 훨씬 어려운 경고도 있다
경고를 제거할 수는 없지만 타입이 안전하다고 확신할 수 있다면 @SuppressWarnings("unchecked") 어노테이션을 통해 경고를 숨기자
단, 타입 안전함을 검증하지 않은 채 경고를 숨기면 스스로에게 잘못된 보안 인식을 심어주는 꼴이다
한편, 안전하다고 검증된 비검사 경고를 그대로 두면, 진짜 문제를 알리는 새로운 경고가 나와도 눈치채지 못할 수 있다
제거하지 않은 수많은 거짓 경고 속에 새로운 경고가 파묻힐 것이기 때문이다
@SuppressWarnings 어노테이션은 개별 지역변수 선언부터 클래스 전체까지 어떤 선언에도 달 수 있다
하지만 @SuppressWarnings 어노테이션은 항상 가능한 한 좁은 범위에 적용하자
보통은 변수 선언, 아주 짧은 메서드, 혹은 생성자가 될 것이다
한 줄이 넘는 메서드나 생성자에 달린 @SuppressWarnings 어노테이션을 발견하면 지역변수 선언 쪽으로 옮기자
이를 위해 지역변수를 새로 선언하는 수고를 해야 할 수도 있지만, 그만한 값어치가 있을 것이다
ArrayList에서 가져온 다음 toArray 메서드를 예로 생각해 보자
public <T> T[] toArray(T[] a) {
if (a.length < size) {
return (T[]) Array.copyOf(elements, size, a.getClass());
}
System.arraycopy(elements, 0, a, 0, size);
if (a.length > size) {
a[size] = null;
}
return a;
}
ArrayList를 컴파일하면 이 메서드에서 다음 경고가 발생한다
어노테이션은 선언에만 달 수 있기 때문에 return 문에는 @SuppressWarnings를 다는 게 불가능하다
범위를 최대한 작게 어노테이션을 달아주기 위해서 반환값을 담을 지역변수를 하나 선언하고 그 변수에 어노테이션을 달아주자
public <T> T[] toArray(T[] a) {
if (a.length < size) {
// 생성한 배열과 매개변수로 받은 배열의 타입이 모두 T[]로 같으므로 올바른 형변환이다
@SuppressWarnings("unchecked")
T[] result = (T[]) Array.copyOf(elements, size, a.getClass());
return result
}
System.arraycopy(elements, 0, a, 0, size);
if (a.length > size) {
a[size] = null;
}
return a;
}
@SuppressWarnings("unchecked")을 사용할 때면 그 경고를 무시해도 안전한 이유를 항상 주석으로 남겨야 한다
- 핵심 정리
비검사 경고는 중요하니 무시하지 말자
모든 비검사 경고는 런타임에 ClassCastException을 일으킬 수 있는 잠재적 가능성을 뜻하니 최선을 다해 제거하라
경고를 없앨 방법을 찾지 못하겠다면, 그 코드가 타입 안전함을 증명하고 가능한 한 범위를 좁혀 @SuppressWarnings("unchecked") 어노테이션으로 경고를 숨겨라
그런 다음 경고를 숨기기로 한 근거를 주석으로 남겨라
3. 배열보다는 리스트를 사용하라
배열과 제네릭 타입에는 중요한 차이가 두 가지 있다
첫 번째, 배열은 공변(convariant)이다
어려워 보이는 단어지만 뜻은 간단하다
Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다
공변은 즉, 함께 변한다는 뜻이다
반면, 제네릭은 불공변(invariant)이다
즉, 서로 다른 타입 Type1과 Type2가 있을 때, List<Type1>은 List<Type2>의 하위 타입도 아니고 상위 타입도 아니다
이것만 보면 제네릭에 문제가 있다고 생각할 수도 있지만, 사실문제가 있는 건 배열 쪽이다
// 런타임에 실패한다
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없음"; // ArrayStoreException을 던짐
// 컴파일에 실패한다
List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입
ol.add("타입이 달라 넣을 수 없음");
배열은 런타임 시 알게 되지만, 리스트를 사용하면 컴파일 시 바로 알아챌 수 있다
두 번째, 배열은 실체화가 된다
배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다
그래서 Long 배열에 String을 넣으려 하면 ArrayStoreException이 발생한다
반면, 앞서 이야기했듯 제네릭은 타입 정보가 런타임에는 소거(erasure)된다
원소 타입을 컴파일 타임에만 검사하며 런타임에는 알 수조차 없다는 뜻이다
소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘으로, 자바 5가 제네릭으로 순조롭게 전환될 수 있도록 해줬다
이상의 주요 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다
예컨대 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다
즉, 코드를 new List<E>[], new List<String>[], new E[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다
제네릭 배열을 만들지 못하게 막은 이유는 타입 안전하지 않기 때문이다
이를 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException이 발생할 수 있다
런타임에 ClassCastException이 발생하는 일을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋나는 것이다
- 핵심 정리
배열과 제네릭에는 매우 다른 타입 규칙이 적용된다
배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소거된다
그 결과 배열은 런타임에는 타입 안전하지만 컴파일 타임에는 그렇지 않다. 제네릭은 반대다
그래서 둘을 섞어 쓰기란 쉽지 않다
둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해 보자
4. 이왕이면 제네릭 타입으로 만들라
JDK가 제공하는 제네릭 타입과 메서드를 사용하는 일은 일반적으로 쉬운 편이지만,
제네릭 타입을 새로 만드는 일은 조금 더 어렵다
단순한 스택 코드를 살펴보자
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e){
ensureCapacity();
elements[size++] = e;
}
public Object pop(){
if(size == 0) {
throw new EmptyStackException();
}
return elements[--size];
}
...
}
이 클래스는 원래 제네릭 타입이어야 마땅하다
지금 상태에서의 스택에서 꺼낸 객체를 형변환해야 하는데, 이때 런타임 오류가 날 위험이 있다
일반 클래스를 제네릭 클래스로 만드는 첫 단계는 클래스 선언에 타입 매개변수를 추가하는 일이다
현재 코드에서는 스택이 담을 원소의 타입 하나만 추가해 주면 된다
이때 타입 이름으로는 보통 E를 사용한다
그런 다음 코드에 쓰인 Object를 적절한 타입 매개변수로 바꾸고 컴파일해보자
// 제네릭 스택으로 가는 첫 단계 - 컴파일 실패
public class Stack<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e){
ensureCapacity();
elements[size++] = e;
}
public E pop(){
if(size == 0) {
throw new EmptyStackException();
}
E result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
...
}
이 단계에서 대체로 하나 이상의 오류나 경고가 발생한다
E와 같은 실체화 불가 타입으로는 배열을 만들 수 없다
배열을 사용하는 코드를 제네릭으로 만들려 할 때는 이 문제가 항상 발목을 잡을 것이다
적절한 해결책은 두 가지다
첫 번째, 제네릭 배열 생성을 금지하는 제약을 대놓고 우회하는 방법이다
Object 배열을 생성한 다음 제네릭 배열로 형변환해 보자
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
이제 컴파일러는 오류 대신 경고를 내보낼 것이다
이렇게도 할 수는 있지만 타입 안전하지 않다
이 비검사 형변환이 프로그램의 타입 안전성을 해치지 않음을 우리 스스로 확인해야 한다
문제의 배열 elements는 private 필드에 저장되고, 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 전혀 없다
push 메서드를 통해 배열에 저장되는 원소의 타입은 항상 E다
따라서 이 비검사 형변환은 확실히 안전하다
안전함을 직접 증명했다면 범위를 최소로 좁혀 @SuppressWarnings 어노테이션을 통해 경고를 숨긴다
// 배열 elements는 push(E)로 넘어온 E 인스턴스만 담는다
// 따라서 타입 안전성을 보장하지만, 이 배열의 런타임 타입은 E[]가 아닌 Ojbect[]다
@SuppressWarnings("unchecked")
public Stack() {
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
두 번째, elements 필드의 타입을 E[]에서 Object[]로 바꾸는 것이다
이렇게 하면 첫 번째와는 다른 오류가 발생한다
E는 실체화 불가 타입이므로 컴파일러는 런타임에 이뤄지는 형변환이 안전한지 증명할 방법이 없다
이번에도 마찬가지로 직접 증명하고 경고를 숨길 수 있다
pop 메서드 전체에서 경고를 숨기지 말고 비검사 형변환을 수행하는 할당문에서만 숨기는 것이 좋다
public E pop(){
if(size == 0) {
throw new EmptyStackException();
}
@SuppressWarnings("unchecked")
E result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
지금까지 설명한 Stack 예는 "배열보다는 리스트를 우선하라"는 주제와 모순돼 보인다
사실 제네릭 타입 안에서 리스트를 사용하는 게 항상 가능하지도, 꼭 더 좋은 것도 아니다
자바가 리스트를 기본(원시) 타입으로 제공하지 않으므로 ArrayList 같은 제네릭 타입도 결국 기본 타입인 배열을 사용해 구현해야 한다
또한 HashMap 같은 제네릭 타입은 성능을 높일 목적으로 배열을 사용하기도 한다
'개발 서적 > 이펙티브 자바' 카테고리의 다른 글
이펙티브 자바 3/E - 4.클래스와 인터페이스(2) (3) | 2023.06.18 |
---|---|
이펙티브 자바 3/E - 4.클래스와 인터페이스(1) (1) | 2023.06.10 |
이펙티브 자바 3/E - 3.모든 객체의 공통 메서드 (1) | 2023.05.20 |
이펙티브 자바 3/E - 2.객체 생성과 파괴 (0) | 2023.04.30 |
이펙티브 자바 3/E - 1.들어가기 (0) | 2023.04.19 |