Kotlin In Action 9장

제네릭스

이번 시간에는 제네릭스를 공부해볼 것이다.

자바의 제네릭스와 코틀린의 제네릭스는 어떤 점이 달라졌는지, reified type parameterdeclaration-site variance등의 새로운 개념에 대해서도 학습할 것이다.

제네릭 타입 파라미터

제네릭스를 사용하면 타입 파라미터를 받는 타입을 정의할 수 있다.

제네릭 타입의 인스턴스를 만들기 위해서는 타입 파라미터를 구체적인 타입 인자로 치환해야한다.

일반적으로 사용은 자바와 거의 일치하나 차이점이 하나 있다.

코틀린은 반드시 제네릭 타입의 타입 인자를 정의해야한다는 규약이 있다.

왜 이런 규약이 있을까?

자바의 경우 최초에는 제네릭이라는 개념이 없었다.

이후 업데이트를 통해 1.5에서 처음 제네릭이 나왔고, 이로 인해 자바에서는 컬렉션을 선언할 때 원소 타입을 지정하지 않아도 컬렉션을 생성할 수 있었다.

그러나 코틀린은 처음부터 제네릭을 도입했기 때문에 반드시 제네릭 타입의 인자를 정의해주어야(프로그래머가 정의하든, 타입 추론에 의해 정의되든) 사용할 수 있다.

또한 주의해야할 점은 제네릭은 확장 프로퍼티에서만 사용가능하다는 점이다.

일반 프로퍼티는 타입 파라미터를 갖는 것이 불가능하다. 클래스 프로퍼티에 여러 타입의 값을 저장하는 것은 말이 안되므로, 제네릭한 일반 프로퍼티는 말이 안된다는 것을 알 수 있다.

일반 프로퍼티를 제네릭하게 정의하면 컴파일러가 다음과 같은 오류를 뱉는다.

ERROR: type parameter of a property must be used in its receiver type

제네릭 클래스 선언

자바와 마찬가지로 꺽쇠 기호(<>)를 사용하여 클래스나 인터페이스를 제네릭하게 만들 수 있다.

일반적으로 자바에서의 제네릭과 코틀린의 제네릭은 큰 차이가 없다.

그러나 지금부터는 자바와 비슷하거나 다른 점들에 대해서 설명하도록 하겠다.

타입 파라미터 제약

type paramter constraint는 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.

가령 모든 원소의 합을 구하려고 했을 때 List<Int>List<Double>은 사용하는데 문제가 없지만, List<String>의 경우 문제가 될 수 있다.

이 경우 타입 파라미터로 숫자 타입 만을 허용하게 하여 조건을 지정해줄 수 있는 기능이 필요할 수도 있다.

어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상한(upper bound)를 지정하면 그 제네릭 타입을 인스턴화할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나 그 상한 타입의 하위 타입이어야 한다.

제약을 가하려면 타입 파라미터 이름 뒤에 콜론을 표시하고 그 뒤에 상한 타입을 적으면 된다.

자바에서는 아래와 같이 사용했다.

<T extends Number> T sum(List<T> list)

코틀린은 아래와 같이 표현한다.

fun <T: Number> List<T>.sum(): T

타입 파라미터 T에 대한 상한을 정하고 나면 T 타입의 값을 그 상한 타입의 값으로 취급할 수 있다.

Number Class

스크린샷 2020-05-02 오후 7 10 06

자바의 Number

스크린샷 2020-05-02 오후 7 10 09

코틀린 Number

차이는 toChar이 추가된 것 외에는 자바와 동일

타입 파라미터 제약이 둘 이상인 경우

드물지만 타입 파라미터에 의해 둘 이상의 제약을 가해야 하는 경우도 있다.

다음 코드는 맨 끝에 마침표(.)가 있는지 검사하는 제네릭 함수이다.

fun <T> ensureTrailingPeriod (seq: T)
    where T: CharSequence, T: Appendable {
    if(!seq.endsWith('.')){
        seq.append('.')
    }
}

fun main() {
    val helloWorld = StringBuilder("Hello World")
    ensureTrailingPeriod(helloWorld)
    println(helloWorld)
}
// output
Hello World.

이 예제는 타입 인자가 CharSequence와 Appendable 인터페이스를 반드시 구현해야한다는 사실을 표현한다.

여기서 한가지 where이라는 낯선 키워드를 봐서 당황했을 것이다.

where은 제네릭에서 사용하는 키워드인데, 둘 이상의 상한(upper bound) 조건이 필요한 경우 where 키워드를 사용해서 명시하는 것이 가능하다.

타입 파라미터 제약을 자주 쓰는 다른 예시는 널이 될 수 없는 타입으로 파라미터를 한정하고 싶은 경우에 사용할 수 있다.

타입 파라미터를 널이 될 수 없는 타입으로 한정

아무런 타입 상한을 정하지 않은 타입 파라미터는 결과적으로 Any?를 상한으로 정한 파라미터와 같다.

다음을 보자.

class Processor<T> {
    fun process(value: T){
        value?.hashCode()
    }
}

여기서 value는 널이 될 수 있는 타입이므로 안전한 호출을 사용했다.

val nullableStringProcessor = Processor<String?>()
nullableStringProcessor.process(null)

만약 여기에 널 가능성을 제외한 아무런 제약이 필요 없다면 간단히 Any를 상한으로 사용하면 된다.

class Processor<T:Any> {
    fun process(value: T){
        value.hashCode()
    }
}

이제부터 자바 개발자에게 낯익은 다른 주제를 살펴보자.

그 주제는 실행 시점에 제네릭스가 어떻게 동작하는가에 대해서 이다.

실행 시 제네릭스의 동작 : type erasure parameter 와 reify type parameter

JVM의 제네릭스는 보통 타입 소거를 사용해 구현된다.

이는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 것을 의미한다.

이번 절에서는 코틀린 타입 소거가 실용적인 면에서 어떤 영향을 끼치는지, 함수를 inline으로 선언함으로써 이런 제약을 어떻게 우회하는지 보도록 하겠다.

실행 시점의 제네릭 : 타입 검사와 캐스트

자바와 마찬가지로 코틀린 제네릭 타입의 인자 정보는 런타임에 지워진다.

이는 제네릭 클래스 인스턴스가 그 인스턴스를 생성할 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다는 뜻이기도 하다.

가령 다음과 같은 두 리스트를 상상해보라.

val list1: List<String> = listOf("a","b")
val list2: List<Int> = listOf(1,2,3)

컴파일러는 두 리스트를 서로 다른 타입으로 인식하지만 실행 시점에 그 둘은 완전히 같은 타입 객체이다.

그럼에도 불구하고 보통은 문자열과 정수로 들어있다고 가정할 수 있는데, 이는 컴파일러가 타입 인자를 알고 올바른 타입의 값만 각 리스트에 넣도록 보장해주기 때문이다.

그러면 다음은 타입 소거로 인한 한계를 살펴보자.

타입 인자를 따로 저장하지 않으므로 실행 시점에는 타입 인자를 검사할 수 없다.

타입 소거로 인한 장점은 저장해야하는 타입의 정보의 크기가 줄어들어 전반적인 메모리 사용량이 줄어든다는 것이 제네릭 타입 소거의 장점이라고 하겠다.

어쨌든, 코틀린에서는 타입 인자를 명시하지않고 제네릭 타입을 사용할 수 없다.

그러면 특정 값이 집합이나 맵이 아니라 리스트라는 사실을 어떻게 확인할 수 있을까?

바로 스타 프로젝션 Star Projection을 사용하면 된다.

이 스타 프로젝션에 대해서는 뒤에서 좀 더 자세히 다루도록 하겠다.

현재는 인자를 알 수 없는 제네릭 타입을 표현할 때 (자바의 List<?>과 유사) 스타 프로젝션을 사용한다는 것만 알아두자.

if(value is List<*>) { ... }

위 예제에서 value가 List임은 알 수 있으나 그 원소의 타입까지는 알 수 없다.

as나 as? 캐스팅에도 제네릭 타입을 사용할 수 있다.

fun printSum(c: Collection<*>) {
    val intList = c as? List<Int>
        ?: IllegalArgumentException("List is expected")
    println(intList.sum())
}

printSum(listOf(1,2,3))

그러나 기저 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 캐스팅을 성공시켜준다는 사실에 유의해야한다.

컴파일러가 unchecked cast라는 경고를 내긴 하지만 항상 성공한다.

캐스팅 관련 경고를 제외하면 모든 문제가 컴파일되는데 이 책의 옮긴이의 말에 따르면 코틀린 REPL은 아직 더 개선할 여지가 많다고 한다.

printSum(setOf(1,2,3))
IllegalArgumentException : List is expected

printSum(listOf("a","b","c"))
ClassCastException: String cannot be cast to Number

여기 두 개의 예제가 있다.

위 예제에서 첫 번째 예제의 경우 정수 집합에 대해서는 IllegalArgumentException를 출력하는 것을 확인할 수 있다.

우리가 눈여겨봐야하는 부분은 두 번째 예제인데, List<int>인지 검사할 수 없으므로 IllegalArgumentException이 발생하지는 않는다.

따라서 as? 캐스트가 성공하고 문자열 리스트에 대해 sum 함수가 호출된다.

하지만 sum이 실행되는 도중 예외가 발생한다 sum은 Number 타입의 값을 리스트에서 가져와 더하려고 시도하지만 String을 Number로 사용하려고 하면 실행 시 ClassCastException이 발생한다.

실체화한 타입 파라미터를 사용한 함수 선언

컴파일 타임에 제네릭 타입의 인자 정보가 지워짐으로 인해 제네릭 클래스의 인스턴스가 있어도 그 인스턴스를 만들 때 사용한 타입 인자를 알아낼 수가 없다.

이는 제네릭 함수도 예외가 아니다.

fun <T> isA(value:Any) = value is T
Error: Cannot check for instance of erased type: T

이런 제약을 피할 수 있는 방법이 하나 있는데, 인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.

앞서 만든 함수를 인라인 함수로 만들고 타입 파라미터를 reified로 지정하면 value 타입이 T의 인스턴스인지를 실행 시점에 검사할 수 있다.

inline fun <reified T> isA(value:Any) = value is T
println(isA<String>("abc"))
// true
println(isA<String>(123))
// false

reified를 활용하는 가장 간단 예제 중 하나는 표준 라이브러리 함수인 filterIsInsance이다.

이 함수는 인자로 받은 컬렉션의 원소 중에서 타입 인자로 지정한 클래스의 인스턴스만을 모아서 만든 리스트를 반환한다.

다음 예제는 filterInstance 사용법을 보여준다.

val items = listOf("one",2,"three")
println(items.filterInstance<String>())
[one,three]

이 함수는 실행 시점에 타입 인자를 알아냈고 그 타입 인자를 사용해 리스트의 원소 중 타입 인자와 일치하는 원소만을 추려냈다.

그러면 인라인 함수에서만 reified type argument를 사용할 수 있는 이유가 무엇일까? reified type argument는 어떻게 동작하며 왜 일반 함수에서는 element is T를 사용할 수 없고 인라인 함수에서만 사용할 수 있을까?

앞서 배웠듯이 컴파일러는 인라인 함수의 본문을 구현한 바이트코드를 그 함수가 호출되는 모든 시점에 삽입한다.

컴파일러는 reified type argument를 통해 인라인 함수를 호출하는 각 부분의 정확한 타입을 모두 알아낼 수 있다.

따라서 컴파일러는 타입 인자로 쓰인 구체적인 클래스를 참조하는 바이트코드를 생성해 삽입할 수 있다.

타입 파라미터가 아니라 구체적인 타입을 사용하기 때문에 만들어진 바이트코드는 실행 시점에 벌어지는 타입 소거의 영향을 받지 않는다.

단, 자바 코드에서는 reified 타입 파라미터를 사용하는 inline 함수를 호출할 수 없다.

자바에서는 코틀린 inline 함수를 일반 함수처럼 호출한다. 그 경우 인라인 함수를 호출해도 실제로 인라이닝이 되지 않는다.

reified 타입 파라미터가 있는 함수의 경우 타입 인자를 바이트코드에 넣기 위해 일반 함수보다 더 많은 작업이 필요하고, 항상 인라이닝 되어야만 한다.

따라서 reified 타입 파라미터가 있는 인라이닝 함수를 일반 함수처럼 자바에서는 호출할 수 없다. 이것이 그 이유이다.

우리는 앞서 인라인 함수는 람다를 파라미터로 받는 경우 사용하면 유용하다고 알고 있다.

그러나 reified 타입 파라미터를 사용하기 위해 inline을 사용했다는 사실을 명심하라.

성능을 좋게 하고 싶거든 inline 함수의 크기를 계속 관찰하라.

함수가 커지면 reified 타입에 의존하지 않는 부분을 별도의 일반 함수로 뽑아내는 편이 더 낫다.

실체화한 타입 파라미터로 클래스 참조 대신

java.lang.Class 타입 인자를 파라미터로 받는 API에 대한 코틀린 어댑터를 구축하는 경우 reified 타입 파라미터를 자주 사용한다.

java.lang.Class를 자주 사용하는 API의 예로는 JDK의 ServiceLoader가 있다.

ServiceLoader는 어떤 추상 클래스나 인터페이스를 표현하는 java.lang.Class를 받아서 그 클래스나 인스턴스를 구현한 인스턴스를 반환한다.

val serviceImpl = ServiceLoader.load(Service::class.java)

::class.java는 코틀린 클래스에 대응하는 java.lang.Class 참조를 얻는 방법이다.

이는 10장에서 리플렉션에 대해 설명할 때 더 자세히 다루도록 하겠다.

이 예제를 reified 타입 파라미터를 사용해 다시 작성해보자.

val serviceImpl = loadService<Service>()

훨씬 간결해졌다. 읽을 서비스 클래스를 loadService 함수의 타입 인자로 지정하면 ::class.java라고 쓰는 경우보다 훨씬 읽고 이해하기 쉽다.

아래는 loadService() 함수의 내용이다.

inline fun <reified T> loadService() {
    return ServiceLoader.load(T::class.java)
}

reified 타입 파라미터의 제약

reified 타입 파라미터는 유용한 도구이지만 몇 가지 제약이 존재한다.

일부는 실체화의 개념으로 인한 생기는 제약이며 나머지는 지금 코틀린이 실체화를 구현하는 방식에 의한 제약이며 향후 개선될 가능성이 있다.

reified 타입 파라미터는 다음과 같은 경우 사용할 수 있다.

  • 타입 검사와 캐스팅 (is,!is,as,as?)

  • 10장에서 설명할 코틀린 리플렉션 API(::class)

  • 코틀린 타입에 대응하는 java.lang.Class 얻기 (::class.java)

  • 다른 함수를 호출할 때 타입 인자로 사용

하지만 다음과 같은 작업은 불가능

  • 타입 파라미터 클래스의 인스턴스 생성하기

  • 타입 파라미터 클래스의 동반 객체 메소드 호출하기

  • reified 타입 파라미터를요구하는 함수를 호출하면서 reified 하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기

  • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기

Variance : 제네릭과 하위 타입

변성(Variance) 개념은 List<String>List<Any>의 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지를 설명하는 개념이다.

Variance가 있는 이유 : 인자를 함수에 넘기기

List<Any> 타입의 파라미터를 받는 함수에 List<String>을 넘기면 안전할까?

안전하다.

그러나 Any와 String이 List 인터페이스의 타입 인자로 들어가는 경우라면 안정성을 확신하기 힘들다.

예제를 하나 보자.

fun printContents(list: List<Any>){
    println(list.joinToString())
}
printContents(listOf("abc","bac"))

이 경우 문자열 리스트도 잘 작동한다.

이 함수는 각 원소를 Any로 취급하며 모든 문자열은 Any 타입이기도 하므로 완전히 안전하다.

리스트를 변경하는 다른 함수를 살펴보자.

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

val strings = mutableListOf("abc","bac")
addAnswer(strings) // 이 줄이 컴파일 된다면
println(strings.maxBy { it.length }) // 실행 시 여기서 예외가 발생

위 함수는 에러를 발생시킨다.

어떤 함수가 리스트이 원소를 추가하거나 변경한다면 타입 불일치가 발생할 수 있어서 List<Any> 대신 List<string>을 넘길 수 없다.

하지만 그런 경우가 아니라면 괜찮다. ( 왜 그런지에 대해서는 나중에 설명 )

클래스, 타입, 하위 타입

지금부터는 하위 타입(subtype)에 대해서 학습해보자.

타입 사이의 관계를 논하기 위해 subtype에 대한 개념에 대해서 잘 알고 있어야한다.

어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 B는 A의 subtype이다.

예를 들어, Int는 Number의 subtype이지만 String의 subtype은 아니다.

supertype은 정확히 반대의 개념이다.

한 타입이 다른 타입의 subtype인지가 왜 중요할까?

컴파일러는 사실 변수 대입이나 함수 인자 전달 시 subtype 검사를 매번 수행한다.

fun test(i:int) {
    val n:Number = i // 성공

    fun f(s: String) { ... }
    f(i) // 실패
}

간단한 경우라면 subtype은 마치 subclass와 근본적으로 같다.

널이 될 수 없는 타입은 널이 될 수 있는 타입의 subtype이다.

자 갑자기 왜 subtype 얘기를 하고 있는지 의아할 것이다.

우리는 앞의 문제로 돌아가서

List<String> 타입의 값을 List<Any>를 파라미터로 받는 함수에 전달해도 괜찮을까?

에 대한 질문을 subtype 관계로 다시 써보면

List<String> 타입은 List<Any>의 subtype인가?

로 귀결된다.

우리는 앞서 MutableList<String>MutableList<Any>의 subtype으로 다루면 안되는지에 대해 알아보았다.

제네릭 타입을 인스턴화 할 때 타입 인자로 서로 다른 타입이 들어간다고 가정하자.

인스턴스 타입 사이의 subtype 관계가 성립하지 않으면 그 제네릭 타입을 invariant(무공변)이라고 말한다.

자바에서는 모든 클래스가 invariant하다.

코틀린의 List 인터페이스는 읽기 전용 컬렉션을 표현한다.

A가 B의 subtype이라면 List<A>List<B>의 subtype이다.

이런 클래스나 인터페이스를 covariant(공변적)이라고 말한다.

covariant : 하위 타입 관계 유지

Producer<T>를 예로 covariant 클래스를 설명해보자.

A가 B의 subtype일 때 Producer<A>Producer<B>의 subtype이고, Producercovariant 하다고 말할 수 있다.

코틀린에서 제네릭 클래스가 타입 파라미터에 대해 covariant임을 표시하려면 타입 파라미터 이름 앞에 out을 넣어야한다.

interface Producer<out T> {
    fun produce() : T
}

클래스의 타입 파라미터를 covariant하게 만들면 함수 정의에 사용한 파라미터 타입과 타입 인자의 파라미터 타입이 일치하지 안하도 그 클래스의 인스턴스를 함수 인자나 반환 값으로 사용할 수 있다.


open class Animal {
    fun feed() {
        println("마이쪙!")
    }
}

class Herd<T : Animal> (val size:Int, private val animal: T){
    operator fun get(i: Int): T = when(i) {
        0 -> animal
        else -> throw IndexOutOfBoundsException("Invalid coordinate $i")
    }

}

fun feedAll(animals:Herd<Animal>){
    for (i in 0 until animals.size) {
        animals[i].feed()
    }
}

class Cat: Animal() {
    fun meow() {
        println("나 핵 배고파")
    }
}

fun takeCareOfCats(cats: Herd<Cat>) {
    for (i in 0 until cats.size) {
        cats[i].meow()
        feedAll(cats) // 에러 발생
    }
}

fun main() {
    takeCareOfCats( Herd(1,Cat()) )
}

불행히도 위 예제는 실행되지 않는다.

feedAll 함수에 고양이 무리를 넘기면 타입 불일치를 발생시키기 때문이다.

물론 이 문제를 풀기 위해 타입 캐스팅을 사용할 수 있지만, 코드가 복잡해지고 실수를 하기 쉽다.

게다가 타입 불일치를 해결하기 위해 강제 캐스팅을 하는 것은 올바른 문제해결 방법이 아니다.

이를 해결하기 위해 out을 사용하여 covariant하게 만들어줄 수 있다.

class Herd<out T : Animal> (val size:Int, private val animal: T){
    operator fun get(i: Int): T = when(i) {
        0 -> animal
        else -> throw IndexOutOfBoundsException("Invalid coordinate $i")
    }

}

Herd의 T 앞에 out을 붙여주는 것 만으로 코드가 정상적으로 작동하는 것을 확인할 수 있다. (신기하지 않은가!?)

그렇다고 해서 모든 클래스를 covariant하게 만들어야하는 것을 의미하지는 않는다.

여기서 out 키워드가 갖는 의미는 클래스가 T 타입의 값을 생산할 수는 있지만 소비할 수 없다는 것을 의미한다.

클래스 멤버를 선언할 때 타입 파라미터를 사용할 수 있는 지점은 모두 in과 out으로 나뉜다.

만약 T가 함수의 반환 타입에 쓰인다면 T는 out인 경우이다.

그게 아니라 T가 함수의 파라미터 타입에 쓰인다면 T는 in인 경우이다.

이 함수는 T 타입의 값을 consume한다.

이 in,out을 적절히 사용하면 T의 사용법을 제한하여 이로 인해 생기는 subtype 관계의 타입 안정성을 보장해줄 수 있다.

당연히 Mutable의 경우 covariant하지 않는다. (앞에서 살펴보았다.)

생성자 파라미터는 in,out 어느 쪽에도 해당되지 않는다는 사실을 기억하자.

variance는 코드에서 위험할 여지가 있는 메소드를 호출할 수 없게 만듦으로써 제네릭 타입의 인스턴스 역할을 하는 클래스 인스턴스를 잘못 사용하는 일이 없게 방지하는 역할을 한다.

생성자는 나중에 호출하는 메소드가 아니므로 위험할 여지가 없다.

하지만 val이나 var 키워드를 생성자 파라미터에 적는다면 게터나 세터를 정의하는 것과 같으므로 읽기 전용 프로퍼티의 경우 out, 변경 가능 프로퍼티의 경우 out, in 모두에 해당한다.

contravariance : 뒤집힌 하위 타입 관계

contravariance는 covariant를 거울에 비친 모습이라고 생각하면 된다.

contravariance 클래스의 subtype 관계는 covariant와는 반대다.

아래의 예시를 보자

interface Comparator<in T> {
    fun compare(e1: T, e2: T) : Int { ... }
}

compare 메소드에서 T 타입의 값을 소비하므로 in을 붙여야만한다.

어떤 타입에 대해 Comparator를 구현하면 그 타입의 subtype에 속하는 모든 값을 비교할 수 있다.

예를 들어 Comparator<Any>가 존재하면 이를 사용해 모든 타입의 값을 비교할 수 있다.

val anyComparator = Comparator<Any> {
    e1, e2 -> e1.hashCode() - e2.hashCode()
}

val strings:List<String> = ...
strings.sortedWith(anyComparator)

여기서 sortedWith는 Comparator<String>을 요구하므로 String보다 더 일반적인 타입을 비교할 수 있는 Comparator를 sortedWith에 넘기는 것이 안전하다.

어떤 타입의 객체를 Comparator로 비교해야 한다면 그 타입이나 그 타입의 조상 타입을 비교할 수 있는 Comparator를 사용할 수 있다.

이는 Comparator<Any>Comparator<String>의 subtype이라는 것을 의미한다.

그러나 Any는 String의 supertype이다.

즉, 서로 다른 타입 인자에 대해 Comparator의 subtype 관계는 타입 인자의 subtype 관계와는 정 반대의 관계임을 알 수 있다.

이제야 contravariance에 대해 이야기할 준비가 되었다.

예시를 들어보겠다.

Cat<T>를 예로 들겠다.

만약 타입 B가 A의 subtype이라고 가정하고, Cat<A>Cat<B>의 subtype이 성립하면 제네릭 클래스 Cat<T>는 타입 인자 T에 대해 contravariance하다고 할 수 있겠다.

이를 요악해서 표로 표현해보면 다음과 같다.

covariantcontravarianceinvariant
Producer<out T>Consumer<in T>MutableList
타입 인자의 subtype 관계가 제네릭 타입에서도 유지타입 인자의 subtype 관계가 제네릭 타입에서 역전subtype 관계 성립 안됨
Producer<Cat>Producer<Animal>의 subtypeConsumer<Animal>Consumer<Cat>의 subtype 
T를 out 위치에만 사용 가능T를 in 위치에만 사용 가능T를 아무데나 사용 가능

사용 지점 variance : 타입이 언급되는 지점에서 variance 지정

클래스를 선언하면서 variance를 지정하면 그 클래스를 사용하는 모든 장소에 variance 지정자가 영향을 끼치므로 편리하다.

이런 방식을 declaration site variance라고 부른다.

자바의 와일드카드 타입(? extends나 ? super)에 익숙하다면 자바는 variance를 다른 방식으로 다룬다는 점을 깨달았을 것이다.

자바에서는 타입 파라미터가 있는 타입을 사용할 때마다 해당 타입 파라미터의 subtype이나 supertype 중 어떤 타입으로 대치할 수 있는지 명시해야한다.

이런 방식을 use-site variance라고 부른다.

자바에서는 Function<? super T, ? extends R>과 같이 와일드카드를 사용해야한다.

하지만 코틀린처럼 클래스 선언 지점에서 variance를 한번만 지정하면 더 간결하고 우아한 코드를 작성할 수 있다.

코틀린도 use-site variance를 지원한다.

따라서 클래스 안에서 어떤 타입 파라미터가 covariant이거나 contravariance인지 선언할 수 없는 경우에도 특정 타입 파라미터가 나타나는 지점에서 variance를 정할 수 있다.

대표적인 예시가 MutableList이다.

이러한 인터페이스는 타입 파라미터로 지정된 타입을 소비하면서 동시에 생산한다.

하지만 이런 인터페이스 타입의 변수가 한 함수 안에서 생산자나 소비자 중 단 한가지 역할만을 담당하는 경우가 종종 있다.

fun <T> copyData(source: MutableList<T>,
                destination: MutableList<T>) {
        for (item in source) {
            destination.add(item)
        }
}

이 함수는 컬렉션의 원소를 다른 컬렉션으로 복사한다.

두 컬렉션 모두 invariant 타입이지만 원본 컬렉션에서 읽기만 하고 대상 컬렉션은 쓰기만 한다.

이 경우 두 컬렉션의 원소 타입이 정확히 일치할 필요가 없다.

만약 다른 리스트 타입에 대해 작동하게 하려면 어떻게 할 수 있을까?

fun <T:R, R> copyData(source: MutableList<T>,
                destination: MutableList<R>) {
        for (item in source) {
            destination.add(item)
        }
}

타입 파라미터가 두 개인 경우 원본 리스트의 원소 타입은 대상 리스트 원소 타입의 subtype이 되어야한다.

하지만 코틀린에서는 이를 더욱 우아하게 표현할 수 있다.

fun <T> copyData(source: MutableList<out T>,
                destination: MutableList<T>) {
        for (item in source) {
            destination.add(item)
        }
}

바로 out 키워드를 타입을 사용하는 위치 앞에 붙이면 in 위치에 사용하는 메소드를 호출하지 않겠다는 의미가 된다.

타입 선언에서 타입 파라미터를 사용하는 위치라면 어디에서나 variance 변경자를 붙일 수 있으며 파라미터 타입, 로컬 변수 타입, 함수 반환 타입 등에 타입 파라미터가 쓰이는 경우 in 또는 out 변경자를 붙일 수 있다.

이때 Type Projection이 일어나는데, 즉 source를 일반적인 MutableList가 아닌 MutableList를 프로젝션 한 타입으로 만든다.

이 경우 copyData 함수는 source의 메소드 중 반환 타입으로 타입 파라미터 T를 사용하는 메소드만 호출이 가능하게 된다.

컴파일러는 타입 파라미터 T를 함수 인자 타입으로 사용하지 못하게 막는다.

당연히 프로젝션 타입의 메소드 중 일부를 호출하지 못하는 경우라면 일반 타입을 사용하면 된다.

물론 여기서 제대로 사용하려면 사실 source를 List<T> 타입으로 지정해야 할 것이다.

하지만 여기서는 covariance를 설명하기 위해 이렇게 하였다.

당연히 List<out T>는 무의미하기 때문에 컴파일러는 불필요한 프로젝션이라고 오류를 뱉는다.

비슷한 방식으로 타입 파라미터가 쓰이는 위치 앞에 in을 붙여서 그 위치에 있는 값이 소비자 역할을 수행한다는 것을 표시할 수 있다.

fun <T> copyData(source: MutableList<out T>,
                destination: MutableList<in T>) {
        for (item in source) {
            destination.add(item)
        }
}

in을 붙이면 그 파라미터를 더 supertype으로 대치할 수 있다.

Start Projection : 타입 인자 대신 * 사용

제네릭 타입 인자 정보가 없음을 표현하기 위해 Star Projection을 사용할 수 있다.

List<*>와 같이 사용 가능하다.

이제 Star Projection의 의미를 자세히 살펴보자.

첫 째, MutableList<*>MutableList<Any?>와는 다르다. (여기서 MutableList<T>가 invariant 하다는 것이 중요하다.)

MutableList<Any?>는 모든 타입의 원소를 받을 수 있다는 사실을 알 수 있는 리스트이다.

반면 MutableList<*>는 어떤 정해진 구체적인 타입의 원소만을 담는 리스트이지만 그 원소의 타입을 정확히 모른다는 사실을 표현한다.

즉, MutableList<*>는 String과 같이 구체적인 타입의 원소를 저장하기 위해 만들어진 것이 아니라는 것을 뜻한다.

그 리스트의 원소 타입이 어떤 타입인지 모른다고 해서 아무거나 넣을 수 있다는 뜻이 아니다.

MutableList<*> 타입의 리스트에서 원소를 얻을 수는 있지만 그 타입이 정확히 어떤 타입인지는 알 수 없다.

Any?의 하위타입이라는 사실만은 분명하다.

조금 더 이해를 위해 아래의 예시를 보자.

val list: MutableList<Any?> = mutableListOf('a',1,"hello")
val chars = mutableListOf('a','b','c')
val unknownElements:MutableList<*> = if(Random().nextBoolean()) list else chars
unknownElements.add(42)
Error: Out-projected type 'MutableList<*>' prohibits the the use of 'fun add(element: E): Boolean'
println(unknownElements.First())
// 출력 : a

왜 컴파일러는 MutableList<*>를 아웃 프로젝션 타입으로 인식할까?

여기서의 MutableList<*>MutableList<out Any?>와 동일하게 동작한다.

어떤 리스트의 원소 타입을 모르더라도 그 리스트에서 안전하게 Any? 타입을 꺼내오는 것은 가능하지만, 타입을 모르는 리스트에 원소를 마음대로 넣는 것은 불가능하다.

자바 와일드카드에 비유하자면 코틀린의 MyType<*>는 자바의 MyType<?>과 동일하다.

Reference

Kotlin In Action (드미트리 제메로프, 스베트라나 이사코바)

https://kychul98.tistory.com/81

https://kotlinlang.org/docs/reference/generics.html



© 2022. by minkuk

Powered by minkuk