안드로이드 개발자 노트
[이펙티브 코틀린] Item24. 제네릭 타입과 variance 한정자를 활용하라 본문
다음과 같은 제네릭 클래스가 있다고 합시다.
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 nothing: Cup<Nothing> = Cup<Int>() // Error: type mismatch
}
만약에 어떤 관련성을 원한다면, out 또는 in이라는 variance 한정자를 붙입니다.
out은 타입 파라미터를 covariant(공변성)로 만듭니다.
이는 A가 B의 서브타입일때, Cup<A>가 Cup<B>의 서브타입이라는 의미입니다.
class Cup<out T>
open class Dog
class Puppy: Dog()
fun main() {
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 한정자는 타입 파라미터를 contravariance(반공변성)으로 만듭니다.
이는 A가 B의 서브타입일 때, Cup<A>가 Cup<B>의 슈퍼타입이라는 것을 의미합니다.
class Cup<in T>
open class Dog
class Puppy: Dog()
fun main() {
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
}
함수 타입
함수 타입은 파라미터 유형과 리턴 타입에 따라서 서로 어떤 관계를 갖습니다.
fun printProcessedNumber(transition: (Int)->Any) {
print(transition(42))
}
(Int)->Any 타입(Int를 받고, Any를 리턴하는 함수)의 함수는
(Int)->Number, (Number)->Any, (Number)->Number, (Number)->Int 등으로도 작동합니다.
val intToDouble: (Int) -> Number = { it.toDouble() }
val numberAsText: (Number) -> Any = { it.toShort() }
val identity: (Number) -> Number = { it }
val numberToInt: (Number) -> Int = { it.toInt() }
val numberHash: (Any) -> Number = { it.hashCode() }
printProcessedNumber(intToDouble)
printProcessedNumber(numberAsText)
printProcessedNumber(identity)
printProcessedNumber(numberToInt)
printProcessedNumber(numberHash)
코틀린 함수의 타입의 모든 파라미터 타입은 contravariant(반공변성)입니다.
또한 모든 리턴 타입은 covariant(공변성)입니다.
함수 타입을 사용할 때는 자동으로 variance 한정자가 사용됩니다.
자주 사용되는 List는 convariant(out 한정자)를 가집니다.
이는 variance 한정자가 붙지 않은 MutableList와 다릅니다.
어떠한 부분이 다른 것인지는 variance 한정자의 안정성과 관련된 내용을 이해하면 알 수 있습니다.
variance 한정자의 안정성
자바의 배열은 convariant입니다.
그러나 자바 배열이 convariant(공변성)라는 속성을 갖기 때문에 문제가 발생합니다.
// java code
Integer[] numbers = {1, 2, 3, 4};
Object[] objs = numbers;
object[2] = "B"; // Runtime에 ArrayStoreException이 발생
이 코드는 컴파일 중에 아무런 문제도 없지만 런타임 오류가 발생합니다.
numbers를 Object[]로 캐스팅해도 구조 내부에서 사용되고 있는 실질적인 타입이 바뀌는 것은 아니며, 여전히 Interger입니다.
따라서 이러한 배열에 String 타입의 값을 할당하면, 오류가 발생하게 됩니다.
이는 자바의 명백한 결함이며, 코틀린은 이러한 결함을 해결하기 위해서 Array(IntArray, CharArray 등)를 invariant(무공변성)로 만들었습니다.
따라서 Array<Int>를 Array<Any> 등으로 바꿀 수 없습니다.
파라미터 타입을 예측할 수 있다면, 어떤 서브타입이라도 전달할 수 있습니다.
따라서 아규먼트를 전달할 때, 암묵적으로 업캐스팅할 수 있습니다.
그럼 다음 코드를 살펴보겠습니다.
open class Dog
class Puppy: Dog()
class Hound: Dog()
fun takeDog(dog: Dog) { }
takeDog(Dog())
takeDog(Puppy())
takeDog(Hound())
이는 convariant(공변성)하지 않으며, convariant(공변성) 타입 파라미터(out 한정자)가 in 한정자 위치(예를 들어 타입 파라미터)에 있다면, convariant(공변성)와 업캐스팅을 연결해서, 우리가 원하는 타입을 아무것이나 전달할 수 있습니다.
즉, value가 매우 구체적인 타입이라 안전하지 않으므로, value를 Dog 타입으로 지정할 경우, String 타입을 넣을 수 없습니다.
open class Dog
class Puppy: Dog()
class Hound: Dog()
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<Puppy>()
val dogBox: Box<Dog> = puppyBox
dogBox.set(Hound()) // Puppy를 위한 공간이므로 문제 발생
val dogHouse = Box<Dog>()
val box: Box<Any> = dogHouse
box.set("Some String") // Dog를 위한 공간이므로 문제 발생
box.set(42) // Dog를 위한 공간이므로 문제 발생
캐스팅 후에 실질적인 객체가 그대로 유지되고, 타이핑 시스템에서만 다르게 처리되기 때문에 위의 코드는 안전하지 않습니다.
Int를 설정하려고 하는데, 해당 위치는 Dog만을 위한 자리이며, 만약 이것이 가능하다면, 오류가 발생할 것입니다.
코틀린은 public in 한정자 위치(private인 경우에는 컴파일에러가 발생하지 않음)에 covariant 타입 파라미터(out 한정자)가 오는 것을 금지하여 이러한 상황을 막습니다.
class Box<out T>
var value: T? = null // 오류
fun set(value: T) {
this.value = value // 오류
}
fun get(): T = value ?: error("value not set") // OK, get하는 위치
}
가시성을 private로 제한하면, 오류가 발생하지 않습니다.
객체 내부에서는 업캐스트 객체에 convariant(out 한정자)를 사용할 수 없기 때문입니다.
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(out 한정자)는 public out 한정자 위치에서도 안전하므로 따로 제한되지 않습니다.
이러한 안정성의 이유로 생성되거나 노출되는 타입에만 covariant(out 한정자)를 사용하는 것입니다.
이러한 프로퍼티는 일반적으로 producer 또는 immutable 데이터 홀더에 많이 사용됩니다.
좋은 예로 T는 covariant인 List<T>와 Response가 있습니다.
함수의 파라미터가 List<Any?>로 예측된다면, 모든 종류를 파라미터로 전달할 수 있습니다.
다만, MutableList<T>에서 T는 in 한정자 위치에서 사용되며, 안전하지 않으므로 invariant 입니다.
fun append(list: MutableList<Any>) {
list.add(42)
}
fun main() {
val strs = mutableListOf<String>("A","B","C")
append(strs) //Type mismatch. Required:MutableList<Any> Found:MutableList<String>
val str: String = strs[3]
print(str)
}
Response를 사용하면 다양한 이득을 얻을 수 있습니다.
오류 타입을 지정하지 않아도 되고, Success는 잠재적인 값을 지정하지 않아도 됩니다.
sealed class Response<out R, out E>
class Failure<out E>(val error: E): Response<Nothing, E>()
class Success<out E>(val value: E): Response<E, Nothing>()
variance 한정자의 위치
variance 한정자는 크게 두 위치에 사용할 수 있습니다.
- 선언부분: 일반적으로 이 위치에 사용합니다. 이 위치에서 사용하면 클래스와 인터페이스 선언에 한정자가 적용됩니다. 따라서 클래스와 인터페이스가 사용되는 모든 곳에 영향을 줍니다.
// 선언 쪽의 variance 한정자
class Box<out T>(val value: T)
val boxStr: Box<String> = Box("Str")
val boxAny: Box<Any> = boxStr
- 클래스와 인터페이스를 활용하는 위치: 이 위치에서 variance 한정자를 사용하면 특정한 변수에만 variance 한정자가 적용됩니다.
class Box<T> (val value: T)
val boxStr: Box<String> = Box("Str")
// 사용하는 쪽의 variance 한정자
val boxAny: Box<out Any> = boxStr
모든 인스턴스에 varaince 한정자를 적용하면 안 되고, 특정 인스턴스에만 적용해야 할 때 이런 코드를 사용합니다.
예를 들어 MutableList는 in 한정자를 포함하면, 요소를 리턴할 수 없으므로 in 한정자를 붙이지 않습니다.
하지만 단일 파라미터 타입에 in 한정자를 붙여서 contravariant를 가지게 하는 것은 가능하며, 여러 가지 타입을 받아들이게 할 수 있습니다.
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("Jim"))
list.add(Puppy("Beam"))
}
fun main() {
val dogs = mutableListOf<Dog>(Hound("Pluto"))
fillWithPuppies(dogs)
println(dogs)
// [Hound(name=Pluto), Puppy(name=Jim), Puppy(name=Beam)]
val animals = mutableListOf<Cutie>(Cat("Felix"))
fillWithPuppies(animals)
println(animals)
// [Cat(name=Felix), Puppy(name=Jim), Puppy(name=Beam)]
}
정리
- 타입 파라미터의 기본적인 variance의 동작은 invariant이다. 만약 Cup<T>라고 하면 타입 파라미터 T는 invariant이다. A가 B의 서브타입이라고 할 때, Cup<A>와 Cup<B>는 아무런 관계를 갖지 않습니다.
- out 한정자는 타입 파라미터를 covariant하게 만든다. 만약 Cup<T>라고 하면, 타입 파라미터 T는 covariant이다. A가 B의 서브타입이라고 할 때, Cup<A>는 Cup<B>의 서브타입이 된다. covariant 타입은 out 위치에 사용할 수 있다.
- in 한정자는 타입 파라미터를 contravariant하게 만든다. 만약 Cup<T>라고 하면, 타입 파라미터 T는 contravariant이다. A가 B의 서브타입이라고 할 때, Cup<A>는 Cup<B>의 슈퍼타입이 된다. contravariant 타입은 in 위치에 사용할 수 있다.
- List와 Set의 타입 파라미터는 covarint(out 한정자)이다. 예를 들어 List<Any>가 예상되는 모든 곳에 전달할 수 있다. 또한 Map에서 값의 타입을 나타내는 타입 파라미터는 covariant(out 한정자)이다. Array, MutableList, MutableSet, MutableMap의 타입 파라미터는 invariant(한정자 지정 없음)이다.
- 함수 타입의 파라미터 타입은 contravariant(in 한정자)이다. 그리고 리턴타입은 covariant(out 한정자)이다.
- 리턴만 되는 타입에는 covariant(out 한정자)를 사용한다.
- 허용만 되는 타입에는 contravaraint(in 한정자)를 사용한다.
'Kotlin > 이펙티브 코틀린' 카테고리의 다른 글
[이펙티브 코틀린] Item25. 공통 모듈을 추출해서 여러 플랫폼에서 재사용하라 (0) | 2023.11.19 |
---|---|
[이펙티브 코틀린] Item23. 타입 파라미터의 섀도잉을 피하라 (1) | 2023.11.11 |
[이펙티브 코틀린] Item22. 일반적인 알고리즘을 구현할 때 제네릭을 사용하라 (0) | 2023.11.11 |