KotlinInAction/연산자 오버로딩과 기타 관례

프로퍼티 접근자 로직 재활용: 위임 프로퍼티

webmaster 2022. 8. 9. 01:40
728x90

위임 프로퍼티의 특성의 기반은 위임에 있다. 위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업을 처리하게 맡기는 디자인 패턴을 말한다. 작업을 처리하는 도우미 객체를 위임 객체라고 부른다.

위임 프로퍼티 소개

class Foo {
    var p: Type by Delegate()
}
  • p 프로퍼티는 접근자 로직을 다른 객체에게 위임한다.(여기서는 Delegate 클래스의 인스턴스를 위임 객체로 사용)
  • by 뒤에 있는 식을 계산해서 위임에 쓰일 객체를 얻는다.
  • 프로퍼티 위임 객체가 따라야 하는 관례를 따르는 모든 객체를 위임에 사용할 수 있다.

디컴파일

class Foo{
	private val delegate = Delegate() //컴파일러가 생성한 도우미 프로퍼티
    var p: Type //p 프로퍼티를 위해 컴파일러가 생성한 접근자는 delegate의 getValue와 setValue 메서드를 호출한다
    set(value: Type) = delegate.setValue(..., value)
    get() = delegate.getValue(...)
}
  • 프로퍼티 위임 관례를 따르는 Delegate 클래스는 getValue와 setValue 메서드를 제공해야 한다.
  • 관례를 사용하는 다른 경우와 마찬가지로 getValue와 setValue는 멤버 메서드이거나, 확장 함수 일 수 있다.
class Delegate {
    operator fun getValue(...) {} //getValue는 게터를 구현하는 로직을 담는다
    operator fun setValue(..., value: Type) { ... } //setValue는 세터 로직을 담는다
}
    
class Foo {
    var p: Type by Delegate() //by는 프로퍼티와 위임 객체를 연결한다
}
fun main(args: Array<String>) {
    val foo =Foo()
    val oldValue = foo.p //내부에서 delegate.getValue(...)를 호출한다
    foo.p = newValue //내부에서 delegate.setValue(..., newValue)를 호출한다
}
  • foo.p 는 일반 프로퍼티처럼 쓸 수 있고 일반 프로퍼티처럼 보이지만 , p의 게터나 세터는 Delegate 타입의 위임 프로퍼티 객체에 있는 메서드를 호출한다.

by lazy()를 사용한 프로퍼티 초기화 지연(위임 프로퍼티 사용)

지연 초기화는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 경우 초기화할 때 사용하는 패턴이다. 초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 지연 초기화 패턴을 사용할 수 있다.

class Email {
	//...
}

fun loadEmails(person: Person): List<Email> {
    println("${person.name}의 이메일을 가져옴")
    return listOf(...) //데이터 베이스에서 이메일을 가지고 오는 코드
}
class Person(val name: String){
    private var _emails: List<Email>? = null //데이터를 저장하고 emails의 위임 객체 역할을 하는 _emails 프로퍼티
    val emails: List<Email>
    get(){
        if(_emails == null){
            _emails = loadEmails(this) //최초 접근 시 이메일을 가져온다
        }
        return _emails!! //저장해 둔 데이터가 있으면 그 데이터를 반환한다
    }
}
fun main(args: Array<String>) {
    val p = Person("Alice")
    p.emails
    p.emails
}
  • 뒷받침하는 프로퍼티 기법을 사용한다.
  • _emails이라는 프로퍼티는 값을 저장하고, 다른 프로퍼티인 emails는 _emails라는 프로퍼티에 대한 읽기 연산을 제공
  • _emails는 널이 될 수 있는 타입인 반면 emails는 널이 될 수 없는 타입이므로 프로퍼티 2개를 사용해야 한다.
  • 이런 코드를 작성하는 것은 성가시며, 이 구현은 스레드 안전하지 않아서 언제나 제대로 동작한다고 말할 수 없다.
    • 위임 프로퍼티를 사용하면, 코드가 훨씬 간단해진다
    • 위임 프로퍼티는 데이터를 저장할 때 쓰이는 뒷받침하는 프로퍼티와 값이 오직 한 번만 초기화됨을 보장하는 게터 로직을 함께 캡슐화해준다.
class Person(val name: String){
    val emails by lazy { loadEmails(this) }
}
  • lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메서드가 들어있는 객체를 반환한다.
    • lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다.
  • lazy 함수의 인자는 값을 초기화할 때 호출할 람다이며, lazy 함수는 기본적으로 스레드 안전하다
    • 하지만 필요에 따라 동기화에 사용할 락을 lazy함수에 전달할 수도 있고, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy함수가 동기화하지 못하게 막을 수도 있다.

위임 프로퍼티 구현

위임 프로퍼티를 사용하지 않고 구현

open class PropertyChangeAware {
    protected val changeSupport = PropertyChangeSupport(this)

    fun addPropertyChangeListener(listener: PropertyChangeListener){
        changeSupport.addPropertyChangeListener(listener)
    }
    fun removePropertyChangeListener(listener: PropertyChangeListener){
        changeSupport.removePropertyChangeListener(listener)
    }
}
  • 필드를 모든 클래스에 추가하고 싶지않아 PropertyChangeSupport 인스턴스를 changeSupport 필드에 저장하고, 프로퍼티 변경 리스너를 추적해주는 도우미 클래스를 만들자
  • 리스너 지원이 필요한 클래스는 이 도우미 클래스를 확장해서 changeSupport에 접근할 수 있다.
class Person(val name: String, age: Int, salary: Int) : PropertyChangeAware(){
    var age: Int = age
        set(newValue){
            val oldValue = field //뒷받침하는 필드에 접근할 때 field 식별자를 사용한다
            field = newValue
            changeSupport.firePropertyChange( //프로퍼티 변경을 리스너에게 통지한다
                "age", oldValue, newValue)
        }
    var salary: Int = salary
        set(newValue){
            val oldValue = field
            field = newValue
            changeSupport.firePropertyChange(
                "salary", oldValue, newValue)
        }
}
fun main(args: Array<String>) {
    val p = Person("Dmitry", 34, 2000)
    p.addPropertyChangeListener( //프로퍼티 변경 리스너를 추가한다
        PropertyChangeListener { event -> 
            println(
                "Property ${event.propertyName} changed " +
                        "from ${event.oldValue} to ${event.newValue}"
            )
        }
    )
    p.age = 35
    p.salary = 2100
}
  • 읽기 전용 프로퍼티와 변경 가능한 프로퍼티 둘을 정의한다.
    • 나이나, 급여가 바뀌면 그 사실을 리스너에게 통지한다
  • field 키워드를 사용해 age와 salary 프로퍼티를 뒷받침하는 필드에 접근하는 방법을 보여주고 있다

도우미 클래스를 통해 프로퍼티 변경통지를 구현해 보자

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 Person(val name: String, age: Int, salary: Int) : PropertyChangeAware(){
    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를 만들고 게터와 세터에서 ObservableProperty에 작업을 위임하는 준비 코드가 필요하다.(코틀린의 위임 프로퍼티를 사용해 준비 코드를 제거하자)

위임 프로퍼티를 사용하고 구현

class ObservableProperty(
    var propValue: Int, val changeSupport: PropertyChangeSupport
) {
    operator fun getValue(p: Person, prop: KProperty<*>): Int = propValue
    operator fun setValue(p: Person, prop: KProperty<*>, newValue:Int){
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
}
  • 코틀린의 관례에 맞게 수정함에 따라 getValue와 setValue에도 operator 변경자가 붙는다
  • getValue, setValue 는 프로퍼티가 포함된 객체와 프로퍼티를 표현하는 객체를 파라미터로 받는다.
    • 코틀린은 KProperty 타입의 객체를 사용해 프로퍼티를 표현한다.
    • KProperty.name을 통해 메서드가 처리할 프로퍼티 이름을 알 수 있다.
  • KProperty 인자를 통해 프로퍼티 이름을 전달받으므로 주 생성자에서는 name 프로퍼티를 제거한다.
class Person(val name:String, age: Int, salary: Int
): PropertyChangeAware(){
    var age: Int by ObservableProperty(age, changeSupport)
    var salary: Int by ObservableProperty(salary, changeSupport)
}
  • by 키워드를 사용해 위임 객체를 지정하면 이전에 직접 코드를 짰어야 했던 여러 작업을 코틀린 컴파일러가 알아서 해준다.
  • by 오른쪽에 오는 개체를 위임객체라 부르고, 코틀린은 위임 객체를 감춰진 프로퍼티에 저장하고, 주 객체의 프로퍼티를 읽거나 쓸 때마다 위임 객체의 getValue와 setValue를 호출해준다.
class Person(val name:String, age: Int, salary: Int
): PropertyChangeAware(){
    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)
}
  • 관찰 가능한 프로퍼티 로직을 직접작성하지 않고 코틀린 표준 라이브러리를 사용해도 된다
    • 단, PropertyChangeSupport 와는 연결돼 있지 않다.
    • 따라서 프로퍼티 값의 변경을 통지할 때 PropertyChangeSupport를 사용하는 방법을 알려주는 람다를 표준 라이브러리 클래스에 넘겨주어야 한다.
  • by 오른쪽에 있는 식이 꼭 새로운 인스턴스를 만들 필요는 없다
    • 함수 호출, 다른 프로퍼티, 다른 식 등이 by 우항에 올 수 있다.
    • 단, 우항에 있는 식을 계산한 결과인 객체는 컴파일러가 호출할 수 있는 올바른 타입의 getValue, setValue를 반드시 제공해야 한다
    • getValue, setValue 모두 객체 안에 정의된 메서드 거나 확장 함수 있수 있다

위임 프로퍼티 컴파일 규칙

fun main(args: Array<String>) {
	var c = C()
}

class C {
	var prop: Type by MyDelegate()
}
  • MyDelegate 클래스의 인스턴스를 감춰진 프로퍼티에 저장하며, (그 감춰진 프로퍼티를 <delegate>라고 부른다)
  • 또한 컴파일러는 프로퍼티를 표현하기 위해 KProperty 타입의 객체를 사용한다(<property>라고 부른다)

컴파일러가 생성한 코드

class C {
	private val <delegate> = MyDelegate()
	var prop: Type 
    	get() = <delegate>.getValue(this, <property>)
        set(value: Type) = <delegate>.setValue(this, <property>, value)
}
  • 컴파일러는 모든 프로퍼티 접근자 안에 getValue, setValue 호출 코드를 생성해 준다
  • 이 매커니즘은 단순하지만 흥미로운 활용방안이 많다.
    • 프로퍼티 값이 저장될 장소 변경이 가능
    • 프로퍼티를 읽거나 쓸 때 벌어질 일을 변경할 수 있다.

프로퍼티 값을 맵에 저장

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

class Person {
    //추가 정보
    private val _attributes = hashMapOf<String, String>()
    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }

    //필수 정보
    val name: String
    get() = _attributes["name"]!! //수동으로 맵에서 정보를 꺼낸다
}
fun main(args: Array<String>) {
    val p = Person()
    val data = mapOf("name" to "Dmitry", "company" to "JetBrains")
    for ((attrName, value) in data){
        p.setAttribute(attrName, value)
    }
    println(p.name)
}
  • 추가 데이터를 저장하기 위해 일반적인 API를 사용하고, 특정 프로퍼티를 처리하기 위해 구체적인 개별 API를 제공한다.
  • 위임 프로퍼티를 사용하면 쉽게 변경이 가능하다.(by 키워드 뒤에 맵을 직접 넣으면 된다)
class Person {
    private val _attributes = hashMapOf<String, String>()
    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }

    val name: String by _attributes//위임 프로퍼티로 맵을 사용한다
}
  • 해당 코드가 동작하는 이유는 표준 라이브러리가 Map과 MutableMap 인터페이스에 대해 getValue와 setValut 확장 함수를 제공하기 때문이다
  • getValue에서 맵에 프로퍼티 값을 저장할 때는 자동으로 프로퍼티 이름을 키로 활용한다.
  • p.name -> _attrbutes.getValue(p , prop) -> _attributes[prop.name] 

프레임워크에서 위임 프로퍼티 활용

객체 프로퍼티를 저장하거나 변경하는 방법을 바꿀 수 있다면 프레임워크를 개발할 때 유용하다.

object Users: IdTable() { //객체는 데이터베이스 테이블에 해당한다
    val name = varchar("name", length=50).index() //프로퍼티는 테이블 칼럼에 해당
    val age = integer("age") 
}
class User(id: EntityID) :Entity(id){ //각 User 인스턴스는 테이블에 들어있는 구체적인 엔티티에 해당
    var name: String by Users.name //사용자 이름은 데이터베이스 name 컬럼에 들어있다
    var age: Int by Users.age
}
  • Users 객체는 데이터베이스 테이블을 표현한다.(데이터베이스 전체에 단 하나만 존재하는 테이블을 표현하므로 싱글턴 객체로 선언)
  • Users 상위 클래스인 Entity 클래스는 데이터베이스 컬럼을 엔티티 속성 값으로 연결해주는 매핑이 있다(name, age가 있다)
  • User의 프로퍼티에 접근할 때 자동으로 Entity클래스에 정의된 데이터 베이스 매핑으로부터 필요한 값을 가져 오므로 편하다.
    • 어떤 User 객체를 변경하면 그 객체는 변경됨 dirty상태로 변하고 프레임워크가 이를 db에 반영
  • 각 엔티티 속성은 위임 프로퍼티며, 컬럼 객체를 위임 객체로 사용한다
class User(id: EntityID) :Entity(id){ 
    var name: String by Users.name //Users.name 은 name 프로퍼티에 해당하는 위임 객체다
    var age: Int by Users.age
}
operator fun <T> Column<T>.getValue(o: Entity, desc:KProperty<*>):T{
	//데이터베이스에서 컬럼 값 가져오기
}
operator fun <T> Column<T>.setValue(o: Entity, desc:KProperty<*>, value:T){
	//데이터베이스의 값 변경하기
}
  • 프레임 워크는 Column 클래스 안에 getValue와 setValue 메서드를 정의한다.
  • Column 프로퍼티를 위임 프로퍼티에 대한 위임 객체로 사용할 수 있다.
    • user.age += 1 -> user.ageDelegate.setValue(user.ageDelegate.getValue() + 1)과 같은 코드로 변환
  • getValue(), setValue() 메서드는 데이터베이스에서 데이터를 가져오고 기록하는 작업을 처리

https://github.com/JetBrains/Exposed

 

GitHub - JetBrains/Exposed: Kotlin SQL Framework

Kotlin SQL Framework. Contribute to JetBrains/Exposed development by creating an account on GitHub.

github.com

 

728x90