공변성과 반공변성(Java, Kotlin Covariance / Contravariance)
안녕하세요. 이번 포스팅은 공변성과 반공변성에 대해서 알아보겠습니다. 아마 많은 분들에게 생소한 단어로 느껴질 것 같습니다만 코드로 보면 단번에 이해할 수 있을 것입니다.
먼저 배열을 통해서 공변성에 대해 알아보겠습니다.
Array는 공변성이다.
배열은 기본적으로 공변성입니다. 예를 들어, T[] 배열은 요소로써 T 하위 타입을 삽입할 수 있는 규칙이 허용된다는 뜻입니다.
public static void main(String[] args) {
Number[] numbers = new Number[4];
numbers[0] = 10;
numbers[1] = 3.14;
numbers[2] = 11L;
numbers[3] = 3.25f;
}
위에 대한 내용 뿐만아니라
타입 S가 타입 T의 하위타입일 때, S[] 타입 또한 T[] 타입의 하위타입이 성립됩니다. 따라서 아래의 코드 또한 문제없이 동작합니다.
public static void main(String[] args) {
Integer[] myInts = {1,2,3,4};
Number[] myNumber = myInts;
}
Java에서의 하위 타입 규칙에 따라 Integer가 Number의 하위타입이기 때문에 배열 Integer[]는 배열 Number[]의 하위타입이 됩니다.
그런데 다음과 같은 시도를 하게 된다면 어떻게 될까요?
Integer[] myInts = {1,2,3,4};
Number[] myNumber = myInts;
myNumber[0] = 3.14; // heap pollution
Storing element of type 'java.lang.Double' to array of 'java.lang.Integer' elements will produce 'ArrayStoreException'
마지막 라인은 컴파일은 무사히 되지만 코드를 실행하게 된다면 Integer 배열에 Double 값을 넣으려고 하기 때문에 ArrayStoreException이 발생하게 됩니다. 여기서 myNumber라는 배열이 Number 타입으로 선언되어있고 그 하위 타입인 Double이 무사히 캐스팅 된다는 사실은 중요하지 않습니다. 이미 Number[] 타입은 Integer[] 인스턴스를 포함하고 있고 여기에 Double을 요소로 삽입하고자 한다는 행위가 문제가 됩니다.
여기서 알 수 있는 점은 컴파일러는 속일 수 있었지만 런타임 시스템을 속일 수는 없었다는 것입니다. 우리는 배열처럼 이러한 유형의 타입을 reifiable type (https://cla9.tistory.com/52)이라고 부릅니다. 런타임 시스템이 Java가 이 배열이 실제로 단순히 Number[] 타입의 참조를 통해 엑세스되는 Integer[] 타입으로 인스턴스화 되었음을 알고 있음을 의미합니다.
따라서 우리가 볼 수 있듯이 하나는(Integer[]) 객체의 실제 유형이고 다른 하나(Number[])는 엑세스하는데 사용하는 참조 유형입니다.
Java의 Generic에서의 문제점
이제 Java의 Generic 에서의 문제점은 코드 컴파일이 완료된 이후 Type Parameter는 컴파일러에 의해 삭제된다는 것입니다. 따라서 이 타입에 대한 정보는 런타임에서 사용할 수 없다는 것입니다. 이러한 내용을 Type Erasure 라고 합니다.
여기서 중요한 것은 런타임에 타입정보를 가지고 있지 않기 때문에 위에처럼 heap pollution을 저지르지 않도록 하는 방법이 없다는 것입니다.
아래의 코드를 살펴보겠습니다.
public static void main(String[] args) {
List<Integer> myInts = new ArrayList<Integer>();
myInts.add(1);
myInts.add(2);
List<Number> myNums = myInts; //compiler error
myNums.add(3.14); //heap polution
}
자바 컴파일러가 위의 행위를 막지 못한다면.. 런타임 타입 시스템도 이것을 막을 방법이 없습니다. 왜냐하면 런타임에 이 목록이 Integer로만 이루어져야한다는 결정할 타입정보를 갖지 못하기 때문입니다. 런타임 시스템은 Integer만 포함해야 할 때 이 list에 넣을 수 있도록 합니다. 생성될 때 애초에 Integer로 선언되었기 때문입니다. 따라서 컴파일러에서 main 메소드의 4번째 행이 안전하지 않기 때문에 컴파일 에러를 내뱉게 됩니다.
따라서 Generic에서는 컴파일러가 배열처럼 타입을 속이는 행위를 하지 못하도록 했습니다. 따라서 런타임에 Generic 타입의 진짜 타입을 결정할 수 없기 때문에 구체화할 수 없다고 말합니다.
하지만 이런 java의 제네릭 타입에 대한 특징은 다형성에 부정적인 영향을 끼칩니다. 아래의 예로 확인해보겠습니다.
public static void main(String[] args) {
Integer[] myInts = {1,2,3,4,5};
Long[] myLongs = {1L, 2L, 3L, 4L, 5L};
Double[] myDoubles = {1.0, 2.0, 3.0, 4.0, 5.0};
System.out.println(sum(myInts)); // 15
System.out.println(sum(myLongs)); // 15
System.out.println(sum(myDoubles)); // 15
}
static long sum(Number[] numbers) {
long summation = 0;
for(Number number : numbers) {
summation += number.longValue();
}
return summation;
}
위의 코드는 배열 타입을 직접 캐스팅하여 전달한 케이스입니다. 정상적으로 잘 동작합니다.
public static void main(String[] args) {
List<Integer> myInts = asList(1,2,3,4,5);
List<Long> myLongs = asList(1L, 2L, 3L, 4L, 5L);
List<Double> myDoubles = asList(1.0, 2.0, 3.0, 4.0, 5.0);
System.out.println(sum(myInts)); //compiler error
System.out.println(sum(myLongs)); //compiler error
System.out.println(sum(myDoubles)); //compiler error
}
static long sum(List<Number> numbers) {
long summation = 0;
for(Number number : numbers) {
summation += number.longValue();
}
return summation;
}
하지만 이번엔 Collection Generic을 활용해서 처리해보겠습니다. 해당 코드는 세번의 함수 사용이 모두 컴파일 오류를 생성하게 됩니다.
이로 인해서 Integer 리스트를 Number 리스트의 하위 타입으로 간주할 수 없다는 것입니다. 이러한 행위가 시스템에 안전하지 않은 것으로 간주되어 컴파일러가 오류를 뱉습니다.
이것은 분명히 다형성에 대한 Java의 이점을 활용하지 못하도록 되고있습니다. 어떻게든 수정이 필요해보입니다. 이러한 문제를 해결하는 방법으로 공변성 및 반공변성에 대한 내용을 알아보겠습니다.
공변성(Covariance)
이 경우 타입 <T>를 사용하는 대신에 <? extends T> 를 사용할 수 있습니다. 이러한 공변성을 활용하면 구조(제네릭 구조)에서 항목(타입)을 읽을 수 있지만 아무것도 쓸 수는 없습니다.(cannot write) 아래의 내용은 모두 유효한 공변선언입니다.
List<? extends Number> myNums1 = new ArrayList<Integer>();
List<? extends Number> myNums2 = new ArrayList<Float>();
List<? extends Number> myNums3 = new ArrayList<Double>();
그리고 다음을 수행하여 제네릭 구조 myNums에서 읽을 수 있습니다.
Number n = myNums.get(0);
왜냐하면 실제 list에는 어떤 값이 들어있든지 Number 타입으로 업캐스팅이 가능하다는 것을 의미합니다.
그러나 공변성 구조는 아래의 행위를 허용하지 않습니다.
public static void main(String[] args) {
List<? extends Number> myNums1 = new ArrayList<Integer>();
myNums1.add(Integer.valueOf(1)); // compile error
}
이는 컴파일러가 실제 제네릭 구조 내에 있는 요소의 실제 타입을 결정할 수 없기 때문에 허용되지 않습니다. Number를 상속하는 모든 타입(Integer, Double 등등의 숫자 타입)일 수는 있지만 컴파일러는 무엇인지 확신할 수 없으므로 제네릭 값을 검색하는 모든 행위를 안전하지 않은 작업으로 간주되며 컴파일러에 의해 즉시 거부됩니다.
따라서 우리는 읽을 수는 있지만 쓸수는 없습니다.(can read, cannot write)
반공변성(Contravariance)
반공변성을 위해 <T> 타입이 아닌 <? super T> 타입을 사용합니다. 반공변성은 공변성의 반대를 할 수 있습니다. 제네릭 구조 내에서 넣을 수는 있지만 아무것도 읽을 수 없습니다.
이 경우 객체의 실제 속성은 List 유형의 Object 이며 반공변성을 통해 Number를 넣을 수 있습니다. 기본적으로 Number는 공통조상이 Object이기 때문입니다. 따라서 모든 숫자도 객체이므로 유효합니다.
그러나 숫자를 얻는다고 가정하면 이 반공변 구조에서 아무것도 안전하게 읽을 수 없습니다.
public static void main(String[] args) {
List<? super Number> myNums = new ArrayList<>();
Number n = myNums.get(0); //compiler-error
}
컴파일러가 이 라인을 작성할 수 있도록 허용한다면 런타임에 ClassCaseException이 발생할 여지가 있습니다. 따라서 다시한번 컴파일러는 이 안전하지 않은 작업을 허용할 위험이 없으며 즉시 거부합니다. (<? super Number> 의 경우 최대한 안전하게 읽기 위해서는 최상위 클래스인 Object로 받아야만 ClassCaseException을 피할 수 있을 것입니다. 따라서 불안전한 모든 타입의 경우 컴파일을 거부하는게 합리적인 것 같습니다.)
Get / Put 원리
요약하자면 구조에서 제네릭 값만 가져오기 위해서는 공변성을 사용합니다.(? extends T) 제네릭 값을 구조에 넣을 때만 반공변성을 사용하고 둘 다 수행하려는 경우 무공변성(invariant)을 사용합니다.
public static void main(String[] args) {
List<Integer> myInts = asList(1,2,3,4);
List<Double> myDoubles = asList(3.14, 6.28);
List<Object> myObjs = new ArrayList<Object>();
copy(myInts, myObjs);
copy(myDoubles, myObjs);
}
public static void copy(List<? extends Number> source,
List<? super Number> destiny) {
for(Number number : source) {
destiny.add(number);
}
}
이러한 공변성과 반공변성의 예제로 가장 적절한 예시는 위와 같습니다. 한 목록에서 다른 목록으로 모든 종류의 숫자를 복사하는 예제입니다. source에서만 요소를 읽고 destiny에만 요소를 넣습니다. 공변성과 반공변성의 특징덕분에 위와 같은 경우 효과가 있습니다!
reference
https://dzone.com/articles/covariance-and-contravariance