본문 바로가기

개발/이펙티브 자바

Effective Java ( 이펙티브 자바 ) - 5장 제네릭 아이템 26

제네릭


제네릭은 자바 5부터 사용할 수 있다. 제네릭을 지원하기 전에는 컬렉션에서 객체를 꺼낼 때마다 형변환을 해야 했다. 실수로 엉뚱한 타입의 객체를 넣어두면 런타임에 형변환 오류가 나곤 했다. 반면, 제네릭을 사용하면 컬렉션이 담을 수 있는 타입을 컴파일러에 알려주게 된다. 그래서 컴파일러는 알아서 형변환 코드를 추가할 수 있게 되고, 안전하고 명확한 프로그램을 만들어 준다. 하지만, 코드가 복잡해진다는 단점이 따라온다. 이번 장에서는 제네릭의 이점을 최대로 살리고 단점을 최소하하는 방법을 이야기한다.

 


 

로 타입은 사용하지 말라


  용어부터 정리하고 가자. 클래스와 인터페이스 선언에 타입 매개변수가 쓰이면, 이를 제네릭 클래스 혹은 제네릭 인터페이스라 한다. 제네릭 클래스와 제네릭 인터페이스를 통틀어 제네릭 타입이라 한다. 

  각각의 제네릭 타입은 일련의 매개변수화 타입을 정의한다. 제네릭 타입을 하나 정의하면 그에 딸린 로 타입(raw type)도 함께 정의된다. 로 타입이란 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때를 말한다. 예컨대 List<E>의 로 타입은 List다. 로 타입은 타입 선언에서 제네릭 타입 정보가 전부 지워진 것처럼 동작하는데, 제네릭이 도래하기 전 코드와 호환되도록 하기 위한 궁여지책이라 할 수 있다.

 

이 책 전반에서 줄기차게 이야기하듯, 오류는 가능한 한 발생 즉시, 이상적으로는 컴파일할 때 발견하는 것이 좋다. 로 타입을 사용하면 런타임에 문제를 겪는 코드와 원인을 제공한 코드가 물리적으로 상당히 떨어져 있을 가능성이 커진다. 

 

  제네릭을 활용하면 이 정보가 타입 선언 자체에 녹아든다.

private final Collection<Stamp> 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);
}

이 코드는 컴파일은 되지만 로 타입인 List를 사용하여 경고가 발생한다. 이 경우엔 컴파일러의 경고를 무시하여 대가를 치르는 코드다. 위의 코드를 List<Object>로 바꾼 다음 다시 컴파일하면 오류 메시지가 출력되며 컴파일 조차 되지 않는다. 

 

로 타입을 쓰고 싶어질 수 있다. 예컨대 2개의 집합을 받아 공통 원소를 반환하는 메서드를 작성한다고 해보자.

static int numElementsInCommon(Set s1, Set s2) {
   int result = 0;
   for (Object o1 : s1)
      if (s2.contains(o1))
         result++;
   return result;
}

위의 코드는 동작은 하지만 로 타입을 사용해 안전하기 않다. 따라서 비한정적 와일드카드 타입을 대신 사용하는게 좋다. 제네릭 타입을 쓰고 싶지만 실제 타입 매개변수가 무엇인지 신경 쓰고 싶지 않다면 물음표(?)를 사용하자. 제네릭 타입인 Set<E>의 비한정적 와일드카드 타입은 Set<?>다. 이것이 어떤 타입이라도 담을 수 있는 가장 범용적인 매개변수화 Set 타입이다. 

 

비한정적 와일드카드 타입인 Set<?>와 로 타입인 Set의 차이는 무엇일까? 특징을 간단히 말하자면 와일드카드 타입은 안전하고, 로 타입은 안전하지 않다. 로 타입 컬렉션에는 아무 원소나 넣을 수 있으니 타입 불변식을 훼손하기 쉽다. 반면, Collection<?>에는 (null 외에는) 어떤 원소도 넣을 수 없다. 다른 원소를 넣으려 하면 컴파일할 때 오류 메시지를 보게 될 것이다.

 

즉, 컬렉션의 타입 불변식을 훼손하지 못하게 막았다. 구체적으로는, (null 이외의) 어떤 원소도 Collection<?>에 넣지 못하게 했으며 컬렉션에서 꺼낼 수 있는 객체의 타입도 전혀 알 수 없게 했다. 이러한 제약을 받아들일 수 없다면 제넬기 메서드나 한정적 와일드카드 타입을 사용하면 된다. 

 

  로 타입을 쓰지말라는 규칙에도 소소한 예외가 몇 개 있다. class 리터럴에는 로 타입을 써야 한다. 자바 명세는 class 리터럴에 매개변수화 타입을 사용하지 못하게 했다. 예를 들어 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은 안전하지 않다.