KotlinInAction/제네릭스

실행 시 제네릭스의 동작: 소거된 타입 파라미터와 실체화된 타입 파라미터

webmaster 2022. 8. 14. 13:00
728x90

JVM의 제네릭스는 보통 타입 소거를 사용해 구현된다. 이는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 뜻이다.

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

코틀린 제네릭 타입 인자 정보는 런타임에 지워진다. 이는 제네릭 클래스 인스턴스가 인스턴스를 생성할 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다는 뜻이다.

컴파일러는 서로 다른 타입 인자를 가지는 리스트를 서로 다른 타입으로 인식하지만, 실제로는 리스트라는 같은 타입의 객체이다. 그럼에도 리스트에는 각자의 타입 인자만을 가지고 있는데, 이는 컴파일러가 타입 인자를 알고 올바른 타입의 값만 리스트에 넣도록 보장해주기 때문이다

타입소거

  • 타입 인자를 따로 저장하진 않기 때문에 실행 시점에 타입 인자를 검사할 수 없다
    • 어떤 리스트가 문자열로 이뤄진 리스트인지 다른 객체로 이뤄진 리스트인지 실행 시점에 검사 불가능
    • is 검사에서 타입 인자로 지정한 타입을 검사할 수는 없다.
  • 실행 시점에 List 타입인지 검사하기는 쉽지만, 그 List가 String인지, Person인지 알 수 있는 방법이 없다.
    • 그런 정보는 지워진다. 저장해야 하는 타입 정보의 크기가 줄어들어서 전반적인 메모리 사용량이 줄어든다는 제네릭 타입 소거의 장점이 있기도 하다.

스타 프로젝션

  • 어떤 값이 집합이나 맵이 아니라, 리스트라는 사실을 확인하기 위해서는 스타 프로젝션을 사용하면 된다.
    • 인자를 알 수 없는 제네릭 타입을 표현할 때 스타 프로젝션을 사용한다고 알아 두자
fun main(args: Array<String>) {
    printSum(listOf(1,2,3))//예상대로 동작
}
fun printSum(c: Collection<*>){
    val intList = c as? List<Int> //여기서 unchecked cast: List<*> to List<Int> 경고
        ?: throw IllegalArgumentException("List is expected")
    println(intList.sum())
}
  • as / as? 캐스팅에도 제네릭 타입을 사용할 수 있다.
    • 단, 기저 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공한다는 사실에 주의하자
  • 실행 시점에는 제네릭 타입의 타입 인자를 알 수 없으므로 캐스팅은 항상 성공하지만, 그런 타입 캐스팅을 사용하면 컴파일러가 unchecked cast이라는 경고를 해준다
  • 하지만 컴파일러는 단순 경고만 하고 컴파일을 진행하므로 제네릭 타입으로 캐스팅은 된다.
fun main(args: Array<String>) {
    printSum(setOf(1, 2, 3)) //집합은 리스트가 아니므로 예외가 발생한다
    printSum(listOf("a", "b", "c")) // as? 캐스팅은 성공하지만 나중에 다른 예외가 발생한다
}
  • 정수 집합에 대해서는(Set) 예외를 발생시킨다(IllegalArgumentException)
  • 잘못된 타입의 원소가 들어있는 리스트를 전달하면 ClassCastException이 발생한다
    • 어떤 값이 List<Int> 인지 검사할 수는 없으므로 IllegalArgumentException이 발생하지 않고 as? 캐스트가 성공하고, 문자열 리스트에 대해 sum 함수가 호출된다.
    • sum이 실행되는 도중에 예외가 발생하는 데 String -> Number로 사용하려면 ClassCastException이 발생한다.
fun printSum(c: Collection<Int>){
    if(c is List<Int>){ //이 검사는 올바르다
        println(c.sum())
    }
}
  • 코틀린 컴파일러는 컴파일 시점에 타입 정보가 주어진 경우에는 is검사를 수행하게 허용한다.
  • 컴파일 시점에 c 컬렉션이 Int값을 저장한다는 사실이 알려져 있으므로 c 가 List<Int>인지 검사 가능하다.
  • 일반적으로 코틀린 컴파일러는 우리들에게 안전하지 못한 검사와 수행할 수 있는 검사를 알려주기 위해 노력한다는 점을 알고 있자

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

함수의 본문에서는 호출 시 쓰인 타입 인자를 알 수 없다

코틀란 제네릭 타입의 타입 인자 정보는 실행 시점에 지워지므로, 제네릭 클래스의 인스턴스가 있어도 그 인스턴스를 만들 때 사용한 타입 인자를 알아낼 수 없다. 제네릭 함수의 타입 인자도 마찬가지로 제네릭 함수가 호출돼도 그 함수의 본문에서는 호출 시 쓰인 타입 인자를 알 수 없다.

fun main(args: Array<String>) {
    println(isA<String>("abc"))
    println(isA<String>(123))
}
inline fun <reified T> isA(value: Any) = value is T //이코드는 컴파일이 된다.
  • 인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.
  • isA함수를 인라인 함수로 만들고 타입 파라미터를 reified로 지정하면, value의 타입이 T의 인스턴스인지를 실행 시점에 검사할 수 있다.
fun main(args: Array<String>) {
    val items = listOf("One", 2, "three")
    println(items.filterIsInstance<String>())
}
  • 실체화한 타입 파라미터를 사용하는 예이다.
  • 인자로 받은 컬렉션의 원소 중에서 타입 인자로 지정한 클래스의 인스턴스만을 모아서 반환한다.
  • filterIsInstance의 타입인자로 String을 지정함으로써 문자열만 필요하다는 사실을 기술한다.
    • 타입 인자를 실행 시점에 알 수 있고, filterIsInstance는 그 타입 인자를 사용해 리스트의 원소 중에 타입 인자와 타입이 일치하는 원소만을 추려낼 수 있다.
public inline fun <reified R> Iterable<*>.filterIsInstance(): List<@kotlin.internal.NoInfer R> {
    return filterIsInstanceTo(ArrayList<R>())
}

/**
 * Appends all elements that are instances of specified type parameter R to the given [destination].
 * 
 * @sample samples.collections.Collections.Filtering.filterIsInstanceTo
 */
public inline fun <reified R, C : MutableCollection<in R>> Iterable<*>.filterIsInstanceTo(destination: C): C {
    for (element in this) if (element is R) destination.add(element)
    return destination
}
  • filterIsIntanceTo 함수에서 각 원소가 타입인자로 지정한 클래스의 인스턴스인지 검사할 수 있다.
  • 인라인 함수에는 실체화한 타입 파라미터가 여럿 있거나 실체화한 타입 파라미터와 실체화하지 않은 타입 파라미터가 함께 있을 수도 있다.
    • 람다를 파라미터로 받지 않지만 filterIsInstance를 인라인 함수로 정의했다는 점에 유의하자
  • 이 경우는 함수를 inline으로 만드는 이유가 성능 향상이 아니라 실체화한 타입 파라미터를 사용하기 위함이다

TIP : 인라인 함수에서만 타입 인자를 쓸 수 있는 이유

컴파일러는 인라인 함수의 본문을 구현한 바이트코드를 그 함수가 호출되는 모든 지점에 삽입하며, 컴파일러는 실체화한 타입 인자를 사용해 인라인 함수를 호출하는 각 부분의 정확한 타입 인자를 알 수 있다. 따라서 컴파일러는 타입 인자로 쓰인 구체적인 클래스를 참조하는 바이트코드를 생성해 삽입할 수 있다.

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

자바 코드에서는 reified 타입 파라미터를 사용하는 inline 함수를 호출할 수 없다. 자바에서는 코틀린 인라인 함수를 다른 보통 함수처럼 호출하고, 그런 경우 인라인 함수를 호출해도 실제로 인라이닝 되지는 않는다. 

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

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

java.lang.Class를 사요하는 API의 예로는 JDK의 ServiceLoader가 있으며, 어떤 추상 클래스나 인터페이스를 표현하는 java.lang.Class를 받아서 그 클래스나 인스턴스를 구현한 인스턴스를 반환한다.

fun main(args: Array<String>) {
    //val serviceImpl = ServiceLoader.load(Service::class.java) //표준 자바 API인 ServiceLoader를 사용해 서비스를 읽어 온다.
    val serviceImpl = loadService<Service>() //타입 파라미터를 이용해 작성
}
inline fun <reified T> loadService(): ServiceLoader<T>? { //타입 파라미터를 reified 로 표시한다  
    return ServiceLoader.load(T::class.java) //T::class 로 타입 파라미터의 클래스를 가져온다
}
  • ::class.java 구문은 코틀린 클래스에 대응하는 java.lang.Class 참조를 얻는 방법을 보여준다.
  • 읽어 들일 서비스를 loadService 함수의 타입인자로 지정한다.

실체화한 타입 파라미터의 제약

실체화한 타입 파라미터는 몇가지 제약이 있다. 일부는 실체화의 개념으로 생기는 제약이고, 나머지는 지금 코틀린이 실체화를 구현하는 방식에 의해 생기는 제약으로 향후 완화될 가능성이 있다.

실체화한 타입 파라미터를 사용할 수 있는 경우

  • 타입 검사와 캐스팅(is, !is, as, as?)
  • 코틀린 리플랙션 API(::class)
  • 코틀린 타입에 대응하는 java.lang.class를 얻기(::class.java)
  • 다른 함수를 호출할 때 타입 인자로 사용

사용할 수 없는 경우

  • 타입 파라미터 클래스의 인스턴스 생성
  • 타입 파라미터 클래스의 동반 객체 메서드 호출하기
  • 실체화한 타입 파라미터를 요구하는 함수를 호출하면서 실체화하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기
  • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기
    • 실체화한 타입 파라미터를 인라인 함수에서만 사용할 수 있으므로 실체화한 타입 파라미터를 사용하는 함수는 자신에게 전달되는 모든 람다와 함께 인라이닝 된다.
    • 람다 내부에서 타입 파라미터를 사용하는 방식에 따라서는 람다를 인라이닝 할 수 없는 경우가 생기기도 하고, 성능상 인라이닝을 하고 싶지 않을 경우가 있는데 이럴떄는 noinline 변경자를 함수 타입 파라미터에 붙여서 인라이닝을 금지할 수 있다.
728x90

'KotlinInAction > 제네릭스' 카테고리의 다른 글

변성: 제네릭과 하위 타입  (0) 2022.08.15
제네릭 타입 파라미터  (0) 2022.08.12