변성(variance)

2020-10-09

변성(variance)

변성(variance)은 자바나 코틀린뿐 아니라 다른 언어에서도 존재하는 개념이다. 제네릭을 포함한 타입의 계층 관계에서 타입의 가변성을 처리하는 방식이다.

  • 타입 S가 T의 하위 타입일때, Box[S]가 Box[T]의 하위 타입인가?
    • Box[S]와 Box[T]는 상속 관계가 없다 -> 무공변
    • Box[S]는 Box[T]의 하위 타입이다 -> 공변
    • Box[T]는 Box[S]의 하위 타입이다 -> 반공변

타입 S와 T의 관계가 동일한 방향의 상하위 관계면 공변, 반대면 반공변, 관계가 없으면 무공변

무공변(invariant)의 의미

무공변은 타입 S가 T의 하위 타입일때, Box[S]가 Box[T] 사이에 상속 관계가 없는 것 을 말한다.

interface Box<T>
open class Language
open class JVM: Language()
class Kotlin: JVM()

Kotlin < JVM < Language 의 상속관계가 존재한다.

상속관계를 사용해서 Box의 인스턴스를 선언하면 다음과 같다.

val languageBox = object : Box<Language> {}
val jvmBox = object : Box<JVM> {}
val kotlinBox = object : Box<Kotlin> {}

languageBox, jvmBox, kotlinBox 사이에 상속이 존재하지 않는다.

fun main() {
    invariant(languageBox) // compile error
    invariant(jvmBox)
    invariant(kotlinBox) // compile error
}
fun invariant(value: Box<JVM>) {}

상속 관계가 없기 때문에 invariant 함수에는 jvmBox외에 입력으로 들어갈 수 없다. 여기서 Box의 변성은 무공변이 된다.


공변(covariant)의 의미

공변은 타입 S가 T의 하위 타입일때, Box[S]가 Box[T]의 하위 타입인 것 을 말한다. 코틀린에서 공변은 <out T> 로 선언한다.

fun main() {
    covariant(languageBox) // compile error
    covariant(jvmBox)
    covariant(kotlinBox)
}
fun covariant(value: Box<out JVM>) {} // 함수 파라미터 확인

covariant 함수의 매개변수는 타입이 Box<out JVM>으로 선언 되었기 때문에 공변이다.

공변인 경우는 Kotlin < JVM < Language 의 상속 관계와 같은 방향으로

Box < Box < Box 의 상속 관계도 성립된다.

따라서 상위 타입인 languageBox를 매개변수로 입력하면 컴파일 오류가나고 동일하거나 하위타입인 jvmBox, kotlinBox는 허용된다.

자바에서는 Box<? extends T>로 표기하는 upper bound를 사용해 공변을 만든다 public Box<? extends T> == fun <T: JVM> upperBound(value: Box<T>) == fun covariant(value: Box<out JVM>)


반공변(contravariant)의 의미

공변은 타입 S가 T의 하위 타입일때, Box[S]가 Box[T]의 상위 타입인 것 을 말한다. 코틀린에서 반공변은 <in T> 로 선언한다.

fun main() {
    contravariant(languageBox) 
    contravariant(jvmBox)
    contravariant(kotlinBox) // compile error
}
fun contravariant(value: Box<in JVM>) {} // 함수 파라미터 확인

contravariant 함수의 매개변수는 타입이 Box<in JVM>으로 선언 되었기 때문에 반공변이다.

반공변인 경우는 Kotlin < JVM < Language 의 상속 관계와 반대 방향으로

Box < < Box < Box의 상속 관계도 성립된다.

따라서 하위 타입인 kotlinBox는 매개변수로 입력하면 컴파일 오류가나고 동일하거나 상위타입인 jvmBox, languageBox 허용된다.


in, out 으로 변성 선언하기

타입 매개변수를 in, out으로 선언하면 기본적으로 변성을 가지게 될 뿐 아니라 타입 매개변수에 대한 몇 가지 정보를 컴파일러에게 알려준다.

// 무공변 타입 T를 가진 인터페이스
interface Box2<T> {
    fun read(): T
    fun write(value: T)
}

무공변이기 때문에 Box2에서 T를 읽어서 반환하거나, Box2에 T타입의 값을 쓰는것 모두 안전하다.

// 공변 타입 T를 가진 인터페이스
interface Box2<out T> {
    fun read(): T
    fun write(value: T) // compile error
}

타입 매개변수 T를 공변으로 선언하면 write 함수에 컴파일 에러가 발생한다. write 함수에서 value 값을 받아서 처리(consume)할 때 어떤 하위 타입이 들어올지 알 수 없기 때문에 런타임 오류가 발생할 가능성이 있다.

// 반공변 타입 T를 가진 인터페이스
interface Box2<in T> {
    fun read(): T // compile error
    fun write(value: T) 
}

반공변으로 선언했을 때 반대로 read 함수에서 컴파일 에러가 발생한다. 호출자가 선언한 T 타입의 변수에 T 보다 상위 타입의 값을 할당하면 런타임 오류가 발생하기 때문이다.

write함수는 T의 상위 타입에 대한 정보는 하위 타입이 이미 상속받아서 알기 때문에 런타임 오류가 발생하지 않는다.

결론

in, out 키워드의 의미는 문자 그대로 이해하면 쉽다.

in으로 선언된 타입은 입력값에만 활용 (읽기 전용)

out으로 선언된 타입은 반환 값의 타입 (출력 전용)