Kotlin의 변성

KIYOUNG KWON·2022년 6월 8일
0

코틀린으로 배우는 함수형 프로그래밍 이란 책을 공부하던 중 뜬금없이(함수형 프로그래밍과 어떤 관계인지 설명이 안되있음, 물론 필수적인 개념이긴 하지만..) 변성에 대한 설명을 해주는데 책에 있는 내용만으론 잘 이해가 되지않아 정리를 해보려고 한다.

보통 일반적인 객체의 경우 다형성을 구현하기 위해 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처럼 컴파일타임에 문제의 가능성을 잡아주지 않는다.

0개의 댓글