✒️ 핵심 정리
로 타입을 사용하면 런타임에 예외가 일어날 수 있으니 사용하면 안 된다.
로 타입은 제네릭이 도입되기 이전 코드와의 호환성을 위해 제공될 뿐이다.
💻 용어 정리
한글 용어 | 영문 용어 | 예 | 아이템 |
매개변수화 타입 | parameterized type | List<String> | 26 |
실제 타입 매개변수 | actual type parameter | String | 26 |
제네릭 타입 | generic type | List<E> | 26, 29 |
정규 타입 매개변수 | formal type parameter | E | 26 |
비한정적 와일드카드 타입 | unbounded wildcard type | List<?> | 26 |
로 타입 | raw type | List | 26 |
한정적 타입 매개변수 | bounded type parameter | <E extends Number> | 29 |
재귀적 타입 한정 | recursive type bound | <T extends Comparable<T>> | 30 |
한정적 와일드카드 타입 | bounded wildcard type | List<? extends Number> | 31 |
제네릭 메서드 | generic method | static <E> List<E> asList(E[] a) | 30 |
타입 토큰 | type token | String.class | 33 |
📌 제네릭(Generic) 이란?
데이터의 타입을 일반화하여, 클래스나 메서드 내부 데이터 타입을 컴파일 시에 미리 지정하는 방법이다.
- 파라미터 타입, 리턴 타입에 대한 정의를 외부로 미룬다.
- 타입에 대해 유연성과 안정성을 확보한다.
- 런타임 환경에 아무런 영향이 없는 컴파일 시점의 전처리 기술이다.
class MyArray<T> {
T element;
void setElement(T element) { this.element = element; }
T getElement() { return element; }
}
MyArray<Integer> myArr = new MyArray<Integer>();
📌 Java 4 이전
제네릭은 자바 5부터 추가되었다. 그렇다면 그전에는 어떻게 했을까?
컬렉션에서 객체를 읽어서 형 변환을 해야만 했다. 여기서 Runtime Cast Error 위험이 있다.
List list = new ArrayList();
list.add("Hellow"); // 문자열
list.add(1); // 정수
list.add(new Object()); // 객체
String str = (String) list.get(0); // 에러
📌 로 타입(raw type)의 위험성 (1)
제네릭 타입을 하나 정의하면 그에 딸린 로 타입도 함께 정의되는데, List<E>의 로 타입은 List다.
로 타입은 제네릭이 도래하기 전 코드와의 호환성을 위한 궁여지책일 뿐이다.
public class RawTypeTest {
static class Coin {
public void cancle(){
System.out.println("Coin.cancle");
}
}
static class Stamp {
public void cancle(){
System.out.println("Stamp.cancle");
}
}
public static void main(String[] args) {
// stamps는 Stamp 인스턴스만 취급
Collection stamps = new ArrayList();
// Coin이 들어가도 아무 오류 없이 컴파일되고 실행됨.
stamps.add(new Stamp());
stamps.add(new Coin()); // "unchecked call" 경고를 내뱉는다.
// 조회시 ClassCastException 발생
for (Iterator i = stamps.iterator(); i.hasNext();) {
Stamp stamp = (Stamp) i.next();
stamp.cancle();
}
}
}
로 타입의 가장 큰 문제점은 컴파일 시점에 에러를 잡을 수가 없다.
stamps에 Coin 객체를 넣을 때, 모호한 "unchecked call" 경고 메시지를 보여주긴 하지만 동작은 한다.
문제는 값을 꺼낼 때 발생한다. 그리고 그 문제의 원인과는 훨씬 동떨어진 곳에서 나타날 것이다.
오류는 가능한 발생 즉시, 이상적으로는 컴파일할 때 발견하는 것이 좋다는 것을 명심하자.
제네릭을 활용하면 이 정보가 주석이 아닌 타입 선언 자체에 녹아들 수 있다.
private final Collection<Stamp> stamps = ...;
이렇게하면 stamps에는 Stamp의 인스턴스만 넣어야 함을 컴파일러가 인지하게 된다.
로 타입을 쓰는 걸 언어 차원에서 막아 놓지는 않았지만 절대로 써서는 안 된다.
로 타입을 쓰면 제네릭이 안겨주는 안전성과 표현력을 모두 잃게 된다.
✒️ 로 타입을 애초에 왜 만들어 놓은 걸까?
• 제네릭이 처음 프로그래밍 세계에 도입한 것은 1973년, 자바가 수용한 것은 2004년이다.
• 이미 제네릭 없이 짠 코드가 세상을 뒤덮어 버려서 기존 코드 수용과 제네릭 코드를 호환되게 해야 했다.
• 마이그레이션 호환성을 위해서 로 타입을 지원하고 제네릭 구현을 소거(erasure, Item28) 방식을 사용하기로 했다.
📌 로 타입(row type)의 위험성 (2)
public class ListRawTypeTest {
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);
}
}
위 코드의 문제점은 unsafeAdd의 매개변수 list가 로 타입을 사용하고 있다는 점이다.
List같은 로 타입은 사용해서는 안 되나, List<Object>처럼 임의 객체를 허용하는 매개변수화 타입은 괜찮다.
- List는 아예 제네릭 타입에서 완전히 발을 뺀 것이다. 사용하지 마라.
- List<Object>는 모든 타입을 허용한다는 의사로 컴파일러에 명확히 전달한 것이다.
만약 List<String>을 넘겼을 때, 매개변수로 List를 받는 메서드에는 넘길 수 있어도, List<Object>를 받는 메서드에는 불가능하다. (List<String>이 List<Object>의 하위 타입은 아니기 때문이다.)
처음에 모든 클래스는 Object를 상속받으니까 가능해야 하는 거 아닌가 싶었는데 그런 의미가 아니었다.
List<Object>에 String 객체가 담길 수 있는 것은 맞다.
하지만 List<String>은 'String'만 들어갈 수 있음을 명시한 리스트이기 때문에, List<Object>와는 다른 타입의 리스트라는 것을 말해주고 있는 것이다.
📌 비한정적 와일드 카드 타입 (Unbounded wildcard type)
- 제네릭타입<?>
- 제네릭 타입을 사용하고 싶지만, 실제 타입 매개변수가 무엇인지 신경쓰고 싶지 않을 때 사용.
- 타입 파라미터를 대치하는 구체적 타입으로 모든 클래스나 인터페이스 타입이 올 수 있다.
static int numElemnetsInCommon(Set s1, Set s2) {
int result = 0;
for (Object o1 : s1) {
if (s2.contains(o1)) {
result++;
}
}
return result;
}
위 Set은 로 타입을 사용하고 있어서 안전하지 않다. 이럴 때는 비한정적 와일드 카드를 사용하는 것이 낫다.
즉 어떤 타입이라도 담을 수 있는 가장 범용적인 매개변수화 Set 타입을 만드는 것이다.
static int numElemnetsInCommon(Set<?> s1, Set<?> s2) { ... }
비한정적 와일드 카드 타입을 사용해 로 타입의 불안전성을 막을 수 있는 예를 하나 더 살펴보자.
Collection collection = new ArrayList<>();
collection.add("test");
collection.add(123);
로 타입 컬렉션에는 아무 원소나 넣을 수 있어 타입 불변식을 훼손하기 쉽다.
비한정적 와일드 카드 타입을 사용하면 Collection<?>에는 null을 제외한 어떤 원소도 넣을 수 없다.
Collection<?> collection = new ArrayList<>();
collection.add(null); // 가능
collection.add("test"); // 컴파일 오류
null을 제외한 어떠한 원소도 추가할 수 없기 때문에, 컬렉션에서 꺼낼 수 있는 타입 또한 전혀 알 수 없게 하여 타입 불변식을 훼손하지 못하게 막을 수 있다.
비한정적 와일드 카드 타입이 사용될 수 있는 시나리오는 다음과 같다.
- Object 클래스에서 제공되는 기능을 사용하여 구현할 수 있는 메서드를 작성하는 경우
- 타입 파라미터에 의존적이지 않은 일반 클래스의 메서드를 사용하는 경우
- ex. List.clear, List.size, Class<?>
이러한 제약을 받아들이기 힘들다면 제네릭 메서드(Item 30)나 한정적 와일드 카드 타입(Item 31)을 사용하라.
✒️ Collection<?>에 대한 고찰
이 부분이 처음에 이해가 안 가서 많이 난감했다.
처음에는 <?>이 모든 클래스나 인터페이스 타입이 올 수 있다는 의미라면, '무엇이든 들어올 수 있는 거 아닌가?'라고 생각했다.
즉, Collection<Object>, Collection<String>, Collection<Integer> 등 어떤 타입의 컬렉션도 할당할 수 있어야 한다는 의미다.
틀린 말은 아니지만, 'Collection<?>'은 타입 안정성을 보장하기 위한 제약이 있다.
실제로 원소 추가시에 'null'만 추가할 수 있는데, 이는 와일드 카드 타입이 어떤 타입의 컬렉션인지 정확히 알 수 없기 때문이다.
다양한 타입의 요소를 허용하므로 컴파일러는 타입 안정성을 위해 원소의 추가를 허용하지 않는 것이다.
이러한 제약으로 인해 타입 안정성을 유지하면서 다양한 타입을 처리할 수 있다는 유연성을 제공하는 것이다.
📌 예외 케이스
- class 리터럴에는 로 타입을 사용해야 한다.
- 자바 명세는 class 리터럴에 매개변수화 타입을 사용하지 못하게 했다. (배열과 기본 타입은 허용)
- 예를 들어 List.class, String[].class, int.class는 허용하고, List<String>.class와 List<?>.class는 허용하지 않는다.
- 클래스 리터럴 C.class는 Class<C>를 나타낸다.
- 즉 C.class에 의해서 반환되는 값은 Class<C>의 레퍼런스인 것이다.
- instanceof 연산
- 런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비한정적 와일드 카드 타입 이외의 매개변수화 타입에는 적용할 수 없다.
- 로 타입과 비한정적 와일드 카드 타입이 완전히 동일하게 동작한다. (꺽쇠괄호와 물음표가 역할이 없이 코드를 지저분하게 만들기만 한다.)
- 그러므로 로 타입을 쓰는 편이 더 깔끔하다.
if (o instanceof Set) {
Set<?> s = (Set<?>) o;
...
}
✅ o의 타입이 Set임을 확인한 다음 와일드 카드 타입인 Set<?>로 형변환해야 한다. 이는 검사 형변환이므로 컴파일러 경고가 뜨지 않는다.
✒️ instanceof 연산자가 비한정적 와일드 카드 타입만 가능한 이유
'instanceof' 연산자는 런타임에 객체의 실제 타입 확인에 사용된다.
그러나 제네릭 타입은 컴파일 시에만 사용되고, 런타임에는 타입 정보가 지워지는 타입 소거(Type Erasure) 과정을 거친다. 이는 제네릭 타입의 타입 매개변수 정보가 제거되고, 해당 타입이 원시 타입(Object)으로 취급되는 것을 의미한다.
따라서, 'instanceof' 연산자는 런타임에 객체의 실제 타입 확인에 사용되므로, 컴파일 시에는 타입 매개변수에 대한 정보가 없어서 제네릭 타입에는 'instanceof' 연산자를 사용할 수 없다.
예를 들어, 'List<String>'과 'List<Integer>'는 컴파일 시에는 다른 타입으로 간주되지만, 런타임에는 둘 다 'List' 타입으로 취급된다.
그러므로 'list instanceof List<String>' 또는 'list instanceof List<Integer>'와 같은 코드는 컴파일 오류가 발생한다.
'instanceof' 연산자는 제네릭 타입에 대한 타입 정보가 없기 때문에 비교 자체가 불가능한 것이다.
따라서 제네릭과 와일드 카드는 주로 컴파일 타임에서의 타입 안정성을 보장하기 위한 도구로 사용된다.