변성(variance)은 파라미터화한 타입이 서로 어떤 하위 타입 관계에 있는지 결정하는 방식을 뜻한다.
공변성(covariance)은 Red가 Color의 하위 타입일 때 Matcher<Red>
가 Matcher<Color>
의 하위 타입이라는 뜻이다.
이런 경우
Matcher<T>
는 타입 파라미터 T에 대해 공변성이라고 한다.
반대로 Red가 Color의 하위 타입일 때 Matcher<Color>
가 Matcher<Red>
의 하위 타입이라면 Matcher<T>
는 타입 파라미터 T에 대해 반공변성(contravariant)이라고 한다.
코틀린에서는 in
과 out
이라는 키워드로 변성을 지정한다. in, out으로 공변성 반공변성이라는 단어보다 훨씬 짧고 이해하기 쉽다. 아무 키워드도 없으면 무공변성이라고 부른다.
String의 인스턴스는 Any의 인스턴스이기도 하다. 따라서 다음과 같이 쓸 수 있다
val s = "A String"
val a: Any = s
Any가 String의 부모 타입이라서 이렇게 쓸 수 있다. 만약 MutableList<Any>
가 MutableList<String>
의 부모 타입이라면 다음과 같이 쓸 수 있다.
val listString = mutableListOf("A String")
val listAny: MutableList<Any> = listString // <-- 컴파일 오류
listAny.add(42)
이 코드를 컴파일할 수 있다면 문자열 리스트에 Int를 집어넣을 수 있다. 불변 리스트를 사용할 때는 이런 일이 크게 문제가 되지 않는다. 문자열로 이뤄진 불변 리스트에 Int 타입의 원소를 추가하면 List<Any>
타입의 리스트가 새로 생기고, 원래 리스트는 변하지 않는다.
val listString = mutableListOf("A String")
val listAny = listString + 42 // <-- 코틀린은 `listAny`의 타입을 List<Any>로 추론함
자바에서 파라미터화한 타입이 파라미터 타입에 대해 무공변성이다. 즉, A가 B의 부모 타입이라 하더라도 List<A>
와List<B>
사이에는 아무런 부모 자식 타입 관계가 성립하지 않는다. 따라서 List<A>
와List<B>
는 컴파일 시점에 전혀 다른 두 가지 타입이다(그리고 런타임에는 두 타입이 같다).
무공변성 타입이면 아래의 코드에서 컴파일 되지 않는다.
fun <T> addAll(list1: MutableList<T>, list2: MutableList<T>) {
for (elem in list2) list1.add(elem)
}
val listString = mutableListOf("A String")
val listAny: MutableList<Any> = mutableListOf()
addAll(listAny, listString) // <-- 컴파일 되지 않음
String 타입의 elem이 List<Any>
에 추가될 수 있으며, 그렇게 해도 아무 문제가 없다. 코틀린에서는 MutableList<Any>
와 MutableList<String>
을 동시에 MutableList<T>
라는 제네릭 타입에 일치시킬 수 없다.
이 제네릭 함수가 작동하게 하려면 MutableList<Any>
가MutableList<String>
의 상위 타입처럼 쓰일 수 있음을 컴파일러에 알려줘야 한다.
MutableList<Any>
가MutableList<String>
의 상위 타입으로 쓰일 수 있는 이유는 listAny
에서 값을 가져오기만 하고(out), 값을 넣는 일은 결코 없기 때문이다(in).
fun <T> addAll(list1: MutableList<T>, list2: MutableList<out T> { // <-- T를 공변성으로 만듦
for (elem in list2) list1.add(elem)
}
val listString = mutableListOf("A String")
val listAny: MutableList<Any> = mutableListOf()
addAll(listAny, listString) // <-- 오류가 발생하지 않음.
여기서 out 키워드는 list2 파라미터가 T타입에 대해 공변성적임을 표시한다. 따라서 반공변성은 in
키워드로 표시한다.
이 문제를 다른 해법은 list1을 in 타입(소비하지만 생산하지는 않음)으로 만드는 것이다.
만약 다음과 같은 인터페이스가 있다고 하자
interface Bag<T> {
fun get(): T
}
이 인터페이스에는 T 타입의 값을 반환하는 함수만 들어 있다. (그리고 T 타입을 인자로 받는 함수는 존재하지 않는다) 따라서 V가 T의 상위 타입인 경우 Bag<T>
를 Bag(V)
참조에 대입할 수 있다고 확신할 수 있다. 하지만 out 키워드를 사용해 의도를 명확히 해야 한다.
open class MyClassParent
class MyClass: MyClassParent()
interface Bag<out T> {
fun get(): T
}
class BagImpl : Bag<MyClass> {
override fun get(): MyClass = MyClass()
}
val bag: Bag<MyClassParent> = BagImpl()
타입 파라미터가 out 변성으로 쓰이는데 아무 변성을 지정하지 않으면 좋은 IDE (IntelliJ 같은..)는 공변성적으로 타입 파라미터를 지정하라고 경고한다.
반대로 이 인터페이스에 T 타입을 인자로 받는 함수만 있고 T 타입을 반환하는 함수는 없다면 in을 사용해 타입 파라미터를 반공변성으로 만들 수 있다.
open class MyClassParent
class MyClass: MyClassParent()
interface Bag<in T> {
fun use(t: T): Boolean
}
class BagImpl : Bag<MyClassParent> {
override fun use(t: MyClassParent): Boolean = true
}
val bag: Bag<MyClass> = BagImpl()
in이나 out을 하나도 지정하지 않으면 파라미터 타입은 무공변성이다.
out과 in 중 어떤 것을 선택할지 고르는 것은 간단하다.
선언 지점 변성(declaration-site variance)은 유용하지만 사용하지 못하는 경우가 많다.
Bag 인터페이스가 T 타입의 값을 소비하는 동시에 생산한다면 변성을 지정할 수 없다.
interface Bag<T> {
fun get(): T
fun use(t: T): Boolean
}
파라미터 T는 get 메서드에서 공변성적(반환)이고 use 메서드에서는 반공변성적(인자)이기 때문에 변성을 지정할 수 없다. 이런 경우 선언 지점 변성을 사용할 수 없다. 그치만 이럴 때도 여전히 사용 지점 변성(use-site variance)을 사용할 수 있다.
open class MyClassParent
class MyClass: MyClassParent()
interface Bag<T> {
fun get(): T
fun use(t: T): Boolean
}
class BagImpl : Bag<MyClassParent> {
override fun get(): MyClassParent = MyClassParent()
override fun use(t: MyClassParent): Boolean = true
}
fun useBag(bag: Bag<MyClass>): Boolean {
//bag으로 작업 수행
return true
}
val bag3 = useBag(BagImpl()) // <-- 컴파일러 오류
오류가 발생하는 이유는 useBag이 Bag<MyClass>
타입의 인자를 받는데, 실제로는 Bag<MyClassParent>
를 넘기기 때문이다.
그래서 이 코드를 작동하게 하려면 T에 대한 반공변성을 선언해야 한다.
하지만 Bag<T>
인터페이스에는 T가 out 위치에 있는 get(): T
함수가 있어서 T를 in으로 선언할 수 없다.
fun useBag(bag: Bag<in MyClass>): Boolean {
//bag으로 작업 수행
return true
}
반대로 out도 사용 지점에서 선언할 수 있다.
fun createBag(): Bag<out MyClassParent> = BagImpl2()
class BagImpl2 : Bag<MyClass> {
override fun get(): MyClass = MyClass()
override fun use(t: MyClass): Boolean = true
}
여기서 in MyClass와 out MyClassParent를 제약이 가해진 타입으로 생각할 수 있다.
in MyClass
는 in 위치에서만 쓰일 수 있는 MyClass의 하위 타입out MyClassParent
는 out 위치에서만 쓰일 수 있는 MyClassParent의 하위 타입컴파일러는 이런 제약을 검사한다.
in MyClass와 out MyClassParent를 MyClass와 MyClassParent의 타입 프로젝션
이 포스팅의 내용은 코틀린을 다루는 기술
의 2장 중 후반에 다루는 out과 in에 대해서 정리했다. 예전에 공부하면서 인지했던 제너릭 타입 중 하나였는데 막상 또 읽으니 새로웠다. 예제를 보면서 흐름을 따라가다보니 조금 더 다양하게 이 제너릭 타입들을 쓸 수 있겠다는 생각이 들었다. 또한 ㅏ와 ㅓ 다르듯 코틀린도 잘못쓰면 컴파일 오류나니 잘 알아야 될 거 같다..!