예시를 통해 제네릭 타입으로 변환하는 방법을 알아보자.
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();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
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 E[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;
}
하지만, 아이템 28에서 배웠듯이 E
와 같은 실체화 불가 타입으로는 배열을 생성하면 컴파일 에러가 뜬다.
그렇다면 어떻게 배열을 사용한 코드를 제네릭으로 만들 수 있을까?
첫번째 해결책은 제네릭 배열 생성을 금지하는 제약을 대놓고 우회하는 방법이다. 즉 Object
배열을 생성한 다음 제네릭 배열로 형변환하는 방식이다.
하지만 이 방법은 안전하지 않아서, 컴파일은 되지만 아래와 같이 오류 대신 경고를 내보낼 것이다.
이런 경우 컴파일러를 대신해서 직접 해당 비검사 변환이 프로그램의 타입 안전성을 해치치 않음을 확인할 수 있다. 문제의 배열 elements는 private
필드에 저장되고, 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 없기 때문에 안전하다.
따라서, 비검사 형변환이 안전함을 직접 증명했다면 범위를 최소로 좁혀 @SuppressWarnings
애너테이션으로 해당 경고를 숨길 수 있다.(참고로 배열의 런타임 타입은 E[]
가 아닌 Object[]
이다)
이로써 Stack
클래스는 깔끔하게 컴파일 되고, 명시적으로 형변환하지 않아도 ClassCastException
걱정 없이 사용할 수 있게 된다.
두번째 방법은, elements 필드의 타입을 E[]
에서 Object[]
로 바꾸는 것이다.
public class Stack<E> {
private Object[] elements;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
...
}
하지만 그렇게 되면, 아래의 코드에서 E
는 실체화 불가 타입이므로 컴파일러가 런타임에 이뤄지는 형변환이 안전한지 증명할 방법이 없어진다.
따라서 마찬가지로 우리가 직접 증명하고 경고를 숨길 수 있다.
아무래도 두 번째 방식은 배열에서 원소를 읽을 때마다 해줘야 하니 형변환을 배열 생성 시 단 한번만 해주면 되는 첫번째 방식이 좋아 보인다.
하지만, 첫 번째 방식은 배열의 런타임 타입( Object
)이 컴파일타입( E
) 과 달라 힙 오염을 일으키는 단점이 존재한다.
주로 매개변수화 타입의 변수가 타입이 다른 객체를 참조하게 되어, 힙 공간에 문제가 생기는 현상을 의미한다. 즉 컴파일 중에 정상적으로 처리되며, 경고를 발생시키지 않고 나중에 런타임 시점에 ClassCastException
이 발생하는 문제를 나타낸다.
힙 오염은 다음과 같은 두 가지 경우에 나타난다.
해당 코드는 ArrayList
에 상속 관계도 아닌 서로 다른 두 타입의 객체가 추가되었다. 말이 안되지만, 컴파일러는 잘못된 상황을 다음과 같은 두 가지 이유로 알아차리지 못하게 된다.
타입 캐스팅 체크는 컴파일러가 하지 않는다. 오직 대입되는 참조변수에 저장할 수 있느냐만 검사한다.
제네릭의 소거 특성으로 인해 컴파일이 끝난 클래스 파일의 코드에는 타입 파라미터 대신 Object
가 남아있게 된다. ArrayList<Object>
에는 String
이나 Integer
모두 넣는 것이 가능해진다.
따라서 실행을 시키고 꺼내는( get
) 코드가 실행되고 나서야 런타임 캐스팅 예외가 발생한다. 컬렉션으로 요소를 꺼내올 때 컴파일러가 해당 객체의 제네릭 타입 파라미터로 캐스팅하는 문장을 자동으로 삽입해주기 때문이다.
String str = (Integer)arrayList.get(0); // 자동 변환
class java.lang.String cannot be cast to class java.lang.Number (java.lang.String and java.lang.Number are in module java.base of loader 'bootstrap')
java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Number (java.lang.String and java.lang.Number are in module java.base of loader 'bootstrap')
근본적인 원인을 잡기 위해서는, 꺼낼때가 아닌 넣을 때를 검사해야 한다. 즉 해당 리스트의 제네릭 타입이 아닌 다른 타입의 요소를 저장할 때 바로 예외를 발생시켜주는 감시자인 Collections.checkedList
를 사용하면 된다.
CheckedList
는 아래와 같이 원소를 넣을 때 타입 체크가 이루어지고, 맞지 않는다면 ClassCastException
을 발생시켜준다.
get
코드를 제거했지만, add
할때 런타임 캐스팅 에러가 발생한 것을 볼 수 있다.
https://velog.io/@adduci/Java-힙-펄루션-Heap-pollution
이번 장은 "배열보다는 리스트를 우선하라"는 아이템 28과 모순되어 보인다.
하지만, 사실 제네릭 타입 안에서 리스트를 사용하는 게 항상 가능하지도, 꼭 더 좋은 것도 아닌 것이 자바는 리스트를 기본 타입으로 제공하지 않고 결국 배열을 사용하고 있기 때문이다. 실제로 HashMap
과 같은 제네릭 타입은 성능 향상을 목적으로 배열을 사용하기도 한다.
Stack
처럼 대다수의 제네릭 타입은 타입 매개변수의 아무런 제약을 두지 않지만, 간혹 받을 수 있는 하위 타입에 제약이 있는 제네릭 타입도 존재한다.
class DelayQueue<E extends Delayed> implements BlockingQueue<E>
이러한 타입 매개변수 E
를 한정적 타입 매개변수라 한다.
📚 핵심 정리
제네릭 타입을 사용하면, 클라이언트에서 직접 형변환 하지 않아도 되어 쓰기 편하고 더 안전하다. 기존 클라이언트에는 아무 영향을 주지 않으면서, 새로운 사용자를 훨씬 편하게 해주는 길이다.