DEV ℧ Developer Diary

[EffectiveJava] item29 - 이왕이면 제네릭 타입으로 만들라

JDK가 제공하는 제네릭 타입과 메서드를 사용하는 일은 일반적으로 쉬운 편 이지만, 제네릭 타입을 새로 만드는 일은 조금 더 어렵다.

아래의 예시코드를 살펴보자. item7의 예제코드 이다.

제네릭 변환

public class StackArray {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public StackArray() {
        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);
    }
}

이 클래스는 본래 Object가 아닌 제네릭 타입 이어야 마땅하다.

이 클래스를 제네릭으로 바꾼다고 해도 현재 버전을 사용하는 클라이언트에는 아무런 해가 없다. 오히려 이전 버전을 사용할 경우 스택에서 꺼낸 객체를 형변환시 런타임 오류가 날 가능성이 있다.

그러니 한번 제네릭 타입을 변환하여 보자.

먼저 해야 할 일은 클래스 선언에 타입 매개변수만 추가하면 된다. 그리고 위의 예시 코드에서는 스택이 담을 원소의 타입 하나만 추가하면 된다. 이때 타입 이름으로는 보통 E를 사용한다.

그러면 Object를 적절한 타입 매개변수로 바꾸어 보자.

public class StackGeneric<E> {
    private E[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public StackGeneric() {
        elements = new E[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(E 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);
    }
}

하지만 이단게에서는 바로 컴파일 되지 않는다.

StackGeneric 생성자의 E[] elements를 초기화 할때 에러가 발생한다.

StackGeneric.java:8: generic array create
      elements = new E[DEFAULT_INITIAL_CAPACITY];
                 ^

위의 에러가 발생하는 이유는 E와 같은 실체화 불가 타입으로는 배열을 만들 수 없기 때문이다. 만약 배열을 사용하는 코드를 제네릭으로 만들려 할때는 방법이 없는것일까?

적절한 해결책은 두가지가 있다.

첫 번째는 제네릭 배열 생성을 금지하는 제약을 대놓고 우회하는 방법이다. Object 배열을 생성한 다음 제네릭 배열로 형변환 해보면 컴파일 오류는 나지않지만 경고를 내보낼 것이다. 하지만 타입 안전하지 않다.

StackGeneric.java:8: warning: [unchecked] unchecked cast
found: Object[], required: E[]
      elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
                     ^

그렇다면 해당 코드는 안전할까?

문제의 배열 elements는 private 필드에 저장되고, 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 전혀 없다. push 메서드를 통해 배열에 저장되는 원소의 타입은 항상 E이므로, 해당 비검사 형변환은 안전하다.

그렇다면 item27에서 배운것 처럼 범위를 최소로 좁혀 @SuppressWarnings 애너테이션으로 해당 경고를 숨겨준다.

// 배열 elements는 push(E)로 넘어온 E 인스턴스만 담는다.
// 따라서 타입 안정성을 보장하지만,
// 이 배열의 런타임 타입은 E[]가 아닌 Object[]다
@SuppressWarnings("unchecked")
public StackGeneric() {
    elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}

그렇다면 두번째 방법은 어떤것이 있을까?

두번째 방법은 elements 필드의 타입을 E[]에서 Object[]로 바꾸는 거이다. 이렇게 하면 첫 번째와는 다른 오류가 발생한다.

StackGeneric.java:19: incompatible types
found Object, required: E
       E result = elements[--size];
                          ^

배열이 반환한 원소를 E로 형변환하면 오류대신 경고가 뜨게된다.

StackGeneric.java:19: warning: [unchecked] unchecked cast
found: Object, required: E
      E result = (E) elements[--size];
                             ^

E는 실체화 불가 타입이므로 컴파일러는 런타임에 이뤄지는 형변환이 안전한지 증명할 방법이 없다.

이번에도 마찬가지로 @SuppressWarnings를 이용하여 경고를 숨겨 줄 수 있다.

메서드 전체로 잡지말고 해당 변수를 할당하는 부분에만 적용해보도록 하자.

public E pop() {
    if (size == 0)
        throw new EmptyStackException();

    // push에서 E 타입만 허용하므로 이 형변환은 안전하다.
    @SuppressWarnings("unchecked") E result = (E) elements[--size];

    elements[size] = null; // 다 쓴 참조 해제
    return result;
}

제네릭 배열 생성을 제거하는 두 방법 모두 나름의 지지를 얻고 있다.

첫번째 방식은 코드가 더 짧고, 가독성이 좋다. 배열의 타입을 E[]로 선언하여 오직 E 타입 인스턴스만 받음을 어필 하기 때문이다.

두번째 방식은 (E가 Object가 아닌 한) 배열의 런타임 타입의 컴파일타임 타입과 달라 힙오염(heap pollution)을 일으킬 가능성이 있을 경우 두번째 방식을 사용한다.

대게 첫번째 방법은 두번째와 달리 형변환을 한번만 해줘도 되고, 해당 배열이 자주 사용되기 때문에 현업에서 주로 사용하는 방법이다.

제네릭을 사용한 배열과 리스트

지금까지 설명한 예시에서는 “배열보다 리스트를 우선시하라”는 item28과 모순돼 보인다.

사실 제네릭 타입 안에서 리스트를 사용하는 게 항상 가능하지도, 꼭 더 좋은것은 아니다.

ArrayList의 같은 컬렉션도 자바에서 기본적으로 List를 지원하지 않기 때문에 배열로 구현되어 있어 컬렉션 객체의 제네릭 타입도 결국은 기본타입인 배열을 사용해 구현해야 한다.

또한 HashMap 같은 제네릭 타입은 메모리의 성능을 높이기 위해 배열을 사용하기도 한다.

제네릭 타입

Stack 예처럼 대다수의 제네릭 타입은 타입 매개변수에 아무런 제약을 두지 않는다.

Stack<Object>, Stack<int[]>, Stack<List<String>> 등 어떤 참조 타입으로도 Stack을 만들 수 있다. 단, Stack나 Stack과 같은 기본 타입은 사용할 수 없다.

이는 자바 제네릭 타입 시스템의 근본적인 문제이나, 박싱된 기본 타입(Integer, Double)을 사용해 우회 할 수있다.