[EffectiveJava] item26 - 로 타입은 사용하지 마라
16 Apr 2023먼저 제네릭에 대해 간단하게 정의하고 출발하도록 하자. 클래스와 인터페이스 선언에 타입매개변수 (type parameter)가 쓰이면, 이를 제네릭 클래스 혹은 제네릭 인터페이스라 한다.
각각의 제네릭 타입은 일련의 매개변수화 타입을 정의한다. 먼저 클래스(혹은 인터페이스) 이름이 나오고. 이어서 꺽쇠괄호 안에 실제 타입 매개변수를 나열한다. 예를 들어, List<String>은 원소의 타입이 String인 리스트를 뜻하는 매개변수화 타입이다.
그렇다면 로 타입은 무엇일까? 로타입이란 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때를 말한다. 제네릭타입을 정의할 경우 그에 딸린 로 타입(raw type)도 함께 정의된다. 예를 들자면, List<E>의 로 타입은 List이다.
제네릭은 자바 5 부터 지원을 시작하였고, 이전의 컬렉션은 아래와 같이 쓰였다.
로 타입(raw type)의 컬렉션
// Stamp 인스턴스의 Collection 객체
final Collection stamps = new ArrayList();
위와 같은 코드를 사용하면 어떤 문제점이 발생할까? 실수로 Stamp가 아닌 Coin객체를 넣어도 아무런 오류가 발생하지 않은채 컴파일 되고 실행된다.
// coin 인스턴스를 Collection에 add
stamps.add(new Coin(500));
만약 이 컬렉션에서 Stamp의 객체를 가져와 출력한다면 어떻게 될까?
for (Iterator i = stamps.iterator(); i.hasNext();) {
Stamp stamp = (Stamp) i.next(); /** => java.lang.ClassCastException가 발생한다. */
stamp.stamp();
}
오류는 가능한 한 발생 즉시, 이상적으로는 컴파일 할 때 발견하는 것이 좋다. 이 예에서는 오류가 발생하고 한참 뒤인 런타임에야 알아 챌 수 있는데, 실행중인 애플리케이션에 심각한 오류를 가져올 수 있을 뿐더러 java.lang.ClassCastException가 발생한 코드를 찾기위해 전체적인 코드를 훑어봐야 할 수도 있다.
제네릭의 컬렉션
final Collection<Stamp> stamps = new ArrayList<>();
만약 Stamp의 타입으로 제네릭으로 선언된 Collection에 Coin객체를 넣는다면 어떻게 될까? 객체를 변환할 수 없다는 컴파일 오류가 발생하며, 무엇이 잘못됐는지를 정확히 알려준다.
java: incompatible types: com.effectiveJava.item25.ItemExample.Coin cannot be converted to com.effectiveJava.item25.ItemExample.Stamp
컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가하여 절대 실패하지 않음을 보장한다.
로 타입의 문제점
앞에서도 얘기했듯, 로 타입(타입 매개변수가 없는 제네릭 타입)을 쓰는 걸 언어 차원에서 막아 놓지는 않았지만 절대로 써서는 안된다. 로 타입을 쓰면 제네릭이 안겨주는 안전성과 표현력을 모두 잃게 된다.
그렇다면 자바에서는 로 타입을 왜 막아두지 않고 풀어둔걸까?
자바가 처음 나온 뒤 제네릭을 받아들이기 까지 10년 이 걸린 탓에 제네릭 없이 짠 코드가 이미 세상에 많이 퍼지게 되었다. 이에 대한 마이그레이션 호환성을 위해 로 타입을 지원하고 제네릭 구현에는 소거 방식을 사용하기로 했다.
실제로 처음 SI 회사에 취업했을 때는, 회사에서는 Map 컬렉션에 <E, E> 에 대한 파라미터 타입을 주지 않고 모조리 Map map = new HashMap(); 과 같은 로 타입 Map을 사용했다.
여태 모든 코드가 로 타입으로 짜여저 있었던 적이 있었다.
Collection<Object>
List와 List<Object>는 모두 모든 타입을 넣을 수 있다는 점이 같다. 하지만 List<Object>처럼 임의 객체를 허용 하는 매개변수화 타입은 괜찮다.
대체 둘의 차이는 무엇일까?
간단힌 이야기 하자면, List는 제네릭 타입에서 완전히 발을 뺀 것이고, List<Object>는 모든 타입을 허용한다는 의사를 컴파일러에 명확히 전달한 것이다.
실제로 List<String>은 List에는 넘길 수 있지만, 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(101));
String s = strings.get(0);
}
private static void unsafeAdd(List list, Object o) {
list.add(o);
}
위의 코드는 컴파일은 되지만 로 타입인 List를 사용하여 다음과 같은 경고가 발생한다.
list.add(o); /** => Unchecked call to 'add(E)' as a member of raw type 'java.util.List' */
이 프로그램을 이대로 실행하면 strings.get(0)의 결과를 형변환 하려 할 때 ClassCaseException을 던진다. Integer를 String으로 변환하려 시도한 것이다.
이제 unsageAdd 메소드 내부의 로 타입인 List를 List<Object>로 바꾼다음 컴파일을 시도해보자. 그렇다면 오류 메시지가 출력되며 컴파일조차 되지 않는다.
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
/** unsafeAdd => java: incompatible types: java.util.List<java.lang.String> cannot be converted to java.util.List<java.lang.Object> */
unsafeAdd(strings, Integer.valueOf(101));
String s = strings.get(0);
}
Collection<?>
그렇다면 로 타입을 사용하지 않고 원소 타입을 몰라도 되는 코드를 작성하려면 어떻게 해야 될까?
아래의 예시를 살펴보자.
static int numElementsInCommon(Set s1, Set s2) {
int result = 0;
for (Object o1 : s1)
if (s2.contains(o1))
result++;
return result;
}
이 메서드는 동작은 하지만 로 타입을 사용하여서 안전하지 않다. 따라서 비한정적 와일드카드 타입(unbounded wildcard type)을 대신 사용하는 것이 좋다.
와일드 카드는 Collection<?>로 표현 할 수 있다.
예컨데 제네릭 타입인 Collection<E>의 비한정적 와일드카드 타입은 Collection<?>이다.
그렇다면 위의 예시에 와일드카드를 적용해 보도록 하자.
static int numElementsInCommon(Set<?> s1, Set<?> s2) {...}
비한정적 와일드카드 타입인 Set<?>와 로 타입 Set의 차이는 무엇일까? 간단하게 말하자면, 와일드카드 타입은 안전하고, 로 타입은 안전하지 않다.
로 타입 컬렉션에는 아무 원소나 넣을 수 있으니 타입 불변식을 훼손하기 쉽다. 반면 Collection<?>에는 (null 외에는) 어떤 원소도 넣을 수 없다.
list.add(null)외에는 전부 컴파일 에러가 나는 것을 확인 할 수 있다.
public static void main(String[] args) {
Collection<?> members = new ArrayList<>();
/**
* 아래 세개의 로직에서는 java: incompatible types: com.effectiveJava.item25.ItemExample.Member cannot be converted to capture#1 of ?
* 컴파일 에러가 발생한다.
*/
members.add(new Member(1L, "mho1"));
members.add(1);
members.add("mho");
members.add(null);
}
예외 사항
그렇다면 로 타입을 사용하는 경우는 아예 없는걸까? 그렇지는 않다. 소소한 몇가지 예외가 있는데
첫번째 예외로는 class 리터럴에는 로 타입을 사용해야 한다.
class 리터럴이란. List.class나, String[].class와 같이 .class로 끝나는 형식을 말한다. 실제로 사용을 해보면 List.class는 허용하나, List<String>.class나 List<?>.class는 허용하지 않는다.
두번째 예외로는 instanceof 연산자는 비한정적 와일드카드 타입 의외의 매개변수화 타입에는 적용할 수 없다.
런타임에는 제네릭 타입 정보가 지워지기 때문이다. 또한 로 타입이든 비한정적 와일드카드 타입이든 instanceof는 완전히 똑같이 동작한다.
꺽쇠괄호와 물음표는 아무런 역할 없이 코드만 지저분하게 만드므로, 차라리 로 타입을 쓰는것이 깔끔하다.
if (o instanceof Set){ /** 로 타입 */
Set<?> s = (Set<?>) o; /** 와일드카드 타입 */
...
}