이펙티브 코틀린 Item 24: 제네릭 타입과 variance 한정자를 활용하라

woga·2023년 7월 1일
0

코틀린 공부

목록 보기
27/54
post-thumbnail

다음과 같은 제네릭 클래스가 있다고 하자

class Cup<T>

위 코드에서 타입 파라미터 T는 variance 한정자(out 또는 in)가 없으므로, 기본적으로 invariant(불공변성)이다. invariant라는 것은 제네릭 타입으로 만들어지는 타입들이 서로 관련성이 없단 의미다.

예를 들어 Cup<Int> Cup<Number> Cup<Any> Cup<Nothing>은 어떤 관련성도 갖지 않는다.

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

만약에 어떤 관련성을 원한다면, out 또는 in이라는 variance 한정자를 붙인다.

out은 타입 파라미터를 convariant(공변성)로 만든다. 이는 A가 B의 서브타입일 때, Cup<A>Cup<B>의 서브타입이라는 의미다.

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 한정자는 반대다. in 한정자는 타입 파라미터를 contravariant로 만든다. 이는 A가 B의 서브타입일 때, Cup<A>Cup<B>의 슈퍼타입이라는 것을 의미한다.

class Cup<out 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
}

함수 타입

함수 타입은 아이템 35에서 자세하게 설명하겠지만 이 타입은 파라미터 유형과 리턴 타입에 따라서 서로 어떤 관계를 갖는다. 예를 들어 Int를 받고, Any를 리턴하는 함수를 파라미터로 받는 함수를 생각해보자.

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

(Int)->Any 타입의 함수는 (Int)->Number, (Number)->Any, (Number)->Number, (Number)->Int 등으로도 작동한다.

코틀린 함수 타입의 모든 파라미터 타입은 contravariant이다. 또한 모든 리턴 타입은 covariant이다.

함수 타입을 사용할 때는 이처럼 자동으로 variance 한정자가 사용된다. 코틀린에서는 자주 사용되는 것으로는 convariant(out 한정자)를 가진 List가 있다.

이는 variance 한정자가 붙지 않은 MutableList와 다르다. 왜 MutableList보다 List를 더 많이 사용하는지, 그리고 어떠한 부분이 다른 것인지는 variance 한정자와 안정성과 관련된 내용을 이해하면 알 수 있다.

variance 한정자의 안정성

자바의 배열은 covariant이다. 많은 출처에 따르면 배열을 기반으로 제네릭 연산자는 정렬 함수 등을 만들기 위해서라고 이야기한다. 그런데 자바의 배열이 convariant라는 속성을 갖기 때문에 큰 문제가 발생한다.

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

numbers를 Object[]로 캐스팅해도 구조 내부에서 사용되고 있는 실질적인 타입이 바뀌는 것은 아니다.(여전히 Integer) 따라서 이러한 배열에 String 타입의 값을 할당하면, 오류가 발생한다.

이는 자바의 명백한 결함이다. 코틀린은 이러한 결함을 해결하기 위해 Array(IntArray, CharArray 등)를 invariant로 만들었다(따라서 Array<Int>Array<Any> 등으로 바꿀 수 없다)

-사실 지금 이 글을 적고 있는 나는 명확하게 왜 결함으로 치부하는지 이해가 잘 되진 않는다. 너무 당연하게 인지하고 써서 그런지 몰라도 아마 영어쓰는 입장에선 object라서 다 포용하는 슈퍼타입이니깐 괜찮을 줄 알았는데 에러가 나니깐 그런가 싶기도 하고..-

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

fun takeDog(dog: Dog) {}

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

이 코드를 보자. 파라미터 타입을 예측할 수 있다면 어떤 서브타입이라도 전달할 수 있다. 따라서 아규먼트를 전달할 때 암묵적으로 업캐스팅할 수 있다.

이는 covariant하지 않는다. covariant 타입 파라미터(out 한정자)가 in 한정자 위치(타입 파라미터 같은 경우)에 있다면, covariant와 업캐스팅을 연결해서, 우리가 원하는 타입을 아무것이나 전달할 수 있다. 즉, value가 매우 구체적인 타입이란 안전하지 않으므로 value를 Dog 타입으로 지정할 경우 String 타입을 넣을 수 없다.

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

val puppyBox = Box<Poppy>()
val dogBox: Box<Dog> = puppyBox
dogBox.set(Hound()) // but it's space for Puppy

val dogHouse = Box<Dog>()
val box: Box<Any> = dogHouse
box.set("Some string") // but it's space for Dog
box.set(42) // but it's space for Dog

이런 상황은 안전하지 않는다. 캐스팅 후에 실질적인 객체가 그대로 유지되고, 타이핑 시스템에서만 다르게 처리되기 때문이다. Int 설정하려고 하는데, 해당 위치는 Dog만을 위한 자리다. 만약 이것이 가능하다면 오류가 발생할 것이다.
그래서 코틀린은 public in 한정자 위치에 covariant 타입 파라미터(out 한정자)가 오는 것을 금지하여 이러한 상황을 막는다.

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

가시성을 private로 제한하면, 오류가 발생하지 않는다. 객체 내부에서는 업캐스트 객체에 covariant를 사용할 수 없다

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")
}

covariant는 public out 한정자 위치에서도 안전하므로 따로 제한되지 않는다. 이러한 안정성의 이유로 생성되거나 노출되는 타입에만 covariant를 사용하는 것이다. 이러한 프로퍼티는 일반적으로 producer or immutable 데이터 홀더에 많이 사용된다.

좋은 예로 T는 covariant인 List<T>가 있다. 지금까지 설명한 이유로 함수의 파라미터가 List<Any?>로 예측된다면, 별도의 변환 없이 모든 종류를 파라미터로 전달할 수 있다.
다만 MutableList<T>에서 T는 in 한정자 위치에서 사용되며, 안전하지 않으므로 invariant이다.

fun append(list: MutableList<Any>) {
	list.add(42)
}

val strs = mutableListOf<String>("A", "B", "C")
append(strs) // not use in kotlin
val str: String = strs[3]
print(str)

다른 좋은 예로는 Response가 있다. 이를 사용하면 다양한 이득을 얻을 수 있다. variance 한정자 덕분에 이 내용은 모두 참이 된다.

  • Response<T>라면 T의 모든 서브타입이 허용된다. 예를 들으 Response<Any>가 예상된다면, Response<Int>Response<String>이 허용된다.

  • Response<T1, T2>라면 T1과 T2의 모든 서브타입이 허용된다.

  • Failure<T>라면, T의 모든 서브타입 Failure가 허용된다. 예를 들어 Failure<Number>라면, Failure<Int>Failure<Double>이 모두 허용된다. Failure<Any>라면, Failure<Int>Failure<String>이 모두 허용된다.

sealed class Response<out R, out E>
class Failure<out E>(val error: E): Response<Nothing, E>()
class Success<out R>(val value: R): Response<R, Nothing>()

Nothing타입과 covariant로 인해서 Failure는 오류 타입을 지정하지 않아도 되고, Success는 잠재적인 값을 지정하지 않아도 된다.

covariant와 public in 위치와 같은 문제는 contravariant 타입 파라미터(in 한정자)와 public out 위치(함수 리턴 타입 또는 프로퍼티 타입)에서도 발생한다. out 위치는 암묵적인 업캐스팅을 허용한다

open class Car
interface Boat
class Amphibious: Car(), Boat

fun getAmphibious(): Amphibious = Amphibious()

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

사실 이는 contravariant(in 한정자)에 맞는 동작이 아니다. 다음 코드를 보면, 어떤 상자에 어떤 타입이 들어 있는지 확실하게 알 수 없다.

class Box<in T>(
	// no using in kotlin
    val value: T
)

val garage: Box<Car> = Box(Car())
val amphibiousSpot: Box<Amphibious> = garage
val boat: Boat = garage.value // but it's space for Car

val noSpot: Box<Nothing> = Box<Car>(Car())
val boat: Nothing = noSopt.value // make Nothing

이러한 상황을 막기 위해, 코틀린은 contravariant 타입 파라미터(in 한정자)를 public out 한정자 위치에 사용하는 것을 금지하고 있다.

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

물론 이번에도 private이면 아무 문제 없다.

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")
}

이런 형태로 쓰이는 예로는 kotlin.coroutines.Continuation이 있다.

variance 한정자의 위치

variance 한정자는 선언 부분클래스 인터페이스를 활용하는 위치에 사용할 수 있다.

일반적으로 선언 부분 위치에 사용하며, 클래스와 인터페이스가 사용되는 모든 곳에 영향을 준다.

// 선언 쪽의 variance 한정자
class Box<out T>(val value: T)
val boxAny: Box<String> = Box("Str")
val boxAny: Box<Any> = boxStr

클래스와 인터페이스를 활용하는 위치에는 variance를 적용하면 특정 변수에만 variance 한정자가 적용된다. 특정 인스턴스에만 적용해야 하는 경우 사용한다.

class Box<T>(val value: T)
val boxAny: Box<String> = Box("Str")
// 사용하는 쪽의 variance 한정자
val boxAny: Box<out Any> = boxStr

MutableList에 in 한정자를 포함하면 요소의 리턴이 불가능하므로 붙이지 않는다.
하지만 여러 타입을 받아들이게 하기 위해 아래와 같이 단일 파라미터 타입에 in을 붙여 활용할 수 있다.

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>) {
    list.add(Puppy("One"))
    list.add(Puppy("Two"))
}

fun main() {
    val dogs = mutableListOf<Dog>(Hound("Pluto"))
    fillWithPuppies(dogs)

    val cats = mutableListOf<Cutie>(Cat("Pluto"))
    fillWithPuppies(cats)
}

variance 한정자를 사용하면 아래처럼 제한이 있을 수 있다.
MutableList get() 요소 추출 시 T 타입이 리턴되지만 set은 Nothing 타입의 아규먼트가 전달될 수도 있을것으로 예상되므로 사용할 수 없다.
MutableList 사용 시 get과 set을 모두 사용할 수 있지만 get()을 사용할 경우 Any?가 리턴된다. 이는 슈퍼타입을 가진 리스트가 존재할 가능성이 있기 때문이다.

정리

코틀린은 타입 아규먼트의 관계에 제약을 걸 수 있는 굉장히 강력한 제네릭 기능을 제공한다. 객체를 연산할 때 다양한 지원을 받을 수 있고 아래와 같은 타입 한정자가 존재한다.

  • 기본적인 variance의 동작은 invariant이다. Cup<A>Cup<B>는 기본적으로는 아무런 관계를 갖지 않는다.

  • out 한정자는 타입 파라미터를 covariant하게 만든다. covariant 타입은 out 위치에 사용할 수 있다.

  • in 한정자는 타입 파라미터를 contravariant하게 만든다. contravariant 타입은 in 위치에 사용할 수 있다.

코틀린에서는

  • List, Set의 타입 파라미터는 covariant(out) 이다.

  • Array, MutableList, MutableSet의 타입 파라미터는 invaraint이다.

  • 함수 타입의 파라미터 타입은 contravariant(in)이다.

  • 리턴 타입의 파라미터 타입은 covariant(out) 이다. 책에는 contravariant로 되어 있음 임의로 수정

  • 리턴만 되는 타입에는 covariant(out)을 사용한다.

  • 허용만 되는 타입에는 contravariant(in)을 사용한다.

profile
와니와니와니와니 당근당근

0개의 댓글