빨간색코딩

[이펙티브 자바3판] 5장 제네릭 본문

Java

[이펙티브 자바3판] 5장 제네릭

빨간색소년 2018. 12. 5. 23:49

해당 내용은 이펙티브 자바 3판 (조슈아 블로크 지음, 이복연 옮김)를 읽고 나같은 초심자의 눈으로 이해한 내용을 정리해보았다.


제네릭 자체를 공부좀 해봐야겠다는 생각이 드는... 챕터였다ㅋㅋㅠ

5장의 아이템 목록

  1. raw type은 사용하지 마라
  2. 비검사 경고를 제거하라
  3. 배열보다는 리스트를 사용하라
  4. 이왕이면 제네릭 타입으로 만들어라
  5. 이왕이면 제네릭 메소드로 만들어라
  6. 한정적 와일드카드를 사용해 API 유연성을 높여라
  7. 제네릭과 가변인수를 함께 쓸 때는 신중해라
  8. 타입 안전 이종 컨테이너를 고려하라

서문

제네릭은 jdk1.5 부터 사용할 수 있다. 제네릭을 지원하기 전에는 컬렉션에서 객체를 꺼낼 때 마다 형변환을 해야 했다. 1.5 부터는 제네릭을 사용하면 컬렉션에 담을 수 있는 타입을 컴파일러에게 알려주며, 컴파일러가 알아서 형변환 코드를 추가한다. 또한 엉뚱한 객체를 넣는 코드가 있다면 컴파일 타임에 차단해준다.

아이템26. raw type은 사용하지 마라

26-1. raw type 이란?

클래스와 인터페이스 선언에 타입 매개변수(ex. <E>)가 있으면 각각 제네릭 클래스, 제네릭 인터페이스라고 부른다. 이것을 제네릭 타입이라고 한다. (ex. List<E>) 제네릭 타입을 정의하면 raw type도 정의되는데, 여기서 List<E>의 raw type이란 List 이다. 즉, 타입 매개변수를 쓰지 않은 경우를 말한다. 이것은 제네릭이 도입되기 전의 코드들의 호환성을 위한 것이다.

26-2. raw type의 사용을 비추천한다.

오류의 발견은 컴파일 타임에 되는게 가장 이상적이다. raw type을 사용할 경우, unchecked 경고가 나오며, 잘못된 타입을 add할 수도 있다. 이것은 런타임에서 문제가 생길 것이다. (ClassCastException 등) 따라서 raw type을 쓰지말고, 제네릭 타입을 쓴다면 컴파일러의 검사력(정적언어의 장점을 활용)과 타입 불변, 안정성을 얻을 수 있다.

자바와 같은 JVM언어 중에 코틀린은 제네릭을 쓰지 않으면 컬렉션을 쓰지 못하도록 아예 막아버렸다. 자바는 하위호환성 때문에 쓰지말라고 권고하면서도 막지 못한 셈..

26-3. raw type, 와일드 카드(<?>), <Object>의 차이

만약 타입 매개변수를 신경쓰지 않고 쓰고싶다해도 raw type보단 제네릭의 와일드카드를 쓰는 것이 좋다. (ex. Set<?>) 이렇게 하면 어떤 타입도 받을 수 있으면서 안전하며 유연해진다.

이 둘의 차이점은 raw type 은 타입에 안전하지 않으나, 와일드카드는 안전하다. 아래 예제를 보자

List rawList = new ArrayList<String>(); // 런타임에는 아무런 타입이 남지 않기때문에 컴파일 성공
List<?> wildList = new ArrayList<String>(); // 컴파일 성공
List<Object> genericList = new ArrayList<String>(); // 컴파일 실패

rawList.add("redboy"); // 잘 동작한다.

wildList.add("redboy"); // 제네릭 타입 매개변수에 의존성이 있는데, <?>는 타입을 알지 못하므로 컴파일에 실패한다. 타입안정성이 있는 셈
wildList.clear(); // 제네릭 타입 매개변수에 의존이 없으므로 동작한다.

<?>와 <Object>의 차이는 링크에 잘 정리되어 있다. 구체적 인스턴스의 차이인데, <?>는 제네릭 타입 매개변수에 의존성이 있는 코드가 있다면 컴파일러가 실패처리한다. <Object>는 내부에서 또 다시 형 변환해야하므로 코드가 좀 더 복잡해지며, 제네릭의 장점이 사라진다.

26-4. raw type을 쓰는 예외

  • class 리터럴은 raw type으로 써야한다. List.class 는 되지만, List<String>.class 은 허용되지 않는다.
  • instanceof 연산자는 런타임에서 타입을 비교한다. 제네릭 타입은 런타임에서 소거되므로 제네릭 타입으로 비교할 수 없다.
    • ex. o instanceof Set

아이템27. 비검사 경고를 제거하라

비검사 경고(ex. warning: [unchecked] unchecked ...)를 제거할수록 타입 안정성이 높아진다고 볼 수 있다. 만약 타입 안정성이 확실한데 컴파일러의 경고를 없애고 싶다면 @SuppressWarnings("unchecked")를 사용하자. 로그에 파묻혀서 필요한 로그를 발견하기 어렵게 하지않도록 말이다.

이때 @SuppressWarnings의 범위를 최대한 줄여서 달자. 메소드레벨, 클래스레벨보단 비검사 경고가 뜨는 지역변수 레벨에 다는 것이 가장 좋다. 또한 이때 타입에 안전한 이유를 주석으로 추가해두는 것이 좋다.

아이템28. 배열보다는 리스트를 사용하라

28-1. 배열과 리스트의 차이

배열은 공변(covariant)이다. class Sub extends Super 이라면 Sub[]는 Super[]의 하위 타입이다. 그러나 리스트는 불공변(invariant)이다. List<Sub>와 List<Super>은 하위-상위 타입의 관계가 아니다.

예를들어 List<String>에는 문자열만 넣을 순 있으나, List<Object>에는 어떤 객체도 넣을 수 있다. 이 둘은 서로 하는 일을 바꾼다면 제대로 수행하지 못한다.

아래 코드 예제를 보자.

Object[] objectArray = new Integer[1];
objectArray[0] = "Hello world"; // 런타임에 ArrayStoreException 발생

List<Object> objectList = new ArrayList<Integer>; // 컴파일 실패
objectList.add("Hello world"); // 위에서 이미 컴파일에 실패했으며, 타입이 달라 넣을 수도 없다

배열이든 리스트이던 Integer용 저장소에 String을 넣을 순 없으나, 전자는 런타임에 실수를 알 수 있고, 후자는 컴파일타임에 알 수 있다. 후자가 당연히 좋으니, 배열보다는 리스트를 사용하자. 계속 위에서 부터 같은 말을 하고 있지만 타입안정성을 얻을 수 있다.

다만 성능적인 측면에선 배열이 앞설 수 있다. 참고로 제네릭 배열은 애초에 만들 수 없게 막아두었다.

아이템29. 이왕이면 제네릭 타입으로 만들어라

다음은 일반 클래스를 제네릭 클래스로 만드는 방법이다.

  1. 클래스 선언에 타입 매개변수를 추가
  2. 일반 타입(ex. Object)를 타입 매개변수로 교체
  3. 비검사(unchecked) 경고 해결해주기

다음은 일반 클래스 Stack 을 제네릭 클래스로 바꿔본 예제이다.

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

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

    public void push(Object e) { // => push(E e)
        ensureCapacity();
        elements[size++] = e;
    }

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

    public Object pop() { // => E pop()
        if (size == 0)
            throw new EmptyStackException();
        Object result = elements[--size]; // => E result = elements[--size];
        elements[size] = null;
        return result;
    }

    ...
}

제네릭화(gernerification)의 장점은 위에서 계속 말한대로이다.

참고로 기본 타입은 제네릭 타입으로 쓸 수 없다. (ex. List<int>) 이럴 땐 박싱된 기본타입으로 쓰면 된다.

아이템30. 이왕이면 제네릭 메소드로 만들어라

클래스와 마찬가지로 메소드도 제네릭이 가능하다면 사용하자. 사용자 측에서 형변환하는 것보다 훨씬 안전하고 유연해진다. 제네릭 클래스 작성법과 비슷하다.

public static Set union(Set s1, Se s2) { // => <E> Set<E> union(Set<E> s1, Set<E> s2)
    Set result = new HashSet(s1); // => Set<E> result = new HashSet<>(s1);
    result.addAll(s2);
    return result;
}

30-1. 제네릭 싱글톤 팩토리

때때로 불변 객체를 여러 타입으로 활용할 수 있게 만들어야 할 때가 있는데, 이때는 제네릭 싱글톤 팩토리를 만들면 된다. Collections.reverseOrder, Collections.emptySet이 좋은 예제이다.

@SuppressWarnings("unchecked")
public static <T> Comparator<T> reverseOrder() {
    return (Comparator<T>) ReverseComparator.REVERSE_ORDER;
}

만약 제네릭을 쓰지 않았다면 요청 타입마다 형변환하는 정적 팩토리를 만들었어야 할 것이다. (타입별로 정적메소드가 1개씩..)

30-2. 재귀적 타입 한정

자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정할 수 있다.

다음과 같이 타입 매개변수을 한정적으로 기술해주는 방식이다. 이를 통해 모든 타입 E는 자신과 비교할 수 있다라는 것을 나타냈다. max 메소드의 리턴값은 Comparable<E>을 구현했으므로, 다른 E와 비교할 수 있는 것이다.

public static <E extends Comparable<E>> E max(Collection<E> c)

아이템31. 한정적 와일드카드를 사용해 API 유연성을 높여라

제네릭의 매개변수화 타입(ex. E)은 불공변이다. 아이템 29의 제네릭 클래스 Stack을 예로 들어보자. 아래 메소드가 추가되었다.

public void pushAll(Iterable<E> src) { // 타입 매개변수는 클래스 레벨에 정의됨
    for (E e : src) push(e);
}

// 메인메소드
Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ...;
numberStack.pushAll(integers); // ERROR 발생. incompatible types: Iterable<Integer>

이것은 에러가 발생하며, 원인은 불공변때문이다. Iterable<Number>가 넘어와야 하는데 Iterable<Integer>가 넘어왔으며, 이 둘은 서로 다른 타입이기 때문에 컴파일 에러가 발생한 것이다. 사실 논리적으로는 문제가 없어야하는데, 컴파일러는 이것을 문제로 삼았다.

31-1. 한정적 와일드카드 타입을 사용하자

위 같은 경우 한정적 와일드카드 타입을 사용하면 쉽게 해결할 수 있다. E의 Iterable이 아닌 E의 하위타입의 Iterable로 만들면 된다. 아래처럼하면 Iterable<Integer>도 문제없이 컴파일된다.

public void pushAll(Iterable<? extends E> src) {
    for (E e : src) push(e);
}

만약 E의 상위타입을 표현하고 싶다면 <? super E> 라고 쓰면 된다.

이것은 어느정도 공식화되어있는데, PECS(producer-extends, consumer-super)라고 기억해두면 좋다. 매개변수화 타입 T가 생산자라면, <? extends T>를 사용하고, 소비자라면 <? super T>를 사용해라. 위의 pushAll()은 인스턴스를 생산하고 있으므로 생산자이다.

위의 30-2 에서 max 메소드도 한정적 와일드카드를 이용해 다듬을 수 있다. 입력 매개변수(c)는 E 인스턴스를 생산하므로 extends 이고, 타입 매개변수는 E 인스턴스를 소비하므로 super 이다.

public static <E extends Comparable<? super E>> E max(Collection<? extends E> c)

어떤 인터페이스를 직접 구현한 클래스를 확장한 타입을 지원하기 위해 한정적 와일드카드가 필요하다. 말이 복잡하지만.. 한정적 와일드카드를 씀으로써 계층구조를 유연하게 이용할 수 있다.

31-2. 타입 매개변수와 와일드카드 메소드

타입 매개변수와 와일드카드에는 공통된 부분이 있어서, 메소드를 정의할 때 어느 것을 사용해도 괜찮다.

public static <E> void swapA(List<E> list, int i, int j) {...}
public static void swapB(List<?> list, int i, int j) {...}

책에서는 swapB 스타일을 선호하는데, 신경써야할 타입 매개변수가 없는 점을 들었다. 그러나 결국 swapB는 겉으로 보기에만 깔끔할 뿐, swapA를 내부적으로 호출하는 wrapping method 일 뿐이다. (나같으면 swapA 스타일로 할 것 같은데..)

아이템32. 제네릭과 가변인수를 함께 쓸 때는 신중해라

가변인자는 제네릭과 함께 jdk 1.5에 추가되었으나, 이 둘을 혼용하면 타입 안정성이 깨질 수 있다.

가변인자를 받는 메소드를 호출하면 호출시점에 배열이 생긴다. 즉, 아이템 28에서 애초에 만들 수 없다던 제네릭 배열이 만들어지는 것이다. 이것은 가변인자가 실무에서 매우 유용하기 때문에 모순이지만 수용한 것이다. 다음은 제네릭 가변인자 메소드다.

// Arrays
@SafeVarargs
@SuppressWarnings("varargs")
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

32-1. @SafeVarargs

jdk 1.7부터 도입된 @SafeVarargs를 사용하면 제네릭 가변인수와 관련된 경고를 숨길 수 있다. @SafeVarargs는 제네릭 가변인자 메소드 작성자가 그 메소드가 타입 안전함을 보장하는 장치다. 컴파일러는 이것을 믿고 경고를 하지않는다.

다음은 제네릭 가변인자 메소드를 안전하게 작성하는 방법이다. 이를 지킨다면 @SafeVarargs를 달아도 되며, 달지 못한다면 작성하면 안된다.

  1. 메소드가 제네릭 가변인자 배열에 아무것도 저장하지 않는다.
  2. 그 배열의 참조가 밖으로 노출되지 않는다. (return varargs)
  3. 즉, 순수하게 인수들을 전달하는 역할만 해야한다. (ex. Arrays.asList 메소드 참조)

참고로 @SafeVarargs은 재정의할 수 없는 메소드(static, final)에만 붙일 수 있다. 재정의한 메소드에서는 안전할 지 보장이 안되기 때문이다.

아이템33. 타입 안전 이종 컨테이너를 고려하라

33-1. 타입 안전 이종 컨테이너는 언제 쓰이는가?

Set<E>Map<K,V> 처럼 클래스 레벨에서 매개변수화 할 수 있는 타입의 수는 제한적이다. (ex. Map 은 2개)

타입의 수에 제약없이 유연하게 필요한 경우, 특정 타입 외에 다양한 타입을 지원해야하는 경우가 있을 수 있다. 이 때 클래스 대신 키를 매개변수화한 다음 get/set 할때 키 타입을 제공해주면 된다. 이것을 타입 안전 이종(heterogeneous) 컨테이너 패턴이라고 한다. (컨테이너 = 클래스 라고 이해하면 될 듯하다)

컴파일타임 타입 정보와 런타임 타입 정보를 위해 메소드에서 주고 받는 class 리터럴을 타입 토큰이라고 한다. (ex. Integer.class는 class 리터럴이며 타입토큰은 Class<Integer>) 타입 토큰은 타입 안전성이 필요한 곳에 주로 사용된다.

아래는 적절한 예제이다.

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

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

public <T> T get(Class<T> type) {
    return type.cast(map.get(type)); // 동적 형 변환
}

// 메인메소드
put(String.class, "Redboy");
get(String.class);

33-2. 타입 안전 이종 컨테이너의 제약과 슈퍼 타입 토큰

실체화 불가 타입에는 사용할 수 없다. String, String[]은 사용할 수 있지만, List<String>은 사용할 수 없다. List<String>.class 라는 리터럴을 얻을 수 없기 때문이다.

이것을 해결하기 위해 슈퍼타입 토큰을 사용할 수 있다. 슈퍼 타입을 토큰으로 사용한다는 의미이며, 상속과 Reflection을 조합해서 List<String>.class 같이 사용할 수 없는 class 리터럴을 타입 토큰으로 사용하는 것과 같은 효과를 낼 수 있다.

스프링 진영에서는 이것을 클래스로 구현을 해두었다. ParameterizedTypeReference Docs 참조

Comments