[Item26] - 로 타입은 사용하지 말라.

Effective Java 3/E를 공부하며 작성한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀

제네릭

제네릭(generic)은 자바 5부터 사용할 수 있으며, 안정적인 형변환을 할 수 있도록 지원하는 타입이다.

제네릭은 클래스 혹은 인터페이스에 타입 매개변수를 통해 붙여 사용할 수 있다.

public class ArrayList<E> {...}

public interface List<E> {...}

이러한 형태를 제네릭 클래스 혹은 제네릭 인터페이스라고 부르며, 이를 통 틀어서 제네릭 타입이라고도 부른다.

매개변수화 타입

각각의 제네릭 타입은 일련의 매개변수화 타입(Parameterized type)을 정의한다.

public class ArrayList<E> {...}

위 코드와 같이 클래스(혹은 인터페이스)이름을 작성하고, 꺾쇠 괄호 안에 실제 타입 매개변수들을 나열한다. List<String>의 경우에는 원소의 타입인 String인 리스트를 뜻하는 매개변수화 타입이다.

로 타입

제네릭 타입을 하나 정의하면 그에 딸린 로 타입(Raw type)도 함께 정의된다.

public class ArrayList<E> {
    ...
}

ArrayList = new ArrayList();

위 코드와 같이 ArrayList가 제네릭 타입이어도, 타입 정보 없이 객체를 생성할 수 있게 된다. 이는, 제네릭이 도래하기 전 코드와 호환되도록 하기 위한 것이다.

단점

public class StampTest {
    static class Coin {
        public void print() {
            System.out.println("Coin.print");
        }
    }

    static class Stamp {
        public void print() {
            System.out.println("Stamp.print");
        }
    }
    @Test
    void stampTest() {
        // Stamp 인스턴스만 취급한다.
        Collection stamps = new ArrayList();

        stamps.add(new Stamp());
        stamps.add(new Coin());

        for (Iterator i = stamps.iterator(); i.hasNext();) {
            Stamp s = (Stamp) i.next();
            s.print();
        }
    }
}

stamps.add()를 확인해보면 CoinStamp를 모두 넣은 것을 볼 수 있다. 이것이 가능한 이유는 ArrayList를 로타입으로 사용했기 때문이다. 위 코드를 실행해보면 다음과 같은 결과가 나온다.

Stamp.print
Exception in thread "main" java.lang.ClassCastException ...

여기서 주의해야할 점은 바로 컴파일타임이 아닌 런타임에서 에러를 발견하고 잡는다는 것이다.

Stamp 인스턴스만 취급한다.라는 주석이 있어도 개발자가 그 외의 것을 넣어도 에러를 알아차릴 수 없다. 이와 같이 로타입만을 사용하면 런타임에 문제를 겪는 코드와 원인을 제공한 코드가 물리적으로 상당히 떨어져 있을 가능성이 커진다.

그렇기 때문에 제네릭을 사용해 다른 인스턴스를 추가할 경우 컴파일러가 경고를 내뱉도록 해야한다.

Collection<Stamp> stamps = new ArrayList<>(); 

이렇게 선언하고 Coin의 인스턴스를 넣을 경우 Required Type : Stamp라는 컴파일 에러가 발생한다.

컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가하여 절대 실패하지 않음을 보장한다.

왜 존재할까?

로 타입을 쓰는걸 언어 차원에서 막아 놓지는 않았지만, 절대로 사용해서는 안 된다. 로 타입을 쓰면 제네릭이 안겨주는 안전성과 표현력을 모두 잃게 된다.

그러면 쓸모도 없는 로 타입을 왜 만들어놓은 것일까? 자바가 제네릭을 받아들이기까지 거의 10년이 걸렸다. 그 과정에서 제네릭 없이 짠 코드가 이미 세상을 뒤 엎은 상태였고, 모든 코드를 수용하면서 제네릭을 사용하는 새로운 코드와도 맞물려 돌아가게 해야만 했다.

로 타입 예외

제네릭 VS Object

List는 제네릭 타입에서 완전히 발을 뺀 것이고, List<Object>는 모든 타입을 허용한다는 의사를 컴파일러에게 명확히 전달한 것이다.

List<Object> 같은 매개변수화 타입을 사용할 때와 달리 List 같은 로 타입을 사용하면 타입 안전성을 잃게 된다.

public class TestCode {
    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를 사용하였고, 값을 호출한 부분에서 IntegerString으로 변환하려 시도했기 때문에 다음과 같은 경고가 발생한다.

Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class

unsafeAdd()의 첫 번째 매개변수를 List<Object>로 바꾸면 컴파일조차 되지 않는다.

비한정적 와일드 카드 타입

이쯤 되면 포기할 법도 한데, 원소의 타입을 몰라도 되는 로 타입을 쓰고싶어질 수 있다고 한다.

다음 코드를 보면 원소의 타입을 몰라도 정상적으로 동작하는 것을 알 수 있다.

public class TestCode {
    public static void main(String[] args) {
        Set<String> s1 = new HashSet<>();
        s1.add("123");
        s1.add("234");

        Set<String> s2 = new HashSet<>();
        s2.add("23");
        s2.add("234");

        System.out.println(numElementInCommon(s1, s2));

    }

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

동작은 하지만 로 타입을 사용해 안전하지는 않은 코드이다. 따라서 비한정적 와일드 카드 타입(unbounded wildcard type)을 대신 사용하는 게 좋다. 제네릭 타입을 쓰고 싶지만 실제 타입 매개변수가 무엇인지 신경 쓰고 싶지 않다면 물음표를 사용하자.

private static int numElementInCommon(Set<?> s1, Set<?> s2){ ... }

비한정적 와일드카드 타입과 로 타입은 어떤 차이가 있을까?

와일드카드 타입은 안전하고, 로 타입은 안전하지 않다. 로 타입 컬렉션에는 아무 원소나 넣을 수 있으니 타입 불변식을 훼손하기 쉽다. 반면, Collection<?>에는 null 외에는 어떤 원소도 넣을 수 없다. 다른 원소를 넣으려 하면 컴파일할 때 오류를 내뿜는다.

private static int numElementInCommon(Set<?> s1, Set<?> s2) {
    s2.add("123"); // 에러 발생!
    ...
}
Required type: capture of ?

s1은 원본을 참조하고 있는 매개변수이다. 그렇기에 외부 변수를 캡쳐해서 사용하고 있기에 이러한 변수를 수정하려고 하면 capture of ?에러가 발생하게 된다. 만약 Set s2와 같이 로 타입으로 받았다면 이와 같은 에러 없이 값을 넣었을 것이다.

이러한 부분에서 로 타입과 비한정적 와일드카드 타입의 차이점을 명확히 알 수 있다.

이 외 소소한 예외

클래스 리터럴에는 로타입을 써야 한다.

자바 명세는 class 리터럴에 매개변수화 타입을 사용하지 못하게 했다. 단, 배열과 기본 타입은 허용한다.

허용 O : List.class, String[].class, int.class
허용 X : List<String>.class, List<?>.class
// 허용 O
Class<String[]> arrayClass = String[].class;
// 허용 X
Class<List<String>> listClass = List<String>.class;

instanceof 연산자

런타임에는 제네릭 타입의 정보가 지워지기 때문에 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다.

로 타입이든 비한정적 와일드카드 타입이든 instanceof는 완전히 똑같이 작동한다.

private static boolean isInstnceOf(Set o) {
    if (o instanceof Set<?>) {
        Set<?> s = (Set<?>) o;
        return true;
    }
    return false;
}

비한정적 와일드카드 타입의 꺽쇠괄호와 물음표는 아무런 역할 없이 코드만 지저분하게 만드므로, 차라리 로 타입을 쓰는 편이 깔끔하다.

정리

  • 로 타입을 사용하면 런타임에 예외가 일어날 수 있으니 사용하지 말자.
  • 로 타입은 제네릭이 도입되기 이전 코드와의 호환성을 위해 제공될 뿐이다.
  • 로 타입인 Set은 제네릭 타입 시스템에 속하지 않는다.
    • Set<Object>는 어떤 타입의 객체도 저장할 수 있는 매개변수화 타입
    • Set<?>는 모종의 타입 객체만 저장할 수 있는 와일드카드 타입
    • Set<Object>, Set<?>은 안전하지만 로 타입인 Set은 안전하지 않다.

댓글남기기