Effective Java : 아이템26. 로 타입은 사용하지 마라 (제네릭)

    들어가기 전

    이 글은 백기선님의 이펙티브 자바 강의를 복습하며 작성한 글입니다. 


    이 글의 요약

    • Raw Type (List) 대신 반드시 제네릭 (List<Integer>)를 사용해라.
    • 제네릭을 사용하면 표현력 / 안정성이 더욱 향상된다. 
    • 제네릭을 사용하면 런타임 에러 대신 컴파일 에러가 발생하도록 바꿔준다. 
    • List<String>, List<Object>는 호환 불가능하다. 다른 타입이기 때문이고 사용하려면 ? extends Object를 사용해야한다. 
    • List와 List<Object>는 Raw Type과 제네릭의 차이다. 단순히 표현력 / 안정성의 차이다.
    • Set과 Set<?>의 차이는 좀 더 크다. Set<?>은 사용되는 ? 타입을 모르기 때문에 null를 제외한 어떠한 값도 들어올 수 없다. 반면 Raw 타입은 어떠한 인스턴스도 모두 추가 가능하다. 

    핵심 정리 : 제네릭 용어 정리

    • 로 타입 : List
      • 사용하는 쪽
    • 제네릭 타입 : List<E>
      • 사용하는 쪽
    • 매개변수화 타입 : List<String> 
      • 사용하는 쪽
      • 'String으로 매개변수화 된 List 타입'이라고 부름
    • 타입 매개변수: E
      • 구현하는 쪽
    • 실제 타입 매개변수: String, Integer
      • 사용하는 쪽
    • 한정적 타입 매개변수: List<E extends Number>
      • 구현하는 쪽
    • 비한정적 와일드카드 타입 : Class<?>
      • 사용하는 쪽
      • 예를 들면 Box<?>
    • 한정적 와일드카드 타입 : Class<? extends Annotation>
      • 사용하는 쪽
      • 예를 들면 Box<? extends Number>

     


    Generic의 등장 전후 변경(자바 5에 등장)

    Generic은 자바 5에 등장했다. Generic이 등장하기 전에는 로타입을 사용했다. 아래 코드에서는 Generic 등장 전후로 코드가 어떻게 변경되었는지를 살펴보고자 한다. 

    먼저 Generic이 등장하기 전 사용되었던 로타입을 살펴보자. 로타입은 아래와 같이 List만 선언된 형식을 의미한다. 로타입은 내부적으로 어떤 타입을 가지는지 명식되지 않았다. 따라서 어떠한 타입도 List 객체 안에 들어갈 수 있게 된다. 

    // 로타입
    List numbers = new ArrayList();

    아래 코드를 보면 numbers 내부로 정수 10, 문자열 'whiteship'이 들어간 것을 볼 수 있다. 즉, 어떠한 타입도 들어갈 수 있는 것이다. 여기서 문제점은 무엇일까? 

    • 컴파일 시점에는 없던 에러가 런타임에서 발생할 수 있다는 것이다. 아래 코드에서는 Collection을 순회하면서 Integer로 타입 캐스팅 후에 출력을 하고 있다. 그런데 문자열이 들어간 경우라면 타입 캐스팅이 안되고 런타임 에러가 발생할 것이다. 
    • 로타입을 사용하면, Collection을 순회할 때 어떤 타입인지 추론할 수 없기 때문에 Object 타입이 된다.

    즉, 코드의 안정성과 표현력에 둘다 문제가 발생한다. 

    public class GenericBasic {
    
        public static void main(String[] args) {
            // Generic 사용하기 전
            List numbers = new ArrayList();
            numbers.add(10);
            numbers.add("whiteship");
    
            // 로 타입으로 사용 → 타입을 추론할 수 없음 → 수동 캐스팅 필요함. 
            for (Object number : numbers) {
                System.out.println((Integer) number);
            }
            
            // Generic 등장 이후
            List<Integer> numberList = new ArrayList<>();
            numberList.add(10);
    //        numberList.add("whiteship"); // 컴파일 에러 발생
    
            // 자동으로 타입 완성됨. 
            for (Integer integer : numberList) {
                System.out.println(integer);
            }
        }
    }

    위에서 Generic이 등장한 이후의 코드도 볼 수 있다. List<Integer>를 이용해서 이 Collection이 어떠한 타입을 가지는지를 명시해준다. 그렇다면 로 타입을 사용할 때의 단점이 모두 극복되었을까? 아래에서 볼 수 있듯이 모두 해결되었다. 

    • 런타임 에러가 발생함 → 이제 String을 넣으려고 하면 컴파일 에러가 발생한다. 
    • Collection 순회 시, Object 타입 → 무슨 타입인지 알기 때문에 정확하게 Integer 타입이 됨. 따라서 캐스팅 하는 코드조차 넣을 필요가 없음. 

    타입 매개변수 E

    타입 매개변수는 제네릭을 사용한 클래스를 선언하는 관점에서 사용한다. 아래 코드는 Box 클래스가 E 타입의 매개변수를 클래스에서 사용한다는 것을 의미한다. 이건 선언하는 사람의 관점이다. 

    // Box<E>에서 E는 타입 매개변수.
    public class Box<E> {
    
        private E item;
    
        private void add(E e) {
            this.item = e;
        }
    
        private E get() {
            return this.item;
        }
    
        public static void main(String[] args) {
            Box<Integer> box = new Box<Integer>();
            box.add(10);
        }
    
        public static void main2(String[] args) {
            Box<String> box = new Box<>();
            box.add("hello");
        }
    
    
        // 비한정적 와일드카드 타입
        private static void printBox(Box<?> box) {
            System.out.println(box.get());
    
        }
    }

    사용하는 관점의 코드는 아래에서 확인할 수 있다. 타입 매개변수 E 자리에 Integer를 넣었다. 그러면 지금부터 box는 Integer 매개변수화 된 Box 타입이라고 부른다.  

    // 타입 매개변수 Integer
    public static void main(String[] args) {
        Box<Integer> box = new Box<Integer>();
        box.add(10);
    }
    
    // 타입 매개변수 String
    public static void main2(String[] args) {
        Box<String> box = new Box<>();
        box.add("hello");
    }

     


    한정적 매개변수 E

    타입 매개변수 E는 어떠한 타입도 가능하면, 단 하나의 타입만 들어올 수 있는 것을 의미한다. 그런데 때때로는 한정 범위에서 타입을 제한하고 싶을 수 있는데, 제네릭에서는 이것을 한정적 매개변수를 지원한다. 주로 super, extends로 표현한다. 

    // 한정적 타입 매개변수. Number 하위 클래스만 E에 대입될 수 있음.
    public class LimitedBox <E extends Number>{
    
        private E item;
    
        private void add(E e) {
            this.item = e;
        }
    
        private E get() {
            return this.item;
        }
    
        public static void main(String[] args) {
            LimitedBox<Integer> box = new LimitedBox<>();
            box.add(10);
        }
    
        private static void printBox(LimitedBox<?> box) {
            System.out.println(box.get());
        }
    }

    위에서처럼 <E extends Number>로 작성하면, Number의 하위 타입만 E에 대입될 수 있다. 예를 들어 String은 E 타입으로 들어올 수 없게 된다. 


    비한정적 와일드카드 타입 : Class<?>

    선언하는 쪽에서는 E를 이용해서 어떠한 타입도 받을 수 있는 기능을 제공했다. 마찬가지로 제네릭을 사용하는 쪽에서도 모든 타입을 다 받을 수 있는 기능을 제공한다. '?'를 이용하면 되고, 이것을 비한정적 와일드카드 타입이라고 한다. 비한정적이라는 것은 어떠한 타입도 다 올 수 있다는 것이다. 

    // Box<E>에서 E는 타입 매개변수.
    public class Box<E> {
    
        private E item;
    
        private void add(E e) {
            this.item = e;
        }
    
        private E get() {
            return this.item;
        }
    
        public static void main(String[] args) {
            Box<Integer> box = new Box<Integer>();
            box.add(10);
        }
    
        public static void main2(String[] args) {
            Box<String> box = new Box<>();
            box.add("hello");
        }
    
    
        // 비한정적 와일드카드 타입
        private static void printBox(Box<?> box) {
            System.out.println(box.get());
    
        }
    }
    

    위에서 비한정적 와일드카드 타입을 printBox() 메서드에서 볼 수 있다. 이것은 Box<String>, Box<Integer> 모두 올 수 있는 것을 의미한다. 

     

    한정적 와일드카드 타입 : Class<? extends Number>

     

    클래스를 구현하는 쪽에서 한정적 매개변수 타입 <E extends Number>를 제공한 것처럼, 사용하는 쪽에서 한정적 와일드카드 타입인 <? extends Number>를 제공한다. 이것의 의미는 Number의 하위클래스라면 무엇이든지 ?로 올 수 있다는 것을 의미한다. 

    // 한정적 타입 매개변수. Number 하위 클래스만 E에 대입될 수 있음.
    public class LimitedBox <E extends Number>{
    
        private E item;
    
        private void add(E e) {
            this.item = e;
        }
    
        private E get() {
            return this.item;
        }
    
        public static void main(String[] args) {
            LimitedBox<Integer> box = new LimitedBox<>();
            box.add(10);
        }
    
        // 한정적 와일드카드 타입
        private static void printBox(LimitedBox<? extends Number> box) {
            System.out.println(box.get());
        }
    }

    위 코드의 printBox() 메서드에서 한정적 와일드카드 타입을 볼 수 있다. 여기서는 Number 하위 타입만 들어올 수 있다. 따라서 box라는 매개변수에는 LimitedBox<Integer>, LimitedBox<Double> 등이 올 수 있다. 


    Box<Integer>와 Box<Object> 타입은 호환 가능할까? 

    Box<Integer> / Box<Object>는 호환할 수 없다. 왜냐하면 Box<Integer> / Box<Object>는 다르기 때문이다. Integer 자체는 Object의 하위 클래스인데도 호환이 안된다. 만약 Object 하위 클래스로 Integer를 넣어주고 싶다면, 와일드카드 타입을 이용해야한다. 

    // 한정적 매개변수 타입
    Box<? extends Object>
    
    // 비한정적 매개변수 타입
    Box<?>

    여기서 <? extends Object>는 실제로는 <?>로 축약해서 사용할 수 있게 된다. 


    핵심 정리 : 매개변수화 타입을 사용해야 하는 이유 (로 타입을 쓰지않고)

    • 런타임이 아닌 컴파일 타임에 문제를 찾을 수 있다. (안정성)
    • 제네릭을 활용하면 이 정보가 주석이 아닌 타입 선언 자체에 녹아든다. (표현력)
    • "로 타입"을 사용하면 안정성과 표현력을 잃는다.
    • 그렇다면 자바는 "로 타입"을 왜 지원하는가? 
    • List와 List<Object>의 차이는?
    • Set과 Set<?>의 차이는?
    • 예외
      • class 리터럴 → 불가능
      • instanceof 연산자 → 가능하지만 무시됨. 

    여기서는 로타입 대신 매개변수화 타입을 사용해야 하는 이유를 공부하고자 한다.


    매개변수화 타입을 사용해야하는 이유

    Raw 타입을 사용할 때는 해당 Collection에 여러 타입의 매개변수를 모두 넣을 수 있다. 예를 들어 Integer, String을 모두 넣을 수 있었다. 그렇지만 아래 코드에서 보면 Collection을 순회하면서 타입 캐스팅을 하는데, 이 때 String 타입이 들어갔다면 런타임 에러가 발생한다. 또한 numbers라는 List가 어떤 값을 받는지 전혀 알 수 없다. 

    public static void main(String[] args) {
        // Generic 사용하기 전
        List numbers = new ArrayList();
        numbers.add(10);
        numbers.add("whiteship");
    
        // 로 타입으로 사용 → 타입을 추론할 수 없음 → 수동 캐스팅 필요함.
        for (Object number : numbers) {
            // String이 들어가있다면, 여기서 런타임 에러 발생. 
            System.out.println((Integer) number);
        }
    }

    제네릭이 등장한 이후, List에는 타입 매개변수를 알려줄 수 있게 되었다. 여기서 두 가지 장점이 생긴다.

    1. List가 어떤 타입을 사용하는지 코드에 표현되어있다. → 표현력이 올라감. 
    2. List<Integer>에 String 인스턴스를 넣으려고 하면, 컴파일 에러가 발생한다. → 런타임에러에서 컴파일 에러로 변환 (fast-fail)
        public static void main(String[] args) {
    
            // Generic 등장 이후
            List<Integer> numberList = new ArrayList<>();
            numberList.add(10);
    //        numberList.add("whiteship"); // 컴파일 에러 발생
    
            // 자동으로 타입 완성됨.
            for (Integer integer : numberList) {
                System.out.println(integer);
            }
        }

     

    정리

    • 매개변수 타입을 사용하면, 코드의 표현력 + 안정성이 올라감. 

    자바는 "로 타입"을 왜 지원하는가? 

    로 타입은 매개변수 타입과 비교했을 때 단점만 가지고 있다. 그렇다면 자바는 왜 단점뿐인 로 타입을 지원하는 것일까? 자바는 자바 내부의 소거 기법을 이용해서 구현을 했기 때문이다. 이 내용은 뒤에서 더 자세히 알아보고자 한다. 

    public static void main(String[] args) {
        Box<Integer> box = new Box<Integer>();
        box.add(10);
        System.out.println(box.get() * 100);
    
        printBox(box);
    }

    예를 들어 위 코드가 컴파일 되어 바이트코드가 되면 어떻게 될까요? 빌드 후 shift를 두 번 누르면 show byte code를 볼 수 있다. 만들어진 바이트 코드는 다음과 같다. 

    100이 있으니 box.get() * 100 코드로 찾아간 셈이다. 여기서 동작하는 것을 살펴보자.

    • Box.get을 호출하는데, java/lang/object다. 
      • 앞서서 우리는 타입 매개변수로 Box<Integer>로 작성해두었다. 그렇지만 컴파일된 코드에서는 애초에 그런 값이 없다. 그냥 Box Raw Type으로 존재하는 것을 볼 수 있다.
    • CHECKCAST java/lang/Integer
      • 바로 아래 코드에서 Integer로 타입 캐스팅을 하는 것을 볼 수 있다. 

    동작하는 것을 살펴보면 이렇게 정리할 수 있다. 

    • 우리 눈에 보이는 모든 Generic은 우리 눈에만 보인다. 실제 컴파일 코드에서는 Raw Type으로만 존재한다.
    • Generic은 컴파일된 바이트 코드에서 Raw Type을 Type Cast하는 명령어만 추가해준다. 

    위와 같은 방식으로 동작하는 것은 자바 하위 버전과의 호환성을 지키기 위함이다. 제네릭은 자바 5 이후에 등장했는데, 이전에도 역시 컴파일 코드에서는 Raw Type이 사용되고 있었다. 이것과 호환되기 위해서 자바는 제네릭을 Raw Type 기반으로 타입 캐스트 해서 동작하도록 작성되었다. 


    List와 List<Object>의 차이는?

    List와 List<Object>는 로타입 / 매개변수 타입을 설정했느냐가 다르고, 실제로는 동일한 코드다. 하지만 여기서는 큰 차이가 발생하는데 앞서 이야기 했던 것처럼 안정성 / 표현력의 차이가 있다. 

    위는 로타입으로 List를 선언한 경우다. List strings는 실제로는 List<String>으로 의도하고 만든 것이다. 따라서 unsafeAdd()가 요구하는 List<Object> 타입에 호환되지 않기 때문에 unsafeAdd() 메서드에서 컴파일 에러가 발생해야한다. 그렇지만 실제로는 발생하지 않는다. 만약, List 로타입을 List<String>으로 타입 매개변수를 선언해주면 어떻게 될까? 

    위는 List<String>으로 타입 매개변수를 설정했을 때다. List<String>은 List<Object>에 호환되지 않기 때문에 매개변수로 전달하는 과정에서 컴파일 에러가 발생한다. 만약 List<String>을 unsafeAdd() 메서드의 인자로 전달하고 싶다면, unsafeAdd() 메서드에서는 아래와 같이 와일드카드 타입 매개변수를 사용해야 한다. 

    private static void unsafeAdd(List<? extends Object> list, Object o) {
        list.add(o);
    }

    Set과 Set<?>의 차이는

    먼저 아래 코드를 살펴보자. numElementsInCommon() 메서드는 매개변수로 Set s1, s2를 전달받는다. 이 때 Set은 Raw 타입으로 선언되어있는데, 아무 형태의 Set이 다 들어올 수 있어 안정성이 깨진다. 예를 들면 아래 경우를 고려해 볼 수 있다.

    • s1 자체에 String, Integer가 들어가 있을 수도 있다. 
    • s1는 Set<String> 이고, s2는 Set<Integer> 일 수도 있다. 

    아무튼 타입 안정성이 깨진다는 것을 알 수 있다. 

    public class Numbers {
    
        static int numElementsInCommon(Set s1, Set s2) {
            int result = 0;
            for (Object o1 : s2) {
                if (s2.contains(o1)) {
                    result++;
                }
            }
            return result;
        }
    
        public static void main(String[] args) {
            System.out.println(Numbers.numElementsInCommon(
                    Set.of(1,2,3),
                    Set.of(1,2)));
        }
    }

    이 때 Set<?>을 사용하면 좀 더 안전하게 사용할 수 있게 된다. 

    타입 매개변수를 Set<?>으로 선언하면, 어떤 타입인지는 모르지만 '단 한 종류의 타입'만 다루는 Set을 받게 된다. 

    따라서 이런 형식으로도 코드를 작성할 수 있게 된다. 왜냐하면 Set<?>의 와일드 카드 타입은 가장 범용적인 매개변수화 Set 타입이기 때문이다. 

    Set<Integer> set = new HashSet<>();
    Set<?> MySet = set;

    다시 돌아가서 아래 메서드 numElmentsInCommon()을 살펴보자. 여기서도 두 가지 차이점이 존재한다.

    • Set (Raw Type)으로 받을 경우, 어떠한 인스턴스도 들어갈 수 있다. 따라서 numElementsInCommon 메서드 내에서 s1, s2에 인스턴스가 새로 추가될 수 있다. 
    • Set<?>으로 받을 경우 s1, s2에서는 null을 제외한 어떤 인스턴스도 들어갈 수 없다. ?는 아직 정해지지 않은 타입이다. 정해지지 않은 타입에 특정 타입을 넣을 수 없다. 예를 들어 ? = String이고, 넣으려고 하는 타입이 Object라고 하면 당연히 들어가지지 않는다. 이런 경우가 존재하기 때문에 null을 제외한 어떤 인스턴스도 들어갈 수 없다. 
    static int numElementsInCommon(Set<?> s1, Set<?> s2) {
        int result = 0;
        for (Object o1 : s2) {
            if (s2.contains(o1)) {
                result++;
            }
        }
        return result;
    }

     

    정리

    • Raw 타입을 사용하면 표현력 / 안정성이 떨어진다
    • Raw 타입을 사용하면, 어떠한 타입이든 들어갈 수 있기 때문에 인스턴스 추가가 가능하다.
    • 와일드카드 타입을 사용하면 어떠한 타입인지 추론할 수 없기 때문에 null을 제외한 어떠한 값도 들어갈 수 없다. 따라서 좀 더 안전한 객체가 될 수 있다. 

    예외: class 리터럴과 instanceof 연산자

    제네릭 타입은 class 리터럴을 사용할 때, instanceof 연산자를 사용할 때 사용할 수 없다. 이렇게 사용하면 컴파일 에러가 발생한다. 왜 이런 일이 일어날까? 컴파일한 코드에서는 제네릭 타입이 소거되기 때문이다. 

    UseRawType<Integer>.class는 잘못 사용된 예시다. 컴파일한 코드는 UseRawType이 되는데, 컴파일 과정에서 <Integer>가 소거되기 때문이다. 따라서 UseRawType<Integer>라는 클래스는 존재하지 않는 클래스다. 자바는 컴파일하는 과정에서 Raw 타입이 존재하고, Raw 타입을 TypeCast 하도록 제네릭이 지원해주는 형태로 동작하기 때문이다. 따라서 .class를 사용할 때는 제네릭을 제외한 타입만을 명시해야한다. 

    instanceof도 마찬가지다. UseRawType<String>은 컴파일 하게 되면 제네릭 타입인 <>은 소거되기 때문에 의미가 없어지고, 세상에 존재하지 않는 클래스가 된다. 따라서 컴파일 에러가 발생한다. 

    댓글

    Designed by JB FACTORY