Kotlin) 변성
변성 (Variance)
Generic Type 의 대체 가능성을 정의 (무공변, 반공변, 공변), 변성은 Genrice Parameter Type 간의 관계를 나타낼때 쓰이는 단어임.
무공변(invariance)
Generic 의 Parameter Type 은 각각 고유하므로 GenericType 사이의 대체 가능성은 기본적으로 성립하지 않음. 이 상태가 무공변(invariance) 상태임. 이 말을 듣고 느낀 점은, 지금 까지 Generic Parameter 에 넣는 Type 을 자꾸, 내가 생성한 Class 들의 상속관계로 가져와서 생각하다보니, 공변을 한동안 이해하기 어려웠던 건가? 이런 생각이 들었다.
class Tree<T>(val value: T)
var tree: Tree<Number> = Tree<Int>(10) // error
공변(covariance)
하지만 Kotlin 도 객체지향 언어이므로, 대체 가능성을 지원하고 있음. 따라서 Generic Parameter Type 에서 대체가능성을 지원하는 경우가 있는데 이때를 "공변(covariance)" 라고 함. Parameter Type 이 기존 Type 의 관계를 가져가려는 시도임.
class Tree<T>(val value: T)
var tree: Tree<Number> = Tree<Int>(10) // success
tree.value.toDouble()
공변이 성립되는 경우는 위의 예제에서 Type Parameter 로 쓰인 T Type 이 바깥으로 노출되는 구조일땐데, 즉 Producer 형태로 값을 노출시키는 구조에만 가능하다. 그 이유는 다른 값을 받게 될 경우에는 문제가 발생할 수도 있다. 예를 들면, 지금은 init 하는 시점에 받은 Int Value 만 사용하는데 후에 다른 Type 의 값이 들어오게 될 수도 있기 때문이다. 아래 예시를 보면 알겠지만, 그래서 코틀린에서는 이를 방지하고 있다.
var list: MutableList<Number> = mutableListOf<Int>(10) // Type mismatch: inferred type is MutableList<Int> but MutableList<Number> was expected
list.add(10.0) // 이런식으로 이상 한 값이 들어올 수 있음. 하지만 Kotlin 에서는 안됨.
println(list)
위의 예제를 보면 일반적인 Type 의 대체 가능성을 생각해본다면 가능할 것 같다. 하지만 불가능하다. 그 이유는 Invariance 하기 때문이다. Invariance 한 이유는 mutableList 는 Producer 와 Consumer 역할을 동시에 하기 때문이다.
fun main() {
var list: List<Number> = listOf<Int>(10)
println(list)
}
반면의 위의 List 를 사용한 예제는 잘 동작한다. 그 이유는 코틀린의 List Collection 은 기본적으로 immutable 하다. 따라서 Producer 의 역할만 하게된다. 그래서 공변이 성립할 수 있게 되는 것이다. 즉, Producer 역할만 하게 되는 경우에는 Covariance 하다.
out
그래서 공변의 경우에는 생산자가 외부에 T Type 을 노출하게 되는 구조이므로, out
식별자를 사용하게 된다. 그래서 아래와 같이 적게 되면, 내가 외부에 이 List 의 원소들을 Number Type 으로 노출시킬꺼야. 라고 컴파일 시점에 공표하는 것과 같다. (Intellij 를 쓴다면 out 식별자를 저기 붙여주면, List 는 이미 invariance 하니까 없애라고 나온다.)
val list: List<out Number> = listOf(10)
반공변성(contravariant) && in
반대로 파라미터 구상타입에 추상타입이 들어오는 경우를 반공변성(contravariant) 라고 함. 즉 in
은 Consumer 구조 일때 사용가능하다.
internal class Node<in T : Number> (
private val value: T,
private val next: Node<T>? = null
) {
operator fun contains(target: T): Boolean {
return if (value.toInt() == target.toInt()) true else next?.contains(target) ?: false
}
fun isPositive(target: T): Boolean {
return target.toInt().isPositive()
}
}
fun main() {
val node: Node<Int> = Node<Number>(10.0)
node.contains(8)
}
위의 예시를 보면 Int Type Node 에 Number Type Node 를 대입하고 있다. 이게 어떻게 가능한 일일까? 일단 하나하나씩 살펴보면 아래와 같은 이유를 추론할 수 있다.
contains 메소드 안에 들어왔을때,
toInt()
메소드가 실행하는데 Number Type 들은 해당메소드로 Convert 시 반드시 Int Type 으로 변한다. 따라서 Number Type 으로만 UpperBound 규약이 있다면 상관없음.contains 로직은 Number 의 method 이므로 아무런 문제가 없음.
값을 UpperBound 로 규정지어서 가능하다. 아래 예시를 한번 보면 좀 더 이해가 편할 것 이다.
internal class Node<in T : Number> (
private val value: T,
private val next: Node<T>? = null
) {
operator fun contains(target: T): Boolean {
return if (value.toInt() == target.toInt()) true else next?.contains(target) ?: false
}
fun isPositive(target: T): Boolean {
return target.toInt().isPositive() // 반드시 Int 로 Bound 를 규정짓고만 확장함수를 사용가능.
}
}
// 확장함수 추가
fun Int.isPositive(): Boolean {
return this >= 0
}
선언지점 변성
코틀린에서는 in
, out
을 이용해서 컴파일 시점에 변성에 대한 조건을 체크할 수 있다. Java 에서는 컴파일 시점에 변성 체크가 불가능하다. 따라서 아래 코드를 만나면 죽는다.
var list: MutableList<Number> = mutableListOf<Int>(10)
list.add(10.0) // dead
list.get(0)
println(list)
그래서 위와 같은 상황에서는 "무공변성(invariance)" 를 유지해야만 한다.