BOOK 6 - 이펙티브 자바(5)
5장 제네릭
- 제네릭은 클래스를 정의할 때 구체적인 타입을 적지않고 변수 형태로 선언함으로써 코드를 다양한 타입의 객체에 대해 재사용할 수 있게 해준다.
- 제네릭 클래스에서는 타입을 변수로 표시하며, 이것을 타입 매개변수라고 한다.
- 타입 매개변수는 객체 생성시에 프로그래머에 의해 결정된다.
raw type은 사용하지 말라
- 클래스와 인터페이스 선언에 타입 매개변수가 쓰이면, 이를 제네릭 클래스 혹은 제네릭 인터페이스 라고 한다.
- 예를 들어,
List인터페이스는 원소의 타입을 나타내는 타입 매개변수E를 받는다.
- 예를 들어,
-
제네릭 클래스와 제네릭 인터페이스를 통틀어 제네릭 타입이라 한다.
- 각각의 제네릭 타입은 일련의 매개변수화 타입(parameterized type)을 정의한다.
List<String>은 원소의 타입이 String인 리스트를 뜻하는 매개변수화 타입이다.
- 제네릭 타입을 하나 정의하면 그에 딸린 raw type도 함께 정의된다.
- raw type이란 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때를 말한다.
- 로 타입은 타입 선언에서 제네릭 타입 정보가 전부 지워진 것처럼 동작한다.
- 하위 호환성을 위해 존재한다.
private final Collection stamps = ...;
- 이 코드는 실수로
Stamp대신Coin을 넣어도 아무 오류 없이 컴파일되고 실행된다. - 컬렉션에서 다시 꺼내 캐스팅하기 전까지는 오류를 알아내지 못한다.
for (Iterator i = stamps.iterator(); i.hasNext()) {
Stamp stamp = (Stamp) i.next();
stamp.cancel();
}
- 위 코드를 실행하면
ClassCastException이 발생할 것이다.
private final Collection<Stamp> stamps = ...;
- 이렇게 선언하면 컴파일러는
stamps에는Stamp인스턴스만 넣어야 함을 인지하게 된다.- 컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가하여 절대 실패하지 않음을 보장한다.
임의 객체를 허용하는 매개변수화 타입
List같은 로 타입은 사용해서는 안되나,List<Object>처럼 임의 객체를 허용하는 매개변수화 타입은 괜찮다.List는 제네릭 타입에서 완전히 발을 뺀 것이고,List<Object>는 모든 타입을 허용한다는 의사를 컴파일러에 명확하게 전달한 것이다.- 즉,
List를 매개변수로 받는 메서드에List<String>을 넘길 수 있지만,List<Object>를 받는 메서드에는 넘길 수 없다. List<String>은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을 던진다.
private static void unsafeAdd(List<Object> list, Object o) {
list.add(o);
}
- 위 코드로 수정 후 다시 컴파일하면 컴파일 오류가 발생한다.
비한정적 와일드카드 타입
- 원소의 타입을 몰라도 되는 로 타입을 쓰고 싶을 수도 있다.
static int numElementsInCommon(Set s1, Set s2) {
int result = 0;
for (Object o1: s1) {
if(s2.contains(o1))
result++;
}
return result;
}
- 위 메서드는 동작은 하지만 로 타입을 사용해 안전하지 않다.
- 제네릭 타입을 쓰고싶지만 실제 타입 매개변수가 무엇인지 신경쓰고 싶지 않다면
?를 사용하자.Set<?>은 어떤 타입이라도 담을 수 있는 가장 범용적인 매개변수화Set타입이다.
static int numElementInCommon(Set<?> s1, Set<?> s2) { ... }
- 로 타입 컬렉션에는 아무 원소나 넣을 수 있어 타입 불변식을 훼손하기 쉽다.
Collection<?>에는 null 외의 어떤 원소도 넣을 수 없다.- 이러한 제약을 받아들일 수 없다면 제네릭 메서드나 한정적 와일드카드 타입을 사용하면 된다.
예외
- class 리터럴에는 로 타입을 써야 한다.
- 예를 들어,
List.class,String[].class는 허용되지만,List<String>.class와List<?>.class는 허용하지 않는다.
- 예를 들어,
instanceof연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다.- 런타임에는 제네릭 타입 정보가 지워지기 때문이다.
- 로 타입이든 비한정적 와일드카드 타입이든
instanceof는 완전히 똑같이 동작하므로 로 타입으로 쓰는 편이 깔끔하다.
if (o instanceof Set) {
Set<?> s = (Set<?>) o;
...
}
o의 타입이Set임을 확인한 다음 와일드카드 타입인Set<?>으로 형변환해야 한다.
비검사 경고를 제거하라
- 제네릭을 사용하기 시작하면 비검사 형변환 경고, 비검사 메서드 호출 경고, 비검사 매개변수화 가변인수 타입 경고, 비검사 변환 경고 등 수많은 컴파일러 경고를 마주하게 된다.
- 이러한 모든 비검사 경고를 제거한다면 그 코드는 타입 안전성이 보장된다.
- 경고를 제거할 수는 없지만 타입 안전하다고 확신할 수 있다면
@SuppressWarnings("unchecked")어노테이션을 달아 경고를 숨기자.- 항상 가능한 한 좁은 범위에 적용하도록 한다.
- 사용할 때는 항상 그 경고를 무시해도 안전한 이유를 주석으로 남겨야 한다.
배열보다는 리스트를 사용하라
- 배열과 제네릭 타입에는 두가지 중요한 차이가 있다.
공변 (covariant)
배열
Sub가Super의 하위 타입이라면 배열Sub[]는Super[]의 하위 타입이 된다.- 공변, 즉 함께 변한다는 뜻이다.
- 공변 : 같은 타입으로 변환된다. (수학적으로는 벡터에서 같은 방향일 때를 의미)
- 리스코프 치환 원칙처럼 타입 A가 타입 B의 하위 클래스일 때 타입 B로 치환될 수 있기 때문에 공변이라고 할 수 있다.
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다";
- 위 코드는 문법상 허용되는 코드이다.
- 그러나 실행했을 때
ArrayStoreException을 던지게 된다.
제네릭
- 반면 제네릭은 서로 다른
Type1과Type2에 대해서List<Type1>은List<Type2>의 하위 타입도 상위 타입도 아니다.
List<Object> ol = new ArrayList<Long>();
ol.add("타입이 달라 넣을 수 없다");
- 위 코드는 컴파일 타임에 컴파일 오류가 발생해 바로 오류를 알아챌 수 있다.
실체화 (reify)
- 배열은 실체화된다.
- 즉, 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다.
Long배열에String을 넣으려하면ArrayStoreException이 발생한다.
- 제네릭은 타입 정보가 런타임에는 소거된다.
- 원소 타입을 컴파일타임에만 검사하며, 런타임에는 알 수조차 없다.
E,List<E>,List<String>같은 타입을 실체화 불가 타입이라 한다.- 실체화되지 않아서 런타임에는 컴파일 타임보다 타입 정보를 적게 가지는 타입이다.
제네릭 배열
- 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다.
- 즉,
new List<E>[],new List<String>[]식으로 작성하면 컴파일 시에 제네릭 배열 생성 오류가 발생한다.
- 즉,
- 제네릭 배열을 만들지 못하게 막은 이유는 타입 안전하지 않기 때문이다.
- 컴파일러가 자동 생성한 형변환 코드에서
ClassCastException이 발생할 수 있다.
- 컴파일러가 자동 생성한 형변환 코드에서
- 제네릭 배열 생성이 가능하다고 가정해보자.
List<String>[] stringLists = new List<String>[1]; // 1
List<Integer> intList = List.of(42); // 2
Object[] objects = stringLists; // 3
objects[0] = intList; // 4
String s = stringLists[0].get(0); // 5
- 3에서
List<String>배열을Object배열에 할당한다.- 배열은 공변이므로 문제없다.
- 4에서
List<Integer>인스턴스를Object배열의 첫 원소로 저장한다.- 제네릭은 소거 방식으로 구현되어서 역시 문제 없다.
- 즉, 런타임에는
List<Integer>인스턴스의 타입은 단순히List가 되고,List<Integer>[]인스턴스의 타입은List[]가 된다.
- 그러면 현재는
List<String>인스턴스만 담겠다고 선언한stringLists배열에List<Integer>인스턴스가 저장되어 있다. - 5에서 배열의 처음 리스트의 첫 원소를 꺼낼 때 컴파일러는 자동으로
String으로 형변환하는데, 이 원소는Integer이므로ClassCastException이 발생한다.
예시
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection choices) {
choiceArray = choices.toArray();
}
public Object choose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}
- 이 클래스를 사용하려면
choose메서드를 호출할 때마다 반환된Object를 원하는 타입으로 형변환해야 한다.- 만약 타입이 다른 원소가 들어있었다면 형변환 오류가 발생할 것이다.
- 이 클래스를 제네릭으로 만들면 다음과 같이 된다.
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices) {
choiceArray = choices.toArray();
}
// choose 메서드는 동일
}
- 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는게 보통은 불가능하다.
- 이 클래스를 컴파일하면 컴파일 오류가 발생할 것이다.
Object배열을T배열로 형변환하면 된다.
choiceArray = (T[]) choices.toArray();
- 이렇게 바꾸면 컴파일 시에 경고가 뜬다. (비검사 형변환 경고)
- T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 경고 메시지가 뜬다.
- 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없기 때문이다.
- 비검사 형변환 경고를 제거하려면 배열 대신 리스트를 사용하면 된다.
public class Chooser<T> {
private final List<T> choiceList;
public Chooser(Collection<T> choices) {
choiceList = new ArrayList<>(choices);
}
public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}
정리
- 배열은 공변이고 실체화 되는 반면, 제네릭은 불공변이고 타입 정보가 소거된다.
- 따라서 배열은 런타임에는 타입 안전하지만, 컴파일 타임에는 그렇지 않다.
- 제네릭은 컴파일 타임에는 타입 안전하지만, 런타임에는 그렇지 않다.
- 그래서 둘을 섞어 쓰기란 쉽지 않다.
- 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면 가장 먼저 배열을 리스트로 대체하는 방법을 적용해보자.
이왕이면 제네릭 타입으로 만들라
- 이전의
Object기반의Stack클래스는 원래 제네릭 타입이어야 마땅하다.- 클라이언트가 스택에서 꺼낸 객체를 형변환할 때 런타임 오류가 발생할 위험이 있다.
일반 클래스를 제네릭 클래스로 만들기
1. 클래스 선언에 타입 매개변수 추가
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 E pop() {
if (size == 0) throw new EmptyStackException();
E result = elements[--size];
elements[size] = null;
return result;
}
}
- 이 단계에서 대체로 하나 이상의 오류나 경고가 발생한다.
- 여기서는
new E[DEFAULT_INITIAL_CAPACITY]에서 오류가 발생한다.E와 같은 실체화 불가 타입으로는 배열을 만들 수 없기 때문이다.
2. 배열을 제네릭으로 변경
- 제네릭은 배열로 만들 수 없다. 제네릭 배열 만들어야 한다면 다음 두가지 방법으로 우회할 수 있다.
- 제네릭 배열 생성 금지 제약 우회
Object배열을 생성한 다음 제네릭 배열로 형변환한다.
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
-
컴파일러는 오류 대신 경고를 내보낼 것이다.
warning: [unchecked] unchecked cast found: Object[], required: E[] -
일반적으로 타입 안전하지 않다.
-
@SuppressWarnings어노테이션으로 비검사 형변환 경고를 숨길 수 있다.- 단, 비검사 형변환이 타입 안정성을 해치치 않음을 확인한 후 숨겨야 한다.
- 예제의
elements는 private 필드에 저장되고, 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 전혀 없다. push메서드를 통해 배열에 저장되는 원소의 타입은 항상E다.- 따라서 이 비검사 형변환은 확실히 안전하다.
@SuppressWarnings("unchecked") public Stack() { elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY]; }
elements필드 타입을E[]에서Object[]로 바꾸기 (원소 반환시에 각 원소를 형변환)
- 이렇게 하면
E result = elements[--size]에서 컴파일 오류가 발생한다. - 배열이 반환한 원소를
E로 형변환 하면 오류 대신 경고가 뜬다.
E result = (E) elements[--size];
-
E는 실체화 불가 타입이므로 컴파일러는 런타임에 이뤄지는 형변환이 안전한지 증명할 방법이 없어 역시 비검사 형변환 경고를 발생시킨다. -
역시 형변환이 타입 안전함을 직접 증명하고 경고를 숨길 수 있다.
public E pop() {
if (size == 0) throw new EmptyStackException();
@SuppressWarnings("unchecked")
E result = (E) elements[--size];
elements[size] = null;
return result;
}
제네릭 Stack 클래스 사용 예제
public static void main(String[] args) {
Stack<String> stack = new Stack<>();
for(String arg : args) {
stack.push(arg);
}
while(!stack.isEmpty()) {
System.out.println(stack.pop().toUpperCase());
}
}
- 스택에서 꺼낸 원소에서
toUpperCase를 호출할 때 명시적 형변환을 수행하지 않는다.
타입 매개변수 제약
- 대다수의 제네릭 타입은 타입 매개변수에 아무런 제약을 두지 않는다.
Stack<Object>,Stack<int[]>,Stack<List<String>>등 어떤 참조 타입으로도 스택을 만들 수 있다.
- 단, 기본 타입은 사용할 수 없다.
- 박싱된 기본 타입을 사용해야 한다.
- 타입 매개변수에 제약을 두는 제네릭 타입도 있다.
class DelayQueue<E extends Delayed> implements BlockingQueue<E>
- 타입 매개변수로
Delayed의 하위 타입만 받는다는 의미이다.DelayQueue자신과 이를 사용하는 클라이언트는DelayQueue원소에서 형변환 없이 바로Delayed클래스의 메서드를 호출할 수 있다.- 이러한 타입 매개변수
E를 한정적 타입 매개변수라고 한다.
이왕이면 제네릭 메서드로 만들어라
-
매개변수화 타입을 받는 정적 유틸리티 메서드는 보통 제네릭이다.
-
다음 로 타입을 사용한 메서드를 제네릭 메서드로 바꿔보자
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;
}
- 타입 매개변수 목록(
<E>) 은 메서드의 제한자와 반환타입 사이에 온다.
제네릭 메서드를 활용하는 코드
public static void main(String[] args) {
Set<String> guys = Set.of("톰", "딕", "해리");
Set<String> stooges = Set.of("래리", "모에", "컬리");
Set<String> aflCio = union(guys, stooges);
System.out.println(aflCio);
}
- 직접 형변환하지 않아도 오류나 경고 없이 컴파일된다.
제네릭 싱글턴 팩터리
- 때때로 불변 객체를 여러 타입으로 활용할 수 있게 만들어야 할 때가 있다.
- 제네릭은 런타임에 타입 정보가 소거되므로 하나의 객체를 어떤 타입으로든 매개변수화할 수 있다.
- 요청한 타입 매개변수에 맞게 그 객체의 타입을 바꿔주는 정적 팩터리를 만들어야 한다.
항등함수 예제
- 입력 값을 수정없이 그대로 반환하는 함수
public static UnaryOperator<Object> IDENTITY_FN = (t) -> t;
@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
return (UnaryOperator<T>) IDENTITY_FN;
}
IDENTITY_FN을UnaryOperator<T>로 형변환하면 비검사 형변환 경고가 발생한다.- 하지만 입력 값을 그대로 반환하는 항등함수 특성상
UnaryOperator<T>를 사용해도 타입 안전하다는 사실을 알고 있기 때문에 경고를 숨길 수 있다.
제네릭 싱글턴 사용 예제
public static void main(String[] args) {
String[] strings = {"A", "B", "C"};
UnaryOperator<String> sameString = identityFunction();
for (String s : strings) {
System.out.println(sameString.apply(s));
}
}
재귀적 타입 한정
- 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정할 수 있다.
- 재귀적 타입 한정은 주로
Comparable인터페이스와 함께 쓰인다. Comparable<T>의 타입 매개변수 T는Comparable<T>를 구현한 타입이 비교할 수 있는 원소의 타입을 정의한다.
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;
}
- 타입 한정인
<E extends Comparable<E>>는 모든 타입E는 자신과 비교할 수 있다는 의미이다.- 즉, 컬렉션에 담긴 모든 원소가 상호 비교될 수 있다는 뜻이다.
한정적 와일드카드를 사용해 API 유연성을 높이라
- 매개변수화 타입은 불공변이다.
- 때로는 불공변 방식보다 유연한 무언가가 필요하다.
- 런타임시에 여러개의 타입을 처리하는데, 그 범위를 내가 타입 안정성을 확보할 수 있는 선까지 제한하기 위해서 한정적 와일드카드를 사용한다.
- 예를 들어, 이 메서드 내에서
Type타입으로만 원소를 활용함을 보장하면,Type의 하위 타입까지 받아 처리할 수 있어<? extends Type>으로 선언할 수 있다.
- 예를 들어, 이 메서드 내에서
producer - extends
- 앞서 본
Stack클래스에pushAll메서드를 추가해야 한다고 해보자.
public void pushAll(Iterable<E> src) {
for (E e : src) {
push(e);
}
}
- 이 메서드는 컴파일되지만 완벽히 작동하지는 않는다.
src의 원소 타입이 스택의 원소 타입과 일치하면 잘 동작하지만, 다음과 같은 경우에는 오류가 발생한다.
Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ...;
numberStack.pushAll(integers);
// 결과 : error: incompatible types: Iterable<Integer> cannot be converted to Iterable<Number>
-
이런 경우 한정적 와일드카드 타입이라는 매개변수화 타입을 통해 해결할 수 있다.
-
pushAll의 매개변수 타입은 “E의 Iterable” 이 아니라 “E의 하위 타입의 Iterable” 이어야 한다.Iterable<? extends E>가 이 뜻을 의미한다.
public void pushAll(Iterable<? extends E> src) {
for (E e : src) {
push(e);
}
}
- 이렇게 수정하면 Stack과 클라이언트 코드 모두 타입 안전하게 컴파일된다.
consumer - super
Stack클래스에popAll메서드를 추가해보자.
public void popAll(Collection<E> dst) {
while(!isEmpty()) {
dst.add(pop());
}
}
- 이 코드 역시 컬렉션의 원소 타입이 스택의 원소 타입과 동일하면 문제없이 동작한다.
- 그러나 다음 상황에서는 오류가 발생한다.
Stack<Number> numberStack = new Stack<>();
Collection<Object> objects = ...;
numberStack.popAll(objects);
- 이 코드를 컴파일하면 ‘
Collection<Object>는Collection<Number>의 하위 타입이 아니다’ 라는 오류가 발생한다. popAll의 매개변수 타입이 “E의 Collection” 이 아니라 “E의 상위 타입의 Collection” 이어야 한다.Collection<? super E>가 이 뜻을 의미한다.
public void popAll(Collection<? super E> dst) {
while(!isEmpty()) {
dst.add(pop());
}
}
- 이처럼 유연성을 극대화하려면 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용해야 한다.
PECS 공식 : producer - extends , consumer - super
- 매개변수화 타입 T가 생산자라면
<? extends T>를 사용하고, - 소비자라면
<? super T>를 사용한다. Stack클래스 예에서pushAll의 매개변수src는 Stack이 사용할 E 인스턴스를 생산하므로 생산자이다.popAll의 매개변수dst는 Stack으로부터 E 인스턴스를 소비하므로 소비자이다.
- 앞서 봤던 예제들에 이 공식을 적용하여 와일드카드 타입을 적용할 수 있다.
public Chooser(Collection<? extends T> choices)
Chooser생성자에 넘겨지는choices컬렉션은T타입의 값을 생산하기만 하니T를 확장하는 와일드카드 타입을 사용해 선언했다.
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2)
-
s1과s2는 모두 생산자이니 역시E를 확장하는 와일드카드 타입을 사용했다. -
이렇게 수정하면 아래 코드는 모두 타입 안전하게 컴파일 된다.
Set<Integer> integers = Set.of(1, 3, 5);
Set<Double> doubles = Set.of(2.0, 4.0, 6.0);
Set<Number> numbers = union(integers, doubles);
타입 매개변수에 와일드카드 적용
- 앞에 설명한
max메서드를 살펴보자.
public static <E extends Comparable<E>> E max(List<E> list)
- 와일드카드 타입을 사용해 다음과 같이 수정할 수 있다.
public static <E extends Comparable<? super E>> E max(List<? extends E> list)
- 와일드카드 타입을 두 번 적용했다.
- 입력 매개변수
list는 생산자이므로List<? extends E>로 수정했다. - 타입 매개변수의 경우, 원래 선언에서는
E가Comparable<E>를 확장한다고 정의했다.Comparable<E>는 E 인스턴스를 소비한다.- 따라서 매개변수화 타입
Comparable<E>를Comparable<? super E>로 수정했다.
Comparable과Comparator는 언제나 소비자이므로,Comparable<? super E>와Comparator<? super E>를 사용하는 편이 낫다.Comparable이나Comparator를 직접 구현하지 않고, 직접 구현한 다른 타입을 확장한 타입을 지원하기 위해 와일드카드가 필요하다.
타입 매개변수와 와일드카드
- 타입 매개변수와 와일드카드에는 공통되는 부분이 있어서 메서드를 정의할 때 어느 것을 사용해도 괜찮을 때가 많다.
- 기본 규칙은 메서드 선언에 타입 매개변수가 한번만 나오면 와일드카드로 대체하는 것이다.
비한정적 타입 매개변수 사용
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의 타입은List<?>인데,List<?>에는 null 이외에 어떤 값도 넣을 수 없다는 문제가 있다.- 와일드카드 타입의 실제 타입을 알려주는 private 도우미 메서드를 따로 작성하여 활용하면 이를 해결할 수 있다.
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)));
}
- 내부에서 제네릭 메서드를 활용하여 외부에서는 유연한 와일드카드 기반의 API 선언을 유지할 수 있다.
제네릭과 가변인수를 함께 쓸 때는 신중하라
- 가변인수는 메서드에 넘기는 인수의 개수를 클라이언트가 조절할 수 있게 해준다.
- 가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 자동으로 하나 만들어진다.
- 거의 모든 제네릭과 매개변수화 타입은 실체화되지 않으므로, 메서드를 선언할 때 실체화 불가 타입으로 varargs 매개변수를 선언하면 컴파일러가 경고를 보낸다.
warning: [unchecked] Possible heap pollution from parameterized varargs type List<String>
- 매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생한다.
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList; // 힙 오염 발생
String s = stringLists[0].get(0); // ClassCastException 발생
}
- 위 코드를 실행하면 마지막 줄에서 컴파일러가 생성한 형변환이 실패하여
ClassCastException이 발생한다. - 이처럼 타입 안정성이 깨지기 때문에 제네릭 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않다.
@SafeVarargs 로 경고 숨기기
- 제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 메서드는 실제로 실무에서 매우 유용하다.
Arrays.asList(T... a),Collections.addAll(Collection<? super T> c, T... elements)가 대표적이다.@SafeVarargs어노테이션을 사용하여 메서드 작성자가 그 메서드가 타입 안전함을 보장할 수 있다.- 단, 메서드가 타입 안전함을 확신한 경우에만 사용해야 한다.
메서드의 타입 안전 여부 확인
- 가변인수 메서드를 호출할 때 varargs 매개변수를 담는 제네릭 배열이 만들어진다.
- 메서드가 이 배열에 아무것도 저장하지 않고, 그 배열의 참조가 밖으로 노출되지 않는다면 타입 안전하다.
- 즉, varargs 매개변수 배열이 호출자로부터 그 메서드로 순수하게 인수들을 전달하는 일만 한다면 그 메서드는 안전하다.
static <T> T[] toArray(T... args) {
return args;
}
- 위 코드는 가변인수로 넘어온 매개변수들을 배열에 담아 반환한다.
- 매개변수 배열의 참조를 노출하므로 타입 안전하지 않다.
- 매개변수 배열에 다른 메서드가 접근하도록 허용하기 때문이다.
안전한 제네릭 가변인수 메서드 예제
@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists) {
result.addAll(list);
}
return result;
}
-
타입 안전하지 않은 varargs 메서드는 절대 작성해서는 안되며, 타입 안전한 모든 제네릭 varargs 메서드에는
@SafeVarargs어노테이션을 달아 사용자를 헷갈리게 하는 컴파일러 경고를 없애야 한다. -
굳이
@SafeVarargs를 사용하지 않고, varargs 매개변수를 List 매개변수로 바꿔서 선언할 수도 있다.
static <T> List<T> flatten(List<List<? extends T>> lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists) {
result.addAll(list);
}
return result;
}
- 정적 팩터리 메서드인
List.of를 활용하면 위 메서드에 임의 개수의 인수를 넘길 수 있다.List.of에@SafeVarargs어노테이션이 선언되어있기 때문에 가능하다.
audience = flatten(List.of(friends, romans, countrymen));
@SafeVarargs어노테이션을 직접 달지 않아도 되며, 실수로 안전하다고 판단할 걱정도 없다.
타입 안전 이종 컨테이너를 고려하라
- 제네릭은
Set<E>,Map<K,V>등의 컬렉션,ThreadLocal<T>,AtomicReference<T>등의 단일원소 컨테이너에서 흔히 쓰인다.- 여기서 매개변수화 되는 대상은 원소가 아닌 컨테이너 자신이다.
- 따라서 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다.
- 더 유연한 수단이 필요할 때도 있다.
- 컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하면 된다.
- 제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해준다.
- 이런 방식을 타입 안전 이종 컨테이너 패턴이라고 한다.
타입 안전 이종 컨테이너 예제 - Favorites 클래스
public class Favorites {
public <T> void putFavorites(Class<T> type, T instance);
public <T> T getFavorites(Class<T> type);
}
- 타입별로 즐겨찾는 인스턴스를 저장하고, 검색할 수 있는 클래스이다.
- 각 타입의
Class객체를 매개변수화한 키 역할로 사용한다.class의 클래스가 제네릭이기 때문에 동작할 수 있다.- 즉,
class리터럴의 타입은Class가 아닌Class<T>이다.
- 컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고 받는
class리터럴을 타입 토큰이라 한다.
Favorites 구현
public class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<>();
public <T> void putFavorites(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), instance);
}
public <T> T getFavorites(Class<T> type) {
return type.cast(favorites.get(type));
}
}
favorites타입이Map<Class<?>, Object>이다.- 비한정적 와일드카드 타입이어서 맵 안에 null 외에 어떤 원소도 넣을 수 없을거라 판단할 수 있지만, 맵이 아니라 키가 와일드 카드 타입이기 때문에 그렇지 않다.
- 모든 키가 서로 다른 매개변수화 타입일 수 있다는 의미이다.
- 비한정적 와일드카드 타입이어서 맵 안에 null 외에 어떤 원소도 넣을 수 없을거라 판단할 수 있지만, 맵이 아니라 키가 와일드 카드 타입이기 때문에 그렇지 않다.
favorites맵의 값 타입이Object이다.- 즉, 맵이 키와 값 사이의 타입 관계를 보증하지 않는다.
- 하지만 우리는 이 관계가 성립함을 알고 있고,
getFavorites에서 이 관계를 되살릴 수 있다.
getFavorites
- 주어진
Class객체에 해당하는 값을favorites맵에서 꺼낸다. - 이 객체의 타입은
Object이지만, 이를T로 바꿔 반환해야 한다. - 따라서
Class의cast메서드를 사용해 객체 참조를Class객체가 가리키는 타입으로 동적 형변환한다.cast메서드는 주어진 인수가Class객체가 알려주는 타입의 인스턴스인지 검사한 후, 맞다면 그 인수를 반환하고, 아니면ClassCastException을 던진다.
cast메서드의 반환 타입이Class객체의 타입 매개변수와 같기 때문에,T로 비검사 형변환하는 손실 없이도Favorites를 타입 안전하게 만들 수 있다.
Favorites 사용 예제
public static void main(String[] args) {
Favorites f = new Favorites();
f.putFavorites(String.class, "Java");
f.putFavorites(Integer.class, 0xcafebabe);
f.putFavorites(Class.class, Favorites.class);
String favoriteString = f.getFavorites(String.class);
int favoriteInteger = f.getFavorites(Integer.class);
Class<?> favoriteClass = f.getFavorites(Class.class);
}
Favorites 클래스 제약 사항
로 타입을 넘기는 클라이언트
- 악의적인 클라이언트가
Class객체를 제네릭이 아닌 로 타입으로 넘기면Favorites인스턴스의 타입 안전성이 쉽게 깨진다. - 하지만 로 타입을 넘기는 클라이언트 코드는 컴파일 시에 비검사 경고가 발생할 것이다.
Favorites가 타입 불변식을 어기는 일이 없도록 보장하려면putFavorites에서 인수로 주어진instance타입이type으로 명시한 타입과 같은지 확인하면 된다.
public <T> void putFavorites(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), type.cast(instance));
}
- 동적 형변환으로 런타임에 타입 안정성을 확보했다.
- 제네릭과 로 타입을 섞어 사용하는 어플리케이션에서 클라이언트 코드가 잘못된 타입의 원소를 넣지 못하게 추적하는데 도움을 준다.
실체화 불가 타입
- 실체화 불가 타입은
Favorites를 사용할 수 없다. - 즉, 즐겨찾는
String이나String[]은 저장할 수 있지만, 즐겨 찾는List<String>은 저장할 수 없다.List<String>용Class객체를 얻을 수 없기 때문이다.
List<String>과List<Integer>는List.class라는 같은Class객체를 공유하므로, 이를 허용한다면 둘 다 똑같은 타입의 객체 참조를 반환해Favorites내부는 아수라장이 될 것이다.
슈퍼 타입 토큰
TypeRef같은 클래스로 감싸고, 이 안에 실제 객체의 타입 정보를 저장해놓는다.- 런타임에는 제네릭 타입 정보는 소거되므로, 타입 정보가 필요할 때 안에 저장된 객체 타입 정보를 가져온다.
- Jackson, Spring 에서 사용
한정적 타입 토큰
- 메서드가 허용하는 타입을 제한하고 싶을 수 있는데, 한정적 타입 토큰을 활용하면 가능하다.
Annotation API
- Annotation API는 한정적 타입 토큰을 적극적으로 사용하는 예이다.
public <T extends Annotation> T getAnnotation(Class<T> annotationType);
annotationType인수는 어노테이션 타입을 뜻하는 한정적 타입 토큰이다.- 위 메서드는 토큰으로 명시한 타입의 어노테이션이 대상 요소에 달려있다면 그 어노테이션을 반환하고, 없다면 null을 반환한다.
- 이 메서드는 리플렉션의 대상이 되는 타입들, 즉,
Class<T>,Method,Field같이 프로그램 요소를 표현하는 타입들에서 구현한다.
asSubClass
Class<?>타입의 객체를 한정적 타입 토큰을 받는 메서드에 넘기려면 형변환을 해야 한다.- 객체를 직접
Class<? extends Annotation>과 같은 타입으로 형변환할 수도 있겠지만, 이는 비검사 형변환 경고가 발생할 것이다. Class클래스가 이런 형변환을 안전하게 해주는 인스턴스 메서드asSubClass를 제공한다.- 호출된 인스턴스 자신의
Class객체를 인수가 명시한 클래스로 형변환한다.- 이 클래스가 인수로 명시한 클래스의 하위클래스일 때 형변환되어 객체를 반환하고, 그렇지 않으면
ClassCastException을 던진다.
- 이 클래스가 인수로 명시한 클래스의 하위클래스일 때 형변환되어 객체를 반환하고, 그렇지 않으면
static Annotation getAnnotation(AnnotatedElement element, String annotationTypeName) {
Class<?> annotationType = null;
try {
annotationType = Class.forName(annotationTypeName);
} catch (Exception ex) {
throw new IllegalArgumentException(ex);
}
return element.getAnnotation(annotationType.asSubClass(Annotation.class));
}