코딩은 마라톤

[이펙티브자바] 아이템 29, 30, 31 요약 정리 본문

Language/Java

[이펙티브자바] 아이템 29, 30, 31 요약 정리

anxi 2024. 6. 9. 00:32

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

  • Object 기반 스택 
public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }
    
    public void push(Object e) {
        elements[size++] = e;
    }
    
    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        Object result = elements[--size];
        elements[size] = null; // 다 쓴 참조 해제
        return result;
    }
...
}

public static void main(String[] args) {
    Stack stack = new Stack();
    stack.push("빵");
    String bread = (String)stack.pop(); // 클라이언트가 객체 형변환
}

 

위와 같은 클래스는 제네릭 타입으로 바꾸는 것이 좋다.

pop() 메서드에서 객체를 형변환할 때 런타임 오류가 날 위험이 생긴다.

 

제네릭 클래스로 만드는 과정

1. 클래스 선언에 타입 매개변수를 추가한다.

  • 스택이 담을 원소의 타입을 추가하면 된다. 이때 타입 이름으로 보통 E를 사용한다.
  • 제네릭 타입으로 바꿀 경우 타입 오류를 컴파일 시점에 잡을 수 있다.
  • 클라이언트가 형변환할 필요 없다.
// Object -> E (타입 매개변수 변경)
public class Stack<E> {
    private E[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

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

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

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

 

위와 같이 할 경우 타입 오류가 발생한다.

this.elements = new E[DEFAULT_INITIAL_CAPACITY]; // 실체화 불가 타입으로 배열을 만들 수 없기 때문이다.

 

해결책 

1. 제네릭 배열 생성을 금지하는 제약을 대놓고 우회한다.

this.elements = new (E[]) new Object[DEFAULT_INITIAL_CAPACITY]; // 실체화 불가 타입으로 배열을 만들 수 없기 때문이다.

 

  • 배열을 Object[]로 생성하고 타입변수 배열로 형변환한다.
  • 컴파일러는 오류 대신 경고를 내보낸다.
  • 비검사 형변환이 안전한지 스스로가 확인해야한다.
  • 안전함을 직접 증명했다면 범위를 최소로 좁혀 @SuppressWarning 애너테이션으로 경고를 숨긴다.
@SuppressWarnings("unchecked")
public Stack() {
	this.elements = new (E[]) new Object[DEFAULT_INITIAL_CAPACITY]; // 실체화 불가 타입으로 배열을 만들 수 없기 때문이다.
}

 

2. 배열 타입을 E[] 에서 Object[]로 바꾼다.

  • elements의 필드 타입을 E[]에서 Object[]로 바꾼다.
public class Stack<E> {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

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

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

    public E pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        @SupressWarnings("unchecked")
        E result = (E) elements[--size];
        elements[size] = null;
        return result;
    }
...
}

 

원소를 E(실체화 불가 타입)로 형변환 시키면 형변환이 안전한지 증명할 방법이 없다.

따라서 1과 마찬가지로 @SupressWarnings를 통해 경고를 숨긴다.

 

1번 방법이 가독성은 더 좋다. 배열의 타입을 E[]로 선언하여 오직 E타입만 받을 수 있음을 확실한다.

1번 방식은 형변환을 배열 생성 시 단 한 번만 해주면 되지만, 2번 방식 경우 원소를 읽을 때마다 해줘야한다.

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

 

하지만 2번 방법을 사용하는 경우도 있다.

런타임 타입과 컴파일 타입이 달라 발생하는 힙 오염 때문이다.

1번 방법의 경우 컴파일 타입은 Object[]지만 런타임 타입은 E[]이기 때문에 타입이 다른 객체를 참조하여 힙 오염이 발생한다.

 

결론

  • 클라이언트에서 직접 형변환 하는 타입보다 제네릭 타입이 더 안전하고 쓰기 편하다.
  • 제네릭 타입을 사용함으로써 명시적으로 타입을 받기 때문에 컴파일 타임에 오류를 감지할 수 있다.

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

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

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

제네릭 메서드 작성법은 제네릭 타입 작성법과 비슷하다.

 

로 타입 메서드

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

 

 

여기서 Set과 HashSet은 로 타입으로 사용되고 있다.

따라서 이 메서드를 타입 안전하게 만들어야 한다.

 

s1, s2, 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;
}

 

항등함수 : 입력 값을 수정 없이 그대로 반환하는 특별한 함수

위의 IDENTITY_FN은 항등 함수이므로 T가 어떤 타입이든 UnaryOperator<T>를 사용해도 타입 안전하다.

 

재귀적 타입 한정

상대적으로 드물지만 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정할 수 있다.

재귀적 타입 한정은 주로 타입의 자연적 순서를 정하는 Comparable 인터페이스와 함께 쓰인다.

 

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

 

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

따라서 String은 Comparable<String>을, Integer은 Comparable<Integer>을 구현한다.

 

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

 

위와 같이 타입 한정을 하여 "모든 타입 E는 자신(Comparable<E>)와 비교할 수 있다" 고 읽을 수 있다. (상호 비교 가능)

public static <E extends Comparable<E>> E max(Collection<E> collection) {
    if (collection.isEmpty()) {
        throw new IllegalArgumentException("비어 있습니다.");
    }

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

 

결론

  • 관용구, 와일드 카드를 사용한 변형, 시뮬레이트한 셀프 타입 관용구를 이해하면 재귀적 타입 한정을 더 잘 이해할 수 있다.
  • 명시적으로 형변환해야 하는 메서드보다 제네릭 메서드가 더 안전하다.
  • 형변환 해줘야 하는 기존 메서드를 제네릭 메서드로 만드는게 좋다.

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

매개변수화 타입은 불공변이다.

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

예를 들어, String은 Object의 하위 타입이라 할 수 있지만 List<String>은 List<Object>의 하위타입이 아니다. (불공변)

 

스택 클래스가 존재한다.

기존 스택 클래스에 일련의 원소를 스택에 넣는 메서드를 추가한다고 가정하면

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

 

이렇게 만들 수 있다. 

src의 타입과 Stack의 타입이 일치하면 무리없이 잘 작동한다. 

그러나 Stack<Number>로 선언하고 pushAll(intVal)을 호출하면 논리적으론 잘 동작해야 한다. (intVal = Integer 타입)

Stack<Number> stack = new Stack<>();
Iterable<Integer> integers = ...;
stack.pushAll(integers); // 오류 : 제네릭은 불공변이기 때문

 

위에서 말한 상속관계가 제네릭에선 없기 때문에 에러를 발생한다.

 

이걸 해결할 수 있는 것이 바로 "한정적 와일드카드 타입" 이다.

한정적 와일드 카드

위 에러를 고치기 위해선 pushAll의 입력 매개변수 타입은 "E의 Iterable"가 아닌 "E의 하위 타입의 Iterable" 이어야 한다.

따라서 Iterable<? extends E> src)로 수정할 수 있다.

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

 

이제 pushAll과 짝을 이루는 popAll 메서드를 작성한다.

popAll 메서드는 Stack 안의 모든 원소를 주어진 컬렉션으로 옮긴다.

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

 

스택의 타입과 dst의 타입이 같다면 문제 없이 동작하지만

그렇지 않을 경우 오류가 발생한다.

Stack<Number> stack = new Stack<>();
Collection<Object> objects = ...;
stack.popAll(objects); // 에러 발생

 

Collection<Object>은 popAll 매개변수인 Collection<Number>의 하위 타입이 아니기 때문이다.

이번에도 와일드 카입을 통해 해결할 수 있는데, 여기서 popAll() 매개변수 타입은 "E의 Collection"이 아닌 "E의 상위 타입 Collection"이어야 한다. 

 

따라서 Collection<? super E>로 수정할 수 있다.

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

 

결론 : 유연성을 극대화하려면 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용하라

 

PECS

펙스(PECS) : Producer - Extends, Consumer - Super

 

위 공식은 어떤 와일드카드 타입을 써야하는지 기억하는데 도움이 된다.

즉, 매개변수화 타입 T가 생산자라면 <? extends E>를, 소비자라면 <? super E>를 사용한다.

 

  • pushAll(src) : src 매개변수는 Stack이 사용할 E 인스턴스를 생산하므로 extends를 사용한다.
  • popAll(dst) : dst 매개변수는 Stack으로부터 E 인스턴스를 소비하므로 super을 사용한다.

타입 매개변수 VS 와일드카드

메서드를 정의할 때 타입 매개변수와 와일드카드 중 어느 것을 사용해도 괜찮을 때가 있다.

 

// 비한정적 타입 매개변수
public static <E> void swap(List<E> list, int i, int j);

// 비한정적 와일드카드
public static void swap(List<?> list, int i, int j)

 

기본 규칙

  • 메서드 선언에 타입 매개변수가 한 번이라도 나오면 와일드카드로 대체하라
    • 비한정적 타입 매개변수라면 비한정적 와일드카드로 변경
    • 한정적 타입 매개변수라면 한정적 와일드카드로 변경
public static void swap(List<?> list, int i, int j) {
    list.set(i, list.set(j, list.get(i));
}
// 에러
// List<?> 비한정적 와일드카드는 null외에 어떤 값도 넣을 수 없음


하지만 위같은 경우 에러를 발생하기 때문에 이럴 경우

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

 

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

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

 

도우미 메서드를 사용하면서 외부에서는 와일드카드 기반의 선언을 유지할 수 있다.

 

결론

  • 와일드카드 타입을 적용하면 API가 훨씬 유연해진다.
  • PECS 공식을 기억하자.
  • Comparable, Comparator은 모두 소비자이다. (super 사용)