스물여덟 번째, 위임 프로퍼티입니다.
본 연재에서는 다음과 같이 용어를 사용합니다.
- 위임 프로퍼티(delegated property): 대리자(delegate)에 접근자 기능을 위임한 프로퍼티. (누구에게) '위임된 프로퍼티'라고도 할 수 있으나 일반적으로 위임 프로퍼티로 많이 사용하고 있어 '된'을 붙이지 않았습니다.
- 대리자(명사 delegate): 권한을 위임 받아 대행하는 대상
- 위임하다(동사 delegate): 말 그대로 접근자 기능을 위임한다는 뜻
원문을 보면 delegated로 명사를 수식할 때, 대부분 명사가 다른쪽으로 위임된 것을 표현하지만, 간혹 위임을 받아 대리 수행하는 대상 명사에도 사용하는 경우가 있습니다. 원문을 보시는 경우에는 (약간의) 주의가 필요합니다.
일부 공통적인 종류의 프로퍼티는 매번 필요할 때마자 직접 구현할 수도 있지만, 한 번 구현하고 이를 라이브러리에 추가하여 추후에 재사용하면 보다 유용합니다. 예를 들어,
- 게으른(lazy) 프로퍼티: 첫번째 접근시에만 값이 계산됩니다.
- 관찰 가능한(observable )프로퍼티: 이 프로퍼티의 변경을 리스너에게 알립니다.
- 프로퍼티들을 각각의 필드에 저장하는 것이 아니라 맵에 저장
이러한 경우들을 다룰수 있게 Kotlin은 위임 프로퍼티(delegated properties)를 지원합니다.
class Example {
var p: String by Delegate()
}
프로퍼티를 위임하는 구문은 val/var <프로퍼티 이름>: <타입> by <표현식>입니다. by 뒤의 표현식 부분의 대리자(delegate)를 나타내는데, 이 대리자의 getValue()와 setValue()로 프로퍼티의 get()과 set()이 위임됩니다. 프로퍼티 대리자는 인터페이스를 구현해서는 안 되고, getValue() 함수와 (var 프로퍼티의 경우) setValue() 함수를 제공해야 합니다.
예를 들면, 위의 코드에서 대리자인 Delegate는 다음과 같이 선언할 수 있습니다.
import kotlin.reflect.KProperty
class Delegate {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "$thisRef, thank you for delegating '${property.name}' to me!"
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
println("$value has been assigned to '${property.name}' in $thisRef.")
}
}
p로부터 값을 읽을 때 Delegate의 인스턴스로 위임이 일어나고 Delegate의 getValue() 함수가 호출됩니다. getValue()의 첫번째 매개변수는 p를 읽어 들이는 객체이고, 두 번째 매개변수는 p자체에 대한 설명을 담고 있습니다(예를 들어 이를 통해 p의 이름을 얻을 수 있습니다).
val e = Example()
println(e.p)
이 코드는 다음 같이 출력합니다.
Example@33a17727, thank you for delegating 'p' to me!
이와 비슷하게 p에 할당할 때는, setValue() 함수가 호출됩니다. 처음 두 매개변수는 getValue()과 같고 세 번째 매개변수는 할당할 값을 가지게 됩니다.
e.p = "NEW"
println(e.p)
다음과 같이 출력됩니다.
NEW has been assigned to 'p' in Example@33a17727.
위임받는 객체의 요구사항 명세는 아래 부분의 "프로퍼티 대리자 요구사항"에서 확인할 수 있습니다.
함수나 코드 블록 내에서도 위임 프로퍼티를 선언할 수 있습니다. 이렇게 하는 경우 클래스 멤버가 되지 않습니다. 아래에 관련된 예가 나옵니다.
표준 대리자 (Standard delegates)
Kotlin 표준 라이브러리는 유용한 종류의 몇가지 대리자를 위한 팩토리 메소드를 제공합니다.
게으른(lazy) 프로퍼티
lazy()는 람다를 취하여 Lazy<T>를 반환하는 함수이며, 이를 통해 반환된 객체는 게으른(lazy) 프로퍼티를 구현하는 대리자로 사용할 수 있습니다. 첫번째 get() 호출은 lazy()에 넘겨진 람다를 실행하고, 그 결과를 기억합니다. 그 이후의 get() 호출에서는 단순히 기억된 값을 반환합니다.
val lazyValue: String by lazy {
println("computed!")
"Hello"
}
fun main() {
// 처음이므로 lazy()에 넘겨진 람다가 호출되고, 그 결과가 반환 및 저장됩니다.
println(lazyValue)
// 저장된 값이 반환됩니다.
println(lazyValue)
}
/* 결과
computed!
Hello
Hello
*/
기본적으로, 게으른(lazy) 프로퍼티에 대한 평가(evaluation)는 동기화됩니다. 즉, 단지 하나의 쓰레드에서 프로퍼티의 값이 계산되고, 모든 쓰레드는 값을 값을 보게 됩니다. 대리자에 의한 초기화가 여러 쓰레드에서 동시에 실행돼도 괜찮을 때는 lazy()의 인수로 LazyThreadSafetyMode.PUBLICATION을 넘깁니다. 이 경우에도 대리자에 의한 초기화 작업 다중 실행을 허용하는 것이지 그 중에 첫번째 완료되는 것으로 초기화가 완료되면 해당 값이 저장되어 계속 사용됩니다. 기본 설정(LazyThreadSafetyMode.SYNCHRONIZED)과 이 설정의 차이는 동기화 지점을 어디에 두는 지에 차이입니다.
항상 초기화가 해당 프로퍼티를 사용하는 쓰레드와 같은 쓰레드에 수행되는 것이 보장된다면, LazyThreadSafetyMode.NONE을 사용할 수 있습니다. 이는 어떠한 쓰레드 안전성(thread-safety)도 보장하지 않으며 관련된 오버헤드도 발생시키지 않습니다.
관찰 가능(observable) 프로퍼티
Delegates.observable()은 두 개의 인수를 받습니다. 첫 번째는 초기값이고 두 번째는 변경을 위한 처리기(handler)입니다.
프로퍼티에 할당할 때마다 (할당 작업이 끝난 후에) 매번 처리기가 호출됩니다. 처리기는 3개의 매개변수를 가지고 있습니다. 첫 번째는 할당 받을 프로퍼티, 두 번째는 이전 값, 세 번째는 새로운 값입니다.
import kotlin.properties.Delegates
class User {
var name: String by Delegates.observable("<no name>") {
prop, old, new ->
println("$old -> $new")
}
}
fun main() {
val user = User()
user.name = "first"
user.name = "second"
}
/* 결과
<no name> -> first
first -> second
*/
할당을 가로채서 거부하고 싶은 경우에는 observable() 대신 vetoable()을 사용합니다. vetoable()에 넘겨진 처리기는 프로퍼티에 새로운 값이 할당되기 전에 호출됩니다.
다른 프로퍼티로 위임
프로퍼티의 게터와 세터를 다른 프로퍼티에 위임할 수 있습니다. 이런 위임은 최상위 수준이나 클래스(의 멤버나 확장) 프로퍼티 모두에 가능합니다.
대리자 프로퍼티는 다음 중 하나일 수 있습니다.
- 최상위 수준 프로퍼티
- 같은 클래스의 멤버나 확장 프로퍼티
- 다른 클래스의 멤버나 확장 프로퍼티
하나의 프로퍼티를 다른 프로퍼티로 위임하기 위해서는 대리자 이름에 :: 한정자(qualifier)를 사용합니다. 예를 들면, this::delegate나 MyClass::delegate 같은 식입니다.
var topLevelInt: Int = 0
class ClassWithDelegate(val anotherClassInt: Int)
class MyClass(var memberInt: Int, val anotherClassInstance: ClassWithDelegate) {
var delegatedToMember: Int by this::memberInt
var delegatedToTopLevel: Int by ::topLevelInt
val delegatedToAnotherClass: Int by anotherClassInstance::anotherClassInt
}
var MyClass.extDelegated: Int by ::topLevelInt
다른 프로퍼티로의 위임은 예를 들어 하위 호환성을 유지하며 프로퍼티 이름을 바꾸고자 할 때 유용합니다. 새로운 프로퍼티를 추가하며, 기존 프로퍼티에는 @Deprecated 어노테이션을 붙이고, 예전 프로퍼티의 구현을 새로운 프로퍼티로 위임합니다.
class MyClass {
var newName: Int = 0
@Deprecated("Use 'newName' instead", ReplaceWith("newName"))
var oldName: Int by this::newName
}
fun main() {
val myClass = MyClass()
// Notification: 'oldName: Int' is deprecated.
// Use 'newName' instead
myClass.oldName = 42
println(myClass.newName) // 42
}
/* 결과
42
*/
프로퍼티를 맵에 저장
공통적인 사용 사례 중 하나는 프로퍼티를 맵에 저장하는 것입니다. 이는 JSON을 파싱하거나 그 외 동적인 작업을 수행하는 경우에 종종 나타납니다. 이런 경우에는 맵 인스턴스 자체를 위임 프로퍼티들의 대리자로 사용할 수 있습니다.
class User(val map: Map<String, Any?>) {
val name: String by map
val age: Int by map
}
이 예에서 생성자는 다음과 같이 맵을 받습니다.
val user = User(mapOf(
"name" to "John Doe",
"age" to 25
))
위임(된) 프로퍼티들은 프로퍼티 이름 문자열을 키로 사용하여 맵에서 값을 얻습니다.
println(user.name) // Prints "John Doe"
println(user.age) // Prints 25
읽지 전용인 Map 대신에 MutableMap을 사용하면 var 프로퍼티에 대해서도 사용할 수 있습니다.
class MutableUser(val map: MutableMap<String, Any?>) {
var name: String by map
var age: Int by map
}
지역 위임 프로퍼티
지역 변수를 위임 프로퍼티처럼 사용할 수 있습니다. 예를 들어 지역 변수에 lazy()를 적용할 수 있습니다.
fun example(computeFoo: () -> Foo) {
val memoizedFoo by lazy(computeFoo)
if (someCondition && memoizedFoo.isValid()) {
memoizedFoo.doSomething()
}
}
memoizedFoo 변수는 단지 첫 접근 시에만 계산될 것입니다. if 문에서 someCodition이 false 인 경우, memoizedFoo는 계산되지 않습니다.
프로퍼티 대리자 요구사항
읽기 전용 프로퍼티(val)를 위하여 대리자(delegate)는 다음과 같은 매개변수를 갖는 연산자 함수 getValue()를 제공해야 합니다.
- thisRef : 타입이 프로퍼티 소유자와 같거나 수퍼 타입이어야 합니다. 확장 프로퍼티에서는 반드시 확장될 수 있는 타입이어야 합니다.
- property : KProperty<*> 타입이거나 그의 수퍼 타입이어야 합니다.
getValue()은 반드시 프로퍼티와 같은 타입이나 그의 하위 타입을 반환해야 합니다.
class Resource
class Owner {
val valResource: Resource by ResourceDelegate()
}
class ResourceDelegate {
operator fun getValue(thisRef: Owner, property: KProperty<*>): Resource {
return Resource()
}
}
가변 프로퍼티(var)를 위하여, 대리자는 추가적으로 다음과 같은 매개변수를 갖는 연산자 함수 setValue()를 제공해야 합니다.
- thisRef : 타입이 프로퍼티 소유자와 같거나 수퍼 타입이어야 합니다. 확장 프로퍼티에서는 반드시 확장될 수 있는 타입이어야 합니다.
- property : KProperty<*> 타입이거나 그의 수퍼 타입이어야 합니다.
- value : 프로퍼티와 같은 타입이나 그의 하위 타입이어야 합니다.
class Resource
class Owner {
var varResource: Resource by ResourceDelegate()
}
class ResourceDelegate(private var resource: Resource = Resource()) {
operator fun getValue(thisRef: Owner, property: KProperty<*>): Resource {
return resource
}
operator fun setValue(thisRef: Owner, property: KProperty<*>, value: Any?) {
if (value is Resource) {
resource = value
}
}
}
getValue()와 setValue()는 모두 대리자 클래스의 멤버나 확장 함수로 제공돼야 합니다. 후자는 원래 이런 함수를 제공하지 않는 객체에 프로퍼티를 위임할 때 유용합니다. 두 함수는 모두 operator 키워드를 지정해야 합니다.
Kotlin 표준 라이브러리에 있는 ReadOnlyProperty와 ReadWriteProperty를 사용하여 새로운 클래스를 만들지 않고 익명 객체를 사용하여 대리자를 만들 수 있습니다. 두 인터페이스는 요구되는 메소드를 가지고 있습니다. ReadOnlyProperty에는 getValue()가 선언돼 있고, 이를 상속하는 ReadWriteProperty에는 setValue()가 추가로 선언돼 있습니다. 그래서, ReadOnlyProperty가 요구되는 곳에 ReadWriteProperty를 사용할 수 있습니다.
fun resourceDelegate(resource: Resource = Resource()): ReadWriteProperty<Any?, Resource> =
object : ReadWriteProperty<Any?, Resource> {
var curValue = resource
override fun getValue(thisRef: Any?, property: KProperty<*>): Resource = curValue
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Resource) {
curValue = value
}
}
val readOnlyResource: Resource by resourceDelegate() // val에 ReadWriteProperty 가능
var readWriteResource: Resource by resourceDelegate()
위임 프로퍼티 변형(translation) 규칙
내부적으로 Kotlin 컴파일어는 특정 종류의 위임 프로퍼티에 대해서 보조 프로퍼티를 생성하고 거기에 위임 시킵니다.
최적화를 위해서 컴파일러는 몇몇 경우에 대해서는 보조 프로퍼티를 생성하지 않습니다. 아래에 나오는 다른 프로퍼티로 위임할 때 변형 규칙 부분에서 최적화 관련된 내용을 확인해 보시기 바랍니다.
예를 들어 아래의 코드를 보면, prop 프로퍼티를 위해서 컴파일러는 prop$delegate라는 숨겨진 프로퍼티를 만들고, 단순히 접근자 코드를 이 프로퍼티에 위임 시킵니다.
class C {
var prop: Type by MyDelegate()
}
// 다음은 컴파일러가 생성하는 코드입니다.
class C {
private val prop$delegate = MyDelegate()
var prop: Type
get() = prop$delegate.getValue(this, this::prop)
set(value: Type) = prop$delegate.setValue(this, this::prop, value)
}
Kotlin 컴파일러는 인수를 통해 prop에 대한 모든 필요한 정보를 제공합니다. 첫번째 인수 this는 바깥 클래스 C에 대한 참조입니다. this::prop은 KProperty 타입의 리플렉션 객체로서 prop 자체의 정보를 가지고 있습니다.
위임 프로퍼티의 최적화 사례
대리자가 다음과 같은 경우에는 $delegate 필드는 생략됩니다.
- 참조된 프로퍼티
class C<Type> {
private var impl: Type = ...
var prop: Type by ::impl
}
- 이름 있는 객체
object NamedObject {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String = ...
}
val s: String by NamedObject
- 뒷받침하는 필드와 게터가 같은 모듈에 있는 final인 val 프로퍼티
val impl: ReadOnlyProperty<Any?, String> = ...
class A {
val s: String by impl
}
- 상수 표현식, enum 항목, this, null. 다음은 this에 대한 예입니다.
class A {
operator fun getValue(thisRef: Any?, property: KProperty<*>) ...
val s by this
}
다른 프로퍼티로 위임할 때 변형 규칙
다른 프로퍼티로 위암할 때, Kotlin 컴파일러는 참조된 프로퍼티로의 직접적인 접근을 생성합니다. 이 말은 컴파일러가 prop$delegate 필드를 만들지 않는다는 뜻입니다. 이러한 최적하는 메모리를 절약하게 해 줍니다.
예를 들어, 다음과 같은 코드를 살펴 보겠습니다.
class C<Type> {
private var impl: Type = ...
var prop: Type by ::impl
}
prop의 접근자는 대리자의 getValue()나 setValue() 연산자 함수 호출을 건너 뛰고 impl을 직접적으로 부릅니다(invoke). 그래서, KProperty 참조 객체는 필요 없습니다.
위에 있는 코드를 컴파일러는 다음과 같이 생성합니다.
class C<Type> {
private var impl: Type = ...
var prop: Type
get() = impl
set(value) {
impl = value
}
fun getProp$delegate(): Type = impl // 이 메소드는 단지 리플레션에만 필요합니다.
}
대리자 제공
provideDelegate 연산자를 정의함으로써 프로퍼티 구현을 위임하는 객체를 생성하는 로직을 확장할 수 있습니다. by의 오른쪽에 사용된 객체가 멤버 함수나 확장 함수로서 provideDelegate를 정의하여 가지고 있으면, 해당 함수는 프로퍼티 대리자 인스턴스를 만들 때 호출됩니다. 즉, by 오른쪽 객체의 provideDelegate가 호출되어 대리자 인스턴스가 만들어집니다.
provideDelegate를 사용 가능한 하나의 실제 예는 프로퍼티 초기화시 프로퍼티의 일관성을 검사하는 것입니다.
예를 들어, 바인딩 되기 전에 프로퍼티의 이름을 검사하고 싶은 경우 다음과 같이 할 수 있습니다.
class ResourceDelegate<T> : ReadOnlyProperty<MyUI, T> {
override fun getValue(thisRef: MyUI, property: KProperty<*>): T { ... }
}
class ResourceLoader<T>(id: ResourceID<T>) {
operator fun provideDelegate(
thisRef: MyUI,
prop: KProperty<*>
): ReadOnlyProperty<MyUI, T> {
checkProperty(thisRef, prop.name)
// 대리자 생성
return ResourceDelegate()
}
private fun checkProperty(thisRef: MyUI, name: String) { ... }
}
class MyUI {
fun <T> bindResource(id: ResourceID<T>): ResourceLoader<T> { ... }
val image by bindResource(ResourceID.image_id)
val text by bindResource(ResourceID.text_id)
}
provideDelegate의 매개변수는 다음과 같이 getVaue와 같습니다.
- thisRef : 타입이 프로퍼티 소유자와 같거나 수퍼 타입이어야 합니다. 확장 프로퍼티에서는 반드시 확장될 수 있는 타입이어야 합니다.
- property : KProperty<*> 타입이거나 그의 수퍼 타입이어야 합니다.
provideDelegate 메소드는 MyUI 인스턴스를 만드는 동안 각각의 프로퍼티를 위해 호출되어 필요한 검증을 즉시 수행합니다.
프로퍼티와 대리자를 바인딩할 때 중간에 개입하는 능력 없이 이런 똑같은 기능을 달성하려면 프로퍼티 이름을 명시적으로 넘겨야 하는데, 이는 간편하지 않습니다.
// "provideDelegate" 기능 없이 프로퍼티 이름 검사
class MyUI {
val image by bindResource(ResourceID.image_id, "image")
val text by bindResource(ResourceID.text_id, "text")
}
fun <T> MyUI.bindResource(
id: ResourceID<T>,
propertyName: String
): ReadOnlyProperty<MyUI, T> {
checkProperty(this, propertyName)
// create delegate
}
컴파일러가 생성하는 코드에서는 provideDelegate 메소드가 보조 프로퍼티인 prop$delegate를 초기화 하는데 호출됩니다. provideDelegate가 있는 경우와 없는 경우에 대해 컴파일러가 생성한 코드를 다음의 예에서 비교해 보시가 바랍니다.
// provideDelegate가 없는 경우
class C {
var prop: Type by MyDelegate()
}
// 컴파일러가 생성한 코드
class C {
private val prop$delegate = MyDelegate()
var prop: Type
get() = prop$delegate.getValue(this, this::prop)
set(value: Type) = prop$delegate.setValue(this, this::prop, value)
}
// provideDelegate가 있는 경우
class C {
var prop: Type by MyDelegate()
}
// 컴파일러가 생성하는 코드입니다.
class C {
// 추가적인 위임 속성 생성을 위해 provideDelegate를 호출합니다.
private val prop$delegate = MyDelegate().provideDelegate(this, this::prop)
var prop: Type
get() = prop$delegate.getValue(this, this::prop)
set(value: Type) = prop$delegate.setValue(this, this::prop, value)
}
provideDelegate 메소드는 보조 프로퍼티의 생성에만 영향을 미치고 생성되는 게터와 세터에는 미치지 않는다는 것을 주의 깊게 보시기 바랍니다.
표준 라이브러리의 PropertyDelegateProvider를 사용하면 새로운 클래스를 만들지 않고 대리자(delegate) 제공자를 만들 수 있습니다.
val provider = PropertyDelegateProvider { thisRef: Any?, property ->
ReadOnlyProperty<Any?, Int> {_, property -> 42 }
}
val delegate: Int by provider
'Kotlin' 카테고리의 다른 글
공식 문서로 배우는 코틀린 - 30. Functions (0) | 2024.03.13 |
---|---|
공식 문서로 배우는 코틀린 - 29. Type aliases (0) | 2024.03.13 |
공식 문서로 배우는 코틀린 - 27. Delegation (0) | 2024.03.12 |
공식 문서로 배우는 코틀린 - 26. Object expressions and declarations (0) | 2024.03.12 |
공식 문서로 배우는 코틀린 - 25. Inline value classes (0) | 2024.03.11 |