ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 제네릭 - 아이템 29. 이왕이면 제네릭 타입으로 만들라
    Study/Effective Java 2023. 1. 5. 14:07

    이왕이면 제네릭 타입으로 만들라

    제네릭 타입의 필요성
    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);
            }
        }
    }

    위 Stack 클래스는 원래 제네릭 타입이어야 마땅하다. 이 클래스를 제네릭 타입으로 만들어보겠다. 이 클래스를 제네릭으로 바꾼다고 해도 현재 버전을 사용하는 클라이언트에는 아무런 해가 없다. 오히려 지금 상태에서의 클라이언트는 스택에서 꺼낸 객체를 형변환해야 하는데, 이때 런타임 오류가 날 가능성이 있다.

     

    제네릭 타입으로의 변환
    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;
        }
    
        public boolean isEmpty() {
            return size == 0;
        }
    
        private void ensureCapacity() {
            if (elements.length == size) {
                elements = Arrays.copyOf(elements, 2 * size + 1);
            }
        }
    }

    일반 클래스를 제네릭 클래스로 만드는 첫 번째 단계는 클래스 선언에 타입 매개 변수를 추가하는 일이다. 여기서는 스택이 담을 원소 하나만 추가해 주면 된다. 이때 타입 이름은 보통 E를 사용한다. 그런 다음 코드에서 쓰인 Object를 적절한 타입 매개변수로 바꾸는 것이다.

    위 소스코드는 Object 타입의 Stack 클래스를 제네릭 타입으로 변경한 코드이다.

     

    하지만 변경된 소스코드 내  elements = new E[DEFAULT_INITIAL_CAPACITY]; 부분에서 컴파일 에러가 발생할 것이다. 그 이유는 이전 게시글에서 설명했듯이, E와 같은 실제화 불가 타입으로는 배열을 만들 수 없기 때문이다.

     

    이에 대한 해결책은 아래와 같이 두 가지가 존재한다.

     

    1. 제네릭 배열 생성을 금지하는 제약을 우회하는 방법으로, Object 배열을 생성한 다음 제네릭 배열로 형변환한다.

    2. elements 필드의 타입을 E[]에서 Object[]으로 변경한다.

     

    1. 첫 번째 방법

    public class Stack<E> {
        private E[] elements;
        private int size = 0;
        private static final int DEFAULT_INITIAL_CAPACITY = 16;
    
        public Stack() {
            elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
        }
    
        ...
    }

    위 코드는 Object 배열을 생성한 다음 제네릭 배열로 형변환하는 방식으로 변경한 코드이다. 이제 컴파일러는 비검사 형변환 경고를 내보낼 것이다. 이 방식은 일반적으로는 타입 안전하지 않은 방식이다. 하지만, 우리는 타입 안전성을 해치지 않음을 스스로 확인할 수 있다.

     

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

    배열 elements는 private 필드에 저장되고, 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 전혀 없다. push() 메서드를 통해 배열에 저장되는 원소의 타입은 항상 E이다. 따라서, 이 비검사 형변환은 확실히 안전하다고 판단할 수 있다.

    비검사 형변환이 안전함을 직접 증명했다면 범위를 최소로 좁혀 @SuppressWarning 애너테이션으로 해당 경고를 숨긴다. 애너테이션을 달면 클래스는 깔끔하게 컴파일되고, 명시적으로 형변환하지 않아도 ClassCastException 걱정 없이 사용할 수 있을 것이다.

     

    2. 두 번째 방법

    public class Stack<E> {
        private Object[] elements; // Obejct[] 타입으로 변경
        private int size = 0;
        private static final int DEFAULT_INITIAL_CAPACITY = 16;
    
        public Stack() {
            elements = new Object[DEFAULT_INITIAL_CAPACITY];
        }
    
        public E pop() {
            if (size == 0) {
                throw new EmptyStackException();
            }
    
            E 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);
            }
        }
    }

    제네릭 배열 생성 오류를 해결하는 두 번째 방법은 elements 필드의 타입을 E[]에서 Object[]로 바꾸는 것이다. 이렇게 하면 E result = elements[--size] 부분에 오류가 발생할 것이다.

     

    E result = (E) elements[--size];

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

     

    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 타입 인스턴스만 받음을 확실히 어필하고 있다. 또한, 코드도 더 짧다. 보통의 제네릭 클래스라면 코드 이곳저곳에서 이 배열을 자주 사용할 것이다. 첫 번째 방식에서는 형변환을 배열 생성 시 단 한 번만 해주면 되지만, 두 번째 방식에서는 배열에서 원소를 읽을 때마다 해줘야 한다. 따라서, 현업에서는 첫 번째 방식을 더 선호하며 자주 사용한다.

    하지만, 첫 번째 방식은 배열의 런타임 타입이 컴파일타임 타입과 달라 힙 오염을 일으킨다. 힙 오염이 마음에 걸리는 프로그래머는 두 번째 방식을 고수하기도 한다.

     

    최종 정리

    클라이언트에서 직접 형변환해야 하는 타입보다 제네릭 타입이 더 안전하고 쓰기 편한다. 그러니 새로운 타입을 설계할 때는 형변환 없이도 사용할 수 있도록 해야 한다. 그렇게 하려면 제네릭 타입으로 만들어야 할 경우가 많다. 기존 타입 중 제네릭이었어야 하는 게 있다면 제네릭 타입으로 변경해야 한다. 기존 클라이언트에는 아무 영향을 주지 않으면서, 새로운 사용자를 훨씬 편하게 해주는 길이다.


    출처

     이펙티브 자바 Effective Java 3/E. 조슈아 블로크 저자(글) · 개앞맵시(이복연) 번역

     

    728x90

    댓글

Designed by Tistory.