KotlinInAction/제네릭스

변성: 제네릭과 하위 타입

webmaster 2022. 8. 15. 13:04
728x90

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

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

List<Any> 타입의 파라미터를 받는 함수에 List<String>을 넘기면 안전할까? String 클래스는 Any를 확장하므로 Any 타입 값을 파라미터로 받는 함수에 String값을 넘겨도 절대로 안전하다. 단, Any와 String이 List 인터페이스의 타입 인자로 들어가는 경우 안정하지 않을 수도 있다

fun main(args: Array<String>) {
    printContents(listOf("abc", "bac"))
}
fun printContents(list: List<Any>){
    println(list.joinToString())
}
  • 이 경우 문자열 리스트도 잘 동작한다.
  • 각 원소를 Any로 취급하며, 모든 문자열은 Any 타입이기도 하므로 완전히 안전하다.
fun main(args: Array<String>) {
    val strings = mutableListOf("abc", "bac")
    addAnswer(strings) //이 줄이 컴파일 된다면
    println(strings.maxBy { it.length })//실행 시점에 예외가 발생할 것이다.
}

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

컴파일 오류 발생

  • MultableList<String> 타입의 strings 변수를 선언해 함수로 넘길 때, 컴파일러가 이식을 받아들인다면 정수를 문자열 리스트 뒤에 추가할 수 있다.
    • 따라서 이 함수 호출은 컴파일 될 수 없다.
  • 코틀린 컴파일러는 이런 함수 호출을 금지한다.

어떤 함수가 리스트의 원소를 추가하거나 변경한다면, 타입 불일치가 생길 수 있어서 List<Any> 대신 List<String>을 넘길 수 없다. 하지만, 원소 추가나 변경이 없는 경우에는 List<String>을 List<Any> 대신 넘겨도 안전하다.

코틀린에서는 리스트의 변경 가능성에 따라 적절한 인터페이스를 선택하면 안전하지 못한 함수 호출을 막을 수 있다. 함수가 읽기 전용 리스트를 받는다면 더 구체적인 타입의 원소를 갖는 리스트를 그 함수에 넘길 수 있고, 변경가능하다면 그럴 수 없다

클래스, 타입, 하위 타입

모든 코틀린 클래스는 적어도 둘 이상의 타입을 구성할 수 있다. 

하위 타입 : 어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 타입 A의 하위 타입이다. 이 정의를 통해 모든 타입이 자신의 하위 타입이라는 의미이기도 하다.

상위 타입 : 하위 타입의 반대, A타입이 B타입의 하위 타입이라면 B는 A의 상위 타입이다.

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

fun test(i: Int){
    val n: Number = i //Int가 Number의 하위타입이어서 컴파일된다
    fun f(s: String){ /* logic */}
    f(i)//Int가 String의 하위타입이 아니어서 컴파일되지 않는다
}
  • 어떤 값의 타입이 변수 타입의 하위 타입인 경우에만 값을 변수에 대입하게 허용한다.
    • i(Int)는 Number의 하위타입이라 대입이 올바르다.
  • 함수에 전달하는 식의 타입이 함수 파라미터 타입의 하위 타입인 경우에만 함수 호출이 허용된다.
    • i 인자의 타입인 Int는 파라미터 타입 String의 하위 타입이 아니라 f(i) 호출은 컴파일되지 않는다.
  • 간단한 경우 하위 타입은 하위 클래스와 같다.
    • 어떤 인터페이스를 구현하는 클래스의 타입은 그 인터페이스 타입의 하위 타입이다.
fun main(args: Array<String>) {
    val s: String = "abc"
    val t: String? = s //String이 String?의 하위 타입이므로 이 대입은 합법적

}
  • 널이 될 수 있는 타입은 하위 타입과 하위 클래스가 같지 않는 경우를 보여주는 예다
    • 널이 될 수 없는 타입은 널이 될 수 있는 타입의 하위 타입이지만, 두 타입 모두 같은 클래스에 해당한다.
    • 항상 널이 될 수 없는 타입의 값을 널이 될 수 있는 타입의 변수에 저장할 수 있지만, 반대는 불가능하다.

제네릭 타입에 대해 이야기 할 때 하위 클래스와 하위 타입의 차이가 중요한데, List<String> 타입의 값을 List<Any>를 파라미터로 받는 함수에 전달해도 괜찮은가 라는 의미는 List<String>가 List<Any>을 하위 타입인가 라는 질문과 같으며, List<String>을 List<Any> 을 하위 타입으로 다루면 안 되므로 하위 타입이 아니다.

무공변 : 제네릭 타입을 인스턴스화 할 때 타입 인자로 서로 다른 타입이 들어가면 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않을 경우의 해당 제네릭 타입을 무공변이라 한다.

공변 : A가 B의 하위 타입이면 List<A> 는 List<B>의 하위 타입인 경우의 클래스나 인터페이스를 공변적이라고 한다.

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

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

interface Producer<out T> { //클래스가 T에 대해 공변적이라고 선언
    fun produce(): T
}
  • 클래스의 타입 파라미터를 공변적으로 만들면, 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않더라도 그 클래스의 인스턴스를 함수 인자나 반환 값으로 사용할 수 있다.
open class Animal {
    fun feed(){
        //Logic
    }
}

class Herd<T: Animal>{
    val size: Int get() = ...
    operator fun get(i: Int): T{
        //logic
        
    }
}
fun feedAll(animal: Herd<Animal>){
    for(i in 0 until animal.size){
        animal[i].feed()
    }
}
class Cat: Animal(){
    fun cleanLitter(){

    }
}
fun takeCareOfCats(cats: Herd<Cat>){
    for(i in 0 until cats.size){
        cats[i].cleanLitter()
    }
    //feedAll(cats) //inferred type is Herd<Cat> but Herd<Animal> was expected 라는 오류가 발생
    
}
  • feedAll 함수에 고양이 무리(cats)를 넘기면 타입 불일치 오류를 볼 수 있다.
    • Herd 클래스의 T 타입 파라미터에 대해 아무 변성도 지정하지 않았기 때문에 고양이 무리는 동물 무리에 하위 클래스가 아니다.
    • 명시적으로 타입 캐스팅을 사용하면, 해결할 수 있지만, 코드가 장황해지고, 실수하기 쉽다.(강제 캐스팅은 답이 아니다)
  • Herd를 공변적인 클래스로 만들고 호출 코드를 적절히 바꿔야 한다.
class Herd<out T: Animal>{
    val size: Int get() = ...
    operator fun get(i: Int): T{
        //logic

    }
}
...
fun takeCareOfCats(cats: Herd<Cat>){
    for(i in 0 until cats.size){
        cats[i].cleanLitter()
    }
    feedAll(cats)
}
  • 모든 클래스를 공변으로 만들 수는 없다(공변적으로 만들면 안전하지 못한 클래스도 있다)
  • 타입 파라미터를 공변적으로 지정하면 클래스 내부에서 그 파라미터를 사용하는 방법을 제한한다.
  • 타입 안정성을 보장하기 위해 공변적 파라미터는 항상 out 위치에만 있어야 한다.(클래스가 T 타입의 값은 생산할 수는 있지만, 소비할 수는 없다는 의미)
interface Transformer<T> {
    fun transform(t: T): //( 인 위치)
            T //아웃 위치
}
  • 클래스 멤버를 선언할 때 타입 파라미터를 사용할 수 있는 지점은 모두 인/ 아웃으로 구분된다. 
    • T가 함수의 반환 타입으로 쓰인다 = 아웃 위치(T 타입의 값을 생산한다), T가 함수의 파라미터 타입에 쓰인다 = 인 위치(T 타입의 값을 소비한다)
  • 클래스 타입 파라미터 T 앞에 out 키워드를 붙이면 클래스 안에서 T를 사용하는 메서드가 아웃 위치에서만 T를 허용하고, 인 위치에서는 T를 사용하지 못하게 막는다.
  • out 키워드는 T의 사용법을 제한, T로 인해 생기는 하위 타입 관계의 타입 안전성을 보장한다.

out 키워드 정리

  • 공변성 : 하위 타입 관계 유지(Producer<Cat>은 Producer<Animal>을 하위 타입이다)
  • 사용 제한: T를 아웃 위치에서만 사용할 수 있다.
interface List<out E> : Collection<E> {
	operator fun get(index: Int): T // T는 항상 아웃 위치에 쓰인다.
	...
    fun subList(fromIndex: Int, toIndex: Int): List<T> //여기서 T도 아웃 위치에 있다
}
  • 코틀린 List는 읽기 전용이고, 그 안에서 T 타입의 원소를 반환하는 get 메서드는 있지만, 리스트에 T타입의 값을 추가하거나 리스트에 있는 기존 값을 변경하는 메서드는 없다. 따라서 List는 공변적이다.
  • 타입 파라미터를 함수의 파라미터 타입이나 반환 타입에만 쓸 수 있는 것은 아니며, 타입 파라미터를 다른 타입 인자로 사용할 수도 있다.
public interface MutableList<E> : List<E>, MutableCollection<E> { //MutableList는 T에 대해 공변적일 수 없다
    override fun add(element: E): Boolean //이유는 T가 인 위치에 쓰이기 떄문

    ...
}
  • MutableList<T>를 타입 파라미터 T에 대해 공변적인 클래스로 사용할 수 없는 점을 기억하라
    • MutableList<T> 에는 T를 인자로 받아 그 타입의 값을 반환하는 메서드가 있기 때문
  • 컴파일러는 타입 파라미터가 쓰이는 위치를 제한하며, 클래스가 공변적으로 선언된 경우 "Type parameter T is decleared as 'out' but occurs in 'in' position"이라는 오류를 보고한다.
  • 생성자 파라미터는 인, 아웃 어느 쪽도 아니다.(타입 파라미터가 out이어도 생성자 파라미터 선언에 사용 가능)

변성은 코드에서 위험할 여지가 있는 메서드를 호출할 수 X 만들어 주므로, 제네릭 타입의 인스턴스 역할을 하는 클래스 인스턴스를 잘못 사용하는 일이 없게 방지하는 역할을 한다( 생성자는 나중에 호출할 수 있는 메서드가 아니므로 위험할 여지가 없다)

하지만, val나 var 키워드를 생성자 파라미터에 적는다면 getter, setter를 정의하는 거와 같고, 읽기 전용 프로퍼티는 아웃 위치, 변경 가능 프로퍼티는 아웃, 인 모두에 해당되므로 변경 가능 프로퍼티는 out 키워드를 쓸 수 없다.

이런 위치 규칙은 오직 외부에서 볼 수 있는 클래스 API에만 적용할 수 있다. 비공개(private) 메서드의 파라미터는 인도 아니고 아웃도 아닌 위치다. 변성 규칙은 클래스 외부 사용자가 클래스를 잘못 사용하는 일을 막기 위한 것이므로 클래스 내부 구현에는 적용되지 않는다.

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

interface Comparator<in T> {
	fun compare(e1: T, e2: T): Int {...} //T를 인 위치에 사용한다
}
  • 해당 인터페이스의 메서드는 T 타입의 값을 소비만 한다.(T가 인 위치에서만 쓰이기 때문에 in 키워드를 붙여야 한다)
fun main(args: Array<String>) {
    val anyComparator = Comparator<Any>{
        e1, e2 -> e1.hashCode() - e2.hashCode()
    }
    val strings = listOf("1", "3", "2")
    strings.sortedWith(anyComparator) // 문자열과 같은 구체적인 타입의 객체를 비교하기 위해 모든 객체를 비교하는 Comparator를 사용할 수 있다.
}
  • 어떤 타입의 객체를 Comparator로 비교해야 한다면 그 타입이나 그 타입의 조상 타입을 비교할 수 있는 Comparator를 사용할 수 있다.
    • Comparator<Any> 가 Comparator<String> 의 하위 타입이라는 것을 뜻하지만, Any는 String의 상위 타입이다.
    • 따라서 서로 다른 타입 인자에 대해 Comparator의 하위 타입 관계는 타입 인자의 하위 타입 관계와는 정반대 방향이다.

반공변성 : 타입 B가 타입 A의 하위 타입인 경우 consumer<A>가 Consumer<B>의 하위 타입 관계가 성립하면 제네릭 클래스 Consumer<T>는 타입 인자 T에 대해 반공변이다. (A - B 관계가 뒤집힌 것 유의)

공변성 타입 Producer<T>에서는 타입인자의 하위 타입 관계가 제네릭 타입에서도 유지 되지만 반공변성 타입 Consumer<T>에서는 타입 인자의 하위 타입 관계가 제네릭 타입으로 오면서 뒤집힌다.

  • in이라는 키워드는 그 키워드가 붙은 타입이 클래스의 메서드 안으로 전달돼 메서드에 의해 소비된다는 뜻이다.
  • in키워드를 타입 인자에 붙이면 그 타입 인자를 오직 인 위치에서만 사용할 수 있다는 의미이다.
  •  
공변성
반공변성 무공변성
Producer<out T> Consumer<in T> MutableList<T>
타입 인자의 하위 타입 관계가 제네릭 타입에서도 유지된다. 타입 인자의 하위 타입 관계가 제네릭 타입에서 뒤집힌다. 하위 타입 관계가 성립하지 않는다.
Producer<Cat>은 Producer<Animal>의 하위 타입이다. Producer<Animal>은 Producer<Cat>의 하위 타입이다.  
T를 아웃 위치에서만 사용할 수 있다. T를 인 위치에서만 사용할 수 있다. T를 아무 위치에서나 사용할 수 있다.
fun enumerateCats(f: (Cat) -> Number){...}
fun Animal.getIndex(): Int = ...
enumerateCats(Animal::getIndex) //Animal은 Cat의 상위 타입 이며, Int는 Number의 하위 타입이므로 올바른 식이다.
  • 클래스나 인터페이스가 어떤 타입 파라미터에 대해서는 공변적이며, 다른 타입 파라미터에 대해서는 반공변적일 수도 있다.

참조 : https://incheol-jung.gitbook.io/docs/study/kotlin-in-action/9#undefined-11

 

9장 제네릭스 - Incheol's TECH BLOG

첫째, MutableList<>는 MutableList 와 같지 않다.MutableList 는 모든 타입의 원소를 담을 수 있다는 사실을 알 수 있는 리스트다. 반면 MutableList<>는 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지

incheol-jung.gitbook.io

잘 이해되지 않는 내용으로 해당 블로그가 더 잘 이해가 된다.

https://velog.io/@lsb156/covariance-contravariance

 

공변성, 반공변성, 무공변성이란?

지난번에 변성에 대한 질문을 받은적이 있습니다.대충은 알고있지만 설명하기 어려웠던 부분에 대해 다시한번 정리하는 시간을 갖고자 정리한것을 포스팅해봅니다.

velog.io

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

클래스를 선언하면서 변성을 지정하면, 그 클래스를 사용하는 모든 장소에 변성 지정자가 영향을 끼치므로 편리하다. 이런 방식을 선언 지점 변성이라 한다.

자바에서의 와일드카드 타입(? extends,? super)에 익숙하면, 자바는 타입 파라미터가 있는 타입을 사용할 때마다 해당 타입 파라미터를 하위 타입이나 상위 타입 중 어떤 타입으로 대치할 수 있는지 명시해야 하는데, 이를 사용 지점 변성이라고 한다.

코틀린도 사용 지점 변성을 지원하며, 클래스 안에서 어떤 타입 파라미터가 공변적이거나, 반공변적인지 선언할 수 없는 경우에도 특정 타입 파라미터가 나타나는 지점에서 변성을 정할 수 있다. MultableList와 같은 상당수 인터페이스는 타입 파라미터로 지정된 타입을 소비하는 동시에 생산할 수 있기 때문에 일반적으로 공변적이지도 반공변적이지도 않지만, 그런 인터페이스 타입의 변수가 한 함수 안에서 생산자나 소비자 중 단 한 가지 역할만을 담당하는 경우가 자주 있다.

무공변 파라미터 타입을 사용하는 데이터 복사 함수

fun <T> copyData(source: MutableList<T>,
                 destination: MutableList<T>){
    for(item in source){
        destination.add(item)
    }
}
  • 컬렉션 원소를 다른 컬렉션으로 복사한다.
  • 두 컬렉션 모두 무공변 타입이지만 원본 컬렉션은 읽기만 하고 대상 컬렉션은 쓰기만 한다.(두 컬랙션의 원소 타입이 일치할 필요가 없다)
fun <T: R, R> copyData(source: MutableList<T>, //source 원소 타입은 destination 원소타입의 하위 타입이어야 한다
                        destination: MutableList<R>){
    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) //Int가 Any의 하위 타입이므로 해당 함수를 호출할 수 있다.
    println(anyItems)
}
  • 두 타입 파라미터는 원본과 대상 리스트의 원소 타입을 표현한다.
    • 한 리스트에서 다른 리스트로 원소를 복사할 수 있으려면 원본 리스트 원소 타입은 대상 리스트 원소 타입의 하위 타입이어야 한다.
fun <T> copyData(source: MutableList<out T>, //out 키워드를 타입을 사용하는 위치 앞에 붙이면 T 타입을 in 위치에 사용하는 메서드를 호출하지 않는다는 뜻이다
                       destination: MutableList<T>){
    for(item in source){
        destination.add(item)
    }
}
  • 함수 구현이 아웃(혹은 인) 위치에 있는 타입 파라미터를 사용하는 메서드만 호출한다면 함수 정의 시 타입 파라미터에 변성 변경자를 추가할 수 있다
    • 파라미터 타입, 로컬 변수 타입, 함수 반환 타입 등에 타입 파라미터가 쓰이는 경우 in/out 변경자를 붙일 수 있다.
    • 이때 타입 프로젝션이 일어나며, source를 MutableList를 프로젝션을 한 타입으로 만든다(제약을 가한 타입)
  • copyData 함수는 MutableList의 메서드 중에서 반환 타입으로 타입 파라미터 T를 사용하는 메서드만 호출할 수 있다.
  • 컴파일러는 타입 파라미터 T를 함수 인자 타입으로 사용하지 못하게 막는다.

  • 프로젝션 타입의 메서드 중 일부를 호출하지 못해도 일반 타입을 사용해 호출하면 된다
    • 다른 타입과 연관이 있는 새 타입 파라미터를 추가해야 할 수도 있다.
  • List<out T> 처럼 out 변경자가 지정된 타입 파라미터를 out 프로젝션하는 것은 의미 없다.
    • List의 정의는 이미 class List<out T> 이므로 의미 없다.
    • 코틀린 컴파일러는 이런 경우 불필요한 프로젝션이라는 경고를 한다.
fun <T> copyData(source: MutableList<T>, 
                 destination: MutableList<in T>){ //원본 리스트 원소 타입의 상위 타입을 대상 리스트 원소 타입으로 허용
    for(item in source){
        destination.add(item)
    }
}
  • 타입 파라미터가 쓰이는 위치 앞에 in을 붙여 그 위치에 있는 값이 소비자 역할을 수행한다고 표시할 수 있다.
    • in을 붙이면 그 파라미터를 더 상위 타입으로 대치할 수 있다. 

TIP : 코틀린 선언 지점 변성과, 자바 와일드카드 비교

선언 지점 변성을 사용하면, 변성 변경자를 단 한 번만 표시하고 클래스를 쓰는 쪽에서는 변성에 대해 신경 쓸 필요가 없으므로 코드가 간결해진다.

MutableList<out T> = MutableList<? extends T> , MutableList<in T> = MutableList<? super T> 

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

제네릭 타입 인자 정보가 없음을 표현하기 위해 스타 프로젝션을 사용한다. 원소 타입이 알려지지 않은 리스트는 List <*>로 표기 가능하다.

 

MutableList<*> 와 MutableList<Any?> 는 같지 않다.

MutableList<Any?> 는 모든 타입의 원소를 담을 수 있다는 사실을 알 수 있는 리스트지만, MutableList<*>은 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지만 그 원소의 타입을 정확히 모른다는 사실을 표현한 것이다. 

MutableList<*>은 그 리스트가 구체적인 타입의 원소를 저장하기 위해 만들어진 것이라는 건데, 그 리스트의 원소 타입이 어떤 타입인지 모른다고 해서 그 안에 아무 원소나 담으면 된다는 의미는 아니다. 리스트에 담는 값의 타입에 따라서는 리스트를 만들어서 넘겨준 쪽이 바라는 조건을 깰 수 있기 떄문이다. 즉 MutableList<*> 타입의 리스트에서 원소를 얻을 수는 있다. 

fun main(args: Array<String>) {
    val list: MutableList<Any?> = mutableListOf('a', 1, "qwe")
    val chars = mutableListOf('a', 'b', 'c')
    val unknownElements: MutableList<*> = //MutableList<Any?> != MutableList<*>
        if(Random().nextBoolean())
            list
        else
            chars
    //unknownElements.add(42) //컴파일 오류
    println(unknownElements.first()) //원소를 가져와도 안전, first()는 Any? 타입의 원소를 반환하기 때문
}

컴파일 오류

  • 왜 mutableList<*>를 아웃 프로젝션 타입으로 인식할까?
    • 여기서는 mutableList<*> == mutableList<out Any?> 와 같이 동작한다.
    • 어떤 리스트의 원소 타입을 모르더라도 그 리스트에서 안전하게 Any? 타입을 원소로 뽑아올 수 있지만, 타입을 모르는 리스트에 원소를 마음대로 넣을 수는 없다.
  • 자바 와일드카드에 비유하면 MyType<*> == MyType<?>와 같다

타입 파라미터를 시그니처에서 전혀 언급하지 않거나 데이터를 읽기는 하지만, 그 타입에는 관심이 없는 경우와 같이 타입 인자 정보가 중요하지 않을 때도 스타 프로젝션 구문을 사용할 수 있다.

fun main(args: Array<String>) {
    printFirst(listOf("Svetlana", "Dmitry"))
}

fun printFirst(list: List<*>){ //모든 리스트를 인자로 받을 수 있다
    if(list.isNotEmpty())//isNotEmpty() 에서는 제네릭 타입 파라미터를 사용하지 않는다
        println(list.first()) //first()는 이제 Any? 를 반환하지만 여기서는 그 타입만으로 충분
}
fun <T> printFirst(list: List<T>){ //이경우에도 모든 리스트를 인자로 받을 수 있다.
    if(list.isNotEmpty())
        println(list.first()) //이제 first()는 T 타입의 값을 반환 
}
  • 사용 지점 변성과 마찬가지로 스타 프로젝션도 우회하는 방법으로 제네릭 타입 파라미터를 도입하면 된다.
  • 스타 프로젝션을 쓰는 쪽이 더 간결하지만, 제네릭 타입 파라미터가 어떤 타입인지 굳이 알 필요가 없을 때 스타 프로젝션을 쓸 수 있다.
  • 스타 프로젝션을 사용할 때는 값을 만들어내는 메서드만 호출할 수 있고, 그 값의 타입에는 신경을 쓰지 말아야 한다.
interface FieldValidator<in T> {//T에 대해 반공변인 인터페이스를 선언
    fun validate(input: T): Boolean //T를 인 위치에만 사용한다(이 메서드는 T 타입의 값을 소비한다)
}

object DefaultStringValidator: FieldValidator<String>{
    override fun validate(input: String): Boolean = input.isNotEmpty()
}

object DefaultIntValidator: FieldValidator<Int>{
    override fun validate(input: Int): Boolean = input >= 0
}

 

모든 타입의 검증기를 맵에 넣을 수 있어야 하므로 KClass를 키로 하고, FieldValidator<*>를 값으로 하는 맵을 선언 한다.

fun main(args: Array<String>) {
    val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()
    validators[String::class] = DefaultStringValidator
    validators[Int::class] = DefaultIntValidator
}

검증기를 사용시점에 컴파일러는 어떤 타입을 검증하는지 모르기 때문에 안전하지 않다고 판단한다.

컴파일 오류

fun main(args: Array<String>) {
    val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()
    validators[String::class] = DefaultStringValidator
    validators[Int::class] = DefaultIntValidator

    validators[String::class]!!.validate("")
 }
  • 오류 발생 -> MutableList<*>에 원소를 넣으려고 했을 때 발생한 오류와 같다.
  • 여기서는 알 수 없는 타입의 검증기에 구체적인 타입의 값을 넘기면 안전하지 못하다는 의미이다.
  • 검증기는 원하는 타입으로 캐스팅하면 이런 문제를 해결할 수 있다.(권장 X)
fun main(args: Array<String>) {
    val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()
    validators[String::class] = DefaultStringValidator
    validators[Int::class] = DefaultIntValidator
    val stringValidator = validators[Int::class] //검증기를 잘못 가져왔지만 컴파일, 타입캐스팅시 아무 문제 없다
            as FieldValidator<String>
    stringValidator.validate("")//검증기를 사용할떄 비로소 오류 발생
}

권장 하지 않는 타입 캐스팅을 통한 해결

  • 컴파일러는 타입 캐스팅이 안전하지 못하다고 경고하고, 이 코드를 실행하면 타입 캐스팅 부분에서 실패하지 않고, 값을 검증하는 메서드 안에서 실패한다.
  • 실행 시점에 모든 제네릭 타입 정보는 사라지므로 타입 캐스팅은 문제없지만, 검증 메서드 안에서 값의 메서드나 프로퍼티를 사용할 때 문제가 발생한다.
  • 올바른 타입의 검증기를 가져와서 정상 작동하는 타입으로 캐스팅하는 것은 프로그래머의 책임이다.
  • 이런 해법은 타입 안정성을 보장할 수도 없고 실수하기 쉽기 때문에 한 장소에 여러 다른 타입의 검증기를 보관할 방법을 찾아야 한다.
object Validators{
    private val validators = //앞 예제와 같은 맵을 사용하지만 외부에서 접근 불가능
        mutableMapOf<KClass<*>, FieldValidator<*>>()
    fun <T: Any> registerValidator(
        kClass: KClass<T>, fieldValidator: FieldValidator<T>){
        validators[kClass] = fieldValidator //어떤 클래스와 검증기가 타입이 맞아 떨어지는 경우에만 그 클래스와 검증기 정보를 맵에 키/값 쌍으로 넣는다
    }
    
    @Suppress("UNCHECKED_CAST") //FieldValidator<T> 캐스팅이 안전하지 않다는 경고를 무시한다.
    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))
}

올바르지 않은 타입이 들어올 경우 컴파일 오류

  • 타입 안전성을 보장하는 API가 작성되었다(안전하지 못한 모든 로직은 클래스 내부에 캡슐화)
  • 안전하지 못한 부분을 외부에서 잘못 사용하지 않음을 보장할 수 있다.
  • Validators 객체에 있는 제네릭 메서드에서 검증기와 클래스의 타입 인자가 같기 때문에 컴파일러가 타입이 일치하지 않는 클래스와 검증기를 등록하지 못하게 막는다.
728x90