한글 용어 | 영문 용어 | 예 |
---|---|---|
매개변수화 타입 | parameterized type | List<String> |
실제 타입 매개변수 | actual type parameter | String |
제네릭 타입 | generic type | List<E> |
정규 타입 매개변수 | formal type parameter | E |
비한정적 와일드카드 타입 | unbounded wildcard type | List<?> |
로 타입 | raw type | List |
한정적 타입 매개변수 | bounded type parameter | <E extends Number> |
재귀적 타입 한정 | recursive type bound | <T extends Comparable<T>> |
한정적 와일드카드 타입 | bounded wildcard type | List<? extends Number> |
제네릭 메서드 | generic method | static <E> List<E> asList(E[] a) |
타입 토큰 | type token | String.class |
-
클래스와 인터페이스 선언에 타입매개변수(type parameter) 가 쓰이면 제네릭 클래스 혹은 제네릭 인터페이스라고 한다. 둘을 통틀어 제네릭 타입이라 한다.
-
각각의 제네릭 타입은 일련의 매개변수화 타입(parameterized type) 을 정의한다. 예컨대 List<String>은 원소의 타입이 String인 리스트를 뜻하는 매개변수화 타입(parameterized type)이다. 여기서 정규 타입 매개변수(formal type parameter) E 에 해당하는 실제 타입 매개변수(actual type parameter) 는 String이다.
-
로타입을 쓰는 걸 언어 차원에서 막아놓지는 않았지만 써서는 안된다
로 타입을 쓰면 제네릭이 안겨주는 타입 안정성과 표현력을 모두 잃게 된다.
오류는 가능한 발생 즉시, 이상적으로는 컴파일할 때 발견하는 것이 좋기때문이다.
로 타입을 애초에 만들어놓은 이유는 제네릭이 나오기 이전 버전과의 호환성 때문이다.
- 로 타입을 사용하는 잘못 된 예
// Fails at runtime - unsafeAdd method uses a raw type (List)! (Page 119)
public class Raw {
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
unsafeAdd(strings, Integer.valueOf(42));
String s = strings.get(0); // Has compiler-generated cast
}
private static void unsafeAdd(List list, Object o) {
list.add(o);
}
}
이 코드를 실행하면 strings.get(0)의 결과를 형변환하려 할 때 ClassCastException이 발생한다.
반면 unsafeAdd의 list를 매개변수화 타입인 List<Object>로 사용할 경우에는 컴파일조차 되지 않는다.
오류는 가능한 발생 즉시, 이상적으로는 컴파일할 때 발견하는 것이 좋기때문에 제너릭을 사용하자
- 제네릭 타입을 쓰고 싶지만 실제 타입 매개변수가 무엇인지 신경쓰고 싶지 않다면 **?**를 사용하자.
제네릭 타입인 Set<E>의 비한정적 와일드카드 타입은 Set<?>이다.
로 타입 컬렉션에는 아무 원소나 넣을 수 있으니 타입 불변식을 훼손하기 쉽지만 Collection<?>에는 어떤 원소도 넣을 수 없다. 또한, 꺼낼 수 있는 객체의 타입도 알 수 없다. 컴파일로선 타입의 불변식을 훼손하지 못하게 제 역할을 한 것이다.
- 로 타입을 쓰는 예외는 있다. class 리터럴과 instanceof를 사용할 때에는 로 타입을 사용한다.
예를 들어, List.class, String[].class, int.class는 허용하고 List<String>.class와 List<?>.class는 허용하지 않는다.
런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다. 로 타입이든 비한정적 와일드카드 타입이든 instanceof는 완전히 똑같이 동작한다.
if (o instanceof Set) { //로 타입
Set<?> s = (Set<?>) o; //와일드카드 타입
...
}
로 타입을 사용하면 런타임에 예외가 일어날 수 있으니 사용하면 안된다. 이전 코드와의 호환성을 위해서만 제공되었을 뿐이다. Set<Object>는 어떤 타입의 객체도 저장할 수 있는 매개변수화 타입이고, Set<?>은 모종의 객체만 저장할 수 있는 와일드카드 타입이다. 이 둘은 제네릭 타입시스템에 속하여 안전하지만, 로 타입인 Set은 안전하지 않다.
- 제너릭을 사용하기 시작하면 수 많은 컴파일러 경고를 볼 수 있다.
비검사 형변환 경고
비검사 메서드 호출 경고
비검사 매개변수화 가변인수 타입 경고
비검사 변환 경고
-
할 수 있는 한 모든 비검사 경고를 제거하라.
-
경고를 제거할 수는 없지만 타입 안전하다고 확실할 수 있다면 @SuppressWarnings("unchecked") 를 달아 경고를 숨기자
-
또한 @SuppressWarnings("unchecked") 은 항상 가능한 한 좁은 범위에 적용하고, 그 경고를 무시해도 안전한 이유를 항상 주석으로 남겨야 한다.
public <T> T[] toArray(T[] a) {
if(a.length < size) {
// 생성한 배열과 매겨변수로 받은 배열의 타입이 모두 T[]로 같으므로
// 올바른 형변환이다.
@SuppressWarnings("unchecked") T[] result =
(T[]) Arrays.coptyOf(elements, size, a.getClass());
}
System.arraycopy(elements, 0, a, 0, size);
if(a.length > size)
a[size] = null;
reutrn a;
}
배열과 제네릭 타입에는 중요한 차이가 두 가지 있다.
- 첫 번째, 배열은 공변(covariant)이다. Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위타입이 된다. 즉 같이 타입이 변한다. 반면, 제네릭은 불공변(invariant)이다. 서로 다른 타입 Type1과 Type2가 있을 때, List<Type1>은 List<Type2>의 하위 타입도 아니고 상위 타입도 아니다.
- 두 번째, 배열은 실체화(reify)된다. 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다. 반면, 제네릭은 타입 정보가 런타임에 소거되기 때문에 원소 타입을 컴파일에서 검사하며 런타임에는 알 수조차 없다.
// 이 코드는 런타임에 실패한다.
Object[] objectArray = new Long[1]
objectArray[0] = "타입이 달라 넣을 수 없다"; // ArrayStoreException을 던진다.
// 이 코드는 컴파일되지 않는다.
List<Object> ol = new ArrayList<Long>(); // 호환되지 않는 타입이다.
ol.add("타입이 달라 넣을 수 없다.");
- 또한, 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다.
즉, 코드 new List<E>[], new List<String>, new E[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다. 이러한 이유는 타입 안전하지 않기 때문이다. 런타임에 ClassCastException이 발생하는 일을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋난다.
- 배열로 형변환할 때 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 배부분은 배열인 E[]대신 컬렉션인 List<E>를 사용하면 해결된다.
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices) {
choiceArray = (T[]) choices.toArray();
}
public Obejct choose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
public static void main(String[] args) {
List<Integer> intList = List.of(1, 2, 3, 4, 5, 6);
Chooser<Integer> chooser = new Chooser<>(intList);
for (int i = 0; i < 10; i++) {
Number choice = chooser.choose();
System.out.println(choice);
}
}
}
위의 예시에서는 비검사 형변환 경고가 뜬다. T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없을을 기억하자. 비검사 형변환 경고를 제거하려면 배열 대신 리스트를 쓰면 된다.
// List-based Chooser - typesafe (Page 129)
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 기반의 스택, 제네릭으로 변경을 해야하는 클래스이다.
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
일단 위 클래스는 Object기반의 스택으로 제네릭화 시키기 좋은 예제이다. 일반 클래스를 제네릭 클래스로 만드는 첫 단계는 클래스 선언에 타입 매개변수를 추가하는 일이다.
타입 매개변수를 추가하고도 E와 같은 실체화 불가 타입으로는 배열을 만들 수 없으므로 Object배열을 생성한 다음 제네릭 배열로 형변환하는 과정을 거치고 이 비검사 형변환에 대하여 @SupressWarnings을 사용하여 경고를 숨기면 된다.
// Generic stack using E[] (Pages 130-3)
public class Stack<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// The elements array will contain only E instances from push(E).
// This is sufficient to ensure type safety, but the runtime
// type of the array won't be E[]; it will always be Object[]!
@SuppressWarnings("unchecked")
public Stack() {
elements = (E[]) new Object[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; // Eliminate obsolete reference
return result;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
// Little program to exercise our generic 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());
}
}
위 방법 이외에도 elements 배열이 반환한 원소를 E로 형변화 시키고 이 비검사 형변환에 대하여 @SupressWarnings을 사용하여 경고를 숨기는 방법도 있다.
// Generic stack using Object[] (Pages 130-3)
public class Stack<E> {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
// Appropriate suppression of unchecked warning
public E pop() {
if (size == 0)
throw new EmptyStackException();
// push requires elements to be of type E, so cast is correct
@SuppressWarnings("unchecked") E result =
(E) elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
// Little program to exercise our generic 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());
}
}
두 가지 방법 중에서 첫 번째 방식은 배열의 타입을 E[]로 선언하여 오직 E 타입 인스턴스만 받음을 확실히 어필하여 가독성도 좋고 코드도 짧다. 또한 형변환을 배열 생성 시 단 한 번만 해주면 된다는 장점을 가지고 있다. 두 번째 방식은 배열에서 원소를 읽을 때마다 해줘야 하므로 현업에서는 첫 번째 방식을 더 선호하며 자주 사용한다. 하지만 (E가 Object가 아닌 한)배열의 런타임 타입이 컴파일타임 타입과 달라 힙 오염(heap pollution)을 일으킨다.(이건 item32에서 따로 설명)
클래스와 마찬가지로, 메서드도 제네릭으로 만들 수 있다. 매개변수화 타입을 받는 정적 유틸리티 메서드는 보통 제네릭이다.(예: Collections의 binarySearch, sort 등) 한번 예제를 통해 알아보자
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>이고 반환 타입은 Set<E>이다.
제네릭은 런타임에 타입 정보가 소거되므로 하나의 객체를 어떤 타입으로든 매개변수화할 수 있다. 하지만 이렇게 하려면 요청한 타입 매개변수에 맞게 매번 그 객체의 타입을 바꿔주는 정적 팩터리를 만들어야 한다. 이 패턴을 제네릭 싱글턴 팩터리 라 하며, Collections.reverseOrder 같은 함수 객체나 Collections.emptySet 같은 컬렉션용으로 사용한다. 예제로 항등함수를 담은 클래스를 만들어보자.
// Generic singleton factory pattern (Page 136-7)
public class GenericSingletonFactory {
// Generic singleton factory pattern
private static UnaryOperator<Object> IDENTITY_FN = (t) -> t;
@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
return (UnaryOperator<T>) IDENTITY_FN;
}
// Sample program to exercise generic singleton
public static void main(String[] args) {
String[] strings = { "jute", "hemp", "nylon" };
UnaryOperator<String> sameString = identityFunction();
for (String s : strings)
System.out.println(sameString.apply(s));
Number[] numbers = { 1, 2.0, 3L };
UnaryOperator<Number> sameNumber = identityFunction();
for (Number n : numbers)
System.out.println(sameNumber.apply(n));
}
}
IDENTITY_FN을 UnaryOperator<T>로 형변환하면 비검사 형변환 경고가 발생한다. T가 어떤 타입이든 UnaryOperator<Obeject>는 UnaryOperator<T>가 아니기 때문이다. 하지만 항등함수란 입력 값을 수정 없이 그대로 반환하는 특별한 함수이므로, T가 어떤 타입이든 타입이 안전하다. 그러므로 비검사 형변환 경고는 숨겨도 된다.
자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정 할 수 있는 재귀적 타입 한정(recursive type bound)라는 개념도 있는데 주로 타입의 자연적 순서를 정하는 Comparable 인터페이스와 함께 쓰인다.
// Using a recursive type bound to express mutual comparability (Pages 137-8)
public class RecursiveTypeBound {
// Returns max value in a collection - uses recursive type bound
public static <E extends Comparable<E>> E max(Collection<E> c) {
if (c.isEmpty())
throw new IllegalArgumentException("Empty collection");
E result = null;
for (E e : c)
if (result == null || e.compareTo(result) > 0)
result = Objects.requireNonNull(e);
return result;
}
public static void main(String[] args) {
List<String> argList = Arrays.asList(args);
System.out.println(max(argList));
}
}
여기서 타입 한정인 <E extends Comparable<E>>는 모든 타입 E는 자신과 비교할 수 있다라는 의미를 가지고 있다.
클라이언트에서 입력 매개변수와 반환값을 명시적으로 형변환해야 하는 메서드보다 제네릭 메서드가 더 안전하며 사용하기도 쉽다. 타입과 마찬가지로, 메서드도 형변환 없이 사용할 수 있는 편이 좋으며, 많은 경우 그렇게 하려면 제네릭 메서드가 되어야 한다. 역시 타입과 마찬가지로, 형변환을 해줘야 하는 기존 메서드는 제네릭하게 만들자.