Programming/Kotlin

코틀린(kotlin) - 실행 시 제네릭스의 동작

JunsuKim 2021. 11. 27.
728x90

소거된 타입 파라미터와 실체화된 타입 파라미터

JVM의 제네릭스는 보통 타입 소거(type erasure)를 사용해 구현된다.

이는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 뜻이다.

함수를 inline으로 만들면 타입 인자가 지워지지 않게 할 수 있다. 이를 코틀린에선 "실체화"라고 한다.

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

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

-> 제네릭 클래스 인스턴스가 그 인스턴스를 생성할 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다.

예를 들어 List<String> 객체를 만들고 그 안에 문자열을 여럿 넣더라도 실행 시점에는 그 객체를 오직 List로만 볼 수 있고, 어떤 타입의 원소를 저장하는지 알 수 없다.'

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

위 두 List를 컴파일러는 서로 다른 타입으로 인식하지만 실행 시점에선 완전히 같은 타입의 객체이다.

하지만 컴파일러가 타입 인자를 알고 올바른 타입의 값만 각 리스트에 넣어주도록 보장하여 List<String>에는 문자열만, 

List<Int>에는 정수만 들어있다고 가정할 수 있다.

 

타입 소거로 인해 생기는 한계를 보면,

타입 인자를 따로 저장하지 않아 실행 시점에 타입 인자를 검사할 수 없다.

-> 어떤 리스트가 문자열로 이뤄진 리스트인지 다른 객체로 이뤄진 것인지 실행 시점에 검사할 수 없다.

(is 검사에서 타입 인자로 지정한 타입을 검사할 수 없다.)

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

 

앞에서 봤듯이 타입 인자를 명시하지 않고 제네릭 타입을 사용할 수 없다.

어떤 값이 집합이나 맵이 아니라 리스트라는 사실을 확인하려면 스타 프로젝션을 사용하면 된다.

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

타입 파라미터가 2개 이상이면 모든 타입 파라미터에 *를 포함해야 한다.

 

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

하지만 기저 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공하므로 조심해야 한다.

실행 시점에는 제네릭 타입의 타입 인자를 알 수 없으므로 캐스팅은 항상 성공한다.

이런 타입 캐스팅을 사용하면 컴파일러가 "unchecked cast(검사할 수 없는 캐스팅)"이라는 경고를 해주지만 컴파일은 진행하므로 원하는 제네릭 타입으로 캐스팅해 사용해도 된다.

fun printSum(c: collection<*>) {
    val inList=  c as? List<Int>
      ?: throw IllegalArgumentException("List is expected")
    println(inList.sum())
}

>>> println(listOf(1, 2, 3))
6

정수 리스트나, 집합에 대해 함수를 호출하면 정수 리스트에 대해선 합계를 출력하고, 집합에 대해선 IllegalArgumentException을 발생하지만, 잘못된 타입의 원소가 들어있는 리스트를 전달하면 실행 시점에 ClassCastException이 발생한다.

 

일반적으로 코틀린 컴파일러는 안전하지 못한 검사와 수행할 수 있는 검사를 알려주기 위해 최대한 노력 중이므로

컴파일러 경고의 의미와 어떤 연산이 안전한 지에 대해 알아야 한다.

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

코틀린 제네릭 타입의 타입 인자 정보는 실행 시점에 지워진다.

-> 제네릭 클래스의 인스턴스가 있어도 그 인스턴스를 만들 때 사용한 타입 인자를 알아낼 수 없다.

제네릭 함수의 타입 인자도 마찬가지로 제네릭 함수가 호출돼도 그 함수의 본문에서 호출 시 쓰인 타입 인자를 알 수 없다.

>>> fun <T> isA(value: Any) = value is T
Error: Cannot check for instance of erased type: T

이런 제약을 피할 수 있는 경우가 하나 있다.

인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.

위의 isA 함수를 인라인 함수로 만들고 타입 파라미터를 reified로 지정하면 value의 타입이 T의 인스턴스인지를 실행

시점에서 검사할 수 있다.

inline fun <reified T> isA(value: Any) = value is T
>>> println(isA<String>("abc"))
>>> println(isA<String>(123))

true
false

실체화한 타입 파라미터를 활용하는 가장 간단한 ㅇ제 중 하나는 표준 라이브러리 함수인 filterIsInstance이다.

이 함수는 인자로 밥ㄷ은 컬렉션의 원소 중에서 타입 인자로 지정한 클래스의 인스턴스만을 모아 만든 리스트를 반환한다.

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

※ 인라인 함수에서만 실체화된 타입 인자를 쓸 수 있는 이유

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

    컴파일러는 실체화한 타입 인자를 사용해 인라인 함수를 호출하는 각 부분의 정확한 타입 인자를 알 수 있으므로

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

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

    영향을 받지 않는다.

 

인라인 함수에는 실체화한 타입 파라미터가 여럿 있거나 실체화한 타입 파라미터와 실체화하지 않은 타입 파라미터가 함께 있을 수도 있다.

함수의 파라미터 중에 함수 타입인 파라미터가 있고 그 파라미터에 해당하는 인자를 함께 인라이닝함으로 얻는 이익이 더 큰 경우 함수를 인라인 함수로 만드는데, 이는 성능 향상이 아닌 실체화한 타입 파라미터를 사용하기 위함이다.

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

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

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

실체화한 타입 파라미터를 활용해 이런 API를 쉽게 호출할 수 있게 만드는 방법을 보도록 하자.

ServiceLoader를 사용해 서비스를 읽어 들이기 위한 호출 방식을 보겠다.

val serviceImp = ServiceLoader.load(Service::Class.java)

이를구체화한 타입 파라미터를 사용해 작성하면 다음과 같다.

val serviceImp = loadService<Serivce>()

이제 읽어 들일 서비스 클래스를 loadService 함수의 타입 인자로 지정한다.

클래스를 타입 인자로 지정하면 ::class.java라고 쓰는 경우보다 훨씬 더 읽고 이해하기 쉽다.

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

일반 클래스에 사용할 수 있는 ::class.java 구문을 이 경우에도 사용할 수 있다.

이를 통해 타입 파라미터로 지정된 클래스에 따른 java.lang.Class를 얻을 수 있고, 얻은 클래스 참조를 보통 때와

마찬가지로 사용할 수 있다.

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

실체화한 타입 파라미터는 유용한 도구이지만 몇 가지 제약이 있다.

일부는 실체화의 개념으로 인해 생기는 제약이며, 나머지는 지금 코틀린이 실체화를 구현하는 방식에 의해 생기는

제약으로 후에 완화될 가능성이 있다.

더 구체적으로 다음과 같은 경우에 실체화한 타입 파라미터를 사용할 수 있다.

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

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

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

실체화한 타입 파라미터를 인라인 함수에만 사용할 수 있어 실체화한 타입 파라미터를 사용하는 함수는 자신에게 전달되는 모든 람다와 함께 인라이닝된다.

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

이 경우 noinline 변경자를 함수 타입 파라미터에 붙여 인라이닝을 금지할 수 있다.

 

728x90

댓글