본문 바로가기
개발/Java, Kotlin

[Effective Kotlin] Item 24: Consider variance for generic types

by 달사쿠 2021. 9. 9.
반응형

Intro

아래의 generic class를 보면, 타입 파라미터인 T는 out이나 in과 같은 제네릭 변성 한정자(generic variance modifier)가 없기 때문에 타입 변경이 불가능합니다(invariant). 이 말은 generic class에 의해 생성된 서로 다른 타입 사이에는 관계가 없다는 것을 의미하는데요.

class Cup<T>

fun main() {
    val anys: Cup<Any> = Cup<Int>() 		//Error: Type mismatch
    val nothings: Cup<Nothing> = Cup<Int>() 	//Error
}

예를 들면, Cup<Int>와 Cup<Number>, Cup<Any>와 Cup<Nothing>사이에는 관계가 없습니다.

만약, 관계가 필요하다면 out이나 in과 같은 제네릭 한정자를 사용해야 합니다.

 

out은 type parameter를 공변(covariant)할 수 있게 합니다.

covariant: 공변성
공변성이란 자신이 상속받은 부모 객체로 타입을 변화시킬 수 있다라는 것을 의미합니다. 
읽기만 가능하고 쓰기는 불가능하며, Java에서 ? extends E로 사용되고 있습니다.

 

즉 쉽게말해 A'가 A의 하위유형이고, Cup이 covariant한 경우 type Cup<A'>가 type Cup<A>의 subtype임을 의미합니다. 아래의 예제에서는 Puppy가 Dog의 하위 유형이므로, out 한정자를 사용하여 타입을 할당 받을 수 있습니다.

class Cup<out T>
open class Dog
class Puppy: Dog()

fun main(args: Array<String>) {
    val b: Cup<Dog> = Cup<Puppy>()	//OK
    val a: Cup<Puppy> = Cup<Dog>()	//Error
    
    val anys: Cup<Any> = Cup<Int>()		//OK
    val nothings: Cup<Nothing> = Cup<Int>()	//Error
}

 

반대로 in 한정자를 사용해 반대 효과(반공변성 - incovariant)를 얻을 수도 있습니다. 쉽게 말해 A'가 A의 하위유형이고, Cup이 incovariant한 경우 Cup<A>가 Cup<A'>의 supertype임을 의미합니다.

incovariant (Contravariant): 반공변성
읽기는 불가능하고 쓰기만 가능하며, 자바에선 ? super E 로 사용되고 있습니다.

 

class Cup<in T>
open class Dog
class Puppy: Dog()

fun main(args: Array<String>) {
    val b: Cup<Dog> = Cup<Puppy>()	//Error
    val a: Cup<Puppy> = Cup<Dog>()	//OK
    
    val anys: Cup<Any> = Cup<Int>()		//Error
    val nothings: Cup<Nothing> = Cup<Int>()	//OK
}

지금까지 설명한 한정자를 다이어그램으로 표현하면 아래와 같습니다.

variance modifier diagram(img src=Effective Kotlin p161)

 


!중요한 용어 한번 정리하고 갈게요~~

Variance (변성)

  • 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하고 있는 개념
  • 변성을 이해하면 타입 안정성을 보장 (참고;*제네릭은 타입소거(컴파일 시에만 타입 검사) 방식으로 동작 - item 21 내용)

Invariant (무공변)

  • 특정 타입 T만 허용 (타입 변경 불가능)

Covariant (공변): Producer

  • 자신이 상속받은 부모 객체로 타입을 변화시킬 수 있음
  • 읽기(get)만 가능하고 쓰기(set)는 불가능

Incovariant (Contravariant; 반공변): Consumer

  • 타입 T의 상위(부모)타입에 대해 허용
  • 읽기(get)는 불가능하고 쓰기(set)만 가능

 

반응형

 


Function types

function type에서는 argument 와 return 으로 서로 다른 타입의 function을 갖습니다. (*function type은 item 35에서 더 자세히 설명됨, higher-order function에서의 type을 말하는 것 같다,,!)

fun printProcessedNumber(transition: (Int) -> Any){
    print(transition(42))
}

위의 코드를 예시로 들자면 (Int) -> Any의 경우,

  • (Int) -> Number
  • (Number) -> Any
  • (Number) -> Number
  • (Any) -> Number
  • (Number) -> Int 

등으로 바꿔서 표현할 수 있습니다.

이게 가능한 이유는 위의 type들이 아래와 같은 관계를 갖기 때문인데요.

all type relation (img src=Effective Kotlin p162)

이 계층에서 아래로 내려가면 parameter type은 typing system 계층에서 더 높은 type으로 이동하고, return type은 더 낮은 type으로 이동합니다.

(img src=Effective Kotlin p163)

이건 절대 우연이 아닌데요! 왜냐면 function types의

모든 parameter type은 in 한정자에서 봤듯 반공변성(contravariant)을 갖고,

모든 return type은 out한정자에서 봤듯 공변성(covariant)를 갖기 때문입니다. 

function types variance modifier (img src=Effective Kotlin p163)

 

Kotlin에서 in과 out한정자 보다 유명한 것은 List입니다. List는 List<out T>로 선언되어 공변성을 갖고있습니다. 따라서, 읽기만 가능할 뿐 add와 같은 consumer function을 사용할 수 없습니다

 

만약 get()이나 add()와 같은 것을 사용하고 싶다면, 무공변(invariant)MutableList<T>를 사용하면 됩니다.

 


The safety of variance modifiers

Java에서 array는 공변성(covariant)을 갖고있습니다.

많은 출처에 따르면, 모든 type의 array에 generic operation을 만들어 정렬과 같은 함수를 생성할 수 있게 하기 위함인데 여기에는 큰 문제가 있습니다.

 

아래의 코드를 보면, 숫자 type array를 Object[]로 캐스팅해도,

구조 내부에서 사용되는 실제 type은 그대로 Integer이므로 이 배열에 String type의 값을 할당하면 error가 발생합니다.

// Java
Integer[] numbers = {1, 4, 2, 1};
Object[] objects = numbers;
objects[2] = "B";	// Runtime error: ArrayStoreException

 

이건 Java의 결함이며 Kotlin은 Array(IntArray, CharrArray 등)을 불변(invariant)하게 만들어 이를 방지합니다.

따라서 Kotlin에서는 Array<Int>에서 Array<Any>로 업캐스팅이 불가능합니다.

 

여기서 우리가 뭐가 잘못된지 이해하려면, 먼저 parameter type으로 subtype을 넘겨줘야합니다. 그렇게 함으로써 인수를 넘길 때 암묵적인 업캐스팅(implicit upcasting)을 할 수 있습니다.

open class Dog
class Puppy: Dog()
class Hound: Dog()

fun takeDog(dog: Dog) {}

takeDog(Dog())
takeDog(Puppy())
takeDog(Hound())

 

아래와 같은 상황은 안전하지 않습니다. 캐스팅 후 실제 객체는 그대로 유지되고 (Dog), 타이핑되는 시스템에서만 다르게 처리되기 때문입니다. 쉽게 말해, Int를 설정하려고 하지만 Dog를 이미 맵핑시켰기 때문에 Dog type의 변수만 들어올 수 있습니다.

class Box<out T> {
    private var value: T? = null
    
    //Illegal in Kotlin
    fun set(value: T) {
        this.value = value
    }
    
    fun get(): T = value ?: error("Value not set")
}

val puppyBox = Box<Puppy>()
val dogBox: Box<Dog> = puppyBox
dogBox.set(Hound())			//Already place for a Puppy

val dogHouse = Box<Dog>()
val box: Box<Any> = dogHouse
box.set("Some string")			//Already place for a Dog
box.set(42)				//Already place for a Dog

 

이런 문제를 방지하기 위해, Kotlin에서는 공변성을 가진 public한 in-position type parameter를 금지하고 있습니다.

(대신, 아래와 같이 제한적으로 사용가능한 private을 통해 객체 내부에서 객체를 업캐스팅하는 것은 가능합니다. - 우리가 수정할 수 없기 때문, 즉 무공변(incovariant)하기 때문)

class Box<out T> {
    private var value: T?= null
    
    private fun set(value: T) {
        this.value = value
    }
    
    fun get(): T = value ?: error("Value not set")
}

 

우리는 이런 Covariance (out 한정자)를 producers나 Immutable data holders에 대해서 자주 사용합니다.

 

대표적인 예제가 위에서 언급한 List<T>입니다. 공변성(covariant)한 특징을 갖고있어, List<Any?>로 예상되는 함수가 있을 때, 무언가 변환없이 어떤 종류의 list를 제공할 수 있습니다.

 

반대로 MutableList<T>에서 T는 in-position에서만 사용되며, 안전하지 않기 때문엔 바뀔 수 없습니다(무공변; invariant).

fun append(list: MutableList<Any>) {	//Any를 String으로 바꿔야함
    list.add(42)
}

val strs = mutableListOf<String>("A", "B", "C")
append(strs)		//Type mismatch error
val str: String = strs[3]
print(str)

 

어쨌거나, 이런 한정자의 경우 Response와 같이 쓰일때 좀 더 유용하게 사용할 수 있습니다.

sealed class Response<out R, out E>
class Success<out R>(val value: R): Response<R, Nothing>()
class Failure<out E>(val error: E): Response<Nothing, E>()
  • Response<T>이라면, T의 subtype을 가진 응답은 허용됩니다.
    예를들어 Response<Any>의 경우, Response<Int>와 Response<String>이 모두 가능합니다.
  • Response<T1, T2>의 경우, T1과 T2의 subtype에 대한 응답이 허용됩니다.
  • Success는 잠재적인 error type을 지정할 필요가 없고, Failure의 경우 잠재적인 성공 값을 지정할 필요가 없습니다.
    이렇게 쓰일 수 있는 것은 covariance(공변)와 Nothing type 덕분입니다.

 

반공변성(contravariant)을 가진 type parameter를 out-position으로 사용될 경우(예: 함수의 속성이나 리턴 타입), 암묵적으로 업캐스팅을 허용합니다. 

open class Car
interface Boat
class Amphibious: Car(), Boat	//Amphibious: 수륙양용,,,?

fun getAmphibious(): Amphibious = Amphibious()

val car: Car = getAmphibious()
val boat: Boat = getAmphibious()

 

in 한정자의 경우, out 한정자와 비슷하지만 반대로 out-position type parameter를 사용하는 것을 막고있습니다.

class Box<out T> {
    private var value: T?= null
    
    fun set(value: T) {
        this.value = value
    }
    
    private fun get(): T = value ?: error("Value not set")
}

 

이런 Contravariance (in 한정자) consumer거나 accepted? type parameter에서 사용됩니다. 대표적인 예로 kotlin.coroutines.Continuation이 있습니다.

public interface Continuation<in T> {
    public val context: CoroutineContext	// 코루틴과 관련된 정보는 담고 있음
    public fun resumeWith(result: Result<T>)	//코루틴 완료시 성공 또는 실패를 보고하는 사용되는 콜백
}

 


Variance modifier positions

Variance modifier(변성 한정자)는 두 위치에서 사용할 수 있습니다.

  • 선언적 측면(declaration-side): 클래스 또는 인터페이스 선언에서의 변성 한정자 (일반적)
//Declaration-side variance modifier
class Box<out T>(val value: T)
val boxStr: Box<String> = Box("Str")
val boxAny: Box<Any> = boxStr
  • 사용적인 측면(use-site): 특정 변수에 대한 변성 한정자
class Box<T>(val value: T)
val boxStr: Box<String> = Box("Str")
//Use-side variance modifier
val boxAny: Box<out Any> = boxStr

모든 인스턴스에 대해 변성 한정자를 제공할 수 없지만, 하나의 변수에 대해서 필요한 경우 사용적인 측면(use-site)를 사용합니다.

 

예를 들어, MutableList는 리턴 값들에 대해서 허용하고 있지 않아서 in 한정자를 갖고있지 않습니다

그러나 단일 매개변수 타입의 경우, 해당 타입을 반공변(in 한정자)으로 만들어 다수의 type을 허용할 수 있습니다.

아래의 코드를 보면 이해하기가 좀 더 수월할 것입니다.

interface Dog
interface Cutie
data class Puppy(val name: String): Dog, Cutie
data class Hound(val name: String): Dog
data class Cat(val name: String): Cutie

fun fillWithPuppies(list: MutableList<in Puppy>) {	//상위 type 허용
    list.add(Puppy("Jim"))
    list.add(Puppy("Beam"))
}

val dogs = mutableListOf<Dog>(Hound("Pluto"))
fillWithPuppies(dogs)
println(dogs)
// [Hound(name=Pluto), Puppy(name=Jim), Puppy(name=Beam)]

val cuties = mutableListOf<Cutie>(Cat("Felix"))
fillWithPuppies(cuties)
println(cuties)
// [Cat(name=Felix), Puppy(name=Jim), Puppy(name=Beam)]

 

variance modifier(변성 제한자)를 사용할 때는 일부 위치가 제한됩니다.

 

MutableList<out T>

  • get을 사용하여 element를 가져올 수 있고 T 타입의 인스턴스를 받을 수 있음
  • set은 사용할 수 없음
    • 이유: set을 사용하면 Nothing타입의 인수도 전달할 수 있기 때문에
    • 이유: 타입 T의 subtype로 구성된 리스트가 Nothing을 포함한 subtype을 포함해 값을 넘길 수 있기 때문
    • *참고: Nothing은 모든 타입의 자식

MutableList<in T>

  • get, set 모두 사용은 가능하다.
  • 하지만! get을 사용할 때 Any?가 반환타입
    • 이유: Any?인 모든 타입의 supertype을 포함해서, T의 supertype의 리스트를 얻을 수 있기 때문
    • *참고:Any는 모든 타입의 조상

따라서 generic 객체에서 읽을 때만 out을 사용할 수 있고, 수정할 때만 in을 사용할 수 있습니다.

 


Summary

정리하자면 Kotlin에서는:

  • 공변(covariant; out modifier)한 타입 매개변수
    • List 및 Set의 타입
      예; List<Any>를 사용하면 아무 타입의 리스트를 넘길 수 있음
    • Map에서 value type
  • 무공변(invariant; no variance modifier)한 타입 매개변수
    • Array, MutableList, MutableSet, MutableMap
  • function type
    • 매개변수 타입은 반공변(contravariant; in modifier)
    • 리턴 타입은 공변(covariant; out modifier)
  • 공변(covariant; out modifier)타입을 쓰는 경우: 리턴되는 타입일 때 (produced 이거나 exposed)
  • 반공변(contravariance; in modifier)타입을 쓰는 경우: 오직 허용될 때 (accepted)

 

공변, 반공변, 무공변, 가변,,,, 아주 난리난리 혼란스럽네요

 

어쨌거나 잘 쓰면 유용할 것 같은 코틀린의 제네릭에 대해서 알아보았습니다.

사실 잘 몰랐던 부분인데, 헷갈리긴 하지만 흥미롭게 읽었던 것 같네요.

반응형

댓글