만쥬의 개발일기
article thumbnail

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

왜 굳이 제네릭 타입을 사용해야할까?

예시와 함께 알아보자.

 

오브젝트를 기반으로 제작된 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 = elemtns[--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);
    }
}

위 클래스는 스택에서 꺼낸 객체를 형변환하는 과정에서 런타임 오류가 날 위험성이 있다.

제네릭 코드로 리팩토링 해보자.

public class Stack {
    private E[] 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;
    }

    public E pop() {
        if (size == 0)
            throw new EmptyStackException();
        E result = elemtns[--size];
        elements[size] = null; // 다 쓴 참조 해제
        return result;
    }
    ...
}

위처럼 제네릭을 이용해 리팩토링을 하고 나면, 다음과 같은 에러가 하나 발생한다.

Stack.java:8: generic array creation

바로 제네릭 같은 실체화 불가 타입으로는 배열을 만들 수 없다는 것인데, 해결 법이 두가지가 있다.

첫번째

다음 처럼 Object 배열을 생성하고 형변환하는 방식으로 생성을 한다. 이후 @SuppressWarnings 애너테이션으로 형변환이 안전함을 증명하자.

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

두번째

elements 필드의 타입만 E[]가 아닌, Object로 사용하는 것이다.

이 경우 pop 메서드에서 오류가 발생한다. result를 E로 받아야하는데, 배열이 Object이기 때문.. 따라서 형변환을 해주어야한다.

형변환을 하면 경고가 뜨는데, 다시 @SuppressWarnings 어노테이션으로 해결하자.


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

      //여기서 elements의 런타임 타입은 Object[]이므로 형변환이 필요하다.
    @SuppressWarnings("unchecked")
    E result = (E) elements[--size]; 

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

첫번째 방법의 장점

  • 가독성이 좋다.
  • 코드도 짧다
  • 형변환을 배열 생성 시 한 번만 해주면 된다.

첫번째 방법의 단점

  • E가 Object가 아닌 한 배열의 런타임 타입이 컴파일타임 타입과 달라 힙 오염을 일으킨다. (아이템32)

힙 오염이 마음에 걸리면, 두번째 방법을 사용하자.

힙 오염이란?

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

힙 오염이 나타나는 경우

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

  1. 타입 캐스팅 체크는 컴파일러가 하지 않는다. 오직 대입되는 참조변수에 저장할 수 있느냐만 검사한다.
  2. 제네릭의 소거 특성으로 인해 컴파일이 끝난 클래스 파일의 코드에는 타입 파라미터 대신 Object 가 남아있게 된다. ArrayList<Object> 에는 String 이나 Integer 모두 넣는 것이 가능해진다.

따라서 실행을 시키고 get 코드를 통해 요소를 꺼낼 때가 되어서야 런타임 캐스팅 예외가 발생하게 된다

힙 오염 해결 방안

근본적인 원인을 잡기 위해서는, 꺼낼때가 아닌 넣을 때를 검사해야 한다.

더 자세한 내용은 아이템 32에서..

한정적 타입 매개변수

Stack 처럼 대다수의 제네릭 타입은 타입 매개변수의 아무런 제약을 두지 않지만, 간혹 받을 수 있는 하위 타입에 제약이 있는 제네릭 타입도 존재한다.

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

이러한 타입 매개변수 E 를 한정적 타입 매개변수라 한다.

결론: 기존 타입 중 제네릭이었어야 하는게 있다면 제네릭 타입으로 변경하자. 기존 클라이언트에는 아무 영향을 주지 않으면서, 새로운 사용자를 훨씬 편하게 해준다.

아이템 30 - 이왕이면 제네릭 메서드로 만들라

클래스와 마찬가지로 메서드도 제네릭이 가능하다.

다음 코드는 컴파일은 가능하지만, 경고가 발생한다.

(s1은 string이고, s2는 int로 받는 경우 등이 발생할 수 있으므로 타입 안전성 x)

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

따라서 제네릭을 사용해 타입 안전하게 만들어주어야한다.

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

이 메서드를 사용하면, 어떠한 경고 없이 컴파일되며, 타입 안전하고, 쓰기 쉽다.

제네릭 싱글턴 팩터리

불변 객체를 여러 타입으로 활용할 수 있게 만들어야 할 때가 있다.

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

하지만 객체를 매개변수화하려면 , 요청한 타입 매개변수에 맞게 매번 그 객체의 타입을 바꿔주는 정적 팩터리가 필요하다.

이 정적 팩터리를 제네릭 싱글턴 팩터리라고 한다.

제네릭 싱글턴 팩터리 예시

private static UnaryOperator<Object> IDENTITY_FN = (t) -> t;

@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction(){
    return (UnaryOperator<T>) IDENTITY_FN;
}

항등함수는 입력 값을 수정 없이 그대로 반환하는 특별한 함수이므로 T가 어떤 타입이든 UnaryOperator<T>를 사용해도 타입 안전하다.

따라서 비검사 형변환 경고는 @SuppressWarnings 어노테이션으로 숨겨도 된다.

재귀적 한정적 타입

재귀적 타입 한정은 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정하는 개념이다.

이런 재귀적 타입 한정은 주로 (거의)같은 타입의 원소와의 순서를 지정해주는 Comparable과 함께 사용된다.

public interface Comparable<T>{
    int compareTo(T o);
}

타입 매개변수 TComparable<T>를 구현한 타입이 비교할 수 있는 원소의 타입을 정의한다.

실제 거의 모든 타입은 자기 자신과 같은 타입만 비교가 가능하다. StringComparable<String>을 구현하고 IntegerComparable<Integer>를 구현한다.

따라서 Integer는 compareTo를 사용할 때 매개변수로 Integer가 강제되는 것이다.

Comparable을 구현한 원소의 컬렉션을 입력받는 메서드들은 정렬, 검색등 기능을 수행하는데 이런 기능을 수행하기 위해 컬렉션에 담긴 모든 원소가 상호 비교되어야 한다.

[재귀적 타입 한정을 이용해 상호 비교 할 수 있음을 표현]

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

<E extends Comparable<E>>가 모든 타입 E는 자신과 비교할 수 있다는 의미를 갖는다.

아래는 재귀적 타입 한정을 이용한 메서드를 구현했다. 컴파일오류나 경고는 발생하지 않으며 컬렉션에 담긴 원소의 자연적 순서를 기준으로 최댓값을 계산한다.

[재귀적 타입 한정을 이용한 최댓값 계산 메서드]

public static <E extends Comparable<E>> E max(Collection<E> c){
    if(c.isEmpty()){
       throw new IllegalArgumentException("컬렉션이 비었습니다.");
    }

    E result = null;
    for (E e : c){
        if(result == null || e.compareTo(result) > 0){
            result = Objects.requireNonNull(e);
        }
    }

    return result;
}

정리

제네릭 타입 메서드는 타입 안전하며 사용하기 쉽다.
메서드도 타입과 마찬가지로 형변환 없이 사용할 수 있는 편이 좋다.
만약 형변환 해야 하는 메서드가 있다면 제네릭하게 만들자.

 

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

와일드 카드 타입을 사용해야 하는 이유

제네릭은 불공변이다. 즉, Type1과 Type2가 있을 때 List<Type1>은 List<Type2>의 하위 타입도 상위타입도 아니게 된다.

List<String>은 List<Object>

의 하위 타입이 아니라는 뜻인데, 곰곰이 따져보면 사실 이쪽이 말이 된다. List<Object>에는 어떤 객체든 넣은 수 있지만 List<String>에는 문자열만 넣을 수 있다.

다음은 Stack 클래스의 public API 리스트이다.

public class Stack<E> {
   public Stack();
   public void push(E e);
   public E pop();
   public boolean isEmpty();
}

이 클래스에 다음 메서드를 추가한다고 생각해보자.

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

보기엔 별 문제 없어보이지만, 다음과 같이 클라이언트 코드를 작성하면 에러가 발생한다.

@Test
public void stackTest() {
    Stack<Number> stack = new Stack<>();
    List<Integer> integers = List.of(1, 2, 3);
    stack.pushAll(integers);
    System.out.println("stack = " + stack);
}

Number타입의 스택에 integer는 하위타입이기 때문에 들어갈 수 있다고 생각하지만, 제네릭은 불공변이어서 에러가 발생한다.

생산자 : 한정적 와일드 카드 타입을 통해 불공변 제네릭 유연하게 만들기

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

위와 같이 작성하면, 와일드 카드 타입 ? 로 타입 E와 타입 E를 상속한 하위 타입들을 모두 받아줄 수 있게된다.

소비자 : 한정적 와일드 카드 타입을 통해 불공변 제네릭 유연하게 만들기

기존코드

public void popAll(Collection<E> dst) {
    while(size > 0) {
        dst.add(pop());
    }

수정된 코드

public void popAll(Collection<? super E> dst) {
    while(size > 0) {
        dst.add(pop());
    }
}

popAll()도 위의 pushAll()와 같이 제네릭의 불공변 특성 때문에 에러가 발생한다.

기존에는 Collection 타입만을 인수로 받을 수 있는데, 한정적 와일드 카드 타입을 통해 상위 타입 까지 수용할 수 있따.

소비자의 경우, 생산자와 다르게 상위 클래스로만 유연하게 확장해야 한다.

PECS란?

생산자와 소비자 패턴에서 제네릭의 형태상위 타입일수록 더 적은 정보를 가지고 있다.

하위 타입일수록 더 구체적인 다른 정보를 많이 가지고 있다.

생산자일 때는 하위 타입을 받아도 객체는 필요한 정보만 쓰면 되니,extends를 쓴다.

소비자일 때는 소비자의 정보를 받아줄 타입이 필요 하니,super를 쓴다.

만일 매개변수가 생산자와 소비자 둘의 역할을 모두 하면, 어느것을 써도 좋을 것이 없기 때문에 그냥 정확한 타입을 써야 한다.

 

즉, 컬렉션으로부터 와일드카드 타입의 객체를 생성 및 만들면(produce) extends를, 갖고 있는 객체를 컬렉션에 사용 또는 소비(consumer)하면 super를 사용하라는 것이다.

public static <E> Set<E> union(Set<E> s1, Set<E> s2) 로 바뀔 수 있는데,

s1과 s2 모두 E의 생산자니까, public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2) 로 바뀐다.

이때 반환 타입은 여전히 Set이다. 반환 타입에는 한정적 와일드카드 타입을 사용하면 안 된다. 유연성을 높여주기는 커녕 클라이언트 코드에서도 와일드카드 타입을 써야 하기 때문이다

메서드 선언에 타입 매개변수가 한번만 나온다면 와일드 카드를 쓰자.

메서드 선언에 타입 매개변수가 단 한번만 나온다면, 와일드 카드를 썼을 때 그 의도가 더 명확하다.

@Test
public void methodSignatureWildcardTest() {
    List<String> strings = new ArrayList<>(List.of("a", "b", "c", "d", "e", "f"));
    swap(strings, 0, 1);
    System.out.println("strings = " + strings);
}

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

private static <E> void swapHelper(List<E> list, int i, int j) {
    list.set(i, list.set(j, list.get(i)));
}

그냥 ? 와일드카드 타입만 쓰면,List<?>에는 null이외의 값을 넣을 수 없다는 에러가 발생한다..

list.add()가 불가능하기에, 와일드카드 타입을 실제 타입으로 바꿔주는 swapHelper()라는 private 도우미 메서드를 만들어서 구현한다.

위와 같이 코드를 구성하면, API 외부로는 ? 와일드카드가 보이고, 내부에서는 <E> 타입을 쓰게 된다.

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

profile

만쥬의 개발일기

@KangManJoo

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!