Kotlin - 프로퍼티 위임, 'ReadOnlyProperty', 'ReadWriteProperty', 프로퍼티 위임 도구 (Delegates.observable(), Delegates.vetoable(), Delegates.notNull()), 위임 프로퍼티 컴파일


이 포스트에서는 프로퍼티 위임과 프로퍼티 위임 도구에 대해 알아본다.

소스는 github 에 있습니다.


목차


개발 환경

  • 언어: kotlin 1.9.23
  • IDE: intelliJ
  • SDK: JDK 17
  • 의존성 관리툴: Gradle 8.5

1. 프로퍼티 위임: by (프로퍼티 접근자 로직 재활용)

9. 프로퍼티 접근자: field 를 참고하세요.

클래스 위임에 대한 좀 더 상세한 내용은 1. 클래스 위임 (class delegation) 을 참고하세요.

위임 프로퍼티를 사용하면 값을 뒷받침하는 필드 (= backing field) 에 단순히 저장하는 것보다 더 복잡한 방식으로 동작하는 프로퍼티를 쉽게 구현 가능하다.

또한 그 과정에서 접근자 로직을 매번 재구현할 필요도 없다.

예) 프로퍼티는 위임을 사용하여 자신의 값을 필드가 아니라 DB, 브라우저 세션, 맵 등에 저장 가능

backing field

대부분의 프로퍼티에는 그 프로퍼티 값을 저장하기 위한 필드가 있는데 그 필드를 backing field 라고 함

위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업을 처리하게 맡기는 디자인 패턴이다.

여기서 작업을 처리하는 도우미 객체를 위임 객체라고 한다.

도우미 객체를 직접 작성할 수도 있지만 더 좋은 방법은 코틀린 언어가 제공하는 기능을 활용하는 것이다.

프로퍼티는 접근자 로직을 위임할 수 있는데, by 키워드를 사용하여 프로퍼티를 위임과 연결할 수 있다.

val (또는 var) 프로퍼티명 by 위임할 객체

위임 프로퍼티의 일반적인 문법

class Foo {
    var p: String by Delegate()
}

위 코드에서 p 프로퍼티는 접근자 로직을 다른 객체에게 위임한다.
여기서는 Delegate 클래스의 인스턴스를 위임 객체(= 작업을 처리하는 도우미 객체)로 사용한다.
by 뒤에 있는 식을 계산하여 위임에 쓰일 객체를 얻는다.

컴파일러는 위 코드를 아래와 같이 숨겨진 도우미 프로퍼티를 만든 후 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다.
p 프로퍼티는 바로 그 위임 객체에게 자신의 작업을 위임한다.
아래에서는 숨겨진 도우미 프로퍼티가 delegate 이다.

컴파일러가 생성한 코드

class Foo {
    // 컴파일러가 생성한 도우미 프로퍼티
    private val delegate = Delegate()
  
    // p 프로퍼티를 위해 컴파일러가 생성한 접근자는 delegate 의 getValue() 와 setValue() 메서드를 호출함
    var p: String 
      set(value: String) = delegate.setValue(..., value)
      get() = delegate.getValue(...)
}

프로퍼티가 val (읽기 전용) 인 경우는 위임 객체의 클래스에 getValue() 함수 정의가 있어야 하고, 프로퍼티가 var (쓰기 가능) 인 경우는 위임 객체의 클래스에 getValue(), setValue() 함수 정의가 있어야 한다.

이 때 getValue(), setValue() 는 멤버 함수이거나 확장 함수일 수 있다.

위임 객체인 Delegate 클래스를 단순화하면 아래와 같다.

class Delegate {
    operator fun getValue(...) { ... }
    operator fun setValue(..., value: Type) { ... }
}

class Foo {
    var p: Type by Delegate()
}

fun main() {
    val foo = Foo()
  
    // foo.p 라는 프로퍼티 호출은 내부에서 delegate.getValue(...) 를 호출함
    val oldValue = foo.p
  
    // 프로퍼티 값을 변경하는 문장은 내부에서 delegate.setValue(..., newValue) 를 호출함
    foo.p = newValue
}

p 의 getter, setter 는 Delegate 타입의 위임 프로퍼티 객체에 있는 메서드를 호출한다.

위임 프로퍼티의 강력함을 보여주는 예 중 하나가 프로퍼티 위임을 사용하여 프로퍼티 초기화를 지연시키는 것이다.

위임을 사용하여 프로프티 초기화를 지연시키는 예시는 1.2. 위임 프로퍼티를 통해 지연 초기화를 구현: lazy() 를 참고하세요.


1.1. 프로퍼티가 val 인 경우: KProperty

이 방식보다는 ReadOnlyProperty 인터페이스를 상속하는 방법을 추천함
해당 내용은 바로 뒤인 1.3. ReadOnlyProperty 인터페이스 상속 에 나옴

아래는 읽기 전용 프로퍼티인 val valueBasicRead 객체에 의해 위임되는 예시이다.

import kotlin.reflect.KProperty

// 위임자 클래스
class Readable(val i: Int) {
    // value 프로퍼티는 BasicRead() 객체에 의해 위임됨
    // 프로퍼티 뒤에 by 라고 지정하여 BasicRead 객체를 by 앞의 프로퍼티인 value 와 연결
    // 이 때 BasicRead 의 getValue() 는 Readable 의 i 에 접근 가능
    // getValue() 가 String 을 반환하므로 value 프로퍼티의 타입도 String 이어야 함
    val value: String by BasicRead()

    // val value by BasicRead() // 이렇게 써도 됨
}

// 위임받는 클래스
class BasicRead {
    // Readable 에 대한 접근을 가능하게 하는 Readable 파라메터를 얻음
    operator fun getValue(
        r: Readable,
        property: KProperty<*>,
    ) = "getValue: ${r.i}~"
}

fun main() {
    val x = Readable(1)
    val y = Readable(2)

    println(x.value) // getValue: 1~
    println(y.value) // getValue: 2~
}

위의 Readablevalue 프로퍼티는 BasicRead 객체에 의해 위임된다.

BasicReadgetValue()Readable 에 대한 접근을 가능하게 하는 Readable 파라메터 (r) 을 얻는다.
프로퍼티 뒤에 by 라고 지정하여 BasicRead 객체를 by 앞의 프로퍼티와 연결한다.
이 때 BasicReadgetValue()Readablei 에 접근할 수 있다.

getValue() 가 String 을 반환하므로 위임 받는 프로퍼티인 value 의 타입도 String 이어야 한다.

getValue() 의 두 번째 파라메터는 KProperty 라는 타입인데 이 타입의 객체는 위임 프로퍼티에 대한 reflection 정보를 제공한다.

reflection

실행 시점에 코틀린 언어의 다양한 요소에 대한 정보를 얻을 수 있게 해주는 기능

refection 에 대한 좀 더 상세한 내용은
Kotlin - 애너테이션과 리플렉션(1): 애너테이션,
Kotlin - 애너테이션과 리플렉션(2): 리플렉션 API, 리플렉션으로 직렬화 구현,
Kotlin - 애너테이션과 리플렉션(3): 애너테이션으로 직렬화 제어, 리플렉션으로 역직렬화 구현
를 참고하세요.


1.2. 프로퍼티가 var 인 경우

이 방식보다는 ReadWriteProperty 인터페이스를 상속하는 방법을 추천함
해당 내용은 바로 뒤인 1.4. ReadWriteProperty 인터페이스 상속 에 나옴

아래는 쓰기 가능한 프로퍼티인 var valueBasicReadWrite 객체에 의해 위임되는 예시이다.

import kotlin.reflect.KProperty

// 위임자 클래스
class ReadWriteable(var i: Int) {
    var msg = ""
    // value 프로퍼티는 BasicReadWrite 객체에 의해 위임됨
    var value: String by BasicReadWrite()
}

// 위임받는 클래스
class BasicReadWrite {
    operator fun getValue(
        rw: ReadWriteable,
        property: KProperty<*>,
    ) = "getValue: ${rw.i}~"
    // ) = "getValue: ${rw.value}, ${rw.i}~" // 여기서 rw.value 에 접근하면 stackoverflow 발생

    operator fun setValue(
        rw: ReadWriteable,
        property: KProperty<*>,
        s: String,
    ) {
        rw.i = s.toIntOrNull() ?: 0
        rw.msg = "setValue to ${rw.i}~"
        // rw.value = "test~ ${rw.value}"   // 런타임 에러
        // rw.msg = "setValue to $rw.i~"   // 이렇게 하면 ReadWritable 에 메모리 주소가 출력됨
    }
}

fun main() {
    val x = ReadWriteable(1)
    println("1: " + x.value) // 1: getValue: 1~
    println("2: " + x.msg) // 2:
    println("3: " + x.i) // 3: 1

    x.value = "99"
    println("4: " + x.value) // 4: getValue: 99~
    println("5: " + x.msg) // 5: setValue to 99~
    println("6: " + x.i) // 6: 99
}

엘비스 연산자 ?: 에 대한 좀 더 상세한 내용은 2.2. 엘비스(Elvis) 연산자: ?: 를 참고하세요.

setValue() 의 앞의 두 파라메터는 getValue() 의 파라메터와 동일하고, 마지막 파라메터는 프로퍼티에 설정하려는 값이다.

getValue() 의 반환 타입과 setValue() 의 세 번째 파라메터 값의 타입은 해당 위임 객체가 적용된 프로퍼티인 var value 의 타입과 일치해야 한다.

또한, setValue()ReadWriteablevalue 뿐 아니라 i, msg 모두에 접근 가능하다.


1.3. ReadOnlyProperty 인터페이스 상속

1.1. 프로퍼티가 val 인 경우: KProperty 에서 위임 클래스인 BasicRead, BasicReadWrite 모두 어떤 인터페이스도 구현할 필요없이, 단순이 필요한 함수 이름과 시그니처만 만족하면 위임 역할을 수행할 수 있다.

하지만 원한다면 ReadOnlyProperty 인터페이스를 상속할 수도 있다.

ReadOnlyProperty 를 구현하면 코드를 읽는 사람에게 BasicRead2 를 위임으로 사용할 수 있다는 사실을 알리고, getValue() 정의가 제대로 들어있도록 보장할 수 있다.

SAM (fun interface)

단일 추상 메서드인 SAM (fun interface) 에 대한 좀 더 상세한 내용은 1.3. 단일 추상 메서드 (Single Abstract Method, SAM): fun interface 를 참고하세요.

아래는 1.1. 프로퍼티가 val 인 경우: KProperty 의 예시를 ReadOnlyProperty 인터페이스를 구현하여 재작성한 예시이다.

import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty

// 위임자 클래스
class Readable2(val i: Int) {
    val value: String by BasicRead2()

    // SAM 변환
    val value2: String by ReadOnlyProperty { _, _ -> "getValue: $i~~" }
}

// 위임받는 클래스
class BasicRead2 : ReadOnlyProperty<Readable2, String> {
    override operator fun getValue(
        thisRef: Readable2,
        property: KProperty<*>,
    ) = "getValue: ${thisRef.i}~"
}

fun main() {
    val x = Readable2(1)
    val y = Readable2(2)

    println(x.value) // getValue: 1~
    println(x.value2) // getValue: 1~~
    println(y.value) // getValue: 2~
    println(y.value2) // getValue: 2~~
}

ReadOnlyProperty 인터페이스는 멤버 함수가 getValue() 하나이고, 인터페이스가 fun interface 로 선언되어 있기 때문에 SAM 변환을 사용해서 value2 를 훨씬 간결하게 작성하였다.


1.4. ReadWriteProperty 인터페이스 상속

ReadWriteProperty 를 구현하면 코드를 읽는 사람에게 ReadWritable2 를 위임으로 사용할 수 있다는 사실을 알리고, getValue()setValue() 의 정의가 제대로 들어있도록 보장할 수 있다.

아래는 1.2. 프로퍼티가 var 인 경우 의 예시를 ReadWriteProperty 인터페이스를 구현하여 재작성한 예시이다.

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

// 위임자 클래스
class ReadWritable2(var i: Int) {
    var msg = ""

    // value 프로퍼티는 BasicReadWrite2 객체에 의해 위임됨
    var value: String by BasicReadWrite2()
}

// 위임받는 클래스
class BasicReadWrite2 : ReadWriteProperty<ReadWritable2, String> {
    override fun getValue(
        rw: ReadWritable2,
        property: KProperty<*>,
    ) = "getValue: ${rw.i}~"

    override fun setValue(
        rw: ReadWritable2,
        property: KProperty<*>,
        s: String,
    ) {
        rw.i = s.toIntOrNull() ?: 0
        rw.msg = "setValue to ${rw.i}~"
    }
}

fun main() {
    val x = ReadWritable2(1)
    println("1: " + x.value) // 1: getValue: 1~
    println("2: " + x.msg) // 2:
    println("3: " + x.i) // 3: 1

    x.value = "99"
    println("4: " + x.value) // 4: getValue: 99~
    println("5: " + x.msg) // 5: setValue to 99~
    println("6: " + x.i) // 6: 99
}

프로퍼티 위임 클래스에 대해 정리하자면 아래와 같다.

위임 클래스는 아래 2개의 함수를 모두 포함하거나, 하나만 포함할 수 있다.
위임 프로퍼티에 접근하면 읽기와 쓰기에 따라 아래 두 함수가 호출된다.

  • 프로퍼티가 읽기 전용인 경우
operator fun getValue(thisRef: T, property: KProperty<*>): V

이 경우 ReadOnlyProperty 인터페이스와 SAM 변환을 통해 구현할 수도 있다.

  • 프로퍼티가 쓰기도 가능한 경우
operator fun getValue(thisRef: T, property: KProperty<*>): V

operator fun setValue(thisRef: T, property: KProperty<*>, value: V)

위 두 함수의 파라메터는 아래와 같다.

  • thisRef
    • T 는 위임자 개체(= 다른 객체에 처리를 맡기는 주체) 의 클래스임
    • thisRef 가 아닌 Any? 를 사용하여 위임자 객체의 내부를 보기 어렵게 할 수도 있음
  • property
    • KProperty<*> 는 위임 프로퍼티에 대한 정보를 제공
    • 가장 일반적으로 사용하는 정보는 name (위임 프로퍼티의 필드명) 임
  • value
    • setValue() 로 위임 프로퍼티에 저장할 값
    • V 는 위임 프로퍼티의 타입

1.5. 위임자 객체의 private 멤버에 접근

위임자 객체의 private 멤버에 접근하려면 위임 클래스를 내포시켜야 한다.

내포된 클래스에 대한 좀 더 상세한 내용은 2. 내포된 클래스 (nested class) 를 참고하세요.

import kotlin.properties.ReadOnlyProperty

class Person(
    private val first: String,
    private val last: String,
) {
    val name by // SAM 변환
        ReadOnlyProperty<Person, String> { _, _ -> "$first $last~" }
}

fun main() {
    val assu = Person("A", "B")

    println(assu.name) // A B~
}

1.6. 위임자 객체의 getValue(), setValue() 를 확장 함수로 만들기

위임자 객체의 멤버에 대한 접근이 된다면 getValue(), setValue() 를 확장 함수로 만들 수 있다.

import kotlin.reflect.KProperty

// 위임자 클래스
class Add(val a: Int, val b: Int) {
    // sum 프로퍼티는 Sum() 객체에 의해 위임됨
    val sum by Sum()
}

// 위임받는 클래스
class Sum

// getValue() 를 확장 함수로 만듬
operator fun Sum.getValue(
    thisRef: Add,
    property: KProperty<*>,
): Int = thisRef.a + thisRef.b

fun main() {
    val add = Add(1, 2)

    println(add.sum) // 3
}

이렇게 getValue(), setValue() 를 확장 함수로 만들면 변경하거나 상속할 수 없는 기존 클래스에 getValue(), setValue() 를 추가함으로써 Sum 클래스의 인스턴스를 위임 객체로 사용할 수 있게 된다.


1.7. 위임받는 클래스를 좀 더 일반적으로 사용

위 코드들에서는 getValue(), setValue() 의 첫 번째 파라메터의 타입을 구체적으로 받았는데, 이런 식으로 정의한 위임은 그 구체적인 타입에 얽매이게 될 수가 있다.

상황에 따라서는 첫 번째 파라메터를 Any? 로 지정함으로써 더 일반적인 목적의 위임을 만들 수 있다.

아래는 String 타입의 위임 프로퍼티가 있고, 이 프로퍼티의 내용은 해당 프로퍼티 이름에 대응하면 텍스트 파일인 예시이다.

import java.io.File
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

var targetDir = File("DataFiles")

class DataFile(val fileName: String) : File(targetDir, fileName) {
    init {
        if (!targetDir.exists()) {
            targetDir.mkdir()
        }
    }

    fun erase() {
        if (exists()) {
            delete()
        }
    }

    fun reset(): File {
        erase()
        createNewFile()
        return this
    }
}

// 위임받는 클래스
class FileDelegate : ReadWriteProperty<Any?, String> {
    override fun getValue(
        thisRef: Any?,
        property: KProperty<*>,
    ): String {
        println("getValue(): ${property.name}")
        val file = DataFile(property.name + ".txt")
        return if (file.exists()) file.readText() else ""
    }

    override fun setValue(
        thisRef: Any?,
        property: KProperty<*>,
        value: String,
    ) {
        println("setValue(): ${property.name}, $value")
        DataFile(property.name + ".txt").writeText(value)
    }
}

// 위임자 클래스
class Configuration {
    var user by FileDelegate()
    var id by FileDelegate()
    var project by FileDelegate()
}

fun main() {
    val config = Configuration()

    // 여기서 setValue() 호출
    config.user = "Assu" // setValue(): user, Assu
    config.id = "test_11" // setValue(): id, test_11
    config.project = "KotlinProject" // setValue(): project, KotlinProject

    val result1 = DataFile("user.txt").readText()
    val result2 = DataFile("id.txt").readText()
    val result3 = DataFile("project.txt").readText()

    println(config.user) // getValue(): user   ASSU
    println(config.id) // getValue(): id   test_11
    println(config.project) // getValue(): project   KotlinProject

    println(result1) // Assu
    println(result2) // test_11
    println(result3) // KotlinProject
}

위 예시에서의 위임은 파일과 상호 작용만 할 수 있으면 되고, 위임받는 클래스에서 thisRef 의 내부 정보는 필요없으므로 thisRef 의 타입은 Any? 로 지정하여 무시한다.

위에선 property.name 에만 관심이 있고, 이 값은 위임 필드(= 위임 프로퍼티) 의 이름이다.


2. 프로퍼티 위임 도구

2.1. 프로퍼티 위임 도구로 사용되는 Map

표준 라이브러리에는 몇 가지 프로퍼티 위임 연산이 들어있는데 Map 도 위임 프로퍼티의 위임 객체로 사용될 수 있도록 미리 설정된 코틀린 표준 라이브러리 타입 중 하나이다.

어떤 클래스의 모든 프로퍼티를 저장하게 위해 Map 을 하나만 써도 되는데, 이 Map 에서 각 프로퍼티는 String 타입의 key 가 되고, 저장한 값은 value 가 된다.

// 위임자 클래스
// MutableMap 이 위임받는 객체
class Driver(
    iMap: MutableMap<String, Any?>,
) {
    // name 프로퍼티는 iMap 객체에 의해 위임됨
    var name: String by iMap
    var age: Int by iMap
    var coord: Pair<Double, Double> by iMap
}

fun main() {
    val info =
        mutableMapOf<String, Any?>(
            "name" to "assu",
            "age" to 20,
            "coord" to Pair(1.1, 2.2),
            "ddd" to "aa",
        )

    val driver = Driver(info)

    // {name=assu, age=20, coord=(1.1, 2.2)}
    println(info)

    // assu
    println(driver.name)
    driver.name = "silby"   // 원본인 info Map 이 변경됨

    // {name=silby, age=20, coord=(1.1, 2.2)}
    println(info)
}

코틀린 표준 라이브러리에서 Map 의 확장 함수로 프로퍼티 위임을 가능하게 해주는 getValue(), setValue() 를 제공하기 때문에 위에서 driver.name = “silby” 로 설정하면 원본 Map 이 변경된다.


2.1.1. 프로퍼티 값을 Map 에 저장

자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때 위임 프로퍼티를 활용하는 경우가 자주 있다.
그런 객체를 확장 가능한 객체 (extendo object) 라고 부르기도 한다.

예를 들어 연락처 관리 시 필수 정보와 추가 정보를 관리해야 한다고 해보자.

이럴 경우 정보를 모두 Map 에 저장하되, 그 Map 을 통해 처리하는 프로퍼티를 통해 필수 정보를 제공하는 방법이 있다.

아래는 값을 Map 에 저장하는 프로퍼티를 직접 정의하는 예시이다.

package com.assu.study.kotlin2me.chap07

class Person1 {
    // 추가 정보
    private val attributes = hashMapOf<String, String>()

    fun setAttribute(
        attrName: String,
        value: String,
    ) {
        attributes[attrName] = value
    }

    // 필수 정보
    val name: String
        get() = attributes["name"]!! // 수동으로 Map 에서 꺼냄
}

fun main() {
    val p = Person1()
    val data = mapOf("name" to "Assu", "addr" to "Seoul")

    for ((attrName, value) in data) {
        p.setAttribute(attrName, value)
    }

    println(p.name) // Assu
}

위임 프로퍼티를 활용하면 위의 코드를 간결하게 리팩토링할 수 있다.

package com.assu.study.kotlin2me.chap07

class Person2 {
    private val attributes = hashMapOf<String, String>()

    fun setAttribute(
        attrName: String,
        value: String,
    ) {
        attributes[attrName] = value
    }

    // 위임 프로퍼티로 Map 을 사용
    val name: String by attributes
}

fun main() {
    val p = Person2()
    val data = mapOf("name" to "Assu", "addr" to "Seoul")

    for ((attrName, value) in data) {
        p.setAttribute(attrName, value)
    }

    println(p.name) // Assu
}

위 코드가 동작하는 이유는 표준 라이브러리가 Map 과 MutableMap 인터페이스에 대해 getValue()setValue() 확장 함수를 제공하기 때문이다.

Map 에 프로퍼티 값을 저장할 때 자동으로 프로퍼티 이름을 key 로 활용한다.

p.nameattributes.getValue(p, prop) 이라는 호출을 대신하고, attributes.getValue(p, prop) 은 다시 attributes[prop.name] 을 통해 구현된다.


2.2. Delegates.observable()

Delegates.observable() 은 가변 프로퍼티의 값을 변경되는지 확인하는 함수이다.

package assu.study.kotlinme.chap07.delegationTools

import kotlin.properties.Delegates

class Team {
  var msg = ""
  var captain: String by Delegates.observable("INIT임") { prop, old, new ->
    msg += "${prop.name} : $old to $new ~\n"
  }
}

fun main() {
  val team = Team()
  team.captain = "assu"
  team.captain = "silby"
  team.captain = "silby2"

  // captain : INIT임  to assu ~
  // captain : assu to silby ~
  // captain : silby to silby2 ~
  println(team.msg)
}

Delegates.observable() 는 2개의 인자를 받는다.

  • 첫 번째 인자
    • 프로퍼티의 초기값
    • 위에서는 “INIT임”
  • 두 번째 인자
    • 프로퍼티가 변경될 때 실행할 동작을 지정하는 함수
    • 위에서는 람다를 사용함
    • 함수의 인자는 변경 중인 프로퍼티, 프로퍼티의 현재값, 프로퍼티에 저장될 새로운 값

2.2.1. 위임 프로퍼티 없이 값 추적

어떤 객체의 프로퍼티가 변경될 때마다 리스너에게 변경 통지를 보내야하는 경우를 생각해보자.

자바에서는 PropertyChangeSupportPropertyChangeEvent 클래스를 사용하여 이런 통지를 처리하는 경우가 자주 있다.

여기서는 위 기능을 아래와 같은 순서로 리팩토링 해본다.

  • 위임 프로퍼티 없이 값 추적
  • 위임 프로퍼티를 이용하여 값 추적
  • Delegates.observable() 을 사용하여 값 추적

PropertyChangeSupport 클래스는 리스너의 목록을 관리하고, PropertyChangeEvent 이벤트가 들어오면 모든 리스너에게 이벤트를 통지한다.
보통 자바 빈 클래스의 필드에 PropertyChangeSupport 인스턴스를 저장하고, 프로퍼티 변경 시 그 인스턴스에게 처리를 위임하는 방식으로 통지 기능을 구현한다.

필드를 모든 클래스에 추가하고 싶지는 않으므로 PropertyChangeSupport 인스턴스를 changeSupport 라는 필드에 저장하고 프로퍼티 변경 리스너를 추적해주는 도우미 클래스를 만든다.
리스너 지원이 필요한 클래스는 이 도우미 클래스를 확장하여 changeSupport 에 접근 가능하다.

PropertyChangeSupport 를 사용하기 위한 도우미 클래스

package com.assu.study.kotlin2me.chap07

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport

// PropertyChangeSupport 를 사용하기 위한 도우미 클래스
open class PropertyChangeAware {
    protected val changeSupport = PropertyChangeSupport(this)

    fun addPropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.addPropertyChangeListener(listener)
    }

    fun removePropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.removePropertyChangeListener(listener)
    }
}

protected 가시성 변경자에 대한 내용은 10. 가시성 변경자 (access modifier, 접근 제어 변경자): public, private, protected, internal 를 참고하세요.

이제 People 클래스를 작성한다.

읽기 전용 프로퍼티 (이름) 와 변경 가능한 프로퍼티 (나이, 급여) 를 정의한다.
이 클래스는 나이가 급여가 변경되면 그 사실을 리스너에게 통지한다.

프로퍼티 변경 통지를 직접 구현한 People 클래스

// 프로퍼티 변경 통지를 직접 구현
class People(
    val name: String, // public final val name: String
    age: Int, // value-parameter val age: Int
    salary: Int,
) : PropertyChangeAware() {
    var age: Int = age
        set(newValue) {
            // field 를 사용하여 age 프로퍼티의 backing field 에 접근
            val oldValue = field
            field = newValue

            // 프로퍼티 변경을 리스너에게 통지
            changeSupport.firePropertyChange("age", oldValue, newValue)
        }

    var salary: Int = salary
        set(newValue) {
            val oldValue = field
            field = newValue
            changeSupport.firePropertyChange("salary", oldValue, newValue)
        }
}

People 코드에서 field 키워드를 사용하여 age, salary 프로퍼티의 backing field 에 접근한다.

프로퍼티 접근자 field 에 대한 내용은 9. 프로퍼티 접근자: field 를 참고하세요.

setter 코드에 중복이 많이 보이는 것을 알 수 있다.

이제 프로퍼티의 값을 저장하고 필요에 따라 통지를 보내주는 클래스를 추출해보자.

main 에서 실행

fun main() {
    val p = People("Assu", 20, 100)

    // 프로퍼티 변경 리스너 추가
    p.addPropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    }

    p.age = 25 // Property age changed from 20 to 25
    p.salary = 200 // Property salary changed from 100 to 200
}

2.2.1.1. value-parameter

클래스 생성 시 val 혹은 var 가 없다면 value-parameter 이다.

val, var 파라메터는 클래스의 멤버로 들어가지만, val 가 없다면 단지 생성자 초기화에 사용되는 파라메터이다.

즉, 2 개의 목적은 아래와 같다.

  • val, var 파라메터를 생성자 안에 사용
    • 이를 클래스의 멤버로써 정의하고 초기화까지 바로 진행
  • valvar 가 없는 파라메터를 생성자 안에 사용
    • 단지 생성자 초기화에 사용

아래와 같은 코드가 있다고 하자.

val 가 있는 경우의 코틀린

class Foo(val bar: String)

이에 대응하는 자바 코드

class Foo {
  String bar;
  public Foo(String bar) {
    this.bar = bar;
  }
}

val 가 없는 경우의 코틀린 (= value-parameter)

class Foo(bar: String)

이에 대응하는 자바 코드

class Foo {

  public Foo(String bar) {

  }
}

val 가 없다면 클래스 내부에서 해당 변수에 접근할 수 없다.

class Foo(a: String, val b: Int)

위와 같은 코드가 있을 때 Foo 클래스 내부에서 b 변수에는 접근 가능하지만 a 변수에는 접근이 불가하다.

관련하여 Kotlin doc: Classes,
Why do I need a parameter in a primary constructor without val/var modifier in Kotlin?,
stackoverflow: value-parameter
를 참고하면 도움이 됩니다.


2.2.2. 위임 프로퍼티를 사용하여 값 추적

아래는 2.2.1. 위임 프로퍼티 없이 값 추적 구현한 People 클래스의 불필요한 중복 로직 제거를 위해 2개로 분리하는 예시이다.

  • 프로퍼티 값을 저장하고 필요에 따라 통지를 보내주는 클래스 추출
  • People 클래스

도우미 클래스를 사용하여 프로퍼티 변경 통지 구현

package com.assu.study.kotlin2me.chap07

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport

// PropertyChangeSupport 를 사용하기 위한 도우미 클래스 (이건 그대로 사용)
open class PropertyChangeAware2 {
    protected val changeSupport = PropertyChangeSupport(this)

    fun addPropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.addPropertyChangeListener(listener)
    }

    fun removePropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.removePropertyChangeListener(listener)
    }
}

// 프로퍼티의 값을 저장하고 필요에 따라 통지를 보내주는 클래스
class ObservableProperty(val propName: String, var propValue: Int, val changeSupport: PropertyChangeSupport) {
    fun getValue(): Int = propValue
    fun setValue(newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(propName, oldValue, newValue)
    }
}
class People2(
    val name: String,
    age: Int,
    salary: Int,
) : PropertyChangeAware2() {
    val _age = ObservableProperty("age", age, changeSupport)
    var age: Int
        get() = _age.getValue()
        set(value) {
            _age.setValue(value)
        }

    val _salary = ObservableProperty("salary", salary, changeSupport)
    var salary: Int
        get() = _salary.getValue()
        set(value) {
            _salary.setValue(value)
        }
}

프로퍼티 값을 저장하고 그 값이 변경되면 변경 통지를 전달해주는 클래스를 통해 로직 중복을 많이 제거했다.

하지만 아직도 각 프로퍼티마다 ObservableProperty 를 만들고, getter/setter 에서 ObservableProperty 에게 작업을 위임하는 준비 코드가 상당 부분 필요하다.

코틀린의 위임 프로퍼티 기능을 사용하면 이런 준비 코드를 없앨 수 있다.

fun main() {
    val p = People2("Assu", 20, 100)

    // 프로퍼티 변경 리스너 추가
    p.addPropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    }

    p.age = 25 // Property age changed from 20 to 25
    p.salary = 200 // Property salary changed from 100 to 200
}

2.2.3. 위임 프로퍼티 by 를 사용하여 값 추적

코틀린의 위임 프로퍼티를 사용하기 위해 위에서 작성한 ObservableProperty 에 있는 getValue(), setValue() 를 코틀린의 관례에 맞게 수정해준다.

package com.assu.study.kotlin2me.chap07

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport
import kotlin.reflect.KProperty

// PropertyChangeSupport 를 사용하기 위한 도우미 클래스 (이건 그대로 사용)
open class PropertyChangeAware3 {
    protected val changeSupport = PropertyChangeSupport(this)

    fun addPropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.addPropertyChangeListener(listener)
    }

    fun removePropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.removePropertyChangeListener(listener)
    }
}

// 프로퍼티의 값을 저장하고 필요에 따라 통지를 보내주는 클래스
// 위임 프로퍼티를 사용하기 위해 getValue(), setValue() 를 코틀린 관례에 맞게 수정
class ObservableProperty3(
    var propValue: Int,
    val changeSupport: PropertyChangeSupport,
) {
    operator fun getValue(
        p: People3,
        prop: KProperty<*>,
    ): Int = propValue

    operator fun setValue(
        p: People3,
        prop: KProperty<*>,
        newValue: Int,
    ) {
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
}

ObservableProperty3 가 달라진 점은 아래와 같다.

  • 코틀린 관례에 사용하는 다른 함수들처럼 operator 변경자를 붙임
  • getValue(), setValue() 는 2개의 인자를 밭음
    • 프로퍼티가 포함된 객체 (위에서는 People3 타입인 p)
    • 프로퍼티를 표현하는 객체 (KProperty)
      • KProperty.name 을 통해 메서드가 처리할 프로퍼티명을 알 수 있음
  • KProperty 를 통해 프로퍼티 이름을 전달받으므로 주 생성자에서 name 프로퍼티는 없앰

이제 위임 프로퍼티를 사용할 수 있다.

class People3(
    val name: String,
    age: Int,
    salary: Int,
) : PropertyChangeAware3() {
    var age: Int by ObservableProperty3(age, changeSupport)
    var salary: Int by ObservableProperty3(salary, changeSupport)
}

by 키워드를 이용하여 위임 객체를 사용하면 위에서 직접 코드를 짜야했던 부분들을 코틀린 컴파일러가 자동으로 처리해준다.

by 오른쪽에 오는 객체를 위임 객체라고 한다.

코틀린은 위임 객체를 감춰진 프로퍼티에 저장하고, 주 객체의 프로퍼티를 읽거나 쓸 때마다 위임 객체의 getValue(), setValue() 를 호출해준다.

fun main() {
    val p = People3("Assu", 20, 100)

    // 프로퍼티 변경 리스너 추가
    p.addPropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    }

    p.age = 25 // Property age changed from 20 to 25
    p.salary = 200 // Property salary changed from 100 to 200
}

2.2.4. 위임 프로퍼티 byDelegates.observable() 를 사용하여 값 추적

코틀린에는 이미 위의 ObservableProperty 와 비슷한 역할 (= 프로퍼티를 관찰) 을 하는 함수인 Delegates.observable() 를 제공하고 있다.

다만 Delegates.observable()PropertyChangeSupport 와는 연결되어 있지 않으므로 프로퍼티 값의 변경을 통지할 때 PropertyChangeSupport 를 사용하는 방법을 알려주는 람다를 함께 넘겨주어야 한다.

위임 프로퍼티 byDelegates.observable() 사용

package com.assu.study.kotlin2me.chap07

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport
import kotlin.properties.Delegates
import kotlin.reflect.KProperty

// PropertyChangeSupport 를 사용하기 위한 도우미 클래스 (이건 그대로 사용)
open class PropertyChangeAware4 {
  protected val changeSupport = PropertyChangeSupport(this)

  fun addPropertyChangeListener(listener: PropertyChangeListener) {
    changeSupport.addPropertyChangeListener(listener)
  }

  fun removePropertyChangeListener(listener: PropertyChangeListener) {
    changeSupport.removePropertyChangeListener(listener)
  }
}

// 위임 프로퍼티 `by` 와 `Delegates.observable()` 사용
class People4(
  val name: String,
  age: Int,
  salary: Int,
) : PropertyChangeAware4() {
  private val observer = { prop: KProperty<*>, oldValue: Int, newValue: Int ->
    changeSupport.firePropertyChange(prop.name, oldValue, newValue)
  }

  var age: Int by Delegates.observable(age, observer)
  var salary: Int by Delegates.observable(salary, observer)
}

by 의 오른쪽에 있는 식을 계산한 결과인 객체는 컴파일러가 호출할 수 있는 올바른 타입의 getValue()setValue() 를 반드시 제공해야 한다.

fun main() {
    val p = People4("Assu", 20, 100)

    // 프로퍼티 변경 리스너 추가
    p.addPropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    }

    p.age = 25 // Property age changed from 20 to 25
    p.salary = 200 // Property salary changed from 100 to 200
}

2.3. Delegates.vetoable()

Delegates.vetoable() 은 새로운 프로퍼티의 값이 Predicate 를 만족하지 않으면 프로퍼티가 변경되는 것을 방지하는 함수이다.

아래의 aName()captain 의 이름이 A 로 시작하도록 강제한다.

package assu.study.kotlinme.chap07.delegationTools

import kotlin.properties.Delegates
import kotlin.reflect.KProperty

fun aName(
  property: KProperty<*>,
  old: String,
  new: String,
) = if (new.startsWith("A")) {
  println("$old to $new ~")
  true
} else {
  println("11 name must start with 'A' ~")
  false
}

interface Captain {
  var captain: String
}

class TeamWithTraditions1 : Captain {
  override var captain: String by Delegates.vetoable("Assu", ::aName)
}

// Delegates.vetoable() 를 aName() 대신 람다를 사용하여 정의
class TeamWithTraditions2 : Captain {
  override var captain: String by Delegates.vetoable("Assu") { _, old, new ->
    if (new.startsWith("A")) {
      println("$old to $new ~~")
      true
    } else {
      println("22 name must start with 'A' ~~")
      false
    }
  }
}

fun main() {
  // Assu to ASSU1 ~
  // 11 name must start with 'A' ~
  // ASSU1
  // Assu to ASSU1 ~~
  // 22 name must start with 'A' ~~
  // ASSU1
  listOf(
    TeamWithTraditions1(),
    TeamWithTraditions2(),
  ).forEach {
    it.captain = "ASSU1"
    it.captain = "BSSU"

    println(it.captain)
  }
}

Delegates.vetoable() 는 2개의 인자를 받는다.

  • 첫 번째 인자
    • 프로퍼티의 초기값
    • 위에서는 “Assu”
  • 두 번째 인자
    • onChange() 함수
    • 위에서는 aName() 과 람다를 사용함

onChange() 는 3개의 파라메터를 받는다.

  • 첫 번째 파라메터
    • KProperty<*> 타입의 위임 프로퍼티의 대한 정보를 얻음
  • 두 번째 파라메터
    • 위임 프로퍼티의 현재 값을 나타내는 old 값
  • 세 번째 파라메터
    • 위임 프로퍼티에 저장하려는 new 값

2.4. Delegates.notNull()

Delegates.notNull() 은 읽기 전에 꼭 초기화해줘야 하는 프로퍼티를 정의하는 함수이다.

import kotlin.properties.Delegates

class NeverNull {
    var nn: Int by Delegates.notNull()
}

fun main() {
    val non = NeverNull()

    // java.lang.IllegalStateException: Property nn should be initialized before get.
    // println(non.nn)

    non.nn = 1
    println(non.nn) // 1
}

3. 위임 프로퍼티 컴파일 규칙

여기서는 위임 프로퍼티가 어떤 방식으로 동작하는지에 대해 알아본다.

위임 프로퍼티가 있는 클래스

class C {
    var prop: Type by MyDelegate()
}

val c = C()

컴파일러는 MyDelegate 클래스의 인스턴스를 감춰진 프로퍼티에 저장하고, 그 감춰진 프로퍼티를 <delegate> 라고 한다.

또한 프로퍼티를 표현하기 위해 KProperty 타입의 객체를 사용하여, 그 객체를 <property> 라고 한다.

위의 클래스에 대해 컴파일러는 아래의 코드를 생성한다.

class C {
    private var <delegate> = MyDelegate()
    var prop: Type
        get() = <delegate>.getValue(this, <property>)
        set(value: Type) = <delegate>.setValue(this, <property>, value)
}

즉, 컴파일러는 모든 프로퍼티 접근자에 대해 getValue(), setValue() 호출 코드를 생성해준다.

val x = c.prop

// 아래의 getValue() 호출코드 생성
val x = <delegate>.getValue(c, <property>)
c.prop = x 

// 아래의 setValue() 호출코드 생성
<delegate>.setValue(c, <property>, x)

위의 메커니즘을 이용하여 프로퍼티 값이 저장될 장소를 바꿀 수도 있고 (맵, DB, 쿠키 등), 프로퍼티를 읽거나 쓸 때 벌어지는 일을 변경할 수도 있다. (값 검증, 변경 통지 등)

이 모든 일을 간결한 코드로 달성할 수 있다.


참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 브루스 에켈, 스베트라아 이사코바 저자의 아토믹 코틀린 과 드리트리 제메로프, 스베트라나 이사코바 저자의 Kotlin In Action 을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10