[이펙티브 자바] 제네릭 Item29 - 이왕이면 제네릭 타입으로 만들어라

이성훈·2022년 5월 21일
0

이펙티브 자바

목록 보기
14/17
post-thumbnail

자바5 전까지는 컬렉션에서 객체를 꺼낼 때마다 형변환(Casting)을 해줘야만 했다.

이는 런타임때 빈번하게 형변환 오류를 발생시키고는 했다.

자바5부터는 제네릭(Generic)을 지원한다.

제네릭을 통해, 컬렉션은 자신이 담을 수 있는 타입을 컴파일러에게 알려준다.

컴파일러는 알아서 형변환 코드를 추가할 수 있고,
엉뚱한 타입의 객체를 넣으려는 시도 또한 차단해준다.


제네릭을 통해, 코드가 간결하고 명확하며 안전한 프로그램을 만들 수 있다.


"5장 - 제네릭" 에서는,
위와 같은 혁신 기능을 제공하는 제네릭에 대해 서술한다.


  • Item26. 로 타입을 사용하지 말라.
  • Item27. 비검사 경고를 제거하라.
  • Item28. 배열보다는 리스트를 사용하라.
  • Item29. 이왕이면 제네릭 타입으로 만들라.
  • Item30. 이왕이면 제네릭 메서드로 만들어라.
  • Item31. 한정적 와일드카드를 사용해 API 유연성을 높이라.
  • Item32. 제네릭과 가변인수를 함께 쓸 때는 신중하라.
  • Item33. 타입 안전 이종 컨테이너를 고려하라.




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


이번 5장에서는 제네릭으로 되어있는 타입, 리소스를 사용하는 것의 편의성에 대해 말하고 있다.


결과적으로 제네릭으로 된 것을 사용하라는 것인데,
JDK 자체에서 제공하고 있는 제네릭 타입과 메서드를 사용하는 것은 쉽다.
(그냥 있는거 가져다 쓰니까)


문제는 제네릭으로 되어있지 않은 타입을 제네릭으로 만들어야 할 때이다.

이번 Item에서는 일반 타입 클래스를 제네릭으로 만드는 방법에 대해 언급한다.




#   제네릭 타입으로 만드는 방법


사실 제네릭으로 만드는게 일반적으로 좋으면 좋았지 나쁠건 없다.

다만, 일반 타입을 제네릭으로 만드는게 쉽지만은 않기 때문에 꼭 필요한게 아니면 그냥 사용하는 것 뿐이다.


백문이 불여일견,
제네릭이 꼭 필요한 경우는 어떨 때일까?


Item7에서 본적이 있는 아래의 Stack 구현 코드를 살펴보자.

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];
  		element[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를 사용한다.)
코드 내부에서 필요한 위치에 타입 매개변수로 변경한다.


아래 변경 코드를 보자.

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

생각보다 단순하게 끝난다.

위와 같이 바꾸면 스택은 매개변수로 들어온 타입에 따라 알맞게 코드에서 사용하고 반환또한 잘 해준다.


다만, 위와 같은 코드는 대체로 하나 이상의 오류나 경고가 발생한다.

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

Item28에서 언급한 것처럼, 원래 E와 같은 실체화 불가 타입으로는 배열을 만들 수가 없다.


위의 경우처럼, 앞으로도 제네릭 타입으로 변경하려고 할 때 코드에 배열이 있으면 항상 이 문제가 발생하게 된다.

이 문제점을 해결하는 방안은 크게 두 가지가 있다.





#   제네릭+배열 이슈를 해결하는 방법 1


첫번째는, 제네릭 배열 생성 금지 제약을 그냥 무시하고 우회하는 것이다.

아까 스택을 변경한 코드에서 문제가 되는 배열 부분을 Object 배열로 생성하고, 제네릭 배열로 형변환 해보자.

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

위와 같이 변경하면,
제네릭 배열로 생성한 것은 아니고 또 나중에 제네릭 배열로 형변환 했으므로
제약은 우외하되 기능은 변함이 없다.


이제 위와 같은 코드는 오류가 아닌 경고를 내보낸다.
(돌아는 간다는 뜻이다.)


다만, 컴파일러는 이 프로그램이 타입 안전한지 증명할 방법이 없다.
따라서 코드를 쓰고있는 개발자가 직접 확인을 해야한다.

위 코드를 보면,
문제의 배열 elements는 private 필드에 저장되고,
클라이언트로 반환되거나 다른 메서드에 전달되는 일이 전혀 없으며,
push 메서드를 통해 저장되는 원소의 타입은 항상 E다.


위와 같은 흐름으로 이 코드가 타입 안전한 것이 확정되면 다음과 같이 경고를 숨겨준다.

@SuppressWarnings("unchecked")
public Stack() {
        elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
    }

이제 이 Stack은 깔끔히 컴파일되고, 제네릭하게 사용이 가능하다.

방법1을 통해 모든 문제를 해결한 제네릭 스택 코드는 다음과 같다.

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

    // 코드 29-3 배열을 사용한 코드를 제네릭으로 만드는 방법 1 (172쪽)
    // 배열 elements는 push(E)로 넘어온 E 인스턴스만 담는다.
    // 따라서 타입 안전성을 보장하지만,
    // 이 배열의 런타임 타입은 E[]가 아닌 Object[]다!
    @SuppressWarnings("unchecked")
    public Stack() {
        elements = (E[]) 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;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }




#   제네릭+배열 이슈를 해결하는 방법 2


두 번째 방법은,
문제가 되는 배열을 Object로 생성하고,
배열이 저장되는 elements 필드 타입은 E[]에서 Object[]로 바꾸며,
반환되는 elements에 형변환을 해준다.


다음과 같다.

..
..
		private Object[] elements;
  	..
  	..

		public Stack() {
        	elements = new Object[DEFAULT_INITIAL_CAPACITY];
  	..
  	..
  		public E pop() {
        	if (size == 0)
            	throw new EmptyStackException();

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

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

위와 같이하면 이제 경고는 사라지고 오류만 남는다.

방법1 때와 마찬가지로 컴파일러는 타입 안전에 대한 보장을 할 수 없으므로,
직접 체크가 필요하다.

위 코드를 보면, push에서 E자체만 허용하고 있으므로 pop에서 E로 형변환 해도 안전하다.


확신이 생겼으면 마찬가지로 어노테이션을 추가해 경고를 지워주자.

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

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

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

방법2를 통해 모든 문제를 해결한 제네릭 스택 코드는 다음과 같다.

public class Stack<E> {
    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(E e) {
        ensureCapacity();
        elements[size++] = e;
    }

    // 코드 29-4 배열을 사용한 코드를 제네릭으로 만드는 방법 2 (173쪽)
    // 비검사 경고를 적절히 숨긴다.
    public E pop() {
        if (size == 0)
            throw new EmptyStackException();

        // push에서 E 타입만 허용하므로 이 형변환은 안전하다.
        @SuppressWarnings("unchecked") E result =
                (E) 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);
    }

}


제네릭 배열을 해결하는 두 가지 방법은 각자 나름의 지지를 받고 있다.


방법1은,
가독성이 더 좋으며, 배열 타입 자체를 E[]로 선언함으로써 오로지 E타입 인스턴스만을 받는다는 것을 어필한다.
또한 형변환을 배열 생성 시 단 한번만 해주면 된다.
결론적으로 현업에서는 방법1을 더 선호한다.


방법 2는,
힙 오염 (heap pollution : Item32) 측면에서 안전하다.


어쨋든 방법1로 만들던 방법2로 만들던 스택을 제네릭 타입으로 만들어봤으니,
다음의 클라이언트 코드를 봐보자.

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

위 코드처럼 스택에 매개변수로 String을 넣고,
elements를 받아올때 형변환 하지 않아도 매끄럽게 돌아간다.
String의 메서드를 사용하는데도 문제가 발생하지 않는다.





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

위의 Stack의 예시에도,
Stack, Stack<int[]>, Stack<List<'String>> 등 어떠한 것도 가능하다.


다만, 기본 타입은 사용할 수가 없다. (Stack, Stack 등.)

대신 박싱된 기본 타입 (Integer 등.)은 사용이 가능하므로 대체가 가능하다.


또한, 다음과 같이 타입 매개변수에 제약을 두는 것도 가능은 하다.

class DelayQueue<E extends Delayed> implements BlockingQueue<E>

위와 같이 하면 Delayed의 하위 타입만 매개변수로 받을 수 있다.





지금까지 일반 타입 클래스를 제네릭으로 만드는 방법에 대해 알아보았다.

필자의 코멘트로 마무리한다.

Item29 정리

  • 제네릭 타입이 더 안전하고 쓰기 편하다.
  • 새로운 타입을 설계할 때는 형변환 없이도 사용할 수 있도록 하라.
  • 기존 타입 중 제네릭이어야 하는 게 있다면 변경하자.
  • 기존 클라이언트에는 아무 영향을 주지 않으면서, 새로운 사용자를 훨씬 편하게 해주는 길이다.

profile
IT 지식 공간

0개의 댓글