Kotlin 제네릭과 타입 소거

JVM의 제네릭은 타입 소거(Type erasure)를 사용해 구형한다. 타입 소거란 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자의 정보가 들어가지 않는다는 뜻이다.

타입 소거

자바와 마찬가지로 코틀린의 제네릭 타입 인자 정보는 런타임에 지워진다. List<String> 객체를 만들고 그 안에 문자열을 여러 개 넣더라도 실행 시점에는 List로만 볼 수 있다.

val list1: List<String> = listOf("a", "b")
val list2: List<Int> = listOf(1, 2, 3)

컴파일러는 두 리스트를 서로 다른 타입으로 인식하지만 실행 시점에 둘은 완전히 같은 타입의 객체가 된다. 그렇지만 컴파일 타임에 타입 인자를 알고 올바른 타입의 값만 넣도록 보장해주기 때문에 실행 시점에서도 올바른 타입만 들어있을 것이라 가정할 수 있다.

타입 소거의 한계

타입 소거로 인해 실행 시점에 타입 인자를 검사할 수 없게 된다. 일반적으로 is 검사를 사용할 때 타입 인자로 지정한 타입을 검사할 수 없다.

if (value is List<String>) // ERROR: Cannot check for instance of erased type

다만 코틀린 컴파일러는 컴파일 타입 시점에 타입 정보가 주어진 경우에는 is 검사를 수행하게 허용할 수 있다.

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

스타 프로젝션

코틀린은 타입 인자를 명시하지 않고 제네릭 타입을 사용할 수 없다(raw 타입을 허용하지 않는다). 어떤 값이 List이나 Set인지 검사하려면 스타 프로젝션(Star projection)을 사용하면 된다.

if (value is List<*>) { /* ... */ }

타입 파라미터가 여러개면 모든 타입 파라미터에 *을 포함시켜야 한다.

as, as? 캐스팅에도 제네릭 타입을 사용할 수 있다. 그러나 기저 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 캐스팅에 성공하기 때문에 조심해서 사용해야 한다. 실행 시점에 제네릭 타입 인자를 알 수 없기 때문에 캐스팅은 항상 성공한다. 이런 상황에서 컴파일러는 Unchecked cast경고를 해준다.

fun printSum(c: Collection<*>) {
  val intList = c as? List<Int> // Unchecked cast
  	?: throw IllegalArgumentException("List is excepted")
  println(intList.sum())
}

printSum(listOf("a", "b", "c")) // ClassCastException!

잘못된 타입의 리스트가 전달될 경우 실행 시점에 ClassCastException이 발생한다.

실체화된 타입 파라미터

타입 소거의 제약을 피할 수 있는 경우가 하나 있다. 인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다. 함수를 인라인 함수로 만들고 타입 파라미터를 reified로 지정하면 어떤 타입이 T의 인스턴스인지 실행 시점에 검사할 수 있다.

fun <T> isA(value: Any) = value is T // Cannot check for instance of erased type: T
inline fun <reified T> isA(value: Any) = value is T // OK
inline fun <reified T> Iterable<*>.filterIsInstance(): List<T> {
  val destination = mutableListOf<T>()
  for (element in this) {
    if(element is T) {
      destination.add(element)
    }
  }
}

작동 원리

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

실제화된 타입 파라미터로 클래스 참조 대신하기

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

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

::class.java 구문으로 코틀린 클래스에 대응하는 java.lang.Class 참조를 얻는 방법이다. 이것을 구체회한 타입 파라미터를 사용해 다시 작성할 수 있다.

val serviceImpl = loadService<Service>()

loadService는 다음과 같이 정의할 수 있다.

inline fun <reified T> loadService() {
  return ServiceLoader.load(T::class.java)
}

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

실체화된 타입 파라미터는 몇가지 제약이 있다. 다음과 같은 경우에만 실체화된 타입 파라미터를 사용할 수 있다.

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

다음과 같은 일은 할 수 없다.

  • 타입 파라미터 클래스의 인스턴스 생성
  • 타입 파라미터 클래스의 Companion 메소드 호출하기
  • 실체화한 타입 파라미터를 요구하는 함수를 호출하면서 실체화하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기
  • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기

© 2021. All rights reserved.

Powered by Hydejack v9.1.6