본문 바로가기

코틀린

코틀린(Kotlin) - 런타임에서의 제네릭의 동작

알고 계시겠지만 JVM의 제네릭은 보통 타입 소거(type erasure)를 사용해서 구현됩니다.

 

타입 소거(type erasure)
- 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것입니다.
- 즉, 컴파일 타임에만 타입에 대한 제약 조건을 적용하고, 런타임에는 타입에 대한 정보를 제거한다는 뜻입니다.

 

이 말은 런타임에서 제네릭 클래스의 인스턴스에 타입 파라미터 정보가 들어있지 않다는 뜻입니다.

 

이번에는 코틀린 타입 소거가 어떤 영향을 끼치는지 살펴보고 함수를 inline으로 선언함으로써 이런 제약을 어떻게 우회할 수 있는지 확인하도록 하겠습니다.

 

먼저 결론부터 말씀드리자면, 함수를 inline으로 만들면 타입 파라미터가 소거되지 않게 할 수 있습니다.

 

런타임에서의 제네릭 : 타입 검사와 캐스트

자바와 마찬가지로 코틀린 제네릭 타입 파라미터 정보는 런타임에서 지워집니다

이는 제네릭 클래스 인스턴스가 생성할 때 쓰인 타입 파라미터에 대한 정보를 유지하지 않는다는 뜻입니다.

예를 들어 List<String> 객체를 만들고 그 안에 문자열을 여러 개 넣더라도 런타임에서는 단지 List로만 볼 수 있게 됩니다.

이 List 객체가 어떤 타입의 원소를 저장하는지 런타임에서는 알 수가 없습니다.

(물론 원소를 하나 읽어오면 그 타입을 검사할 수는 있지만, 그렇게 한 원소의 타입을 안다고 해도 여러 원소가 서로 다른 타입일 수도 있으므로 결과적으론 모든 원소를 검사해야만 하게 됩니다.)

 

코드가 실행될 때 다음 두 리스트에 어떤 일이 벌어질까요?

 

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

 

런타임에서는 각 객체는 그저 List일 뿐입니다.

 

컴파일러는 두 리스트를 서로 다른 타입으로 인식하지만 런타임에는 list1이나 list2가 어떤 타입의 리스트로 선언됐다는 사실을 알 수 없고, 이 두 객체는 그저 List일 뿐입니다.

 

이러한 타입 소거로 인해 생기는 한계로는, 런타임 시 타입 파라미터 정보가 없기 때문에 타입 파라미터를 검사할 수 없습니다.

예를 들어, 어떤 리스트가 문자열로 이뤄진 리스트인지 아니면 다른 타입으로 이뤄진 리스트인지를 런타임에서는 검사할 수 없습니다.

일반적으로 말하자면 is 검사에서 타입 파라미터로 지정한 타입을 검사할 수가 없다는 뜻입니다.

예로 아래와 같은 코드는 컴파일 시 오류를 발생시킵니다.

 

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

 

런타임 시 어떤 값이 List 인지는 확실히 알아낼 수는 있지만, 그 리스트가 String의 리스트인지 Int의 리스트인지는 알 수가 없습니다.

이러한 타입 파라미터 정보는 타입 소거로 지워지기 때문입니다.

다만 저장해둬야 하는 타입 정보의 크기가 줄어들기 때문에 전반적인 메모리 사용량이 줄어든다는 제네릭 타입 소거의 나름의 장점이 있긴 합니다...ㅎㅎ

 

앞에서 말한 대로 코틀린에서는 타입 파라미터를 명시하지 않고는 제네릭 타입을 사용할 수 없습니다.

그렇다면 어떤 값이 Map이나 다른 객체가 아니라 List 라는 사실을 어떻게 확인할 수 있을까요?

 

그것은 바로 스타 프로젝션(star projection)을 사용하면 됩니다.

 

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

 

타입 파라미터를 알 수 없는 제네릭 타입을 표현할 때(자바의 List<?>와 비슷) 스타 프로젝션을 쓰게 됩니다.

 

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

 

/* 타입 정보가 주어진 경우 타입 검사하기 */
fun printSum(c: Collection<Int>) {
   if (c is List<Int>) {
      println(c.sum())
   }
}

 

컴파일 시점에 c 컬렉션이 Int 타입이라는 것이 알려져 있기 때문에 c가 List<Int> 인지 검사할 수 있는 이유입니다.

 

이처럼 코틀린은 제네릭 함수의 본문에서는 그 함수의 타입 파라미터를 가리킬 수 있는 기능을 제공하지 않습니다.

하지만, inline 함수 안에서는 타입 파라미터를 사용할 수 있습니다.

 

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

앞에서 알아봤듯이 런타임 시점에서 제네릭 타입 파라미터 정보가 소거되기 때문에 제네릭 클래스의 인스턴스가 있어도 그 인스턴스가 어떤 타입 파라미터를 사용했는지는 알아낼 수가 없습니다.

제네릭 함수도 동일하게 그 함수가 호출돼도 함수 본문에서는 호출 시 쓰인 타입 파라미터를 알 수가 없습니다.

 

 

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

인라인(inline) 함수의 타입 파라미터는 실체화되므로 런타임 시점에서 인라인 함수의 타입 파라미터를 알 수 있습니다.

 

인라인(inline) 함수는 컴파일러가 그 함수를 호출한 식을 모두 함수 본문으로 바꿉니다.

함수가 람다를 파라미터로 사용하는 경우 그 함수를 인라인 함수로 만들면 람다 코드도 함께 인라이닝되고, 그에 따라 임의의 클래스와 객체가 생성되지 않아서 성능이 더 좋아지기도 합니다.

 

2021.05.25 - [코틀린] - 코틀린(Kotlin) - inline 함수 : 람다의 부가 비용 없애기

 

코틀린(Kotlin) - inline 함수 : 람다의 부가 비용 없애기

람다의 경우 컴파일 단계에서 파라미터 개수에 따라 FunctionN 형태의 인터페이스로 변환이 됩니다. 예를 들어 아래와 같이 파라미터가 두 개인 람다 식은 Function2 의 인터페이스로 변환이 되는 것

0391kjy.tistory.com

 

위에서 만든 genericTest 함수를 인라인(inline) 함수로 만들고 타입 파라미터를 reified로 지정하면 value의 타입이 T의 인스턴스인지를 런타임 시점에 알 수 있게 됩니다.

 

 

/* 실체화한 타입 파라미터를 사용하는 인라인 함수 정의하기 */
inline fun <reified T> genericTest(value: Any) = value is T  //컴파일 가능해짐

>>> println(genericTest<String>("ABC"))
true

>>> println(genericTest<String>(123))
false

 

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

이 함수는 컬렉션의 원소 중에서 타입 파라미터로 지정한 클래스의 인스턴스만 모아서 리스트를 반환합니다.

 

/* 실체화한 타입 파라미터 예로, filterIsInstance 라이브러리 함수 사용하기 */
>>> val items = listOf("one", 2, "three")
>>> println(items.filterIsInstance<String>())
[one, three]

 

filterIsInstance의 타입으로 String을 지정함으로써 문자열만 필요하다는 사실을 명시하게 됩니다.

그러므로 이 함수의 리턴 타입은 List<String> 입니다.

여기서 우리가 주목해야 하는 건 타입 파라미터를 런타임 시점에 알 수 있고 filterIsInstance는 그 타입을 이용해 리스트의 원소 중에 타입과 일치하는 원소만 추려낼 수 있는 겁니다.

 

/* filterIsInstance 함수 내부의 타입 검사 구문 */
//"reified"는 이 타입 파라미터가 런타임 시점에 지워지지 않음을 표시한다.
public inline fun <reified T>
   Iterable<*>.filterIsInstance() : List<T> {  
      ...
      for (element in this) {
         if (element is T) {  //타입 파라미터로 지정한 클래스의 인스턴스인지 검사할 수 있다.
            destination.add(element)
         }
      }
      return destination
}

 

Q. 왜 일반 함수에서는 쓸 수 없고, 인라인(inline) 함수에서만 실체화된 타입 파라미터를 쓸 수 있는 이유는?

A. 인라인 함수는 컴파일 시에 함수 본문 바이트코드가 해당 함수를 호출한 지점에 삽입되는 원리입니다.
그렇기 때문에 컴파일러는 실체화된 타입 파라미터를 사용해 인라인 함수를 호출하는 각 부분의 정확한 타입을 알 수가 있습니다.
따라서 컴파일러는 타입 파라미터로 쓰인 클래스를 참조하는 바이트코드를 생성해서 해당 함수 호출 부분에 삽입할 수 있게 되는 겁니다.

인라인 함수는 타입 파라미터가 아니라 구체적인 타입을 사용하므로 생성된 바이트코드는 런타임 시점에서의 타입 소거 영향을 받지 않습니다.

자바에서는 reified 타입 파라미터를 사용하는 inline 함수를 호출할 수 없다는 점을 주의해야 합니다.
자바에서는 코틀린 인라인 함수를 일반 보통 함수처럼 호출하기 때문에 인라인 함수를 호출하더라도 실제로 인라이닝 되지 않습니다.

 

인라인 함수를 만드는 이유가 성능 향상의 이점도 있지만, 여기에서는 실체화 된 타입 파라미터를 사용하기 위해서 인라인 함수를 사용하였습니다.

 

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

java.lang.Class 를 사용하는 API에 대해서 코틀린 코드를 작성하는 경우 실체화한 타입 파라미터를 사용하면 코드를 훨씬 간결하게 만들 수 있습니다.

간단한 예로 안드로이드의 startActivity 함수를 간단하게 만들어 볼 수 있습니다.

액티비티 클래스를 java.lang.Class로 전달하는 대신에 실체화한 타입 파라미터를 사용하면 됩니다.

 

/* reified로 startActivity 함수 간단하게 만들기 */
//타입 파라미터를 "reified"로 표시한다.
inline fun <reified T : Activity> Context.startActivity() {
   val intent = Intent(this, T::class.java)  //T::class로 타입 파라미터의 클래스를 가져온다.
   startActivity(intent)
}

>>> startActivity<DetailActivity>()  //액티비티 클래스를 명시하여 간단하게 메소드 호출

 

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

실체화한 타입 파라미터는 유용한 기능이지만 사용하는 부분에 있어 몇 가지 제약이 있습니다.

다음 아래와 같은 경우에는 실체화한 타입 파라미터를 사용할 수 있습니다.

 

사용 가능

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

 

하지만 다음 아래와 같은 경우는 사용할 수 없습니다.

 

사용 불가

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

 

마지막 제약사항으로 인해 재밌는 효과가 생기는데요, 실체화한 타입 파라미터를 인라인 함수에만 사용할 수 있으므로 실체화한 타입 파라미터를 사용하는 함수는 자신에게 전달되는 모든 람다와 함께 인라이닝 됩니다.

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

그런 경우를 위해 noinline 키워드를 함수 타입 파라미터에 붙여서 인라이닝을 금지할 수도 있으니 noinline에 대해서는 추후 뒤에서 다뤄보도록 하겠습니다.