ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [kotlin in action] 제네릭스와 변성
    프로그래밍 언어/kotlin 2021. 8. 25. 04:11
    728x90
    반응형

    9.3 변성: 제네릭과 하위 타입

    변성이 있는 이유: 인자를 함수에 넘기기

    리스트의 내용을 출력만 하는 함수는 잘 작동한다.

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

    만약 리스트의 내용을 변경하는 함수라면?

    fun addAnswer(list: MutableList<Any>) {
        list.add(42)
    }
    
    fun main(args: Array<String>) {
        val strings = mutableListOf("abc")
        addAnswer(strings) // 컴파일 안됨.
    }

    MutableList<Any> 가 필요한 곳에 MutableList<String>을 넘기면 안된다. 따라서 addAnswer 함수 호출을 금지시킨다.

    어떤 함수가 리스트에 원소를 추가하고 변경한다면, List<Any> 대신 List<String> 또한 넘길 수 없다. 

     

    정리하자면, 리스트의 변경 가능성에 따라서 적절한 인터페이스를 선택해야 한다. 함수가 읽기 전용 리스트를 받으면, 더 구체적인 타입의 원소를 갖는 리스트를 넘길 수 있다.

     

    클래스, 타입, 하위 타입

    타입과 클래스의 차이

    제네릭 클래스가 아니면 클래스를 바로 타입으로 사용 가능하다.

    var x: String

    같은 클래스 이름을 널이 될 수 있는 타입에도 쓸 수 있다. ( = 모든 코틀린 클래스는 둘 이상의 타입을 구성할 수 있다. )

     

    제네릭 클래스인 경우. 클래스를 바로 타입으로 사용이 불가능하고, 구체적인 타입 인자로 바꿔주어야 한다.

     

    하위 타입이란, A값이 필요한 장소에 B값을 넣어도 문제가 없다면 타입B는 타입A의 하위 타입이다. 상위 타입은 하위 타입의 반대다.

    하위 타입은 근본적으로 하위 클래스와 같다.

    널이 될 수 없는 타입널이 될 수 있는 타입의 하위 타입이다. 근데 두 타입 모두 같은 클래스에 해당한다. 

    하위 클래스와 하위 타입의 차이는 뭘까.

     

    무공변

    제네릭 타입을 인스턴스화 할 때, 타입 인자로 서로 다른 타입이 들어가 인스턴스간 하위 타입 관계가 성립하지 않으면, 그 제네릭 타입은 무공변. 자바에서는 모든 클래스가 무공변이다.

     

    공변

    타입 A 가 B의 하위 타입이라면, List<A>는 List<B>의 하위 타입이다. 이렇게 하위 타입 관계가 유지되면 해당 타입은 공변이라고 한다. 코틀린에서는 읽기 전용 컬렉션 List 등이 공변적이다.

     

    공변성: 하위 타입 관계를 유지 

    코틀린에서 제네릭 클래스가 공변적임을 표시하려면 타입 파라미터 앞에 out 키워드를 붙인다.

    클래스의 타입을 공변적으로 만들면 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않아도 그 클래스의 인스턴스를 함수 인자나 반환값으로 사용할 수 있다.

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

     

    모든 클래스를 공변적으로 만들 수는 없다. 공변적으로 만들면 안전하지 못한 클래스도 있다. 공변적으로 타입 파라미터를 지정하면, 클래스가 T 타입의 값을 생산할 수는 있지만 소비할 수는 없다

    T라는 타입 파라미터를 선언하고, T를 사용하는 멤버함수가 있다고 했을 때, T를 함수 반환 타입에 쓴다면 생산. T를 함수의 파라미터 타입에 쓴다면 소비.

    interface Transformer<T> {
        fun transform(t: T): T // 함수 인자의 T 는 in 위치. 반환의 T는 out 위치
    }

    클래스가 공변적일때, T가 out 위치에서만 쓰인다면 안전하다. 

     

    생성자 파라미터는 in, out 어느쪽 위치도 아니다. 타입 파라미터가 out이라고 해도 그 타입을 여전히 생성자 파라미터 선언에 사용할 수 있다. 생성자는 인스턴스 생성 뒤 호출할 수 있는 메서드가 아니기 때문에 위험할 여지가 없다.

    class Herd<out T: Animal>(vararg animals: T) { ... }

     

    변성 규칙은 클래스 외부의 사용자가 클래스를 잘못 사용하는 일을 막기 위한 것이기 때문에 private 메서드의 파라미터는 in도 out도 아니다. 

     

     

    반공변성: 뒤집힌 하위 타입 관계

    interface Comparator<in T> {
        fun compare(e1: T, e2: T): Int { ... }
    }
    val anyComparator = Comparator<Any> {
        e1, e2 -> e1.hashCode() - e2.hashCode()
    }
    val strings: List<String> = ...
    strings.sortedWith(anyComparator) // sortedWith 는 Comparator<String> 을 요구하므로, 더 일반적인 타입을 비교하는 Comparator를 넘기는게 안전하다.

    T 가 in 위치에서 쓰인다. 이 인터페이스의 compare 메소드는 T 타입의 값을 소비하기만 한다. Comparator로 비교하면 그 타입이나 그 타입의 조상 타입을 비교할 수 있다. 이는 Comparator<Any> 는 Comparator<String> 의 하위 타입이라는 의미다. Comparator의 하위 타입 관계는 타입 인자의 하위 타입 관계와는 정반대 방향이다.

    Consumer 클래스에서는 타입 인자의 하위 타입 관계와 정반대이다. 

     

    요약하자면 다음과 같다.

    공변성 반공변성 무공변성
    Producer<out T> Consumer<in T> MutableList<T>
    타입 인자의 하위 타입 관계가 제네릭 타입에서도 유지된다. 타입 인자의 하위 타입 관계가 제네릭 타입에서 뒤집힌다. 하위 타입 관계가 성립하지 않는다.
    Producer<Cat>은 Producer<Animal>의 하위 타입이다. Consumer<Animal>은 Consumer<Cat>의 하위 타입이다.  
    T를 out 위치에서만 사용 가능 T를 in 위치에서만 사용 가능 T를 아무 위치에서나 사용 가능

    클래스나 인터페이스가 어떤 타입 파라미터에 대해서는 공변적이면서 다른 파라미터에 대해서는 반공변적일 수 있다. Function 인터페이스가 그 예다.

    interface Function<in P, out R> {
        operator fun invoke(p: P): R
    }
    
    fun enumerateCats(f: (Cat) -> Number) { ... }
    fun Animal.getIndex(): Int = ...
    >>> enumerateCats(Animal::getIndex)

     

    자바는 변성을 지원하지 않아서, 대신에 클래스를 사용하는 위치에서 와일드카드를 사용해서 그때그때 변성을 지정해야 한다. 

     

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

    코틀린의 선언 지점 변성 vs 자바 와일드카드

    -> 선언 지점 변성이 코드가 더 간결하다. 자바의 와일드카드는 사용자의 예상대로 작동하는 API를 만들기 위해서 항상 사용해야 한다.

     

    코틀린도 사용 지점 변성 사용 지원. MutableList는 무공변 타입이지만 원본 컬렉션에서는 읽기만 하고, 대상 컬렉션에서는 쓰기만 할 경우. 두 컬렉션의 원소 타입이 일치할 필요가 없다.

    fun <T: R, R> copyData(source: MutableList<T>,
                           destination: MutableList<R>) { // source 원소 타입이 destination보다 하위 타입이어야 한다.
        for (item in source) {
            destination.add(item)
        }
    }
    
    fun main(args: Array<String>) {
        val ints = mutableListOf(1, 2, 3)
        val anyItems = mutableListOf<Any>()
        copyData(ints, anyItems)
        println(anyItems)
    }

    함수 구현이 out 위치에 있는 타입 파라미터를 사용하는 메소드만 호출한다면, 함수 정의 시 타입 파라미터에 변성 변경자를 추가할 수 있다.

    fun <T> copyData(source: MutableList<out T>, // in위치에 사용하는 메소드를 호출하지 않는다.
                     destination: MutableList<T>) {
        for (item in source) {
            destination.add(item)
        }
    }
    
    fun main(args: Array<String>) {
        val ints = mutableListOf(1, 2, 3)
        val anyItems = mutableListOf<Any>()
        copyData(ints, anyItems)
        println(anyItems)
    }

     

    타입 선언에서 타입 파라미터를 사용하는 위치라면 어디에나 변성 변경자를 붙일 수 있고, 따라서 파라미터 타입, 로컬 변수 타입, 함수 반환 타입 등에 타입 파라미터가 쓰이는 경우 in, out 변경자를 붙일 수 있고, 이때 타입 프로젝션이 일어난다.

    List<out T> 처럼 out 변경자가 지정된 타입 파라미터를 out 프로젝션 하는 것은 의미가 없다.(당연..)

     

    fun <T> copyData(source: MutableList<T>,
                     destination: MutableList<in T>) { // 원본 리스트 원소 타입의 상위 타입을 대상 리스트 원소 타입으로 허용한다.
        for (item in source) {
            destination.add(item)
        }
    }
    
    fun main(args: Array<String>) {
        val ints = mutableListOf(1, 2, 3)
        val anyItems = mutableListOf<Any>()
        copyData(ints, anyItems)
        println(anyItems)
    }

    코틀린의 사용 지점 변성은 자바의 와일드카드와 똑같다.

    이제, 모든 타입 인자를 받아들일 수 있게 만드는 방법을 보자.

     

    스타 프로젝션: 타입 인자 대신 * 사용

     

    제네릭 타입 인자 정보가 없음을 표현하기 위해서 스타 프로젝션을 사용한다.

    MutableList<*> 와 MutableList<Any?>는 다르다.

    MutableList<Any?> -> 모든 타입을 담을 수 있다.

    MutableList<*>  -> 어떤 정해진 구체적인 타입의 원소만을 담는 리스트이지만, 그 원소 타입을 정확히 모른다.

     

    어떤 리스트의 원소 타입을 알 수 없을 때, Any? 타입의 원소를 꺼내올 수는 있지만, 타입을 모르는 리스트에 마음대로 원소를 넣을 수는 없다. 따라서 MutableList<*> 는 Mutable<out Any?> 로 인식한다. 

    코틀린의 MyType<*> 은 자바의 MyType<?>와 같다.

     

    타입 파라미터를 시그니처에서 언급하지 않았거나, 데이터를 읽기는 하지만 그 타입에는 관심이 없을 경우 스타 프로젝션을 사용한다.

    fun printFirst(list: List<*>) {
        if (list.isNotEmpty()) {
            println(list.first())
        }
    }
    
    fun main(args: Array<String>) {
        printFirst(listOf("Svetlana", "Dmitry"))
    }

    스타 프로젝션을 사용할 때는 값을 만들어내는 메소드만 호출할 수 있고, 그 값의 타입에는 신경쓰지 말아야 한다. 

     

    사용자 입력을 검증해야 해서 FieldValidator라는 인터페이스를 정의했다고 가정하자. 

    import kotlin.reflect.KClass
    
    interface FieldValidator<in T> {
        fun validate(input: T): Boolean
    }
    
    object DefaultStringValidator : FieldValidator<String> {
        override fun validate(input: String) = input.isNotEmpty()
    }
    
    object DefaultIntValidator : FieldValidator<Int> {
        override fun validate(input: Int) = input >= 0
    }
    
    fun main(args: Array<String>) {
        val validators = mutableMapOf<KClass<*>, FieldValidator<*>>() // 모든 타입의 검증기를 맵에 넣는다.
        validators[String::class] = DefaultStringValidator // String 타입의 필드를 FieldValidator<*> 타입의 검증기로 검증할 수 없다.
        validators[Int::class] = DefaultIntValidator
    }

    컴파일러는 FieldValidator가 어떤 타입을 검증하는지 모르기때문에 컴파일 에러가 난다.

    명시적으로 타입 캐스팅을 사용할 수 있지만, 안전하지 않은 타입캐스팅이라는 경고는 한다.

    잘못된 검증기를 가져온 경우, 검증시를 사용하는 시점에, 검증 메소드 안에서 값의 메소드나 프로퍼티를 사용할 때 문제가 생긴다.

     

    한 장소에 여러 다른 타입의 검증기를 보관하는 방법이 없을까?

    검증기 컬렉션에 대한 접근 캡슐화

    import kotlin.reflect.KClass
    
    interface FieldValidator<in T> {
        fun validate(input: T): Boolean
    }
    
    object DefaultStringValidator : FieldValidator<String> {
        override fun validate(input: String) = input.isNotEmpty()
    }
    
    object DefaultIntValidator : FieldValidator<Int> {
        override fun validate(input: Int) = input >= 0
    }
    
    object Validators {
        private val validators =
                mutableMapOf<KClass<*>, FieldValidator<*>>()
    
        fun <T: Any> registerValidator( // 어떤 클래스와 검증기가 타입이 맞아 떨어지는 경우에만 그 클래스와 그 클래스의 검증기 정보를 맵에 키/값 쌍으로 넣는다.
                kClass: KClass<T>, fieldValidator: FieldValidator<T>) {
            validators[kClass] = fieldValidator
        }
    
        @Suppress("UNCHECKED_CAST")
        operator fun <T: Any> get(kClass: KClass<T>): FieldValidator<T> =
            validators[kClass] as? FieldValidator<T>
                    ?: throw IllegalArgumentException(
                    "No validator for ${kClass.simpleName}")
    }
    
    fun main(args: Array<String>) {
        Validators.registerValidator(String::class, DefaultStringValidator)
        Validators.registerValidator(Int::class, DefaultIntValidator)
        println(Validators[String::class].validate("Kotlin"))
        println(Validators[Int::class].validate(42))
    }

     

     

     

     

     

    728x90

    댓글

Designed by Tistory.