코틀린으로 배우는 함수형 프로그래밍 이란 책을 공부하던 중 뜬금없이(함수형 프로그래밍과 어떤 관계인지 설명이 안되있음, 물론 필수적인 개념이긴 하지만..) 변성에 대한 설명을 해주는데 책에 있는 내용만으론 잘 이해가 되지않아 정리를 해보려고 한다.
보통 일반적인 객체의 경우 다형성을 구현하기 위해 interface나 abstract class같은 것을 선언하고 이를 parameter로 받는 식으로 아래와 같이 처리가 가능하다.
interface Base {
fun func()
}
class Child1: Base {
override fun func() {
println("Hello I'm child1")
}
}
class Child2: Base {
override fun func() {
println("Hello I'm child2")
}
}
fun func(base: Base) {
base.func()
}
이런 상속관계를 generic에도 적용하는 것이 가능할까? 아래 두 자료형은 관계가 있을까?
val listBase: mutableListOf<Base> = mutableListOf<Base>()
val listChild1: mutableListOf<Child1> = mutableListOf<Child1>()
listBase = listChild1 // 상속관계가 유지된다면 문제없이 동작해야함
어찌보면 당연하겠지만 이는 동작하지 않는다. 이 때 listBase와 listChild1의 관계를 무공변(invariant)이라고 한다.
val listBase: listOf<Base> = mutableListOf<Base>()
val listChild1: listOf<Child1> = mutableListOf<Child1>()
listBase = listChild1 // 상속관계가 유지된다면 문제없이 동작해야함
그러면 immutableList의 경우에는 어떨까? 이 경우에는 문제없이 동작을 한다. 즉.. 상속관계가 유지된다! 이를 공변(covariant)이라고 한다. 무슨 차이일까? 이는 변수의 내용을 변경이 가능한가 불가능한가로 차이가 난다고 볼 수 있다. 아래의 코드를 살펴보자.
// 동작하지 않는 코드입니다
fun invariant(list: MutableList<Base>) {
list.add(Child1())
list.add(Child2()) // 공변이라면 Base를 상속받은 객체를 넣을 수 있어야함, 그런데 상속받은 서로다른 type을 넣게되면 문제가 없을까?
}
위 처럼 mutalbleList의 경우에는 객체를 런타임에 삽입이 가능한데 mutableList에 공변을 허용해버린다면 위 경우 런타임 도중 감당하기 힘든 문제를 야기하게 될 것이다.
마지막으로 반공변(contravariant)이 있는데 이는 아래와 같은 관계가 성립됨을 의미한다. 반공변의 경우 immutableList로도 성립되지 않는다.
// 동작하지 않는 코드입니다
fun contravariant(list: MutableList<Base>) {
val tempList: MutableList<Child1> = list // 상위 type을 히위 type이
}
kotlin에서 이러한 변성을 제어하는 keyword로 in과 out이 있는데 다음과 같이 활용할 수 있다.
fun variant(list: MutableList<out Base>) {
list.add(Child1()) // 동작하지 않음
}
fun contravariant(list: MutableList<in Child1>) {
list.add(Child1())
}
fun main(args: Array<String>) {
val mutableListBase = mutableListOf<Base>()
val mutableListChild1 = mutableListOf<Child1>(Child1(), Child1())
contravariant(mutableListBase)
variant(mutableListChild1)
contravariant(mutableListChild1) // 동작하지 않음
variant(mutableListBase) // 동작하지 않음
}
코드를 보면 대충 감이 올 것이다. out은 공변, in은 반공변이다. 그렇다면 왜 공변은 out이라는 keyword를 사용하고 반공변은 in이라는 keyword를 사용할까?
책의 예제가 가장 이해하기 편해서 그대로 가져왔다.
interface Box<out T> {
fun read() : T
fun write(value: T) //컴파일 에러
}
interface Box<in T> {
fun read() : T //컴파일 에러
fun write(value: T)
}
해당 keyword를 사용하게 되면 컴파일러에게 이 변수는 특정용도로 사용할거라고 알려주는 것이고 잘못사용하게 되면 컴파일에러를 통해 런타임에러가 발생하는 것을 사전에 차단한다. keyword의 의미대로 out은 반환에 in은 매개변수로서 정상동작하는 것을 볼수있는데 그렇다면 왜 out은 공변이고 in은 비공변일까? 맨처음 immutableList의 사례를 살펴보면 자연스럽게 답이 나온다.
공변의 경우 하위타입에 대한 변성을 허용한다, 그런데 하위타입의 경우 상위타입이 갖고있지 않은 정보를 갖고있을 수 있기 때문에 수정작업 즉 write작업을 하는경우 런타임에 문제가 발생할 가능성이 있게된다. 하지만 하위타입을 상위타입에 대입하는 경우에는 문제가 생기지 않기에 out이라는 키워드 즉 반환작업에 사용할 수 있다는 것이다.
반공변도 마찬가지로 하위타입은 상위타입의 모든 정보를 갖고있기에 수정작업에 문제가 없다. 하지만 반환을 하는 경우 하위타입을 하위타입에 변성을 허용하게 되면 동작에 문제를 야기할 수 있는 것이다.
Java에서도 이와 비슷한 기능을 하는 wildcard인 extends와 super가 존재하는데 kotlin의 in과 out처럼 컴파일타임에 문제의 가능성을 잡아주지 않는다.