JVM의 제네릭스는 보통 타입 소거(type erasure)를 사용해 구현됩니다. 이는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 뜻입니다.
자바와 마찬가지로 코틀린은 제네릭 타입 인자 정보는 런타임에 지워집니다. 이는 제네릭 클래스의 인스턴스가 그 인스턴스를 생성할 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다는 뜻입니다. 예를 들어 List<String>
객체를 만들어도 실행 시점에는 그 객체를 오직 List로만 보기에 어떤 타입의 원소를 저장했는지 실행시점에는 알 수 없습니다.
val list1: List<String> = listOf("a", "b")
val list2: List<Int> = listOf(1, 2, 3)
컴파일러는 두 리스트를 서로 다른 타입으로 인식하지만 타입 소거로 인해 실행 시점에 위의 둘은 완전히 같은 타입의 객체입니다. 그래서 타입 인자를 따로 저장하지 않기 때문에 실행 시점에 타입 인자를 검사할 수 없습니다. 즉, is 검사에서 타입 인자로 지정한 타입을 검사할 수가 없습니다.
// 이 코드는 오류가 발생
fun <T> test(value: List<T>) {
if(value is List<String>) {
}
}
as나 as? 캐스팅에도 제네릭 타입을 사용할 수 있습니다. 하지만 기저 클래스(List<String>
, List<Int>
의 기저 클래스는 List)는 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공합니다. 왜냐하면 실행 시점에는 제네릭 타입의 타입 인자를 알 수 없으므로 캐스팅은 항상 성공하기 때문입니다. 이러한 캐스팅을 사용하면 컴파일러는 단지 "unchecked cast"라는 경고만 해줍니다. 예제를 통해 알아보겠습니다.
fun printSum(c: Collection<*>) {
val intList = c as? List<Int> // 이 부분에서 Unckecked cast 발생
?: throw IllegalArgumentException("List is expected")
println(intList.sum())
}
fun main() {
// 예상대로 작동
printSum(listOf(1,2,3))
// 집합은 리스트가 아니므로 throw에서 걸림
printSum(setOf(1, 2, 3))
// 기저 클래스(List)가 같기에 캐스팅에 성공하지만
// 문자열이므로 sum을 호출하는 곳에서 오류가 발생
printSum(listOf("a", "b", "c"))
}
예상한대로 List<Int>
를 인자로 넘기면 정상적으로 작동하고, Set<Int>
를 던지면 List가 아니기에 오류가 발생합니다. 위에서 주목할 점은 문자열 리스트를 전달하면 발생하는 예외입니다. 문자열 리스트는 실행 시점에 타입 소거로 인해서 정상적으로 List<Int>
타입으로 캐스팅됩니다. 하지만 sum 함수가 호출하는 과정에서 String을 Number로 사용하려 하기에 ClassCastException이 발생하는 것입니다.
물론 컴파일 시점에 타입 정보가 주어진 경우에는 is 검사를 수행할 수 있습니다. 즉, 아래의 코드는 오류가 발생하지 않습니다.
// 타입 정보가 주어짐
fun printSum(c: Collection<Int>) {
if(c is List<Int>) {
println(c.sum())
}
}
fun main() {
// 정상 실행
printSum(listOf(1, 2, 3))
// 컴파일 오류
printSum(listOf("a", "b", "c"))
}
제네릭 클래스의 인스턴스가 있어도 그 인스턴스를 만들 때 사용한 타입 인자를 알아낼 수 없습니다. 제네릭 함수의 타입 인자도 마찬가지입니다. 제네릭 함수가 호출되도 그 함수의 본문에서는 호출 시 쓰인 타입 인자를 알 수 없습니다.
// 제네릭 함수의 타입 인자
// 컴파일 오류 발생
fun <T> isA(value: Any) = value is T
이러한 제약을 피할 수 있는 경우가 하나 있습니다. 인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있습니다. 따라서 위의 코드의 isA 함수를 인라인 함수로 만들고 타입 파라미터를 reified로 지정하면 value의 타입이 T의 인스턴스인지를 실행 시점에 검사할 수 있습니다.
// inline 함수 선언 및 타입 파라미터를 reified로 지정
inline fun <reified T> isA(value: Any) = value is T // 실체화한 타입 파라미터
인라인 함수에 대한 자세한 내용은 링크에 나와있습니다.
실체화한 타입 파라미터를 사용하는 예제로는 코틀린 라이브러리에 filterIsInstance
라는 함수가 있습니다. 이 함수는 인자로 받은 컬렉션의 원소 중에서 타입 인자로 지정한 클래스의 인스턴스만을 모아서 만든 리스트를 반환합니다. 아래의 예제는 filterIsInstance의 선언을 간단하게 정리하고 실제로 사용하는 코드입니다.
// 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
}
fun main() {
val items = listOf("one", 2, "three")
// String 타입의 인스턴스만 모은 리스트 반환
println(items.filterIsInstance<String>())
}
결과 : [one, three]
그렇다면 왜 인라인 함수에서만 실체화한 타입 인자를 쓸 수 있을까요? 그 이유는 인라인 함수의 원리에 있습니다. 인라인 함수는 함수의 본문을 구현한 바이트코드를 그 함수가 호출되는 모든 지점에 삽입합니다. 컴파일러는 실체화한 타입 인자를 사용해 인라인 함수를 호출하는 각 부분의 정확한 타입 인자를 알 수 있습니다. 따라서 컴파일러는 타입 인자로 쓰인 구체적인 클래스를 참조하는 바이트코드를 생성해 삽입할 수 있습니다.
즉, filterIsInstance는 인라인 함수이기에 아래와 같이 호출되는 지점에 코드가 삽입되고, 타입 파라미터가 아니라 구체적인 타입(String)을 사용하므로 실행 시점에 벌어지는 타입 소거의 영향을 받지 않는 것입니다.
// 인라인 함수이므로 호출되는 곳에 코드가 삽입
for(element in this) {
if(element is String) {
destination.add(element)
}
}
다만 filterIsInstance는 람다를 파라미터로 받지도 않는데 인라인 함수로 정의했다는 점에 유의해야합니다. 인라인 함수는 파라미터 중에 함수 타입인 파라미터가 있고 그 파라미터에 해당하는 인자(람다)를 함께 인라이닝함으로써 얻는 이익이 큰 경우에만 함수를 인라인으로 만들라고 책에서는 권고합니다. 하지만 이 경우는 함수를 inline으로 만드는 이유가 성능 향상이 아니라 실체화한 타입 파라미터를 사용하기 위함입니다.
참조
Kotlin in action
틀린 부분은 댓글로 남겨주시면 수정하겠습니다..!!