본문 바로가기
Language/Kotlin

[Kotlin] Generics - 공변성(covariant)과 반공변성(contravariant)

by 돈코츠라멘 2019. 10. 5.

What is generic?

Generic이란 Class 또는 method에서 매개변수에 사용되는 자료형의 정의를 개체 생성시 정하게 하여 타입에 대한 안정성을 높이는 도구를 말한다. 일반적으로 <T>로 표기된다.

 

Generic 사용의 장점 –

  1. Type casting is evitable- typecasting을 하지 않고 객체를 사용할 수 있다.
  2. Type safety- Generic allows only single type of object at a time.
  3. Compile time safety- 런타임 에러를 방지하기 위해 Generics code는 컴파일 타임에 체크된다.

이런 Generic에서 사용할 수 있는 타입의 범위를 지정하는것이 Type Bound이다. Modern Language들은 대부분 Type Bound 개념을 제공한다. Type Bound는 세 가지로 분류할 수 있다.

 

Type Bound (Parameter Variance) 분류 -

  1. 불변성(무공변성, invariant)
  2. 공변성(covariant)
  3. 반공변성(contravariant)

1. 불변성(무공변성, invariant)

상속 관계에 상관없이 자신의 타입만 허용하는 것을 뜻한다. Kotlin에서는 따로 지정해주지 않으면 기본적으로 모든 Generic Class는 무공변이다. Java에서의 <T>와 같다.

open class Alcohol
class Soju : Alcohol()

interface Drinker<T> {
    fun drink()
}

fun varianceTest(input: Drinker<Alcohol>){
    input.drink()
}

fun main() {

    val alchol: Drinker<Alcohol> = object:Drinker<Alcohol>{
        override fun drink(){
            println("Drink!")
        }
    }

    val soju: Drinker<Soju> = object:Drinker<Soju>{
        override fun drink(){
            println("Drink Soju!")
        }
    }

    // Success
    println(varianceTest(alchol)) // Drink!

    // Error
    println(varianceTest(soju)) // Type mismatch: inferred type is Drinker<Soju> but Drinker<Alcohol> was expected

}

예시에서 SojuAlcohol을 상속받았지만, 별도로 공변성을 지정하지 않았으므로 무공변이다. 따라서 입력으로 Drinker<Alcohol> 타입을 받는 함수 varianceTest에서 Drinker<Soju>를 입력하면 Type mismatch 에러가 발생한다.

2. 공변성(covariant)

자기 자신과 자식 객체를 허용한다. Java에서의 <? extends T>와 같다. Kotlin에서는 out 키워드를 사용해서 이를 표시한다.

open class Alcohol
class Soju : Alcohol()

interface Drinker<T> {
    fun drink()
}

fun varianceTest(input: Drinker<out Alcohol>){ // out keyword 추가
    input.drink()
}

fun main() {

    val alchol: Drinker<Alcohol> = object:Drinker<Alcohol>{
        override fun drink(){
            println("Drink!")
        }
    }

    val soju: Drinker<Soju> = object:Drinker<Soju>{
        override fun drink(){
            println("Drink Soju!")
        }
    }

    val any: Drinker<Any> = object:Drinker<Any>{
        override fun drink(){
            println("Drink Any!")
        }
    }

    // Success
    println(varianceTest(alchol)) // Drink!

    // Success
    println(varianceTest(soju)) // Drink Soju!

    // Error
    println(varianceTest(any)) //Type mismatch: inferred type is Drinker<Any> but Drinker<out Alcohol> was expected

}

SojuAlcohol을 상속받아 구현한 하위 타입이다. varianceTest 함수에서 입력을 Drinker<out Alcohol>로 지정하였으므로 AlcoholAlcohol의 하위 타입인 Soju를 모두 입력으로 사용할 수 있다.

 

cf. SojuAlcohol의 하위 타입일 때 Drinker<Soju>Drinker<Alcohol>의 하위 타입이므로 Drinker는 타입 인자 T에 대해 공변적이다.

 

cf. 공변/반공변에 대해 설명하기 위해 작성한 위 코드는 오직 개념 설명만을 위한 예시이다. 실제 코드를 작성할 때에는 in, out 키워드에 대한 개념을 확실히 이해하는 것이 중요하다.

3. 반공변성(contravariant)

공변성의 반대 - 자기 자신과 부모 객체만 허용한다. Java에서의 <? super T>와 같다. Kotlin에서는 in 키워드를 사용해서 표현한다.

open class Alcohol
class Soju : Alcohol()

interface Drinker<T> {
    fun drink()
}

fun varianceTest(input: Drinker<in Alcohol>){ // in keyword 추가
    input.drink()
}

fun main() {

    val alchol: Drinker<Alcohol> = object:Drinker<Alcohol>{
        override fun drink(){
            println("Drink!")
        }
    }

    val soju: Drinker<Soju> = object:Drinker<Soju>{
        override fun drink(){
            println("Drink Soju!")
        }
    }

    val any: Drinker<Any> = object:Drinker<Any>{
        override fun drink(){
            println("Drink Any!")
        }
    }

    // Success
    println(varianceTest(alchol)) // Drink!

    // Error
    println(varianceTest(soju)) // Type mismatch: inferred type is Drinker<Soju> but Drinker<in Alcohol> was expected

    // Success
    println(varianceTest(any)) // Drink Any!

}

varianceTest 함수에서 입력을 Drinker<in Alcohol>로 지정하였으므로 AlcoholAlcohol의 상위 타입(예시 코드에서는 별도로 지정된 상위 타입이 없음, Any는 모든 객체의 상위 타입이므로 사용 가능)을 입력으로 사용할 수 있다.

open class Alcohol
class Soju : Alcohol()

interface Drinker<T> {
    fun drink()
}

fun varianceTest(input: Drinker<in Soju>){ // in Soju로 변경
    input.drink()
}

fun main() {

    val alchol: Drinker<Alcohol> = object:Drinker<Alcohol>{
        override fun drink(){
            println("Drink!")
        }
    }

    val soju: Drinker<Soju> = object:Drinker<Soju>{
        override fun drink(){
            println("Drink Soju!")
        }
    }

    val any: Drinker<Any> = object:Drinker<Any>{
        override fun drink(){
            println("Drink Any!")
        }
    }

    // Success
    println(varianceTest(alchol)) // Drink!

    // Success
    println(varianceTest(soju)) // Drink Soju!

    // Success
    println(varianceTest(any)) // Drink Any!

}

varianceTest 함수에서 입력을 Drinker<in Soju>로 지정하였으므로 SojuSoju의 상위 타입인 Alcohol도 입력 가능하다.



How to use?

여기서 왜 outin 이라는 키워드를 사용하는지 살펴보자면, 이는 Producer(생산자, read-only)와 Consumer(소비자, write-only)에 빗댄것이라는 것을 알 수 있다. - C#에서도 Kotlin과 같이 공변성과 반공변성을 out, in 키워드를 사용한다. Kotlin이 이 개념을 따서 만든듯하다. Kotlin에 비해 C# 레퍼런스가 더 많으니 한번 찾아볼 만 하다.

In "clever words" they say that the class C is covariant in the parameter T, or that T is a covariant type parameter. You can think of C as being a producer of T's, and NOT a consumer of T's. In addition to out, Kotlin provides a complementary variance annotation: in. It makes a type parameter contravariant: it can only be consumed and never produced.

공식 홈페이지에서는 이렇게 설명하고 있다. - class Ctype parameter T에 대해 공변성(out)을 가지면 CT의 소비자가 아닌 생산자로 볼 수 있다. 또한 T가 반공변성(in)을 가지면 그것은 소비만 할 수 있고 생산될 수는 없다.

out = Producer

// Java
interface Source<T> {
    T nextT();
}

void demo(Source<String> strs) {
    Source<Object> objects = strs; // !!! Not allowed in Java
    // ...
}

위 코드는 Java에서의 Generic 예시이다. Source<T>T를 parameter로 쓰지 않고 오직 리턴만 한다. 그러므로 Source<String> 인스턴스에 대한 레퍼런스를 Source<Object>에 저장하는 것은 안전하다. 어차피 Consumer 호출이 없기 때문이다 .하지만 Java는 이를 알지 못하기 때문에 금지한다. 그래서 Source<? extends Object> 타입으로 선언해서 써야하는데, 이는 변수에 대해 동일한 method를 호출할 수 없으므로 큰 이득이 없고 타입만 더 복잡해진다. 또한 컴파일러가 이를 모른다.

abstract class Source<out T> {
   abstract fun nextT(): T
}

fun demo(strs: Source<String>) {
   val objects: Source<Any> = strs // This is OK, since T is an out-parameter
   // ...
}

그래서 Kotlin은 이를 컴파일러에게 알려준다. 이 방식을 선언 위치 변성(declaration-site variance)이라고 부른다. Source의 type parameter T에 대해 Source<T>의 멤버에서 T를 리턴만 하고 소비하지 않는다는 것을 명시한다. 이를 out 키워드로 표현한다.

그러므로 위에서 공변/반공변이 무엇인지 설명하면서 작성한 코드는 Java에서의 개념을 그대로 답습한 코틀린스럽지 못한 코드이다!!

in = Consumer

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0 has type Double, which is a subtype of Number
    // Thus, we can assign x to a variable of type Comparable<Double>
    val y: Comparable<Double> = x // OK!
}

위 예제코드는 반공변의 대표적인 예시인 compareTo이다. Comparable의 type parameter T는 소비되기만 하고 생산되지는 않는다.

 

 

외우기 쉽게 정리하자면 class C에 대해

  • class C가 type T를 생산 = class C가 type T를 리턴 = out
  • class C가 type T를 소비 = class C가 type T를 파라미터로 사용 = in




Reference

댓글