코틀린 제네릭 - 배열과 리스트, 제네릭과 무공변
지난 시간의 문제를 정리해 보면, 우리는 금붕어 Cage에서 금붕어를 꺼내 물고기 Cage에 옮길 수 없었다. 하지만 사실은, 그냥 금붕어를 물고기 Cage에 넣는 것은 아무 문제가 없다.
val cage = Cage2<Fish>()
cage.put(GoldFish("금붕어"))
물고기 Cage에 금붕어가 들어가는 것은 문제가 아니라는 의미이다. 그렇다면 도대체 왜 이런 차이가 발생하는 것일까??! 근본적인 이유를 알기 위해 우리는 상속관계의 의미를 살펴보아야 한다.
여기 상속 관계인 두 클래스가 있다. Number
와 Int
라고 해보자. Number
는 Int
의 상위 타입이고 Int
는 Number
의 하위 타입이다. 이때 다음과 같은 코드는 정상 동작할 수 있다.
fun doSomething(num: Number) {
// ...
}
val a: Int = 3
doSomething(a) // Number 타입에 Int 타입이 들어갔다!
Number
를 파라미터로 받는 함수에 Int
가 들어간 것이다! 즉, 두 클래스가 상속관계에 있을 때 하위 클래스는 상위 클래스 대신 함수 파라미터에 들어갈 수 있다.
또 다른 예시도 있다.
val intNum: Int = 5
val num: Number = intNum // Number 타입 변수에 Int 타입 변수가 들어갔다!
이번에도 마찬가지로 Number
타입 변수에 Int
타입 변수가 들어갔는데, Number
가 상위 타입이고 Int
가 하위 타입이기에 가능하다. 즉 상속관계는 다음과 같은 의미를 갖는다.
- 상위 타입이 들어가는 자리에 하위 타입이 대신 위치할 수 있다.
이제 다시 Cage2
코드를 찬찬히 뜯어보자.
fun main() {
val goldFishCage = Cage2<GoldFish>()
goldFishCage.put(GoldFish("금붕어"))
val cage = Cage2<Fish>()
cage.moveFrom(goldFishCage) // Error: Type Mismatch
}
cage
의 타입 파라미터는 Fish
이므로 cage.moveFrom(otherCage: Cage2<Fish>)
가 된다.
그리고 이 함수에 넣으려는 goldFishCage
는 Cage2<GoldFish>
타입을 가지고 있고, Type Mismatch 에러가 발생하고 있다.
따라서 우리는 Fish
와 GoldFish
는 상속관계이지만, Cage2<Fish>
와 Cage2<GoldFish>
는 아무 관계가 아니라는 것을 추측할 수 있다.
Cage2<Fish>
와 Cage2<GoldFish>
가 아무 관계가 아니어서, Type Mismatch가 발생한 것이다.
이를 어려운 말로 가리켜 “Cage2는 무공변 (in-variant, 불공변)하다”고 한다.
그렇다면 왜 Fish
와 GoldFish
간의 상속관계가 제네릭 클래스에 유지되지 않는 것일까? 즉, 왜 제네릭 클래스는 타입 파라미터 간의 상속관계가 있더라도, 무공변할까? 그 근본적인 이유를 알기 위해서는 잠시 Cage를 잊고, Java 코드를 살펴보아야 한다. Java의 배열과 리스트를 비교해 보자. 먼저 Java의 배열이다.
Java의 배열은 제네릭과 다르다. Java의 배열에서는 A 객체가 B 객체의 하위 타입일 때, A 배열이 B 배열의 하위 타입으로 간주된다. 즉, String
은 Object
의 하위 타입이므로 String[]
도 Object[]
의 하위 타입으로 여겨진다. 이를 어려운 말로 “배열은 공변 (co-variant) 하다”라고 한다.
배열은 공변하기 때문에 아래와 같은 코드가 가능하다.
public static void main(String[] args) throws IOException {
String[] strs = new String[]{"A", "B", "C"};
// Object가 String의 상위 타입이므로 Object[]가 String[]의 상위 타입으로 간주된다.
// 따라서 objs에 strs를 대입할 수 있다.
Object[] objs = strs;
objs[0] = 1; // java.lang.ArrayStoreException: java.lang.Integer
}
Object[]
는 사실 String[]
인데 숫자를 넣을 수 있는 것이다! 이러한 동작은 런타임 때 예외를 낼 수 있기 때문에 굉장히 위험하다!
반면 List
는 다르다. List
는 제네릭을 사용하고 있고, 불공변 하기 때문에 컴파일 때 오류를 바로 확인할 수 있다.
public static void main(String[] args) throws IOException {
List<String> strs = List.of("A", "B", "C");
List<Object> objs = strs; // Type Mismatch
}
만약 제네릭도 배열과 같은 원리로 동작했다면, List 역시 배열과 같은 결함을 가지게 되고 타입-안전하지 않은 코딩을 해야 했을 것이다. 제네릭은 이런 결함 자체를 막기 위해 무공변하도록 만들어졌다. 또한 이러한 차이가 바로, 배열보다 리스트를 사용하라는 격언의 배경이기도 하다. (Effective Java #3 Item28)
매우 좋다~! 👍 우리는 왜 금붕어 Cage에서 물고기 Cage로 금붕어를 옮기지 못하는지 이해했다.
하지만 여전히 문제가 해결된 것은 아니다. 분명 물고기 Cage에 금붕어를 넣을 수 있는데도 금붕어 Cage에서 물고기 Cage로 금붕어를 옮기지 못하는 것은 이상하다.
어떻게 하면 이 문제를 해결할 수 있을까?! 바로 moveFrom
함수를 호출할 때 Fish
와 GoldFish
의 상속관계를 Cage2<Fish>
와 Cage2<GoldFish>
에도 이어주는 것이다!
이를 어려운 말로, “공변(co-variant)하도록 만든다” 라고 한다.
공변에 대해 더 궁금하시다면 아래 링크를 확인해보세요~!! 😊
내용 출처 : https://inf.run/F9gX