[이펙티브 자바] 04. 제네릭
제네릭은 자바 5부터 사용할 수 있다. 제네릭을 지원하기 전에는 컬렉션에서 객체를 꺼낼 때마다 형변환을 해야했는데, 런타임에 형변환 오류가 나곤 했다. 반면 제네릭을 사용하면 컬렉션이 담을 수 있는 타입을 컴파일러에 알려주게 되어 컴파일러는 알아서 형변환 코드를 추가할 수 있게 된다. 덕분에 더 안전하고 명확한 프로그램을 만들 수 있다는 장점이 있지만, 코드가 복잡해진다는 단점이 있다.
아이템 26. 로 타입은 사용하지 말라
클래스와 인터페이스 선언에 타입 매개변수가 쓰이면, 이를 제네릭 클래스 혹은 제네릭 인터페이스라 한다. 각각의 제네릭 타입은 일련의 매개변수화 타입을 정의한다. 제네릭 타입을 하나 정의하면 그에 딸린 로 타입(raw type)도 함께 정의된다. 로 타입이란 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을때를 말한다.
List<E>의 로 타입은 List다.
로 타입은 제네릭이 도래하기 전의 코드와 호환되도록 하기 위한 궁여지책이다. 로 타입 사용 시 컴파일 시 발견하지 못하고 런타임때 알아챌 수 있는 경우가 많다 위험하다.
// raw type
// stamp 인스턴스만 취급하는 Collection에 Coin 인스턴스를 넣어도 컴파일된다. -> 런타임 오류 발생
private final Collection stamps = ...;
// 매개변수화된 컬렉션 타입
// stamp 인스턴스만 취급하는 Collection에 Coin 인스턴스를 넣으면 컴파일 에러.
private final Collections<Stamp> stamps = ...;
컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가하여 절대 실패하지 않음을 보장한다.
26.1 List와 List<Object>
List같은 로 타입은 사용해선 안되나, List<Object>처럼 임의 객체를 허용하는 매개변수화 타입은 괜찮다. List는 제네릭 타입에서 완전 벗어난것이고, List<Object>는 모든 타입을 허용한다는 의사를 컴파일러에 명확히 전달한 것이다. List<Object>같은 매개변수화 타입을 사용할 때와 달리 List 같은 로 타입을 사용하면 타입 안정성을 잃게 된다.
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
unsafeAdd(strings, Integer.valueOf(42));
String s = strings.get(0); // 컴파일러가 자동으로 형변환 코드를 넣어준다.
}
private static void unsafeAdd(List list, Object o) {
list.add(o);
}
위 코드를 실행하면 strings.get(0)의 결과를 형변환하려 할 때 ClassCastException을 던진다. Integer를 String으로 변환하려 시도한 것이다. 이 코드를 로 타입인 List를 List<Object>로 바꾼다면, 컴파일 조차 되지 않는다.
26.2 비한정적 와일드 카드 타입
제네릭 타입을 쓰고 싶지만 실제 타입 매개변수가 무엇인지 신경 쓰고 싶지 않다면 물음표(?)를 사용하자. 예를들어 Set<E>의 비한정적 와일드카드 타입은 Set<?>다. 이것은 어떤 타입이라도 담을 수 있는 가장 범용적인 매개변수화 Set 타입이다.
로 타입은 안전하지 않고, 와일드카드 타입은 안전하다. 로 타입 컬렉션에는 아무 원소나 넣을수 있으니 타입 불변식을 훼손하기 쉽지만 Collection<?>에는 null 이외의 어떤 원소도 넣을 수 없으며 컬렉션에서 꺼낼 수 있는 객체의 타입도 전혀 알 수 없게 된다.
26.3 로 타입 사용 예외
- class 리터럴은 로 타입을 써야 한다. List.class, String[].class, int.class는 허용하고 List<String>.class와 List<?>.class는 허용하지 않는다.
- 런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다. 그리고 로 타입이든 비한정적 와일드카드 타입이든 instanceof는 완전히 똑같이 작용하므로 깔끔하게 로타입을 쓴는것이 좋다.
if(o instanceof Set) {
Set<?> s = (Set<?>) o; // o의 타입이 set임을 확인 후 와일드 카드로 형변환해야 한다.
}
아이템 27. 비검사 경고를 제거하라
제네릭을 사용하기 시작하면 수많은 컴파일러 경고를 보게 될 것이다. 할 수 있는 한 모든 비검사 경고를 제거하라. 모든 비검사 경고는 런타임에 ClassCastException을 일으킬수 있는 잠재적 가능성을 뜻하니, 모두 제거한다면 코드는 타입 안전하다. 경고를 제거할 수는 없지만 타입 안전하다고 확신할 수 있다면 @SuppressWarning("unchecked") 애너테이션을 달아 경고를 숨기자.
안전하다고 검증된 비검사 경고를 숨기지 않고 그대로 두면, 진짜 문제를 알리는 새로운 경고가 나와도 눈치채지 못할 수 있다. @SuppressWarning는 어떤 선언에도 달수 있지만, 항상 가능한 좁은 범위에 적용하자. 한줄이 넘는 메서드나 생성자에 달렸다면 지역변수 선언 쪽으로 옮기자.
public <T> T[] toArray(T[] a) {
if(a.length < size) {
// 생성한 배열과 매개변수로 받은 배열의 타입이 모두 T[]로 같으므로
// 올바른 형변환이다.
@SuppressWarnings("unchecked") T[] result =
(T[]) Arrays.copyOf(elements, size, a.getClass());
return result;
}
...
}
마지막으로 @SuppressWarning를 사용할 때면 그 경고를 무시해도 안전한 이유를 항상 주석으로 남겨야 한다.
아이템 28. 배열보다는 리스트를 사용하라
28.1 공변과 불공변
배열은 공변(함께 변함)이다. Sub가 Super의 하위 타입이라면, 배열 Sub[]는 배열 Super[]의 하위 타입이 된다. 반면 제네릭은 불공변이다. 서로 다른 타입 Type1과 Type2가 있을때 List<Type1>은 List<Type2>의 하위 타입도 아니고 상위 타입도 아니다.
런타임에 실패하는 배열 코드를 보자
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다"; // ArrayStoreException을 던진다.
반면 컴파일시 실패하는 제네릭 코드를 보자
List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입이다.
ol.add("타입이 달라 넣을 수 없다.");
어느쪽이든 Long용 저장소에 String을 넣을 수는 없지만, 배열에서는 그 실수를 런타임때 알게 되지만 리스트를 사용하면 컴파일시 바로 알 수 있다.
28.2 실체화
배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다. 그래서 위에서 보듯 Long 배열에 String을 넣으려고 하면 Exception이 발생한다. 반면, 제네릭은 타입 정보가 런타입에는 소거된다. 원소 타입을 컴파일타임에만 검사하며 런타임에는 알수조차 없다는 뜻이다. 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘이다. 이런 주요 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다.
제네릭같이 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입을 실체화 불가 타입이라고 한다. 배열대신 컬렉션을 사용할 경우 코드가 조금 복잡해지고 성능이 살짝 나빠질 수도 있지만, 그 대신 타입 안전성과 상호운용성을 좋아진다.
아이템 29. 이왕이면 제네릭 타입으로 만들라
일반 클래스를 제네릭 클래스로 만드는 첫 단계는 클래스 선언에 타입 매개 변수를 추가하는 일이다. 이때 타입 이름으로는 보통 E를 사용한다. 위에서 배열보다는 리스트를 사용하라고 했으나, 제네릭 타입 안에서 리스트를 사용하는게 항상 가능하지도 않고, 꼭 더 좋은것도 아니다.
아래의 클래스를 제네릭 클래스로 만들어 보자.
public class Stack {
private Object[] elements;
...
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
...
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
...
}
...
}
29.1 일반 클래스를 제네릭으로 만드는 법
public class Stack<E> {
private E[] elements;
...
public Stack() {
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
...
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
E result = elements[--size];
...
}
...
}
Object를 적절한 타입 매개변수로 바꾸고 컴파일하면, 대체로 하나 이상의 오류나 경고가 뜬다.
Stack.java8: generic array creation
elements = new E[DEFAULT_INITAIL_CAPACITY];
E와 같은 실체화 불가 타입으로는 배열을 만들수 없다는 뜻이다. 배열을 사용하는 코드를 제네릭으로 만들려 할때는 이 문제가 항상 발목을 잡는데 적절한 해결책은 두가지다. 첫번째는 제네릭 배열 생성을 금지하는 제약을 대놓고 우회하는 방법이다.
Stack.java:8: warning: [unchecked] unchecked cast
found: Object[], required: E[]
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
^
위와 같이 Object 배열을 생성한 다음 제네릭 배열로 형변환한다. 컴파일러는 이 프로그램이 안정한지 알수 없기 때문에 비검사 경고를 보내고, 프로그래머는 스스로 확인해 @SuppressWarnings 으로 해당 경고를 숨겨야 한다.
제네릭 배열 생성 오류를 해결하는 두 번째 방법은 elements 필드의 타입을 E[]에서 Object[]로 바꾸는 것이다. 이 역시 직접 증명하고 경고를 숨길 수 있다.
public E pop() {
if (size == 0)
throw new EmptyStackException();
// push에서 E 타입만 허용하므로 이 형변환은 안전하다.
@SuppressWarnings("unckecked") E result = (E) elements[--size];
...
}
첫번째 방법이 가독성이 더 좋고 배열의 타입을 E[]로 선언해 오직 E타입 인스턴스만 받음을 확실히 어필하기 때문에 더 자주 사용되고 있다. 또한 첫번째 방식은 형변환을 배열 생성시 한번만 해주면 되지만, 두번째 방식에서는 배열에서 원소를 읽을때마다 해줘야한다.
아이템 30. 이왕이면 제네릭 메서드로 만들라
클래스와 마찬가지로, 메서드도 제네릭으로 만들 수 있다. 매개변수화 타입을 받는 정적 유틸리티 메서드는 보통 제네릭이다. 아래의 로 타입 메서드는 컴파일은 되지만 경고가 발생한다.
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;
}
아이템 31. 한정적 와일드카드를 사용해 API 유연성을 높이라
매개변수화 타입은 불공변이다. List<Object>에는 어떤 객체든 넣을 수 있지만 List<String>에는 문자열만 넣을 수 있다. 즉 List<String>은 List<Object>가 하는 일을 제대로 수행하지 못하니 하위 타입이 될 수 없다.
31.1 <? extends E>
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(Integer)을 호출하면 에러가 뜬다. 매개변수화 타입이 불공변이기 때문이다.
이를 해결하기 위해 한정적 와일드카드 타입이라는 특별한 매개변수화 타입을 지원한다. Iterable<? extends E>는 pushAll의 입력 매개변수 타입은 'E의 Iterable'이 아니라 'E의 하위 타입의 Iterable'이어야 한다는 뜻이다.
31.2 <? super E>
push와 짝을 이루는 popAll 메서드를 작성해보자. popAll은 Stack안의 모든 원소를 주어진 컬렉션으로 옮겨 담는다.
public void popAll(Collection<E> dst) {
while (!isEmpty())
dst.add(pop());
}
이 경우, Stack<Number>의 원소를 Object용 컬렉션으로 옮길 때 "Collection<Object>는 Collection<Number>의 하위타입이 아니다"라는 에러가 발생한다. 이번에도 와일드 카드 타입으로 해결할 수 있다. Collection<? super E>는 popAll의 입력 매개변수 타입이 'E의 Collection'이 아니라 'E의 상위 타입의 Collection'이어야 한다는 뜻이다.
31.3 펙스(PECS) : producer-extends, consumer-super
매개변수화 타입 T가 생산자라면 <? extends T>를 사용하고, 소비자라면 <? super T>를 사용하라
아이템 32. 제네릭과 가변인수를 함께 쓸 때는 신중하라
가변인수는 메서드에 넘기는 인수의 개수를 클라이언트가 조절할 수 있게 해주는데, 가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 자동으로 하나 만들어진다. 그 결과 varargs 매개변수에 제네릭이나 매개변수화 타입이 포함되면 알기 어려운 컴파일 경고가 발생한다.
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList; // 힙 오염 발생
String s = stringList[0].get(0); // ClassCastException
}
메서드를 선언할 때 실체화 불가 타입으로 varargs 매개변수를 선언하면 컴파일러가 경고를 보낸다. 매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생한다. 이처럼 제네릭 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않다.
그럼에도 불구하고 제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 메서드는 실무에서 매우 유용하기 때문에 자바는 이를 허용해준다. 메서드가 이 배열에 아무것도 저장하지 않고, 그 배열의참조가 밖으로 노출되지 않는다면 타입 안전하므로 @SafeVarargs 애너테이션을 이용해 경고를 숨길수 있다.
아이템 33. 타입 안전 이종 컨테이너를 고려하라
제네릭은 Set<E>, Map<K,V> 등의 컬렉션과 ThreadLocal<T>, AtomicReference<T> 등의 단일 원소 컨테이너에도 흔히 쓰인다. 예컨대 Set에는 원소의 타입을 뜻하는 단 하나의 타입 매개변수만 있으면 되며, Map에는 키와 값의 타입을 뜻하는 2개만 필요한 식이다.
더 유연한 수단이 필요할 때도 종종 있다.
컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하면 된다. 이런 설계 방식을 타입 안전 이종 컨테이너 패턴이라 한다.
public class Favorite {
private Map<Class<?>, Object> favorites = new HashMap<>();
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), instance);
}
public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(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);
System.out.printf("%s %x %s\\n", favoriteString, favoriteInteger, favoriteClass.getName());
}
Favorites 인스턴스는 타입 안전하다. 따라서 Favorites는 타입 안전 이종 컨테이너라 할 만하다. Map<Class<?>, Object>는 비한정적 와일드카드 타입이라 이 맵 안에 아무것도 넣을 수 없다고 생각할 수 있지만, 사실은 그 반대다. 와일드카드 타입의 중첩으로 모든 키가 서로 다른 매개변수화타입일 수 있다는 뜻으로, 첫 번째는 Class<String>, 두 번째는 Class<Integer>식으로 될 수 있다.
33.1 Favorite 클래스의 두가지 제약
1. 악의적인 클라이언트가 Class 객체를 로 타입으로 넘기면 Favorite 인스턴스의 타입 안정성이 쉽게 깨진다. 하지만 이는 컴파일 시 비 검사 경고가 뜰 것이다.
2. Favorites 클래스의 두 번째 제약은 실체화 불가 타입에는 사용할 수 없다는 것이다. 다시 말해, 즐겨 찾는 String이나 String[]은 저장할 수 있어도 즐겨 찾는 List<String>은 저장할 수 없다. List<String>을 저장하려는 코드는 컴파일되지 않을 것이다. List<String>용 Class 객체를 얻을 수 없기 때문이다. List<String>.class라고 쓰면 문법 오류가 난다. List<String>과 List<Integer>는 List.class라는 객체를 공유하기 때문이다. 이는 한정적 타입 토큰을 활용하면 가능하다. 한정적 타입 토큰이란 단순히 한정적 타입 매개변수나 한정적 와일드카드를 사용하여 표현 가능한 타입을 제한하는 타입 토큰이다.
'스터디 > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] 06. 람다와 스트림 (0) | 2022.09.14 |
---|---|
[이펙티브 자바] 05. 열거 타입과 애너테이션 (0) | 2022.09.01 |
[이펙티브 자바] 03. 클래스와 인터페이스 (0) | 2022.08.20 |
[이펙티브 자바] 02. 모든 객체의 공통 메서드 (0) | 2022.08.10 |
[이펙티브 자바] 01. 객체 생성과 파괴 (0) | 2022.08.02 |
댓글