[Kotlin] 런타임 시 제네릭의 동작: 타입 소거(Type erasure), reified 타입 파라미터
Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.
런타임의 제네릭: 타입 소거(type erasure)
자바와 마찬가지로 코틀린 제네릭 타입 인자 정보는 런타임에 지워진다.
제네릭 인스턴스가 생성될 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다.
예를 들어 `List<String>` 객체를 만들더라도, 런타임에는 그 객체를 오직 `List` 로만 볼 수 있다.
즉, 그 `List` 객체가 어떤 타입의 원소를 저장하는지 런타임에는 알 수 없다.
이를 타입 소거 (type erasure) 라고 한다.
런타임에 둘은 완전히 같은 타입의 객체이다.
하지만 컴파일러가 타입 인자를 알고 올바른 타입의 값만 각 리스트에 넣도록 보장해주기 때문에 `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` 변경자를 함수 타입 파라미터에 붙여서 인라이닝을 금지할 수 있다.