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

Loopy·2022년 8월 6일
0

이펙티브 자바

목록 보기
28/76
post-thumbnail

예시를 통해 제네릭 타입으로 변환하는 방법을 알아보자.

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

제네릭 스택으로 가는 첫 단계

  1. 클래스 선언에 타입 매개변수를 추가한다 ( 타입 이름 : E )
  2. 코드에 쓰인 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 와 같은 실체화 불가 타입으로는 배열을 생성하면 컴파일 에러가 뜬다.

그렇다면 어떻게 배열을 사용한 코드를 제네릭으로 만들 수 있을까?

☁️ 방법 1

첫번째 해결책은 제네릭 배열 생성을 금지하는 제약을 대놓고 우회하는 방법이다.Object 배열을 생성한 다음 제네릭 배열로 형변환하는 방식이다.

하지만 이 방법은 안전하지 않아서, 컴파일은 되지만 아래와 같이 오류 대신 경고를 내보낼 것이다.

이런 경우 컴파일러를 대신해서 직접 해당 비검사 변환이 프로그램의 타입 안전성을 해치치 않음을 확인할 수 있다. 문제의 배열 elements는 private 필드에 저장되고, 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 없기 때문에 안전하다.

따라서, 비검사 형변환이 안전함을 직접 증명했다면 범위를 최소로 좁혀 @SuppressWarnings 애너테이션으로 해당 경고를 숨길 수 있다.(참고로 배열의 런타임 타입은 E[] 가 아닌 Object[] 이다)

이로써 Stack 클래스는 깔끔하게 컴파일 되고, 명시적으로 형변환하지 않아도 ClassCastException 걱정 없이 사용할 수 있게 된다.

☁️ 방법 2

두번째 방법은, elements 필드의 타입을 E[] 에서 Object[] 로 바꾸는 것이다.

public class Stack<E> {
    private Object[] elements;

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

하지만 그렇게 되면, 아래의 코드에서 E 는 실체화 불가 타입이므로 컴파일러가 런타임에 이뤄지는 형변환이 안전한지 증명할 방법이 없어진다.

따라서 마찬가지로 우리가 직접 증명하고 경고를 숨길 수 있다.

아무래도 두 번째 방식은 배열에서 원소를 읽을 때마다 해줘야 하니 형변환을 배열 생성 시 단 한번만 해주면 되는 첫번째 방식이 좋아 보인다.

하지만, 첫 번째 방식은 배열의 런타임 타입( Object )이 컴파일타입( E ) 과 달라 힙 오염을 일으키는 단점이 존재한다.

☁️ (추가) 힙 오염이란?

주로 매개변수화 타입의 변수가 타입이 다른 객체를 참조하게 되어, 힙 공간에 문제가 생기는 현상을 의미한다. 즉 컴파일 중에 정상적으로 처리되며, 경고를 발생시키지 않고 나중에 런타임 시점에 ClassCastException이 발생하는 문제를 나타낸다.

힙 오염은 다음과 같은 두 가지 경우에 나타난다.

unchecked 캐스팅을 하는 경우

해당 코드는 ArrayList 에 상속 관계도 아닌 서로 다른 두 타입의 객체가 추가되었다. 말이 안되지만, 컴파일러는 잘못된 상황을 다음과 같은 두 가지 이유로 알아차리지 못하게 된다.

  1. 타입 캐스팅 체크는 컴파일러가 하지 않는다. 오직 대입되는 참조변수에 저장할 수 있느냐만 검사한다.

  2. 제네릭의 소거 특성으로 인해 컴파일이 끝난 클래스 파일의 코드에는 타입 파라미터 대신 Object 가 남아있게 된다. ArrayList<Object> 에는 String 이나 Integer 모두 넣는 것이 가능해진다.

따라서 실행을 시키고 꺼내는( get ) 코드가 실행되고 나서야 런타임 캐스팅 예외가 발생한다. 컬렉션으로 요소를 꺼내올 때 컴파일러가 해당 객체의 제네릭 타입 파라미터로 캐스팅하는 문장을 자동으로 삽입해주기 때문이다.

String str = (Integer)arrayList.get(0);  // 자동 변환

2. raw 타입과 매개변수화 타입을 같이 사용하는 경우

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 를 한정적 타입 매개변수라 한다.

📚 핵심 정리
제네릭 타입을 사용하면, 클라이언트에서 직접 형변환 하지 않아도 되어 쓰기 편하고 더 안전하다. 기존 클라이언트에는 아무 영향을 주지 않으면서, 새로운 사용자를 훨씬 편하게 해주는 길이다.

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글