[Effective Java] 5장. 제네릭

kkatal_chae·2022년 8월 31일
0

Effective Java

목록 보기
3/11
post-thumbnail

아이템 26. 로 타입은 사용하지 말라

클래스와 인터페이스 선언에 타입 매개변수가 쓰이면, 이를 제네릭 클래스 혹은 제네릭 인터페이스라 한다.

로 타입이란 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때를 말한다. ex) List<E> 의 로 타입은 List 이다.

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

그렇다면 자바에서는 로 타입을 허용하는 것일까?

⇒ 자바가 제네릭을 받아들이기 이전의 코드들과의 호환성 때문

⇒ 이러한 마이그레이션 호환성을 위해 로 타입을 지원하고 제네릭 구현에는 소거 방식을 사용하기로 했다.

List 는 제네릭 타입에서 완전히 발을 뺀 것이고, List<Object> 는 모든 타입을 허용한다는 의사를 컴파일러에 명확히 전달한 것이다. 매개변수로 List 를 받는 메서드에 List<String> 을 넘길 수 있지만, List<Object> 를 받는 메서드에는 넘길 수 없다. 이는 제네릭의 하위 타입 규칙 때문이다.

제네릭 타입을 쓰고 싶지만 실제 타입 매개변수가 무엇인지 신경 쓰고 싶지 않다면 물음표를 사용하자.

Set<?> vs Set

? 는 타입 안전하고 로 타입은 안전하지 않다.

로 타입 컬렉션에는 아무 원소나 넣을 수 있으니 타입 불변식을 훼손하기 쉽다.

반면, Collection<?> 에는 null 을 제외한 어떤 원소도 넣을 수 없다.

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

  • class 리터럴에는 로 타입을 써야 한다.
  • 런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다.
💡 Set < object > 는 어떤 타입의 객체도 저장할 수 있는 매개변수화 타입이고, Set < ? > 는 모종의 타입 객체만 저장할 수 있는 와일드카드 타입이다.

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

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

@SuppressWarnings 애너테이션은 항상 가능한 한 좁은 범위에 적용하자.

@SuppressWarnings(”unchecked”) 애너테이션을 사용할 때면 그 경고를 무시해도 안전한 이유를 항상 주석으로 남겨야 한다.

💡 비검사 경고는 중요하니 무시하지 말자. 모든 비검사 경고는 런타임에 ClasssCastException 을 일으킬 수 있는 잠재적 가능성을 뜻하니 최선을 다해 제거하라.

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

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

첫 번째, 배열은 공변 ( 함께 변한다 ) 이다.

SubSuper 의 하위 타입이라면 Sub[]Super[] 의 하위 타입이 된다.

두 번째 주요 차이로, 배열은 실체화된다. 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다.

예컨데 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 즉, 코드를 new List<E> [] , new List<String> [], new E[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다.

⇒ 타입 안전하지 않기 때문에 막아놓음

E, List, List 같은 타입을 실체화 불가 타입이라 한다.

⇒ 실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입이다.

소거 매커니즘 때문에 매개변수화 타입 가운데 실체화될 수 있는 타입은 List, Map 같은 비한정적 와일드카드 타입뿐이다.

💡 배열과 제네릭에는 매우 다른 타입 규칙이 적용된다. 배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소거 된다. 그 결과 배열은 런타임에는 타입 안전하지만 컴파일 타임에는 그렇지 않다.

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

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

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

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

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

위의 코드에서는 다음과 같은 오류가 발생한다.

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

// 배열을 사용한 코드를 제네릭으로 만드는 방법 1
// 배열 elements 는 push(E e) 로 넘어온 E 인스턴스만 담는다.
// 따라서 타입 안전성을 보장하지만,
// 이 배열의 런타임 타입은 E[] 가 아닌 Object[]다!
@SuppressWarnings("unchecked")
public Stack() {
	elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}

E 가 실체화 불가 타입이기 때문에 Object [] 로 생성한 후 타입을 바꿔주면 오류를 해결할 수 있다.

또 다른 방법에서는 pop 메서드에서 형변환 경고가 발생한다.

E 는 실체화 불가 타입이므로 컴파일러는 런타임에 이뤄지는 형변환이 안전한지 증명할 방법이 없다.

// 배열을 사용한 코드를 제네릭으로 만드는 방법 2
// 비검사 경고를 적절히 숨긴다
public E pop() {
	... 
	@SuppressWarnings("unchecked")
	E result = (E) elements[--size];

	...
}

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

⇒ 따라서 현업에서는 첫 번째 방식을 더 선호하며 자주 사용한다. 하지만 배열의 런타임 타입이 컴파일 타임 타입과 달라 힙 오염을 일으킨다.

타입 매개변수 목록인 <E extends Delayed>java.util.concurrent.Delayed 의 하위 타입만 받는다는 뜻이다.

💡 클라이언트에서 직접 형변환해야 하는 타입보다 제네릭 타입이 더 안전하고 쓰기 편하다. 그러니 새로운 타입을 설계할 때는 형변환 없이도 사용할 수 있도록 하라. 제네릭은 기존 클라이언트에는 아무 영향을 주지 않으면서, 새로운 사용자를 훨씬 편하게 해주는 길이다.

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

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

위에서 union 메서드는 집합 3개 ( 입력 2개, 반환 1개 ) 의 타입이 모두 같아야 한다. 이를 한정적 와일드카드 타입을 사용하여 더 유연하게 개선할 수 있다.

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

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

하지만 이렇게 하려면 요청한 타입 매개변수에 맞게 매번 그 객체의 타입을 바꿔주는 정적 팩터리를 만들어야 한다. 이 패턴을 제네릭 싱글턴 팩터리 라 한다.

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

타입 한정인 <E extends Comparable<E>> 는 모든 타입 E 는 자신과 비교할 수 있다라고 읽을 수 있다. 상호 비교 가능하다는 뜻을 아주 정확하게 표현했다고 할 수 있다.


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

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

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

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

💡 PECS : producer ⇒ extends / consumer ⇒ super

즉, 매개변수화 타입 T 가 생산자라면 < ? extends T > 를 사용하고,

소비자라면 < ? super T > 를 사용하라.

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

메서드 선언에 타입 매개변수가 한 번만 나오면 와일드 카드로 대체하라. 이때 비한정적 타입 매개변수라면 비한정적 와일드카드로 바꾸고, 한정적 타입 매개변수라면 한정적 와일드카드로 바꾸면 된다.

💡 조금 복잡하더라도 와일드카드 타입을 적용하면 API 가 훨씬 유연해진다. PECS 공식을 기억하자. 생산자는 extends 를 소비자는 super 를 사용한다. Comparable 과 Comparator 는 모두 소비자라는 사실도 잊지 말자.

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

가변인수는 메서드에 넘기는 인수의 개수를 클라이언트가 조절할 수 있게 해주는데, 구현 방식에 허점이 있다.

거의 모든 제네릭과 매개변수화 타입은 실체화되지 않는다.

제네릭 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않다.

그러나 실무에서 varargs 매개변수를 받는 메서드가 유용하기 사용되기 때문에 경고에 그치도록 만들었고, @SafeVarargs 애너테이션을 통해서 해당 메서드가 타입 안전함을 보장하고 있다는 것을 표시할 수 있도록 하였다.

가변인수 메서드를 호출할 때 varargs 매개변수를 담는 제네릭 배열이 만들어진다는 사실을 기억하자.

제네릭 varargs 메서드를 사용한다면 두 가지를 유념하자

  • varargs 매개변수 배열에 아무것도 저장하지 않는다
  • 그 배열 혹은 복제본을 신뢰할 수 없는 코드에 노출하지 않는다
💡 가변인수와 제네릭은 궁합이 좋지 않다. 가변인수 기능은 배열을 노출하여 추상화가 완벽하지 못하고, 배열과 제네릭의 타입 규칙이 서로 다르기 때문이다.

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

제네릭은 Set< E >, Map< K, V > 등의 컬렉션과 단일원소 컨테이너에도 흔히 쓰인다. 이런 모든 쓰임에서 매개변수화되는 대상은 원소가 아닌 컨테이너 자신이다.

⇒ 따라서 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다.

데이터 베이스에 데이터를 저장할 때 데이터들을 타입 안전하게 이용할 수 있다면 좋다. 이는 컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공함으로서 해결할 수 있다.

각 타입의 Class 객체를 매개변수화한 키 역할로 사용하면 되는데, 이 방식이 동작하는 이유는 class 의 클래스가 제네릭이기 때문이다. class 리터럴의 타입은 Class 가 아닌 Class<T> 다.


// Example
@SuppressWarnings("unchecked")
  public List<?> getVoList(Class<?> cls, String key) {
    if (this.dataset == null) {
      return null;
    }

    Object obj = this.get(key);

    if (obj == null) {
      return null;
    }

    return DataConverter.mapToVo((List<Map<String, Object>>) obj, cls);
  }

// Use
List<WorkshopVo> list = (List<WorkshopVo>) requestData.getVoList(WorkshopVo.class, "chosen");

컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고받는 class 리터럴을 타입 토큰 이라 한다.

// 타입 안전 이종 컨테이너 패턴 - API
public class Favorite {
	public <T> void putFavorite(Class<T> type, T instance);
	public <T> T getFavorite(Class<T> type);
}

// 타입 안전 이종 컨테이너 패턴 - 클라이언트
public static void main(String[] args) {
	Favorite f = new Favorite();

	f.putFavorite(String.class,"Java");
	f.putFavorite(Integer.class ...);
	...

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

	...
}

Favorites 가 사용하는 private 맵 변수인 favorites 의 타입은 Map< Class<?>, Object> 이다.

비한정적 와일드카드 타입이라 이 맵 안에 아무것도 넣을 수 없다고 생각할 수 있지만, 사실은 그 반대다. 와일드카드 타입이 중첩되었다는 점을 깨달아야 한다.

맵이 아니라 키가 와일드카드 타입인 것이다. 이는 모든 키가 서로 다른 매개변수화 타입일 수 있다는 뜻이다.

그 다음으로 알아둘 점은 favorites 맵의 값 타입은 단순히 Object 라는 것이다. 무슨 뜻인고 하니, 이 맵은 키와 값 사이의 타입 관계를 보증하지 않는다는 말이다. 즉 모든 값이 키로 명시한 타입임을 보증하지 않는다.

Favorite 클래스에는 알아두어야 할 제약이 두 가지 있다.

  • 첫 번째, 악의적인 클라이언트가 Class 객체를 제네릭이 아닌 로 타입으로 넘기면 Favorites 인스턴스의 타입 안전성이 쉽게 깨진다.

  • 두 번째 제약은 실체화 불가 타입에는 사용할 수 없다는 것이다. 다시 말해, 즐겨 찾는 String 이나 String[] 은 저장할 수 있어도 즐겨 찾는 List<String> 은 저장할 수 없다.

List<String>List<Integer>List.class 라는 같은 Class 객체를 공유하므로 두 번째 제약에 대한 완벽히 만족스러운 우회로는 없다.

한정적 타입 토큰이란 단순히 한정적 타입 매개변수나 한정적 와일드카드를 사용하여 표현 가능한 타입을 제한하는 타입 토큰이다.

annotationType 인수는 애너테이션 타입을 뜻하는 한정적 타입 토큰이다. 즉, 애너테이션된 요소는 그 키가 애너테이션 타입인, 타입 안전 이종 컨테이너인 것이다.

💡 컬렉션 API 로 대표되는 일반적인 제네릭 형태에서는 컨테이너가 다룰 수 있는 타입 매개변수의 수가 고정되어 있다. 하지만 컨테이너 자체가 아닌 키를 타입 매개변수로 바꾸면 이런 제약이 없는 타입 안전 이종 컨테이너를 만들 수 있다. 타입 안전 이종 컨테이너는 Class 를 키로 쓰며, 이런 식으로 쓰이는 Class 객체를 타입 토큰이라 한다.

0개의 댓글