← 학습기록 (Java · 이펙티브 자바 3E)

5장 - 제네릭

제네릭은 자바 5부터 사용할 수 있다. 제네릭을 지원하기 전에는 컬렉션에서 객체를 꺼낼 때마다 형변환을 해야 했다.

반면 제네릭을 사용하면 컬렉션이 담을 수 있는 타입을 컴파일러에 알려주게 된다.

그래서 컴파일러는 알아서 형변환 코드를 추가할 수 있게 되고, 엉뚱한 타입의 객체를 넣으려는 시도를 컴파일 과정에서 차단하게 된다.

26. Raw 타입은 사용하지 말라

용어 정리

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

예컨데 List 인터페이스는 원소의 타입을 나타내는 타입 매개변수 E를 받는다. 그래서 이 인터페이스의 완전한 이름은 List<E>이지만 짧게 그냥 List라 쓴다.

각각의 제네릭 타입은 일련의 매개변수화 타입을 정의한다. 먼저 클래스 이름이 나오고, 이어서 꺽쇠괄호 안에 실제 타입 매개변수들을 나열한다. (ex. List<String>는 원소의 타입이 String인 매개변수화 타입이다)

마지막으로 제네릭 타입을 하나 정의하면 그에 딸린 로 타입(raw type)도 함께 정의된다.

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

로 타입은 타입 선언에서 제네릭 타입 정보가 전부 지워진 것처럼 동작하는데, 제네릭이 도래하기 전 코드와 호환되도록 하기 위한 궁여지책이라 할 수 있다.

제네릭 타입이 생기기 전에는 이런 식으로 사용되었다.

private final Collection stamps = ...;

이 컬렉션에는 Stamp 인스턴스 뿐만 아니라 다른 타입의 인스턴스도 들어갈 수 있는데, 컬렉션에서 그 원소를 꺼내기 전까지는 오류를 알아채지 못한다.

이 책 전반에서 줄기차게 얘기했지만, 오류는 가능한 한 발생 즉시, 이상적으로는 컴파일할 떄 발견하는 것이 좋다.

위 코드에 주석을 달아 Stamp 인스턴스만 취급하려 해도 컴파일러가 이해하지 못하니 별 소용이 없다.

private final Collection<Stamp> stamps = ...;

이렇게 선언하면 stamps에는 Stamp 인스턴스만 넣어야 함을 컴파일러가 인지하게 된다.

이제 stamps에 엉뚱한 타입의 인스턴스를 넣으려 하면 컴파일 오류가 발생하며 무엇이 잘못됐는지를 정확히 알려준다.

앞에서도 얘기했듯, 로 타입(타입 매개변수가 없는 제네릭 타입)을 쓰는 걸 언어 차원에서 막아 놓지는 않았지만, 절대로 써서는 안 된다.

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

그렇다면 절대 써서는 안되는 로 타입을 왜 만들어 놓은걸까? 바로 호환성 때문이다.

자바가 제네릭을 받아들이기 전 까지 거의 10년이 걸린 탓에 제네릭 없이 짠 코드가 이미 세상을 뒤덮여 버렸다. 그래서 기존 코드를 모두 수용하면서 제네릭을 사용하는 새로운 코드와도 맞물려 돌아가게 해야만 했다.

List 같은 로 타입을 사용해서는 안 되나, List<Object>처럼 임의 객체를 허용하는 매개변수화 타입은 괜찮다.

List 는 제네릭에서 완전히 발을 뺀 것이고, List<Object>는 모든 타입을 허용한다는 의사를 컴파일러에 명확히 전달한 것이다.

매개 변수로 List를 받는 메서드에 List<String>을 넘길 수 있지만, List<Object>를 받는 메서드에는 넘길 수 없다.

List<String>은 List의 하위 타입이지만, List<Object>의 하위 타입은 아니기 때문.

그 결과, List<Object>같은 매개변수화 타입을 사용할 때와 달리 List같은 로 타입을 사용하면 타입 안정성을 잃게 된다.

이쯤 되면 원소의 타입을 몰라도 되는 고 타입을 쓰고 싶어질 수 있다. 예컨대 2개의 집합(Set)을 받아 공통 원소를 반환하는 메서드를 작성한다고 해보자.

public int test(Set s1, Set s2) {
	some code...;
}

이 메서드는 동작은 하지만 로 타입을 사용해 안전하지 않다. 따라서 비한정적 와일드카드 타입을 대신 사용하는 게 좋다.

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

public int test(Set<?> s1, Set<?> s2) {
	some code..;
}

비한정적 와일드카드 타입인 Set<?>와 Set의 차이는 무엇일까?

특징을 간단히 말하자면 와일드카드 타입은 안전하고 로 타입은 안전하지 않다.

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

반면 Collection<?>에는 어떤 원소도 넣을 수 없다. (null 외에는) 다른 원소를 넣으려 하면 컴파일할 때 다음의 오류 메시지를 보게 될 것이다.

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

class 리터럴에는 로 타입을 써야 한다. 예를 들어 List.class는 허용하고 List<String>.class는 허용하지 않는다.

두 번째 예외는 instanceof 연산자와 관련이 있다. 런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다. 예를 들어 if(o instanceof Set) 과 같이 사용할 수 있다.

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

제네릭을 사용하기 시작하면 수많은 컴파일러 경고를 보게 될 것이다. 비검사 형변환 경고, 비검사 메서드 호출 경고, 비검사 매개변수화 가변인수 타입 경고, 비검사 변환 경고 등이다.

대부분의 비검사 경고는 쉽게 제거할 수 있다. 다음과 같은 코드

Set<String> strings = new HashSet();
//                        HashSet() -> Set<String> 으로 변경해야 함

컴파일러가 알려준 대로 수정하면 경고가 사라진다. 사실 컴파일러가 알려준 타입 매개변수를 명시하지 않고, 자바 7부터 지원하는 다이아몬드 연산자만으로 해결할 수 있다.

Set<String> strings = new HashSet<>();

물론 제거하기 어려운 경고도 있지만, 할 수 있는 한 모든 비검사 경고를 제거해야 한다.

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

@SuppressWarnings 어노테이션은 항상 가능한 한 좁은 범위에 적용하자

변수 선언, 메서드, 생성자가 될 것이다. 자칫 심각한 경고를 놓칠 수 있으니 클래스 전체에 적용하면 안된다.

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

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

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

첫 번째, 배열은 공변이다. Sub가 Super의 하위 타입이라면 배열 Sub[]는 Super[]의 하위 타입이 된다. 즉 함께 변한다는 뜻이다.

반면, 제네릭은 불공변이다. 서로 타른 타입 Type1과 Type2가 있을 때, List<Type1>List<Type2>의 하위 타입도, 상위 타입도 하니다.

다음은 문법상 허용되는 코드다.

Object[] objects = new Long[1];
objects[0] = "String"; // 런타임에 실패함 ArrayStoreException이 발생

하지만 다음은 문법에 맞지 않는다

List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입
ol.add("String");

어느쪽이든 Long용 저장소에 String을 넣을 수는 없다. 다만 배열에서는 그 실수를 런타입에야 알게 되지만, 리스트를 사용하면 컴파일할 때 바로 알 수 있다.

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

위위의 코드에서 알 수 있듯, Long배열에 String을 넣으려 하면 ArrayStoreException이 발생한다.

반면, 앞서 얘기했듯 제네릭은 타입 정보가 런타임에는 소거된다.

원소 타입을 컴파일타임에만 검사하며 런타임에는 알 수조차 없다는 뜻이다.

이상의 주요 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다. 예컨대 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다.

즉 코드를 new List<E>[], new List<String>[], new E[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다.

제네릭 배열을 만들지 못하게 막은 이유는 무엇일까? ⇒ 타입 안전하지 않기 때문이다.

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

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

JDK가 제공하는 제네릭 타입과 메서드를 사용하는 일은 일반적으로 쉬운 편이지만, 제네릭 타입을 새로 만드는 일은 조금 더 어렵다. (그래도 배워두면 그만한 값어치는 한다)

Stack을 제네릭 없이 구현한 예시

public class Stack {
	private Object[] elements;
	...;
}

Stack을 제네릭으로 구현한 예시

public class Stack<E> {
	private E[] elements;
	public Stack() {
		elements = new E[16];
	}
	...;
}

이 단계에서 오류가 발생한다.

아이템 28에서 설명한 것처럼, E와 같은 실체화 불가 타입으로는 배열을 만들 수 없다.

적절한 해결책은 두 가지다. 첫 번째는 제네릭 배열 생성을 금지하는 제약을 대놓고 우회하는 방법이다.

Object 배열을 생성한 다음 제네릭 배열로 형변환해보자. 이렇게 할 수 있지만 컴파일러는 오류대신 경고를 내보낼 것이다. 타입 안전하지 않다.

elements = (E[]) new Object[16];

이 비검사 형변환이 타입 안정성을 해치지 않음을 확인한 후 @SuppressWarnings 어노테이션으로 해당 경고를 숨긴다.

두 번째 방법은 elements 필드의 타입을 E[]에서 Object[]로 바꾸는 것이다.

이렇게 하면 첫 번째와는 다른 오류가 발생한다.

E result = (E) elements[--size];

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

이번에도 마찬가지로 직접 증명하고 경고를 숨겨야 한다.

제네릭 배열 생성을 제거하는 두 방법 모두 나름의 지지를 얻고 있지만, 첫 번째 방법은 가독성이 더 좋다.

배열의 타입을 E[]로 선언하여 오직 E타입 인스턴스만 받음을 확실히 어필한다. 코드도 더 짧다.

Stack의 예처럼 대다수의 제네릭 타입은 타입 매개변수에 아무런 제약을 두고 있지 않는다.

하지만 기본 타입 (int, double)은 사용할 수 없다. 이는 자바 제네릭 타입 시스템의 근본적인 문제이나, 박싱된 기본 타입을 사용해 우회할 수 있다.

타입 매개변수에 제약을 두는 제네릭 타입도 있다. (ex class DelayQueue<E extends Delayd> ..

Delayed의 하위 타입만 매개변수로 받겠다는 뜻이다.

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

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

메서드 선언에서의 세 집합(입력 2개, 반환 1개)의 원소 타입을 타입 매개변수로 명시하고, 메서드 안에서도 이 타입 매개변수만 사용하게 수정하면 된다.

(타입 매개변수들을 선언하는) 타입 매개변수 목록은 메서드의 제한자와 반환 타입 사이에 온다.

다음 코드에서 타입 매개변수 목록은 <E>이고 반환 타입은 Set<E>이다.

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

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

제네릭 싱글턴 패턴 <

재귀적 타입 한정 <

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

매개변수화 타입은 불공변이다. 즉 서로 다른 타입 Type1과 Type2가 있을 때 List<Type1>List<Type2>의 하위 타입도 상위 타입도 아니다.

하지만 때론 불공변 방식보다 유연한 무엇가가 필요하다. 여기 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);
}

이 메서드는 깨끗이 컴파일되지만 완벽하진 않다. Iterable src의 원소 타입이 스택의 원소 타입과 일치하면 잘 동작한다.

하지만 Stack<Number>로 선언한 후 pushAll(intVal)을 호출하면 어떻게 될까? 여기서 intVal은 Integer 타입이다.

Integer는 Number의 하위 타입이니 잘 동작한다. 아니, 잘 동작해야 할 것 같다.

하지만 실제로는 매개변수화 타입이 불공변이기 때문에 incompatible types 에러가 발생한다

해결책은 한정적 와일드카드 타입을 사용하는 것이다.

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

pushAll의 입력 매개변수 타입은 ‘E의 Iterable’이 아니라 ‘E의 하위 타입의 Iterable’이어야 하며, 와일드카드 타입 Iterable<? extends E>가 정확히 이런 뜻이다.

Iterable<E> 의 하위 타입이 아니고 E의 하위타입의 Iterable이어야 한다는 얘기다.

Collection<? super E> 는 E의 Collection이 아닌 E의 상위 타입의 Collection이어야 한다는 뜻의 한정적 와일드카드 타입이다.

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

한편, 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 와일드카드 타입을 써도 좋을 게 없다.

타입을 정확히 지정해야 하는 상황으로, 이때는 와일드카드 타입을 쓰지 말아야 한다.

PECS 공식

생산자(producer)는 extends를, 소비자(consumer)는 super를 사용한다.

Comparable과 Comparator는 모두 소비자라는 사실 잊지 말자

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

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

가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 자동으로 하나 만들어진다.

내부로 감추었어야 할 이 배열을 클라이언트에 노출하는 문제가 생긴것이 바로 문제이다.

그 결과 varargs 매개변수에 제네릭이나 매개변수화 타입이 포함되면 알기 어려운 컴파일 경고가 발생한다.

제네릭 varargs 배열 매개변수에 값을 저장하는 것은 타입 안정성이 깨지기 때문에 안전하지 않다.

제네릭 배열을 프로그래머가 직접 생성하는 건 허용하지 않으면서, 제네릭 varargs 매개변수를 받는 메서드를 선언할 수 있게 한 이유는 무엇일까?

제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 메서드가 실무에서 매우 유용하기 때문에 모순을 수용하기로 한 것이다.

자바 라이브러리에서도 많이 제공하는데 Arrays.asList(T… a), Collections.addAll(Collection<? super T> c, T… elements) … 가 대표적이다.

다행이 이 메서드들은 타입 안정적이다.

자바 7에서는 @SafeVarargs 어노테이션이 추가되어 제네릭 가변인수 메서드 작성자가 클라이언트 측에서 발생하는 경고를 숨길 수 있게 되었다.

@SafeVarargs 어노테이션은 메서드 작성자가 그 메서드가 타입 안전함을 보장하는 장치

가변인수 메서드를 호출할 때 varargs 매개변수를 담는 제네릭 배열이 만들어진다는 사실을 기억하자. 메서드가 이 배열에 아무것도 저장하지 않고(그 매개변수들을 덮어쓰지 않고) 그 배열의 참조가 밖으로 노출되지 않는다면(신뢰할 수 없는 코드가 배열에 접근할 수 없다면) 타입 안전하다.

매개변수 배열이 호출자로부터 그 메서드로 순수하게 인수들을 전달하는 일만 한다면 그 메서드는 안전하다.

이때, varargs 매개변수 배열에 아무것도 저장하지 않고도 타입 안정성을 깰 수도 있으니 주의해야 한다.

다음 코드는 가변인수로 넘어온 매개변수들을 배열에 담아 반환하는 메서드이다. 위험하다.

static <T> T[] toArray(T... args) {
	return args;
}

이 메서드가 반환하는 배열의 타입은 이 메서드에 인수를 넘기는 컴파일타임에 결정되는데, 그 시점에는 컴파일러에게 충분한 정보가 주어지지 않아 타입을 잘못 판단할 수 있다.

따라서 자신의 varargs 매개변수 배열을 그대로 반환하면 힙 오염을 이 메서드를 호출한 쪽의 콜스택으로까지 전이하는 결과를 낳을 수 있다.

제네릭 varargs 매개변수 배열에 다른 메서드가 접근하도록 허용하면 안전하지 않다

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

매개변수화 타입의 수가 보통은 제한되어 있다. 하지만 더 유연한 수단이 필요할 때도 종종 있다.

컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하면 된다.

이렇게 하면 제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해줄 것이다.

이러한 설계 방식을 타입 안전 이종 컨테이너 패턴이라 한다.

public class Favorites {
	public <T> void putFavorite(Class<T> type, T instance);
	public <T> getFavorite(Class<T> type);
}
public static void main(String[] args) {
	Favorites f = new Favorites();
	
	f.putFavorite(String.class, "Java");
	f.putFavorite(Integer.class, 0xcafebabe);
	f.putFavorite(Class.class, Favorites.class);
	
	String favoriteString = f.getFavorite(String.class);
	int favoriteInteger = f.getFavorite(Integer.class);
	Class<?> favoriteClass = f.getFavorite(Class.class);
}

위 Favorites의 구현은 놀랍도록 간단한데 다음과 같다

public class Favorites {
	private Map<Class<?>, Object> favorites = new HashMap<>();
	...;
}

위 private 맵 변수인 favorites의 타입은 Map<Class<?>, Object>이다. 비한정적 와일드카드 타입이라 이 맵 안에 아무것도 넣을 수 없다고 생각할 수 있지만, 사실은 그 반대다.

와일드카드 타입이 중첩되었다는 점을 깨달아야 한다.

맵이 아니라 키가 와일드카드 타입인 것이다.

이는 모든 키가 서로 다른 매개변수화 타입일 수 있다는 뜻으로. 첫 번째는 Class<String>, 두 번째는 Class<Integer>식으로 될 수 있다. 다양한 타입을 지원하는 힘이 여기서 나온다.

타입 안전 이종 컨테이너는 Class를 키로 쓰며, 이런 식으로 쓰이는 Class 객체를 타입 토큰이라 한다.

또한, 직접 구현한 키 타입도 쓸 수 있다. 예컨대 데이터베이스의 행(컨테이너)을 표현한 DatabaseRow 타입에는 제네릭 타입인 Column<T>를 키로 사용할 수 있다.