Kotlin

[Kotlin] 런타임 시 제네릭의 동작: 타입 소거(Type erasure), reified 타입 파라미터

sh1mj1 2024. 1. 31. 17:08

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

런타임의 제네릭: 타입 소거(type erasure)

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

제네릭 인스턴스가 생성될 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다.

예를 들어 `List<String>` 객체를 만들더라도, 런타임에는 그 객체를 오직 `List` 로만 볼 수 있다.

즉, 그 `List` 객체가 어떤 타입의 원소를 저장하는지 런타임에는 알 수 없다.

이를 타입 소거 (type erasure) 라고 한다.

런타임에 list1 이나 list2 가 문자열이나 정수의 리스트로 선언되었다는 사실을 알 수 없다. 각 객체는 단지 List 일 뿐이다.

런타임에 둘은 완전히 같은 타입의 객체이다.

하지만 컴파일러가 타입 인자를 알고 올바른 타입의 값만 각 리스트에 넣도록 보장해주기 때문에 `List<String>` 에는 문자열만 들어있고 `List<Int>` 에는 정수만 들어있다고 가정할 수 있다.

  • 타입 인자를 따로 저장하지 않아서 런타임에 타입 인자를 검사할 수 없다.

어떤 리스트가 문자열로 리워진 리스트인지 다른 객체로 이뤄진 리스트인지를 런타임에 검사할 수 없다.

 

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

 위 코드를 실행하면, `Cannot check for instance of erased type: List<String>` 이라는 에러가 뜬다.

런타임에는 `value` 가 `List` 인지 여부는 알 수 있지만, 어떤 타입의 리스트인지에 대한 정보는 지워진다.

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

  • 스타 프로젝션(star projection: `*`)을 사용하여 어떤 값이 리스트(OR 다른 컬렉션의 구현)라는 사실을 확인할 수 있다.
if (value is List<*>) { ... }

 자바의 `List<?>` 와 비슷하다.

`value` 가 `List` 임을 알 수는 있지만, 그 원소 타입은 알 수 없다.

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

하지만 기반 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅이 성공한다.

이러한 타입 캐스팅을 사용하면 컴파일러는 "unchecked cast" 라는 경고를 준다.

 

제네릭 타입으로 타입 캐스팅하기

fun printSum(c: Collection<*>) {
    val intList = c as? List<Int> // [WARNING] Unchecked cast: Collection<*> to List<Int>
        ?: throw IllegalArgumentException("List is expected")
    println(intList.sum())
}
printSum(listOf(1, 2, 3)) // print /* 6 */

 컴파일러가 캐스팅 관련 경고를 한다는 것을 제외하면 모든 코드가 문제없이 컴파일된다.

 

알려진 타입 인자를 사용해 타입 검사

fun printSum2(c: Collection<Int>) {
    if (c is List<Int>) {
        println(c.sum())
    }
}

printSum2(listOf(1, 2, 3)) // print /* 6 */

 컴파일러는 컴파일 타임에 타입 정보가 주어진 경우에는 `is` 검사를 수행하게 허용한다.

이렇게 코틀린은 제네릭 함수의 바디에서 그 함수의 타입 인자를 가리킬 수 있는 특별한 기능을 제공하지 않는다.

reified 타입 파라미터를 사용한 함수 선언

`inline` 함수의 타입 파라미터는 실체화되므로 런타임에 인라인 함수의 타입 인자를 알 수 있다. 

`inline` 함수에 대해 컴파일러는 그 함수를 호출한 식을 모두 함수 바디로 바꾸어서 람다를 사용할 때의 얻을 수 있는 장점을 공부했었다. (inline 함수 글)

 

함수를 인라인 함수로 만들고, 타입 파라미터를 `reified` 로 지정하면, `value` 의 타입이 `T` 의 인스턴스인지를 런타임에 검사할 수 있다.

 

`reified` 타입 파라미터를 사용하는 함수 정의하기

inline fun <reified T> isA(value: Any?) = value is T

assertTrue(isA<String>("abc"))
assertFalse(isA<String>(123))

 

`filterIsInstance` 표준 lib 함수 간단하게 정리한 버전

// reified 키워드는 이 타입 파라미터가 런타임에 지워지지 않음을 표시함
inline fun <reified T> Iterable<*>.filterIsInstance(): List<T> { 
    val destination = mutableListOf<T>()
    for (element in this) {
        if (element is T) { // 각 원소가 타입 인자로 지정한 클래스의 인스턴스인지 검사 가능
            destination.add(element)
        }
    }
    return destination
}

 `filterIsInstance` 함수는 람다를 파라미터로 받지 않지만, 인라인 함수로 정의되어 있다!

이 경우 inline 으로 함수를 만드는 이유는 성능 향상이 아닌, `reified` 타입 파라미터를 사용하기 위함이다.

 

`filterIsInstance` 표준 lib 함수 사용하기

val items = listOf("one", 2, "three")
assert(items.filterIsInstance<String>() == listOf("one", "three"))

 `filterIsInstace` 의 타입 인자로 `String` 을 지정하여, 문자열만 필요하다는 사실을 기술했다.

여기서는 타입 인자를 런타임에 알 수 있고 `filterIsInstance` 는 그 타입 인자를 사용해 리스트의 원소 중에 타입 인자와 타입이 일치하는 원소만을 추려낼 수 있다.

왜 인라인 함수에서만 실체화한 타입 인자를 쓸 수 있나?

컴파일러는 인라인 함수의 바디를 구현한 바이트코드를 그 함수가 호출되는 모든 지점에 삽입한다.

그리고 컴파일러는 `reified` 타입 인자를 사용해 인라인 함수 호출부의 정확한 타입을 인자를 알 수 있다.

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

인라인 함수와 `reified` 타입 인자를 사용하면 타입 파라미터가 아닌 구체적인 타입을 사용하므로, 만들어진 바이트 코드는 런타임에 벌어지는 타입 소거의 영향을 받지 않는다.

자바 코드에서는 `reified` 타입 파라미터를 사용하는 `inline` 함수를 호출할 수 없다.
자바에서는 코틀린 인라인 함수를 다른 보통 함수처럼 호출하여 실제로는 인라이닝이 되지 않는다.
따라서 `reified` 타입 파라미터가 있는 인라이닝 함수를 일반 함수처럼 자바에서 호출할 수는 없다.

reified 타입 파라미터로 클래스 참조 대신

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

표준 자바 API 인 `ServiceLoader` 를 사용해 서비스를 읽어 들이려면 다음 코드처럼 호출해야 한다.

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

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

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

`Service::class.java` 코드는 자바코드 `Service.class` 와 완전히 같다. (이와 관련해서는 리플렉션 설명에서 자세히 다룸)

우리는 위 코드를 아래처럼 작성할 수 있다.

val serviceImpl = loadService<Service>()

inline fun <reified T> loadService() { // 타입 파라미터를 reified 로 표시
    return ServiceLoader.load(T::class.java) // T::class 로 타입 파라미터의 클래스를 가져온다.
}

 이렇게 되면 코드를 훨씬 더 읽고,이해하기 쉽다.

이를 통해 타입 파라미터로 지정된 클래스에 따른 `java.lang.Class` 를 얻을 수 있고, 그렇게 얻은 클래스 참조를 보통 때와 마찬가지로 사용할 수 있다.

안드로이드의 startActivity 예시

액티비티를 표시하는 과정에서 위처럼 사용할 수 있다.

액티비티의 클래스를 `java.lang.Class` 로 전달하는 대신, `reified` 한 타입 파라미터를 사용할 수 있다.

inline fun <reified T: Activity> 
        Context.startActivity() { // 타입 파라미터를 reified 로 표시
    val intent = Intent(this, T::class.java) // T::class 로 타입 파라미터의 클래스를 가져온다.
    startActivity(intent)
}

startActivity<DetailActivity>() // 액티비티를 표시하기 위해 메서드를 호출

reified 타입 파라미터의 제약

아래와 같은 경우에 `reified` 타입 파라미터를 사용할 수 있다.

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

아래와 같은 일은 할 수 없다.

  • 타입 파라미터 클래스의 인스턴스 생성하기
inline fun <reified T> createInstance(): T = T() // Error: Cannot create an instance of the type parameter T
  • 타입 파라미터 클래스의 동반 객체 메서드 호출하기
class Example<T> {
    companion object {
        fun <reified T> companionFunction(): T { // [ERROR] Only type parameters of inline functions can be reified
            /* ... */
        }
    }
}
  • reified 타입 파라미터를 요구하는 함수를 호출하면서, reified 하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기
  • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified 로 지정하기
// Error: Only type parameters of inline functions can be reified
fun <reified T> nonInlineFunction() {
   println("Non-inline function with reified type parameter")
}

 `reified` 타입 파라미터는 인라인 함수에만 사용할 수 있으므로 `reified` 타입 파라미터를 사용하는 함수는 자신에게 전달되는 모든 람다와 함께 인라이닝된다.

람다 내부에서 타입 파라미터를 사용하는 방식에 따라서는 람다를 인라이닝할 수 없는 경우가 생기기도 하고, 람다를 인라이닝 하고 싶지 않을 수도 있다. 

만약 그렇다면, `noinline` 변경자를 함수 타입 파라미터에 붙여서 인라이닝을 금지할 수 있다.