Kotlin

[Kotlin] 리플렉션(Reflection) -1 (KClass, KCallable, KFunction, KProperty)

sh1mj1 2024. 4. 11. 11:12


리플렉션(reflection)을 사용하면 런타임에 컴파일러 내부 구조를 분석할 수 있다.

이전 글과 마찬가지로, 이번 글에서는 JKid 에서 리플렉션 API 를 사용하는 방법을 직렬화, JSON 파싱, 역직렬화 순으로 살펴본다.

이전 글: 애노테이션-1(적용과 사용 지점 대상) , 애노테이션-2(JKid 라이브러리를 통해 알아보기)  

리플렉션 소개

간단히 말해 리플렉션런타임에(동적으로) 객체의 프로퍼티와 메서드에 접근할 수 있게 해주는 방법이다.

 

보통 객체의 메서드나 프로퍼티에 접근할 때는 소스 코드 안에  구체적인 선언이 있는 메서드나 프로퍼티 이름을 사용한다.

그리고 컴파일러는 그런 이름이 실제로 가리키는 선언을 컴파일 타임에(정적으로) 찾아내서 해당하는 선언이 실제 존재함을 보장한다.

만약 정적으로 찾았지만 존재하지 않다면, 컴파일 에러가 발생하는 것이다.

 

하지만 타입과 관계없이 객체를 다루어야 할 때가 있다.

혹은 객체가 제공하는 메서드나 프로퍼티 이름을 오직 런타임에만 알 수 있는 경우가 있다.

JSON 직렬화 라이브러리가 그런 경우이다.

 

직렬화 라이브러리는 어떤 객체든 JSON 으로 변환할 수 있어야 한다.

그리고 런타임이 되기 전까지는 라이브러리가 직렬화할 프로퍼티나 클래스에 대한 정보를 알 수 없다.

이런 경우에 리플렉션을 사용해야 한다.

자바 표준 리플렉션 API

자바가 `java.lang.reflect`패키지를 통해 제공하는 표준 리플렉션 API 가 있다.

코틀린 클래스는 일반 자바 바이트코드로 컴파일되므로 자바 리플렉션 API도 코틀린 클래스를 컴파일한 바이트코드를 완벽히 지원한다.

코틀린 리플렉션 API

코틀린이 `kotlin.reflect`패키지를 통해 제공하는 코틀린 리플렉션 API 가 있다.

이 API 는 자바에는 없는 프로퍼티나 nullable 타입과 같은 코틀린 고유 개념에 대한 리플렉션을 제공한다.

하지만 코틀린 리플렉션 API 가 자바 표준 리플렉션 API 를 완전히 대체할 정도로 많은 기능을 제공하지는 않는다.

물론, 코틀린 리플렉션 API 를 사용해도 다른 JVM 언어에서 생성한 바이트코드를 충분히 다룰 수 있다.

코틀린 리플렉션 API: KClass, KCallable, KFunction, KProperty

`java.lang.Class`에 해당하는 `KClass`를 사용하면 클래스 안에 있는 모든 선언을 열거하고 각 선언에 접근할 수 있다.

또 클래스의 상위 클래스를 얻는 등의 작업이 가능하다.

class Person(val name: String, val age: Int)

val person = Person("Alice", 29)
val kClass = person.javaClass.kotlin // KClass<Person>의 인스턴스를 리턴
println(kClass.simpleName) // Person 출력

kClass.memberProperties.forEach { println(it.name) } 
/*
name
age 출력
*/

 갑자기 새로운 타입들이 튀어나와서 어지럽다. 천천히 알아보자. `Class` 와 `KClass` 가 뭘까?

Class

Java의 `Class`타입은 JVM에서 클래스의 메타데이터를 표현한다.

이를 통해 런타임에 클래스의 이름, 필드, 메소드, 슈퍼클래스 등의 정보를 조회할 수 있다.

예를 들어, Java에서는 `Object.getClass()`메소드나 `ClassName.class`문법을 사용하여 어떤 객체나 클래스의 Class 객체를 얻을 수 있다.

KClass

`KClass`Kotlin에서 클래스의 메타데이터를 표현하며, `Class`에 해당하는 Kotlin 버전이다. `KClass`를 통해 클래스의 이름, 속성(properties), 메소드(functions) 등에 대한 정보를 조회할 수 있다.

Kotlin에서는 `ClassName::class`를 사용하여 어떤 클래스의 KClass 인스턴스를 얻을 수 있다.

또한, 런타임에 객체로부터 해당 객체의 클래스를 얻기 위해서는 `객체.javaClass.kotlin`을 사용한다.

여기서 `javaClass`는 객체의 자바 `Class`객체를 반환하고, `.kotlin`확장 프로퍼티를 통해 이를 `KClass`객체로 변환한다.

 

`KClass` 선언을 보면, 클래스의 내부를 살펴볼 때 사용할 수 있는 다양한 메서드를 볼수 있다.

public actual interface KClass<T : Any> : KDeclarationContainer, KAnnotatedElement, KClassifier {
    public actual val simpleName: String?
    public actual val qualifiedName: String?
    override val members: Collection<KCallable<*>>
    public val constructors: Collection<KFunction<T>>
    public val nestedClasses: Collection<KClass<*>>
    public val objectInstance: T?
    // ...
}

 위에서 사용한 `memberProperties`를 비롯해 KClass 에 대해 사용할 수 있는 다양한 기능은 실제로는 kotlin-reflect 라이브러리를 통해 제공하는 확장함수이다.

`kotlin.reflect.full`라는 패키지에 `KClasses.kt`라는 파일에 확장 함수들이 있다.

KCallable

`KClass`의 `members`는 `KCallable`이 원소인 컬렉션이다.

`KCallable`은 함수와 프로퍼티를 아우르는 공통 상위 인터페이스이다. 그 안에는 `call`이라는 메서드가 있다. 

`call`을 사용하면 함수나 프로퍼티의 getter 를 호출할 수 있다.

// Represents a callable entity, such as a function or a property
public actual interface KCallable<out R> : KAnnotatedElement {
    public actual val name: String
   
    public val parameters: List<KParameter>
    public val returnType: KType
    @SinceKotlin("1.1")
    public val typeParameters: List<KTypeParameter>
    
    /**
     * Calls this callable with the specified list of arguments and returns the result.
     * Throws an exception if the number of specified arguments is not equal to the size of [parameters],
     * or if their types do not match the types of the parameters.
     */
    public fun call(vararg args: Any?): R
    
    // ...
}

 아래처럼 리플렉션이 제공하는 `call`을 사용해 함수를 호출할 수 있다.

fun foo(x: Int) = println(x)

val kFunction = ::foo
kFunction.call(42) // 42 출력

 `call`에 붙은 주석 설명처럼 `call`에 넘기는 인자 개수와 원래 함수에 정의된 파라미터 개수가 맞아 떨어져야 한다.

안 그러면 `"IllegalArgumentException: Callable expects 1 arguments, but 0 were perovided"`의 모습으로 런타임 예외가 발생한다.

KParameter

`KParameter`는 함수나 생성자의 파라미터(매개변수)를 나타내는 인터페이스이다.

이 인터페이스를 통해 파라미터의 이름, 타입, 옵션(예: 기본값이 있는지 여부) 등의 정보에 접근할 수 있다.

KParamter 의 주요 속성과 메서드는 아래와 같다.

  • `name`: 파라미터의 이름을 나타내는 `String` 값이다. 파라미터 이름은 컴파일 타임에 parameters 옵션을 사용해서 컴파일했을 때만 사용 가능하다.
  • `type`: 파라미터의 타입을 나타내는 `KType` 객체이다. 이를 통해 파라미터의 타입 정보에 접근할 수 있다.
  • `kind`: 파라미터의 종류를 나태난다. 예를 들어 일반 파라미터인지, 생성자 파라미터인지 구분한다.
  • `isOptional`: 파라미터의 기본값이 지정되어 있는지 여부를 나타낸다.
  • `index`: 함수/생성자 정의에서 파라미터의 위치(index)를 나타낸다.

간단한 사용 예시를 보자.

fun sum(a: Int, b: Int): Int = a + b

이제 이 함수의 KFunction 인스턴스를 얻고, 그 인스턴스를 통해 파라미터 정보에 접근하는 방법을 보자.

import kotlin.reflect.full.*
import kotlin.reflect.KFunction
import kotlin.reflect.KParameter

// 함수 참조를 통해 KFunction 인스턴스 얻기
val sumFunction: KFunction<Int> = ::sum

// 함수의 파라미터들을 순회하며 정보 출력
sumFunction.parameters.forEach { param: KParameter ->
    println("Parameter name: ${param.name}, type: ${param.type}")
}

/* 출력
Parameter name: a, type: kotlin.Int
Parameter name: b, type: kotlin.Int
*/

 

`KParameter`는 코틀린에서 리플렉션을 사용하여 함수나 생성자의 파라미터에 대한 정보를 동적으로 조사하고 다루기 위한 핵심 요소이다.

이를 통해 타입, 이름, 기본값 존재 여부 등 파라미터에 관한 다양한 메타데이터에 접근할 수 있다.

KFunction

// Represents a function with introspection capabilities.
public expect interface KFunction<out R> : KCallable<R>, Function<R>

함수를 호출하기 위해 더 구체적인 메서드를 사용할 수도 있다.

`::foo`의 타입 `KFunction1<Int, Unit>`에는 파라미터와 리턴값 타입 정보가 들어 있다. `1`은 이 함수의 파라미터가 1개라는 의미이다.

`KFunction1` 인터페이스를 통해 함수를 호출하려면 `invoke` 메서드를 사용해야 한다.

`invoke`는 정해진 개수의 인자만을 받아들이며 인자 타입은 `KFunction1` 제네릭 인터페이스의 첫번째 타입 파라미터와 같다.

`invoke` 키워드 없이 `kFunction`을 직접 호출할 수도 있다.

import kotlin.reflect.KFunction2

fun sum(x: Int, y: Int) = x + y

val kFunction: KFunction2<Int, Int, Int> = ::sum
println(kFunction.invoke(1,2) + kFunction.invoke(3,4)) // 출력 10

kFunction(1) // [COMPILE ERROR] No value passed for parameter 'p2'

 `KFunction`의 `invoke` 메서드를 호출할 때는 인자 개수나 타입이 맞아 떨어지지 않으면 컴파일되지 않는다.

그러므로 `KFunction` 의 인자 타입과 리턴 타입을 모두 안다면 `call` 보다 `invoke` 메서드를 호출하는 게 낫다.

`KFunctionN`는 어떻게 가능할까?

이런 함수타입들은 컴파일러가 생성한 합성 타입(synthetic compiler-generated type)이다.
그래서 `kotlin.reflect` 패키지에서 이 타입의 정의를 찾을 수 없다.
컴파일러가 생성한 합성 타입을 사용하기 때문에 원하는 수만큼 많은 파라미터를 갖는 함수에 대한 인터페이스를 사용할 수 있다.
이로써 kotlin-runtime.jar 의 크기를 줄일 수 있고 파라미터 개수에 대한 인위적인 제약을 피할 수 있다.

KProperty

`KProperty`의 `call`은 프로퍼티의 게터를 호출한다.

하지만 프로퍼티 인터페이스는 프로퍼티 값을 얻는 더 좋은 방법 `get` 메서드를 제공한다.

  • 최상위 프로퍼티

최상위 프로퍼티는 `KProperty0` 인터페이스의 인스턴스로 표현된다.

`KProperty0` 안에는 인자가 없는 get 메서드가 있다.

// 최상위 프로퍼티
var counter = 0

val kProperty = ::counter
kProperty.setter.call(21) // 리플렉션 기능을 통해 setter 를 호출하면서 21 을 인자로 넘긴다
println(kProperty.get()) // 출력: 21 , get 을 호출해서 프로퍼티 값을 가져온다.

멤버 프로퍼티는 `KProperty1` 인스턴스로 표현된다. 그 안에는 인자가 1개인 get 메서드가 들어 있다.

멤버 프로퍼티는 어떤 객체에 속해있는 프로퍼티이다.

그래서 멤버 프로퍼티의 값을 가져오려면 `get` 메서드에게 프로퍼티를 얻고자 하는 객체 인스턴스를 넘겨야 한다.

class Person(val name: String, val age: Int)

val person = Person("Alice", 29)
val memberProperty = Person::age
println(memberProperty.get(person))

 여기서 `memberProperty`의 타입은 `KProperty1<Person, Int>` 가 된다.

`KProperty1`은 제네릭 클래스다.

// Represents a property, operations on which take one receiver as a parameter.
public expect interface KProperty1<T, out V> : KProperty<V>, (T) -> V {
    public fun get(receiver: T): V
}

 `T` 는 수신 객체 타입, `V`는 프로퍼티 타입을 의미한다.

즉, 수신 객체를 넘기려면 `KProperty1` 의 타입 파라미터와 일치하는 타입의 객체만을 넘길 수 있다.

코틀린 리플렉션 API 인터페이스 계층 구조

  • `KClass`: 클래스와 객체를 표현할 때
  • `KProperty`: 모든 프로퍼티를 표현 가능
  • `KMutableProperty`: var 로 정의한 병경 가능한 프로퍼티를 표현
  • `KProperty.Getter`, `KMutableProperty.Setter`: 이를 통해 접근자를 함수처럼 다룰 수 있음.

이렇게 리플렉션 API 에 대한 기본적인 내용을 배웠다.

이제 다음 글에서부터 JKid 라이브러리 구현에 대해 살펴보면서 공부하자.