개발

[제네릭] Java에서 배열을 공변(covariant)으로 만든 이유는 무엇인가?

오렌지색 귤 2022. 2. 14. 01:37
반응형

이펙티브 자바 스터디에서 제가 발표한 아이템 28의 내용에 대해 스터디원께서 좋은 질문을 해주셨습니다.

 

바로 Java에서 배열을 공변(covariant)으로 만든 이유가 무엇일까? 라는 질문이었습니다.

 

 

공변과 불공변

 

질문에 대한 답에 앞서 공변과 불공변에 대해 먼저 알아보도록 하겠습니다.

 

 

공변 (covariant)

 

정의 : 함께 변한다

 

배열은 공변입니다

 

SubSuper의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 됩니다

 

예시 코드

// 런타임에 실패한다
Object[] objectArray = new Long[1];  // 컴파일이 되어 버린다..
objectArray[0] = "Hello";             // 런타임에 ArrayStoreException을 던진다..

 

위의 코드에서 배열은 공변이므로 Long 배열은 Object 배열의 하위 타입으로 인식되어 1번 코드가 문제없이 컴파일 됩니다

 

이윽고 Object 배열으로 참조된 objectArray에는 Long 타입이 아닌 String도 들어갈 수 있게 됩니다

 

컴파일러 입장에서는 String은 Object의 하위 타입이므로 Object 배열에 들어갈 수 있다고 판단한 것이죠

 

그리고 런타임에 오류가 발생하게 됩니다 (배열을 쓰면 안되는 이유)

 

 

불공변 (invariant)

 

정의 : 변하지 않는다

 

제네릭은 불공변입니다

 

서로 다른 타입 Type1Type2가 있을 때, List<Type1>List<Type2>의 하위 타입도 아니고 상위 타입도 아닙니다

 

예시 코드

// 컴파일되지 않는다
List<Object> ol = new ArrayList<Long>();  // 컴파일 안된다. 호환되지 않는 타입이다
ol.add("Hello");

 

위의 코드에서 제네릭은 불공변이므로 List<Object>List<Long>은 아예 다른 타입으로 인식됩니다

 

따라서 2번 코드로 넘어가기도 이전에 1번 코드에서 컴파일 오류가 발생합니다. (리스트를 써야하는 이유)

 

 


질문에 대한 답변

 

그렇다면 타입 안전성이 보장되지도 않는 공변의 특성을 배열에 왜 넣은 것일까요?

 

그 이유는 배열을 불공변(invariant)로 만들게 되면 다형성의 이점을 살릴 수 없기 때문입니다.

 

public class Arrays {
    private static final int MIN_ARRAY_SORT_GRAN = 8192;
    private static final int INSERTIONSORT_THRESHOLD = 7;

    private Arrays() {
    }

    private static void swap(Object[] x, int a, int b) {
        Object t = x[a];
        x[a] = x[b];
        x[b] = t;
    }

    .. // 생략
}

 

위의 코드는 Arrays 클래스의 일부분입니다

 

Arrays 클래스의 private static 메서드인 swap은 배열에 들어있는 원소의 타입과는 상관없이 단순히 두 원소의 위치만 바꿔주면 되는 메서드 입니다

 

따라서 배열을 공변으로 만들게 되면 각 타입마다 오버로딩으로 모든 swap 메서드를 만들어줄 필요가 없어집니다

 

String 배열이든, Integer 배열이든 Object 배열의 하위 타입으로 인식되어 하나의 swap 메서드에서 작동이 가능하게 됩니다

 

초기의 자바에서는 제네릭이나 parametric polymorphism의 개념이 없었습니다

 

이런 상황에서 형변환 과정에서 발생할 수 있는 오류를 어느정도 감안하더라도 다형성으로 얻을 수 있는 이점이 크다고 생각 되었을 것입니다

 

이후 자바에서 제네릭이 도입되고 와일드카드 등의 기능을 통해 다형성까지 챙길 수 있게 되었고, 이제는 배열보다는 리스트를 사용하여 컴파일러가 타입 안전성을 보장할 수 있도록 코드를 만드는 것이 옳게 되었습니다

 

 


추가 질문

 

그럼 제네릭을 만들 때에는 왜 불공변으로 만들어야 겠다고 생각했을까요?

 

추가 질문에 대한 답변

 

List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs;        // 불공변이므로 여기서 컴파일 에러 발생
animals.add(new Cat());
Dog dog = dogs.get(0);

 

바로 위의 상황에서 발생하는 타입 안전성에 대한 문제를 해결하기 위함입니다

 

만약 제네릭이 공변이었다면 AnimalDog의 상위 타입이라면 List<Animal>List<Dog>의 상위 타입으로 인식되어 2번 코드에서 컴파일 에러가 발생하지 않게 됩니다

 

그렇게 되면 AnimalCat의 상위 타입이므로 3번 코드에서 List<Animal>Cat 객체가 들어갈 수 있게 됩니다

 

하지만 다시 List<Dog>dogs에서 값을 꺼낸다면, 컴파일러는 당연히 제네릭으로 인해 Dog 객체가 나올 것으로 판단했겠지만, 실제로 나오는 것은 Cat 객체이므로 ClassCastException이 발생할 것입니다

 

 

 

추가로 제네릭이 불공변이지만, 한정적 와일드카드 타입 등을 사용하면 Dog 객체와 Cat 객체를 모두 담을 수 있는 컬렉션을 쉽게 만들 수 있습니다

 

추후 포스팅에서는 더 많은 내용을 담을 수 있도록 하겠습니다

 


PS

 

 

Why are arrays covariant but generics are invariant?

From Effective Java by Joshua Bloch, Arrays differ from generic type in two important ways. First arrays are covariant. Generics are invariant. Covariant simply means if X is subtype of Y then X[]...

stackoverflow.com

 

 

Is List<Dog> a subclass of List<Animal>? Why are Java generics not implicitly polymorphic?

I'm a bit confused about how Java generics handle inheritance / polymorphism. Assume the following hierarchy - Animal (Parent) Dog - Cat (Children) So suppose I have a method doSomething(List<

stackoverflow.com

 

반응형