item 26. 로 타입은 사용하지 말라

로 타입을 쓰면 제네릭이 안겨주는 안전성과 표현력을 모두 잃게 된다.

비한정적 와일드카드 타입인 Set<?>와 로 타입인 Set의 차이

  • 와일드카드 타입은 안전하고, 로 타입은 안전하지 않다.
  • Collection<?>에는 (null 외에는) 어떤 원소도 넣을 수 없고 컬렉션에서 꺼낼 수 있는 객체의 타입도 전혀 알 수 없다.
  • 이러한 제약을 받아들일 수 없다면 제너릭 메서드(item 30)나 한정적 와일드카드 타입(item 31)을 사용하면 된다.

로 타입을 쓰지 말라는 규칙에도 소소한 예외가 몇 개 있다.

첫 번쨰 예외. class 리터럴에는 로 타입을 써야 한다.

  • 허용: List.class, String[].class, int.class
  • 허용하지 않음: List.class, List<?>.class

두 번째 예외. instanceof 연산자와 관련이 있다.

  • 런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비 한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다.
  • 로 타입이든 비한정적 와일드카드 타입이든 instanceof는 완전히 똑같이 동작한다.
  • 차라리 깔끔한 로 타입을 쓰자.

item 27. 비검사 경고를 제거하라

비검사 경고는 중요하니 무시하지 말자.

모든 비검사 경고는 런타임에 ClassCastException을 일으킬 수 있는 잠재적 가능성을 뜻하니 최선을 다해 제거하라.

경고를 제거할 수는 없지만 타입이 안전하다고 확신할 수 있다면 @SuppressWarnings("unchecked") 에너테이션을 달아 경고를 숨기자.

  • @SuppressWarnings 에너테이션은 항상 가능한 한 좁은 범위에 적용하자.
  • 한 줄이 넘는 메서드나 생성자에 달린 @SuppressWarnings 에너테이션을 발견하면 지역변수 선언 쪽으로 옮기자.
  • 경고를 무시해도 안전한 이유를 항상 주석으로 남겨야 한다.

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

배열은 공변이고 실체화되지만, 제네릭은 불공변이고 타입 정보가 소거된다.

따라서 배열은 런타임에는 안전하지만, 컴파일타임에는 안전하지 않다.

반면에 제네릭은 컴파일타임에 안전하다.

배열과 제네릭 타입의 차이 두가지

1) 배열은 함께 변하고 제네릭은 함께 변하지 않는다.

2) 배열은실체화 된다.

  • 배열은 런타임시에도 자신이 담기로 한 원소의 타입을 인지하고 확인하는 반면 제네릭은 타입정보가 런타임시에 하위호환성을 위해 제거된다.
  • 제네릭 타입안에 배열을 넣게 되면 런타임시 타입이 제거되기에 에러가 발생한다. 이러한 이유로 제네릭 배열이 생성되지 않도록 되어있다.
  • E, List, List 같은 타입을 실체화 불가 타입이라 하며 실체화될 수 있는 타입은 List<?>같은 비 한정적 와일드 카드 타입 뿐이다.
public class Chooser {
    private final Object[] choiceArray;

    public Chooser(Collection choices) {
        this.choiceArray = choices.toArray();
    }

    public Object choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceArray[rnd.nextInt(choiceArray.length)];
    }
}

choose() 메서드를 사용하는 곳에서는 반환된 Object를 매번 형변환 해야 한다.

혹시나 타입이 다른 원소가 들어 있었다면 런타임에 형변환 오류가 날 것이다.

위의 코드를 제네릭을 사용하여 바꿔보자.

public class Chooser<T> {
    private final T[] choiceArray;

    public Chooser(Collection<T> choices) {
        // incompatible types 오류를 해결하기 위해 형변환 사용
        this.choiceArray = (T[]) choices.toArray();
    }

    // choose 메소드는 동일하다.
}

위의 코드에서는 Unchecked Cast 경고가 발생한다.

타입 매개변수 T가 어떤 타입인지 알 수 없으니 형변환이 런타임에도 안전한지 보장할 수가 없다는 메시지이다.

제네릭은 런타임에는 타입 정보가 소거되므로 무슨 타입인지 알 수 없다.

Unchecked Cast과 같은 비검사 형변환 경고를 제거하려면 배열 대신 리스트를 사용하면 된다.

// 리스트 기반 Chooser - 타입 안전성 확보 !
class Chooser<T> {
    private final List<T> choiceList;

    public Chooser(Collection<T> choices) {
        this.choiceList = new ArrayList<>(choices);
    }

    public T choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceList.get(rnd.nextInt(choiceList.size()));
    }
}

배열과 제네릭을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해보자 !

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

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

E와 같은 실체화 불가 타입으로는 배열을 만들 수 없다.

이에 대한 적절한 해결책은 두 가지다.

  1. 제네릭 배열 생성을 금지하는 제약을 대놓고 우회하는 방법
  2. elements 필드의 타입을 E[]에서 Object[]로 바꾸는 방법

첫 번째 방식에서는 형변환을 배열 생성 시 단 한 번만 해주면 되지만, 두 번쨰 방식에서는 배열에서 원소를 읽을 때마다 해줘야 한다.

현업에서는 첫 번째 방식을 더 선호하며 자주 사용한다.

하지만 배열의 런타임 타입이 컴파일타임 타입과 달라 힙 오염(item 32)을 일으킨다.

아래의 코드는 첫 번째 방법을 사용한 코드이다.

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]}
    }


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

제네릭 타입 안에서 리스트를 사용하는 게 항상 가능하지도, 꼭 더 좋은 것도 아니다.

자바가 리스트를 기본 타입으로 제공하지 않으므로 ArrayList 같은 제네릭 타입도 결국은 기본 타입인 배열을 사용해 구현해야 한다.

또한 HashMap 같은 제네릭 타입은 성능을 높일 목적으로 배열을 사용하기도 한다.


클라이언트에서 직접 형변환해야 하는 타입보다 제네릭 타입이 더 안전하고 쓰기 편하다.

그러니 새로운 타입을 설계할 때는 형변환 없이도 사용할 수 있도록 하라.

기존 클라이언트에는 아무 영향을 주지 않으면서, 새로운 사용자를 훨씬 편하게 해주는 길이다.(item 26)

item 30. 이왕이면 제너릭 메서드로 만들라

클래스와 마찬가지로, 메서드도 제네릭으로 만들 수 있다.

매개변수화 타입을 받는 정적 유틸리티 메서드는 보통 제네릭이다.

타입 매개변수 목록은 메서드의 제한자와 반환 타입 사이에 온다.

제네릭 싱글톤 팩터리

제네릭은 런타임에 타입 정보가 소거(item 28)되므로 하나의 객체를 어떤 타입으로든 매개변수화 할 수 있다.

하지만 요청한 타입 매개변수에 맞도록 매번 그 객체의 타입을 바꿔주는 정적 팩터리를 만들어야 한다.
(예: Collections.reverseOrder, Collections.emptySet)

재귀적 타입 한정

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

주로 타입의 자연적 순서를 정하는 Comparable 인터페이스(item 14)와 함께 쓰인다.

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

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

형변환 해야할 일이 많다면 경우 제네릭 메서드를 사용하자.

정리가 잘 되어있는 글

[ Java] Java의 Generics - Leopold Baik (백중원) - Medium

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

제네릭은 불공변

item 28에서 이야기했듯 매개변수화 타입은 불공변(invariant) 이다.

즉, 서로 다른 타입 Type1과 Type2가 있을 때, List은 List의 하위 타입도 상위 타입도 아니다.

생산자(Producer)와 와일드카드

// 와일드카드 타입을 사용하지 않은 pushAll 메서드 - 결함이 있다!
public void pushAll(Iterable<E> src) {
    for (E e : src) {
        push(e);
    }
}

위의 코드는 컴파일은 정상적으로 수행되지만 아래와 같이 Number 타입으로 선언된 Stack 객체의 메서드에 Integer 타입의 매개변수를 전달하면 컴파일 오류가 발생한다.

Integer는 Number의 하위 타입이니 정상적으로 잘 동작할 것 같지만 그렇지 않다.

제네릭의 매개변수화 타입은 불공변이기 때문에 상위-하위 자료형의 관계가 없다. 이러한 문제를 해결하기 위해 한정적 와일드카드(bounded wildcard) 자료형을 사용한다.

Integer 클래스는 Number를 상속한 구현체 이므로 아래와 같이 매개변수 부분에 선언한다.

// 생산자(producer) 매개변수에 와일드카드 타입 적용
public void pushAll(Iterable<? extends E> src) {
    for (E e : src) {
        push(e);
    }
}

선언한 부분은 매개변수는 E의 Iterable이 아니라 E의 하위 타입의 Iterable 이라는 뜻이다.

이제 Number 클래스를 상속하는 Integer, Long, Double 등의 타입 요소를 가질 수 있게 된다.

소비자(Consumer)와 와일드카드

// 와일드카드 타입을 사용하지 않은 popAll 메서드 - 결함이 있다!
public void popAll(Collection<E> dst) {
    while(!isEmpty()) {
        dst.add(pop());
    }
}

pushAll 함수와 유사하게 Collection의 요소 타입과 Stack의 요소 타입이 일치하면 오류는 발생하지 않으나, 위에서 작성한 예제처럼 타입이 일치하지 않으면 컴파일 에러가 발생한다.

// 소비자(consumer) 매개변수에 와일드카드 타입 적용
public void popAll(Collection<? super E> dst) {
    while(!isEmpty()) {
        dst.add(pop());
    }
}

선언한 부분은 매개변수는 E의 Collection이 아니라 E의 상위 타입인 Collection 이라는 뜻이다.

이제 받을 수 있는 모든 타입은 자기 자신의 상위 타입이다.

PECS

Producer-Extends-Consumer-Super의 약자이다.

메서드의 매개변수 타입이 생산자를 나타내면 <? extends T>를 사용하고 소비자의 역할을 한다면 <? super T>를 사용하면 된다.

Advanced

메서드의 리턴값에는 와일드카드 타입을 사용하면 안된다.

왜냐하면 메서드를 사용하는 클라이언트 코드에서도 메서드 반환 값으로 와일드카드 자료형을 써야하기 때문이다.

재귀적 타입 한정(Recursive Type Bound)을 사용한 메서드를 살펴보자.

// 변경 전
public static <E extends Comparable<E>> E max(Collection<E> collection)

// 변경 후(PECS 공식 2번 적용)
public static <E extends Comparable<? super E>> E max(Collection<? extends E> collection)

매개변수는 Producer 이므로 매개변수 선언 부분은 Collection<? extends E>가 되어야 한다.

그리고 Comparable 은 E 인스턴스를 소비하는 Consumer이므로 super가 적용된다. 따라서 아래와 같이 PECS 공식을 2번 적용한 형태로 변경되어야 한다.

복잡하지만 위와 같은 방식은 Comparable을 예로 들었을 때, Comparable을 직접 구현하지 않고 직접 구현한 다른 클래스를 확장한 타입을 지원할 때 필요하다.

List<ScheduledFuture<?>> scheduledFutures = ...;

위에서 수정 전 max는 이 리스트를 처리할 수 없다. 그 이유를 알기 위해 ScheduledFuture 인터페이스의 구현 코드를 살펴보자.

public interface ScheduledFuture<V> extends Delayed, Future<V> 
public interface Delayed extends Comparable<Delayed>
public interface Comparable<T>

ScheduledFuture은 Delayed의 하위 인터페이스이며 Delayed는 Comparable를 확장했다.

반면에 ScheduledFuture은 Comparable를 확장하지 않았다.

ScheduledFuture의 인스턴스는 다른 ScheduledFuture 인스턴스뿐 아니라 Delayed 인스턴스와도 비교할 수 있어서 수정 전 max가 이 리스트를 거부하는 것이다.

Comparable(혹은 Compatator)을 직접 구현하지 않고 직접 구현한 다른 타입을 확장한 타입을 지원하기 위해 와일드카드가 필요하다.

와일드카드와 관련해 하나 더 살펴보자.

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

public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, in i, int j);

public API라면 간단한 두 번째가 낫다.

  • 어떤 리스트든 이 메서드에 넘기면 명시한 인덱스의 원소들을 교환해 줄 것이다.
  • 신경 써야 할 타입 매개변수도 없다.

메서드 선언에 타입 매개변수가 한 번만 나오면 와일드카드로 대체하라.

  • 비한정적 타입 매개변수 -> 비한정적 와일드카드
  • 한정적 타입 매개변수 -> 한정적 와일드카드

하지만 아래와 같은 코드에서 컴파일되지 않는다.

public static void swap(List<?> list, in i, int j) {
  list.set(i, list.set(j, list.get(i)));
}

List<?>에는 null 외에는 어떤 값도 넣을 수 없다.

이 상황에서 형변환이나 리스트의 로 타입을 사용하지 않고도 해결할 길이 있다.

바로 와일드카드 타입의 실제 타입을 알려주는 메서드를 private 도우미 메서드로 따로 작성하여 활용하는 방법이다.

public static void swap(List<?> list, in i, int j) {
  swapHelper(list, i, j);
}

// 와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 메서드
private static <E> void swapHelper(List<E> list, int i, int j) {
  list.set(i, list.set(j, list.get(i)));
}

이 리스트에서 꺼낸 값의 타입은 항상 E이고, E 타입의 값이라면 이 리스트에 넣어도 안전함을 알고 있다.


조금 복잡하더라도 와일드카드 타입을 적용시키면 API가 훨씬 유연해진다. 그러니 널리 쓰일 라이브러리를 작성한다면 반드시 와일드카드 타입을 적절히 사용해줘야 한다. PECS 공식을 기억하자 !

+ 따끈한 최근 게시물