본문 바로가기
Effective Java

[Effective Java] 아이템29 이왕이면 제네릭 타입으로 만들라

by byeongoo 2021. 5. 23.

 

Object를 사용할 경우 클라이언트가 매번 형변환을 해야하고 그 과정에서 Runtime 에러가 발생하는 것을 막기 위해 이왕이면 제네릭타입으로 만들자.

■ Object기반 스택

이 클래스는 제네릭 타입이어야한다. 스택에서 꺼낸 객체를 형변환할 때 런타임 오류가 발생할 수 있다.

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);
    }
}

 

■ 제네릭 스택으로 가는 첫 단계 - 컴파일되지 않는다.

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;
    }
    ... // isEmpty와 ensureCapacity 메서드는 그대로다. 
}

위의 코드에서는 하나의 오류가 발생한다. E와같은 실체화 불가 타입으로는 배열을 만들 수 없다.

Stack.java:8: generic array creation
        elements = new E[DEFAULT_INITIAL_CAPACITY];

 

이를 우회할 수 있는 방법으로 첫번째가 Object 배열을 생성한 후 제네릭 배열로 형변환을 하는 것 이다. 이제 컴파일러는 오류 대신 경고를 내보낸다. 이 방식은 일반적으로 타입 안전하지 않다. 컴파일러는 이 타입이 안전한지 증명할 수 없지만 우리는 할 수 있다. 따라서 비검사 형변환 프로그램의 타입 안전성을 해치지 않음을 우리가 확인해야한다. push 메서드를 통해 배열에 저장되는 원소의 타입은 항상 E다.  따라서 이 비검사 형변환은 안전하기 때문에 @SuppressWarings를 붙여서 경고를 숨긴다.

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

 

두번째 방법으로는 elements 필드의 타입을 E[]에서 Object[]로 바꾸는 것 이다.  이럴 경우 다른 에러가 발생한다.

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

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

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

E는 실체화 불가 타입이므로 컴파일러는 런타임에 이루어지는 형변환이 안전한지 증명할 수 없다. 이번에도 마찬가지로 pop 메서드 전체에서 경고를 숨기지 말고 형변환을 수행하는 할당문의 경고만 숨긴다.

// 비검사 경고를 적절히 숨긴다
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)을 일으킨다. 힙 오염이 맘에 걸리는 프로그래머는 두 번째 방식을 고수하기도 한다. 

다음은 명령줄 인수들을 역순으로 바꿔 대문자로 출력하는 프로그램으로, 방금 만든 제네릭 Stack 클래스를 사용하는 모습을 보여준다. Stack에서 꺼낸 원소에서 String의 toUpperCase 메서드를 호출할 때 명시적 형변환을 수행하지 않으면, 이 형변환이 항상 성공함을 보장한다. 

```java
public static void main(String[] args) {
    Stack<String> stack = new Stack<>();
    for (String arg : args)
        stack.push(arg);
    while (!stack.isEmpty())
        System.out.println(stack.pop().toUpperCase());
}

 

지금까지 설명한 Stack 예는 "배열보다는 리스트를 우선하라"는 아이템 28과 모순돼 보인다. 사실 제네릭 타입 안에서 리스트를 사용하는 게 항상 가능하지도, 꼭 더 좋은 것도 아니다. 자바가 리스트를 기본 타입으로 제공하지 않으므로 ArrayList 같은 제네릭 타입도 결국은 기본 타입인 배열을 사용해 구현해야 한다. 또한 HashMap 같은 제네릭 타입은 성능을 높일 목적으로 배열을 사용하기도 한다.