05. 제네릭

zwundzwzig·2023년 8월 26일
0

이펙티브 자바

목록 보기
4/4
post-thumbnail

제네릭과 함께 슬기롭게 형변환하기

jdk 1.5부터 제공되는 제네릭을 사용하면 컬렉션이 담을 수 있는 타입을 컴파일러에 알려 엉뚱한 타입의 객체가 만들어 지는 것을 막을 수 있다. 하지만 제네릭은 코드가 복잡해질 수 있는 단점이 있는데, 제대로 사용하기 위해 공부해보자.

로 타입은 사용하지 말라

로 타입은 제네릭 타입에서 타입 매개변수를 사용하지 않은 것을 말한다. 예를 들어, List<E> 대신 List로 선언한 것을 말한다.

이렇게 선언하면 컴파일 오류를 발생하지 않지만, 런타임 중 다른 타입 매개변수가 사용됐을 때 ClassCastException이 발생하고 이를 파악하기 위한 소요 시간이 오래 걸린다.

무엇보다 제네릭이 안겨주는 안전성과 표현력을 모두 상실하게 되는 것이다.

만약 제네릭 타입을 쓰고는 싶지만 타입 매개변수가 무엇인지 신경쓰고 싶지 않다면 비한정적 와일드카드 타입을 사용하면 된다.

Collection<?>과 같이 사용하면 null 외에는 어떤 원소도 넣을 수 없다는 의미이다. 이렇게 타입 불변식을 훼손하지 않도록 막을 수 있다.

예외는 있다. 1. 클래스 리터럴은 로 타입을 사용해야 한다.

즉, List.class, String[].class, int.class는 허용되지만, List<String>.class, List<?>.class는 허용되지 않는다.

  1. instanceof 연산자에는 비한정적 와일드카드 타입 외 다른 타입에는 적용되지 않는다.
if( o instanceof Set) {
    Set<?> s = (Set<?>) o;
}
// o의 타입이 Set인지 확인한 다음, 와일드카드 타입으로 형변환해야 합니다.
// 여기서 로 타입인 Set이 아닌 와일드카드 타입으로 변환함에 주의!

이렇게 써도 된다는 말이다.

비검사 경고를 제거하라

비검사 경고(unchecked warnings)를 제거하면 런타임에 형변환 관련 예외(ClassCastException)가 발생할 일이 없으며 코드의 올바른 동작도 기대할 수 있게 된다.

만일 경고를 제거할 수 없지만 타입이 안전하다고 확신할 수 있다면 @SuppressWarnings("unchecked") 어노테이션을 붙여 경고를 숨기자.

리턴 문장을 제외한 개별 지역변수 선언부터 클래스 전체까지 어떤 선언에도 달 수 있지만, @SuppressWarnings 어노테이션은 가능한 좁은 범위에 적용해야 한다.

@SuppressWarnings("unchecked") 어노테이션을 사용하면 그 경고를 무시해도 안전한 이유를 항상 주석으로 남겨야 한다.

할 수 있는 한 모든 비검사 경고를 모두 제거하자.

배열보다는 리스트를 사용하라

배열과 제네릭 타입에는 중요한 차이가 두 가지 있다.

첫째로 공변의 유무이다. 배열 Sub가 Super의 하위라면, Sub[]는 Super[]의 자식이다. 하지만, 서로 다른 타입 Type1과 Type2가 있을 때, List은 List의 상위 타입도 하위 타입도 아니다. 이러면 잘못된 타입으로 원소에 add또는 put하고자 할때 배열은 런타임에서나 알 수 있지만, List는 컴파일 자체가 안 되기 때문에 디버깅이 더 용이하다

두번째로 실체화 유무이다. 배열은 런타임 시에도 원소의 타입을 인지하고 확인하지만, 리스트는 런타임 시 타입 정보가 소거된다. 그렇게 jdk 1.5가 제네릭으로 이관할 수 있던 것이다.

소거는 제네릭의 중요한 개념이라고 한다. 제네릭 배열을 생성하지 못하는 이유는? 타입 안전하지 않기 때문인데, 컴파일러가 자동 생성한 형변환 코드에서 런타임에 에러가 발생할 수 있다. 이는 런타임에 ClassCastException을 막고자하는 제네릭의 취지에 어긋난다.

리스트 타입은 실체화 불가 타입으로 불리는데, 제네릭의 소거 특성으로 인해 실체화되지 않아 런타임 시 컴파일 타입보다 타입 정보를 적게 갖는 타입을 말한다.

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

클라이언트에서 직접 형변환하는 타입보다 제네릭 타입이 더 안전하고 확장성이 있다.

다음은 제네릭 클래스로 변경하는 과정이다. 먼저, (1) 클래스 선언에 타입 매개 변수를 추가한다. 그리고 (2) 일반 타입을 타입 매개변수로 바꾸면 된다. 끝으로 이 과정에서 발생하는 비검사 경고를 해결해준다.

// Object 기반으로 구현된 스택
public class Stack { // (1) Stack<E> 로 변경해준다.
    private Object[] elements; // (2) E[] elements 로 변경해준다.
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        // (2) (E[]) new Object[DEFAULT_INITIAL_CAPACITY]; 로 변경해준다.
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) { // (2) push(E e) 로 변경한다.
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() { // (2) E pop() 로 변경한다.
        if (size ==0) {
            throw new EmptyStackException();
        }

        // (2) E result = elements[--size]; 로 변경한다.
        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);
    }
}

이왕이면 제네릭 메서드로 만들라

메서드도 제네릭으로 만들 수 있다. 아래는 타입 매개변수 목록은 이고 반환 타입은 Set 이다.

// 제네릭 메서드
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
    Set<E> result = new HashSet<>(s1);
    result.addAll(s2);
    return result;
}

제네릭 싱글톤 팩터리

제네릭은 런타임 시 타입 정보가 소거되기 때문에 하나의 객체를 어떤 타입으로든 매개변수화할 수 있다. 하지만 요청한 타입 매개변수에 맞도록 매번 그 객체의 타입을 변경해주는 정적 팩터리를 만들어야 한다.

이를 제네릭 싱글톤 팩터리라고 한다. 대표적으로 Collections.reverseOrder와 Collections.emptySet이 있다.

재귀적 타입 한정

재귀적 타입 한정(recursive type bound)이란 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정하는 것을 말한다. 주로 타입의 순서를 정하는 Comparable 인터페이스와 함께 쓰인다.

// 재귀적 타입 한정을 이용해 상호 비교할 수 있음을 표현
public static <E extends Comparable<E>> E max(Collection<E> c);

위의 타입 한정인 <E extends Comparable>는 “모든 타입 E는 자신과 비교할 수 있다” 라고 읽을 수 있다.

한정적 와일드카드를 사용해 API 유연성을 높이라

매개변수화 타입은 불공변(invariant)이다. 자바에서는 타입의 유연성을 극대화하기 위해 한정적 와일드카드 타입이라는 특별한 매개변수화 타입을 지원한다. <? extends E> 혹은 <? super E> 처럼 말이다.

유연성을 극대화하려면 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용하라 한편, 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 와일드카드 타입을 써도 좋을 게 없다.

PECS : producer-extends, consumer-super

즉, 매개변수화 타입 T가 생산자라면 <? extends T>를 사용하고, 소비자라면 <? super T>를 사용하라. T의 인스턴스를 생산한다면 extends, T 인스턴스를 소비한다면 super인 것이다.

제네릭과 가변인수를 함께 쓸 때는 신중하라

가변인수와 제네릭은 궁합이 좋지 않다.
1) 가변인수 기능은 배열을 노출해 완벽한 추상화를 만들지 못하고 2) 배열과 제네릭의 타입 규칙이 서로 다르기 때문이다.

제네릭 varargs 매개변수는 타입 안전하지는 않지만 허용된다. 메서드에 제네릭 (혹은 매개변수화된) varargs 매개변수를 사용하고자 한다면 먼저 그 메서드가 타입 안전한지 확인 후 @SafeVarargs 애너테이션을 달아 사용하는데 불편함이 없도록 하자.

타입 안전 이종 컨테이너를 고려하라

제네릭은 Set<E>, Map<K, V>과 같은 컬렉션과 ThreadLocal<T> 등의 단일원소 컨테이너에서도 사용된다. 여기서 매개변수화되는 대상은 원소가 아닌 컨테이너 자신이다. 그렇기 때문에 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다.

컨테이너라는 말을 “클래스”로 의미를 두면 이해하기 쉽다.

제한없이 유연하게 사용되어야 하는 경우 컨테이너 대신 키를 매개변수화한 다음에 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하면 된다. 이러한 설계 방식을 타입 안전 이종 컨테이너 패턴(type safe heterogeneous container pattern)이라고 한다. 한편 컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고받는 class 리터럴을 타입 토큰(type token)이라고 한다.

public class Favorites {
    // 제네릭을 중첩해서 썼으므로 class 리터럴이면 뭐든 넣을 수 있다.
    private Map<Class<?>, Object> favorites = new HashMap<>();

    public <T> void putFavorite(Class<T> type, T instance) {
        favorites.put(Objects.requireNonNull(type), instance);
    }

    public <T> T getFavorite(Class<T> type) {
        return type.cast(favorites.get(type));
    }

    public static void main(String[] args) {
        Favorites f = new Favorites();
        f.putFavorite(String.class, "Java");
        f.putFavorite(Class.class, Favorites.class);

        String favoriteString = f.getFavorite(String.class);
        Class<?> favoriteClass = f.getFavorite(Class.class);

        // 출력 결과: Java Favorites
        System.out.printf("%s %s%n", favoriteString, favoriteClass.getName());
    }
}

위 Favorites 클래스는 타입 안전하다. String을 요청했을 때 Integer를 반환하는 등의 예외가 발생하지 않는다. 하지만 이 구현에도 단점은 존재한다. 먼저, 넣을 때 잘못 넣으면 오류가 발생할 수 있습니다.

f.putFavorite((Class)Integer.class, "This is not integer !!!");
Integer notInteger = f.getFavorite(Integer.class); // ClassCastException
또한 실체화가 불가능한 타입은 넣을 수 없다. 그러니까, String이나 String[]은 저장할 수 있지만, List은 저장할 수 없다. 우회하기 위한 방법으로는 슈퍼 타입 토큰을 사용할 수 있다. 슈퍼 타입을 토큰으로 사용한다는 뜻이다. 스프링 프레임워크에서는 이를 클래스로 미리 구현해놓았다.

List<String> pets = Arrays.asList("강아지", "고양이");
f.putFavorite(new TypeRef<List<String>>(){}, pets);
List<String> list = f.getFavorite(new TypeRef<List<String>>(){});

컨테이너 자체가 아닌 키를 타입 매개변수로 바꾸면 타입 안전 이종 컨테이너를 만들 수 있다.

🧷 참조 교재

  • [프로그래밍 인사이트] 이펙티브 자바 - 조슈아 블로크
profile
개발이란?

0개의 댓글