Kotlin – Reflection (4) – Create Custom Annotation


Reflection 시리즈

  1. With Java API
  2. With Kotlin API
  3. Type Erasure on the JVM
  4. Create Custom Annotation

 

이제 마지막으로 Reflection을 이용한 간단한 어노테이션을 만들어보려고 한다.

예제

먼저 데이터 클래스를 하나 만든다.

data class Contact(val id: Int, val name: String, val email: String)

그리고 만들 어노테이션에 대해 생각해보자.

여기서는 음… @Table 라고 적으면 저 데이터 클래스가 자동으로 데이터베이스의 한 테이블로서 들어가는 기능을 만들 것이다.

Kotlin 에서 어노테이션을 만드는 방법은 class 앞에 annotation을 붙여주면 된다.

annotation class Table(val name: String)

내친 김에 하나 더 만들면, 데이터 클래스에 있는 각각의 필드를 테이블의 요소로서 집어넣을 수 있을 것이다.

annotation class Field(val name: String)

그런데 이렇게 하면 문제가 있다.

@Table("ContactTable")
@Field("Contact")
data class Contact(val id: Int, @Field("name") val name: String, @Field("email") val email: String)

이런 식으로, 분명히 Contact는 클래스인데 Field 를 선언할 수 있게 하면 분명히 버그가 날 것이다.

그래서 annotation class 에 추가적으로 달 수 있는 어노테이션이 있다.

타겟 제한

이름제한하는 범위
AnnotationTarget.CLASS클래스, 인터페이스, Object, 어노테이션 클래스
AnnotationTarget.ANNOTATION_CLASS어노테이션 클래스
AnnotationTarget.TYPE_PARAMETER타입 파라미터 (제너릭)
AnnotationTarget.PROPERTY요소
AnnotationTarget.FIELD필드, 요소 포함
AnnotationTarget.LOCAL_VARIABLE지역 변수
AnnotationTarget.VALUE_PARAMETER함수 또는 생성자의 값 매개변수
AnnotationTarget.CONSTRUCTOR주 생성자, 부 생성자
AnnotationTarget.FUNCTION함수
AnnotationTarget.PROPERTY_GETTER요소 중 GETTER
AnnotationTarget.PEROPERTY_SETTER요소 중 SETTER
AnnotationTarget.TYPE타입
AnnotationTarget.EXPRESSION표현
AnnotationTarget.FILE파일
AnnotationTarget.TYPEALIAS타입 별칭

쓰는 방법은 아래와 같다.

@Target(AnnotationTarget.CLASS)
annotation class Table(val name: String)

여러개의 제한을 두고 싶다면 ,(쉼표)로 이으면 된다.

Reflection가 수행 가능한 단계

Kotlin 에서는 기본적으로 런타임 상과 코드 상에서 둘 다 가능하지만 @Retention으로 설정할 수 있다.

어노테이션 반복

@Repeatable 로 어노테이션을 반복해서 쓸 수 있다.

문서  필수화

@MustBeDocumented로 문서에 반드시 정의되게 할 수 있다.

Kotlin – Reflection (3) – Type Erasure on the JVM

이미지 출처: https://www.youtube.com/watch?v=ZPk8HuyrKXU

 


Reflection 시리즈

  1. With Java API
  2. With Kotlin API
  3. Type Erasure on the JVM
  4. Create Custom Annotation

 

Type Erasure 는 프로그램이 런타임에서 실행되기 전에 명시된 유형의 주석을 제거하는 프로세스를 뜻한다.

JVM에 익숙하다면 Generic의 Type Erasure 에 대해 알겠지만, 적어도 나는 익숙하지 않다.

문제점?

두 개의 리스트가 있다고 해보자.

val listOfStrings = listOf("One", "Two", "Three")
val listOfNumbers = listOf(1, 2, 3)

그리고 메서드를 만들어 이 리스트 안에 있는 구성요소 들이 어떤 타입인지에 따라서 다르게 처리하고 싶다.

fun <T> printList(list: List<T>) {
    when (list) {
        is List<String> -> println("This is a list of Strings")
        is List<Integer> -> println("This is a list of Integers")
    }
}

컴파일을 해보면, Cannot check for instance of erased type: List<String> 라는 오류가 발생한다.

즉, List<T> 에서 T에 들어오는 Generic는 런타임에서 실행되기 전 지워지기 때문에 판단할 수 없다는 뜻이다.

그 이유로는 예전 JVM에서 메모리를 절약하기 위해 그렇다고 한다.

여기서 리스트 가지고 할 수 있는 것은, 겨우 이 것 밖에 없다.

if (list is List<*>) {
    println("This is a list");
}

만일 제너릭을 활용한다면 이정도 까지만 가능하다.

fun <T> printList(obj: T) {
    when (obj) {
        is Int -> println("This is an int")
        is String -> println("This is an Strings")
    }
}

이 정도 까지만 가능하지만, 이 것이 과연 우리가 원하는 것일까? 하면 아니다.

해결 방법

코틀린에서는 기존 자바와 다르게 이 문제를 해결할 수 있는 방법이 있다.

바로 inlinereified의 조합이다.

fun <T> ereased(input: List<Any>) {
    if (input is T) {
        
    }
}

이와 같은 코드가 있다고 하면, 역시 T 부분에 Cannot check for instance of erased type: T란 오류가 뜰 것이다.

T 앞에 reified 를 붙여주면, T 부분에는 오류가 없어졌지만 Only type parameters of inline functions can be reified란 오류가 뜬다.

그래서 저 함수 자체를 inline 선언을 하면 드디어 코드를 쓸 수 있게 된다.

inline fun <reified T> ereased(input: List<Any>) {
    if (input is T) {

    }
}

이 것 말고도, 실제로 들어오는 코드의 실제 타입을 알 수도 있다.

inline fun <reified T> typeInfo() {
    println(T::class)
}

부를 때는 typeInfo<String>()면 되는데, 호출 결과로는 class kotlin.String 로 String 의 실제 타입이 나온다.

구체적인 reified 에 대한 설명

https://github.com/JetBrains/kotlin/blob/master/spec-docs/reified-type-parameters.md 문서를 번역했다.

정의: ‘런타임-사용 가능 타입’ 는 다음과 같은 경우에만 허용된다.

  • 형식 C를 가진다. 여기서 C는 타입 매개변수가 없거나 모든 타입 매개변수가 구체화 되어 있는 Classifier인데 Nothing 클래스는 제외한다.
  • G<A1,…,An> 라는 형식이 있을 때 (G는 n이라는 매개변수를 가지는 Classifier) 모든 유형 매개변수 T1는 적어도 아래의 조건 중 하나를 만족해야 한다.
    • T1는 reified 된 타입 매개변수이고, Ai는 타입 인수 이다.
    • Ai는 Star-projection 가 적용되어 있어야 한다. (List<*> 이면 A1은 Star-projection 이다.)
  • reified 된 T라는 타입 매개변수를 가지고 있어야 한다.

예제

  • 런타임-사용 가능 타입: String, Array<String>, List<*>
  • 런타임-사용 불가능 타입: Nothing, List<String, List<T> (for any T)
  • T가 reified 된 경우(조건부) T는 런타임-사용 가능 타입이 된다. 즉, Array<T> 도 된다.

런타임-사용 가능 타입이 허용될 때

  • 오른쪽에 오는 요소가 is, !is, as, as? 일 때
  • reified된 타입의 인수 타입에 대한 reified 매개 변수
  • Array<List<String>> 는 유효하다.

결과적으로 T가 reified 된 타입 매개변수이면 다음과 같은 구성이 허용됨.

  • x is T, x !is T
  • x as T, x as? T
  • T에 대한 Reflection적 접근, javaClass<T>(), T::class

reified된 매개 변수의 제한

  • inline된 함수만 가능함
  • 내장 클래스인 Array는 타입의 매개변수가 reified된 유일한 클래스. 다른 클래스는 reified된 유형 매개변수를 선언 할 수 없음
  • 런타임-사용 가능 타입만 reified된 타입 매개변수에 인수로 전달할 수 있다.

메모
inline 가능한 매개 변수를 선언하지 않고 선언된 reified 매개 변수를 갖는 inline 함수에 대해서는 Warning가 표시되지 않는다.

JVM를 위한 구현 메모

inline 함수에서 reified된 타입 매개변수 T의 발생은 실제 타입 인수로 대체된다. 실제 타입 인수가 primitive type 일 경우에는 wrapper가 reified된 바이트 코드 안에서 실행된다.

open class TypeLiteral<T> {
    val type: Type
        get() = (javaClass.getGenericSuperclass() as ParameterizedType).getActualTypeArguments()[0]
}

inline fun <reified T> typeLiteral(): TypeLiteral<T> = object : TypeLiteral<T>() {} // T는 실제 타입으로 대체된다.

typeLiteral<String>().type // returns 'class java.lang.String'
typeLiteral<Int>().type // returns 'class java.lang.Integer'
typeLiteral<Array<String>>().type // returns '[Ljava.lang.String;'
typeLiteral<List<*>>().type // returns 'java.util.List<?>'

 

Kotlin – Reflection (2) – With Kotlin API


Reflection 시리즈

  1. With Java API
  2. With Kotlin API
  3. Type Erasure on the JVM
  4. Create Custom Annotation

 

지난번엔 Java API로 Reflection 기능을 어떻게 수행했는지 알아보았는데 이번에는 Kotlin API로 어떻게 가져오는지 살펴보자.


Kotlin Reflection API는 Kotlin의 기본 라이브러리 상에 포함되어 있지 않다.

Kotlin Reflection API를 사용하기 위해서는 build.gradle 에 kotlin-reflect 의존성을 추가하면 된다.

compile group: 'org.jetbrains.kotlin', name: 'kotlin-reflect', version: '1.1.51'

예제

먼저 이전 글에서 쓰인 Transaction 클래스를 그대로 가져오자.

class Transaction(val id: Int, val amount: Double, var description: String = "Default Value") {
    fun validate() {
        if (amount > 10000) {
            println("$this is too large")
        }
    }
}

코틀린에서 Java API를 쓰려면 ::class.java로 써야 되는데, 이전 글에서도 언급했다싶이 ::class 자체는 KClass 라는 인터페이스를 반환하기 때문이다.

이전 글과 비슷하게 아래 정보를 가져오는 코드를 짜보자.

  • 클래스의 이름
  • 해당 클래스에 선언된 필드 와 그 필드의 타입
  • 해당 클래스에 선언된 함수
println(Transaction::class)
val classInfo = Transaction::class

classInfo.memberProperties.forEach {
    println("Property ${it.name} of type ${it.returnType}")
}

classInfo.functions.forEach {
 println("Functions ${it.name} of type ${it.returnType}")
}

호출해보면 Java API와는 다른 점을 느낄 수 있는데, Java API는 타입을 출력할 때 해당 타입이 코틀린 타입임에도 불구하고 자바로 치환되는 타입을 리턴한다.

하지만 Kotlin Reflection API는 자바로 치환되는 타입이 아닌 그대로의 타입을 출력한다.

class Transaction
Property amount of type kotlin.Double
Property description of type kotlin.String
Property id of type kotlin.Int
Functions validate of type kotlin.Unit
Functions equals of type kotlin.Boolean
Functions hashCode of type kotlin.Int
Functions toString of type kotlin.String

그 외 코틀린에서는 getter / setter 대신 해당 클래스의 멤버에 직접 접근하는 특성을 가지고 있어 함수로는 뜨지 않지만 validate 메소드와 추가적으로 equals, hashcode, toString 등이 구현되어 있는 것을 확인할 수 있다.

타입

그러면 지난 글에 언급한 ‘타입’ 은 어떻게 불러올까.

먼저 메소드를 하나 만든다.

fun getKotlinType(obj: KClass<*>) {
    println(obj.qualifiedName)
}

파라미터로는 KClass를 받는데, 사실상의 모든 코틀린 상 클래스는 KClass를 구현하고 있으므로 어떠한 클래스가 올 지 예측할 수 없어 Generic 쪽에서 잠깐 언급했던 Star projection 을 사용했다.

그리고 getKotlinType(Transaction::class) 로 부르게 되면 Transaction가 튀어나온다.

그 외 추가 정보

특이하게도 생성자의 정보를 불러오는 기능이 있다.

classInfo.constructors.forEach {
    println("Constructor ${it.name} - ${it.parameters}")
}
Constructor <init> - [parameter #0 id of fun <init>(kotlin.Int, kotlin.Double, kotlin.String): Transaction,
                      parameter #1 amount of fun <init>(kotlin.Int, kotlin.Double, kotlin.String): Transaction,
                      parameter #2 description of fun <init>(kotlin.Int, kotlin.Double, kotlin.String): Transaction]

결과로는 생성자의 정보를 각각 반환한다.

생성자를 변수로 받기

Kotlin Reflection API에는 특별한 기능이 있는데, 생성자 자체를 KFunctions 라는 클래스 변수로 담아 호출을 할 수 있게 해주는 것이다.

클래스 변수로 담는 방법은 간단한데, 클래스 이름 앞에 :: 를 붙여주면 된다. 예) val constructor = ::Transaction

Type hint 기능으로 실제 타입을 보면 KFunction3<Int, Double, String, Transaction> 라는 타입이 나온다.

그리고 이 constructor 를 println 에 노출시키면 생성자의 정보가 나오게 된다.  fun <init>(kotlin.Int, kotlin.Double, kotlin.String): Transaction

변수로 담은 생성자를 활용하기

단순히 담는 것에 끝나는 것이 아니라 역으로 활용해서 해당 클래스의 인스턴스를 생성할 수 있다.

val transaction = constructor.call(1, 2000, "some description")

call 메서드는 varang를 받는데, 여기에 각각 해당하는 변수 값을 넣어주면 실제 Transaction 클래스의 인스턴스가 생성된다.

만일 Transaction 의 파라미터 중 맨 마지막 description 필드가 기본 값이 있어서 두 개만 넣는다면 IllegalArgumentException 예외를 발생시킨다.

정확히는 Exception in thread "main" java.lang.IllegalArgumentException: Callable expects 3 arguments, but 2 were provided. 라 뜨게 되는데 Callable 가 3개의 파라미터를 요구했으나 두 개의 파라미터 밖에 주어지지 않았다 라는 예외다.

이를 해결하기 위해서는 두 가지 방법이 있다.

변수의 위치를 기준으로 기본값이 없는 필드를 지정하거나, 해당 필드의 이름을 기준으로 기본값이 없는 필드를 지정하는 것이다.

위 두 방법은 공통적으로 callBy 라는 메소드를 사용하게 된다.

변수의 위치를 기준으로 지정하기

val transaction2 = constructor.callBy(mapOf(constructor.parameters[0] to 1, constructor.parameters[1] to 2000))

callBy 메소드는 Map<KParameter, Any?> 를 받는데, KParameter는 파라미터의 Reflection 타입이다.

해당 생성자 변수(constructor)의 파라미터 리스트(parameters) 에서 0번째와 1번째를 꺼내 각각 1과 2000을 지정해주면 나머지 하나는 기본 필드값이 들어가게 된다.

필드의 이름을 기준으로 지정하기

val idParam = constructor.parameters.first { it.name == "id" }
val amountParam = constructor.parameters.first { it.name == "amount" }

val transaction3 = constructor.callBy(mapOf(idParam to 1, amountParam to 2000))

각각 idParam, amountParam 라는 변수를 선언하고 생성자 변수의 파라미터 리스트에서 “id”, “amount” 라는 이름으로 객체를 찾는다.

여기서 first 메서드가 T를 반환해서 idParam, amountParam 의 실제 타입은 KParameter가 된다.

이 것을 각각 callBy에 넘기면 역시 마찬가지로 나머지 하나에 기본 필드값이 들어가게 된다.

필드의 이름만 알 때 그 필드에 선언된 실제 값을 알아내기

val trans = Transaction(1, 20.0, "New Value")

val nameProperty = Transaction::class.memberProperties.find { it.name == "description" }

먼저 Transaction 의 인스턴스가 있다고 해보자.

그런데 갑자기 해당 인스턴스에 있는 필드의 실제 값을 알고 싶을 때, Transaction 의 KClass 에서 memberProperties 라는 이름의 Collection<KProperty1<T, *>> 기능을 사용한다.

Collection는 List와 비슷하게 Iterable를 구현하고 있는 인터페이스고, KProperty1은 Map.Entry<Key, Value> 와 비슷하다고 생각하면 될 것이다.

그리고 해당 memberProperties 에서 name 가 description 을 찾으면 KProperty<Transaction, *>가 리턴된다.

마지막으로 nameProperty?.get(trans)처럼 Transaction의 인스턴스를 넘기면 해당 인스턴스의 description 값인 New Value가 리턴되는 것이다.

마무리

Kotlin Reflection API는 Java Reflection API의 대부분 기능을 구현하면서도 Kotlin 만의 기능들을 제공하고 있다.

다음 글에서는 이 Kotlin Reflection API 을 사용하여 Custom Annotation 등을 만들어보려고 한다.