ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 11-2. 도메인 특화 언어 : 위임 프로퍼티
    Study(종료)/Kotlin 22.09.13 ~ 12.18 2022. 12. 1. 14:06

    11장

    11-1-1. 도메인 특화 언어 : 연산자 오버로딩(1~5)

    11-1-2.  도메인 특화 언어 : 연산자 오버로딩(6~8)

    11-2.  도메인 특화 언어 : 위임 프로퍼티

    11-3. 도메인 특화 언어 : 고차 함수와 DSL


    11-2.  도메인 특화 언어 : 위임 프로퍼티

     

    위임 프로퍼티를 사용해 간단한 문법적인 장식 뒤에 프로퍼티 접근 로직을 구현할 수 있다.

    예전에 lazy 위임을 통해 최초 접근 시 까지 프로퍼티 계산을 지연시키는 경우를 봤었다.

    val result by lazy{ 1 + 2 }

    11-1에서 설명한 연산자와 마찬가지로 위임 프로퍼티 구현도 몇 가지 관습에 의해 이뤄지는데,

    이 관습을 통해 프로퍼티를 읽고 쓰는 방법, 위임 객체 자체의 생성을 제어하는 방법을 알 수 있다.

     

    1) 표준 위임들

    코틀린 표준 라이브러리에 다양한 예제를 지원하는 바로 사용 가능한 몇개의 위임 구현이 들어있다.

    이런 위임의 예로 지연 계산 프로퍼티를 표현하는 위임이 있다.

    val text by lazy { File("data.txt").readText() }

    lazy() 함수는 다중 스레드 환경에서 지연 계산 프로퍼티의 동작을 미세하게 제어하기 위해

    세 가지 다른 버전을 가지고 있다. 디폴트는 스레드 안전한 구현을 만들어낸다.

    디폴트 구현은 동기화를 사용해 지연 계산된 값이 항상 한 가지 스레드에 의해서만

    초기화되도록 보장하며, 이 경우 위임 인스턴스가 동기화 객체 역할까지 수행한다.

     

    다른 lazy() 버전을 사용해 원하는 동기화 객체를 지정할 수 있다.

    private val lock = Any()
    val text by lazy(lock) {File("data.txt").readText()}
    

    그리고 LazyThreadSafetyMode 이넘 상수를 활용해 세 가지 기본 구현 중 하나를 선택할 수 있다.

     

     ●  SYNCHRONIZED

      프로퍼티 접근을 동기화, 한 번에 한 스레드만 프로퍼티 값을 초기화, 디폴트 구현

     ●  PUBLICATION

     초기화 함수가 여러 번 호출될 수 있지만,

     가장 처음 도착하는 결과가 프로퍼티 값이 되도록 프로퍼티 접근을 동기화

     ●  NONE

      프로퍼티 접근 동기화 X , 이 방식을 선택하면 다중 스레드 환경에서 프로퍼티의 올바른 동작 보장 X

     

    초기화 함수에 부수 효과가 있는 경우 SYNCHRONIZED / PUBLICATION의 차이가 명확해진다.

    val myValue by lazy{ // default : LazyThreadSafetyMode.SYNCHRONIZED
        println("Initializing myValue")
        123
    }

    위 프로퍼티는 디폴트인 SYNCHRONIZED모드의 경우

    초기화 함수가 여러 번 호출되지 않게 보장하므로 메시지가 최대 한 번 출력된다.

    val myValue2 by lazy(LazyThreadSafetyMode.PUBLICATION){
        println("Initializing myValue")
        123
    }

    하지만 스레드 안전성 모드를 PUBLICATION으로 바꾸면 프로퍼티 값은 그대로이지만,

    여러 스레드가 myValue2를 초기화하려고 시도하면서 메시지가 여러 번 출력될 수 있다.

     

    NODE 모드는 가장 빠르며, 초기화 코드가 한 스레드에서만 불린다고 확신할 수 있는 경우 유용하다.

    일반적인 예시로는 lazy인 지역 변수가 있다.

    fun main() {
        val x by lazy(LazyThreadSafetyMode.NONE) { 1 + 2 }
        println(x)
    }

    이 경우 프로퍼티에 다시 접근하려 시도하면, 초기화 함수 역시 다시 실행된다.

    초기화 함수가 예외를 던지면 프로퍼티가 초기화되지 않는다.

     

    Kotlin.properties.Delegates의 멤버를 활용해 몇 가지 표준 위임을 사용할 수 있다.

     

     ● notNull() 함수 : 프로퍼티 초기화를 미루며 널이 아닌 프로퍼티를 정의할 수 있게 함

     기본적으로 lateInit 프로퍼티와 같다.

    import kotlin.properties.Delegates.notNull
    
    var text: String by notNull()
    fun readText() {
        text = readLine()!!
    }
    fun main() {
        readText()
        println(text)
    }

     ● observable() 함수 : 프로퍼티 값이 변경될 때 통지를 받을 수 있음(변경 후 통지)

     초깃값과 람다를 인자로 받으며 프로퍼티 값이 바뀔 때 마다 람다가 호출됨.

     관례적으로 람다에서 사용하지 않을 파라미터를 _ 로 받음

    import kotlin.properties.Delegates.observable
    
    class Person(name: String, val age: Int){
        var name: String by observable(name) {
            _, old, new ->
            println("Name changed: $old to $new")
        }
    }
    fun main() {
        val person = Person("Kim", 25)
    
        person.name = "Harry" // Name changed: Kim to Harry
        person.name = "Potter" // Name changed: Harry to Potter
        person.name = "Lon" // Name changed: Potter to Lon
    }

     ● vetoable() 함수 : 프로퍼티 값이 변경되기 전 통지를 받을 수 있음(변경 후 통지)

     observable과 유사,  초깃값과 Boolean을 반환하는 람다를 인자로 받음

     프로퍼티 값을 변경하려고 시도할 때 마다 값을 변경하기 직전에 이 람다 호출,

     람다가 true를 반환하면 실제 값 변경이 일어나고, false를 반환하면 값 변경 X

    import kotlin.properties.Delegates.vetoable
    
    var password: String by vetoable("password"){ _, old, new ->
        if(new.length < 8){
            println("Password should be at least 8 characters long")
            false
        }else{
            println("Password is OK")
            true
        }
    }
    fun main() {
        password = "pAsSwOrD" // Password is OK
        password = "qwerty" // Password should be at least 8 characters long
    }

     ● vetoable() + observable()이 제공하는 변경 전 + 변경 후 통지를 함께 조합하고 싶을 경우

     ObservableProperty를 상속해 beforeChagne(), afterChange() 함수를 오버라이드


    맵에 프로퍼티 값을 설정하고 읽어올 수 있는 위임 기능을 표준 라이브러리는 제공한다.

    맵에 값을 저장하고 읽을 때 프로퍼티 이름을 키로 사용한다.

    map 인스턴스를 위임 객체로 사용해 이런 기능을 활용할 수 있다.

    class CarItem(data: Map<String, Any?>){
        val title: String by data
        val price: Double by data
        val quantity: Int by data
    }
    
    fun main() {
        val item = CarItem(mapOf(
                "title" to "Laptop",
                "price" to 999.9,
                "quantity" to 1
        ))
    
        println(item.title) // Laptop
        println(item.price) // 999.9
        println(item.quantity) // 1
    }

    표준 위임만으로 충분하지 않을 경우 코틀린의 위임 관습을 이용해 커스텀 위임을 구현할 수 있다.


    2) 커스텀 위임 만들기

    커스텀 위임을 만드려면 특별한 연산자 함수(들)를 정의하는 타입이 필요하다.

    이 함수들은 프로퍼티 값을 읽고 쓰는 방법을 구현하는데,

    읽기 함수의 이름은 getValue()이어야 하고 두 가지 파라미터를 받는다.

     ● receiver : 수신 객체 값이 들어있고, 위임된 프로퍼티의 수신 객체와 같은 타입(or 상위타입) 이어야 한다.

     ● property : 프로퍼티 선언을 표현하는 리플렉션이 들어있따. KProperty<*>(or 상위타입) 이어야 한다.

    두 파라미터의 이름은 실제로는 중요하지 않고 타입만 중요하다.

    getValue() 함수의 반환 타입은 반드시 위임 프로퍼티와 같거나 하위타입이어야 한다.

     

    예를들어 프로퍼티 값과 수신 객체를 연관시켜 기억하는 일종의 캐시 역할을 하는 위임을 만들어 보자

    import kotlin.reflect.KProperty
    
    class CachedProperty<in R, out T : Any>(val initiator: R.() -> T){
        private val cachedValues = HashMap<R, T>()
        
        operator fun getValue(receiver: R, property: KProperty<*>): T {
            return cachedValues.getOrPut(receiver) { receiver.initiator() }
        }
    }
    
    fun <R, T : Any> cached(initializer: R.() -> T) = CachedProperty(initializer)
    
    class Person(val firstName: String, val familyName: String)
    
    val Person.fullName: String by cached { "$firstName $familyName" }
    
    fun main() {
        val johnDoe = Person("John", "Doe")
        val harrySmith = Person("Harry", "Smith")
        
        // johnDoe 에 저장된 수신 객체에 최초 접근, 값을 계산해 캐시에 담음
        println(johnDoe.fullName) // John Doe
    
        // harrySmith 에 저장된 수신 객체에 최초 접근. 값을 계산해 캐시에 담음 
        println(harrySmith.fullName) // Harry Smith
        
        // johnDoe 에 저장된 수신 객체에 재접근. 캐시에서 값을 읽음
        println(johnDoe.fullName) // John Doe
        
        // harrySmith 에 저장된 수신 객체에 재접근. 캐시에서 값을 읽음
        println(harrySmith.fullName) // Harry Smith
    }

    fullName은 최상위 확장 프로퍼티이므로 위임도 저역 상태에 속하게 된다.

    따라서 프로퍼티의 값은 수신 객체마다 단 한번만 초기화된다.

     

    var 프로퍼티에 해당하는 읽고 쓸 수 있는 프로퍼티의 경우,

    getValue() 외에 프로퍼티에 값을 저장할 때 호출될 setValue() 함수를 정의해야 한다.

    이 함수의 반환 타입은 Unit이며 세 가지 파라미터를 받는다.

    1. receiver : getValue()와 동일

    2. property : getValue()와 동일

    3. newValue : 프로퍼티에 저장할 새 값. 프로퍼티 자체와 같은 타입(or상위 타입)이어야 함

     

    아래 코드는 lateinit 프로퍼티의 final 버전인 위임 클래스를 정의한다.

    즉 이 프로퍼티는 초기화를 단 한번만 허용한다.

    import kotlin.reflect.KProperty
    
    class FinalLateinitProperty<in R, T : Any>{
        private lateinit var value: T
        operator fun getValue(receiver : R, property: KProperty<*>): T{
            return value
        }
        operator fun setValue(receiver: R,
                              property: KProperty<*>,
                              newValue: T){
            if(this::value.isInitialized) throw IllegalStateException(
                    "Property ${property.name} is already initialized"
            )
            value = newValue
        }
    }
    
    fun <R, T : Any> finalLateInit() = FinalLateinitProperty<R, T>()
    
    var message: String by finalLateInit()
    fun main() {
        message = "Hello"
        println(message) // Hello
        message = "Bye" // Exception : Property message is already initialized 
    }

    getValue() / setValue() 함수를 멤버 함수로 정의할 수도 있지만 확장 함수로 정의해도 된다.

    확장 함수를 사용하면 원하는 객체를 언제든지 일종의 위임 객체로 바꿀 수 있다.

    provideDelegate() 함수를 통해 위임 인스턴스를 제어할 수 있으며

    일종의 위임 팩토리 역할을 하는 중간 인스턴스를 provideDelegate()를 통해 넘길 수도 있다.

    provideDelegate()도 멤버 함수나 확장 함수로 정의할 수 있다.


    3) 위임 표현

    런타임에 위임이 어떻게 표현되고 이를 어떻게 접근하는지 보면,

    런타임에 위임은 별도의 필드에 저장된다. 반면 프로퍼티 자체에 대해서는

    접근자가 자동으로 생성되며 이 접근자는 위임에 있는 적절한 메서드를 호출한다.

    예를들어 아래 코드가 변환되는 것을 보자

    class Person(val firstName: String, val familyName: String){
        var age: Int by finalLateInit()
    }

    위 코드는 아래와 같은 코드로 변환되는데,

    코틀린 코드에서는 age$delegate 같은 위임 필드를 직접 명시적으로 사용할 수 없다

    그래서 실제로 아래 코드는 동작하지는 않는다.

    class Person(val firstName: String, val familyName: String){
        private val 'age$delegate' = finalLateInit<Person, Int>()
        
        var age: Int
            get() = 'age$delegate'.getValue(this, this::age)
            set(value) {
                'age$delegate'.setValue(this, this::age, value)
            }
    }

    다음글

    11-3. 도메인 특화 언어 : 고차 함수와 DSL

    반응형

    댓글

Designed by Tistory.