Kotlin 위임 프로퍼티
위임 프로퍼티를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다.
위임?
위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업 처리하게 하는 디자인 패턴을 의미한다. 이때 실직적으로 작업을 처리하는 도우미 객체를 위임 객체(Delegate)라고 한다.
위임 프로퍼티
위임 프로퍼티의 일반적인 문법은 다음과 같다.
class Foo {
var p: Type by Delegate()
}
p
프로퍼티는 접근자 로직을 다른 객체(Delegate
class)에 위임한다. by 뒤의 식을 계산해서 위임에 쓰일 객체를 얻는다.
위임을 처리하기 위해 컴파일러는 숨겨진 도우미 프로퍼티를 만들고 위임 객체의 인스턴스로 초기화한다. p
프로퍼티는 그 위임 객체에서 자신의 작업을 위임한다.
class Foo {
private val delegate = Delegate()
var p: Type
set(value) = delegate.setValue(..., value)
get() = delegate.getValue()
}
프로퍼티 위임 컨벤션을 다르는 Delegate
클래스는 getValue
와 setValue
(var
에서만 사용함) 메소드를 제공해야 한다. 다른 컨벤션과 마찬가지로 getValue
와 setValue
는 멤버 메소드이거나 확장 함수일 수도 있다. Delegate 클래스를 단순화하면 다음과 같다.
class Delegate {
operator fun getValue(...) { ... }
operator fun setValue(..., value: Type) { ... }
}
Lazy initialization: by lazy()
지연 초기화(Lazy initialization)는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 경우 초기화할 때 사용하는 패턴이다. 초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 적용할 수 있다.
class Email { /*...*/ }
fun loadEmails(person: Person): List<Email> {
println("${person.name}의 이메일을 가져옴")
return listOf(/*...*/)
}
class Person(val name: String) {
private var _emails: List<Email>? = null
val emails: List<Email>
get() {
if(_emails == null) {
_emails = loadEmails(this)
}
return _emails!!
}
}
여기서는 뒷받침하는 프로퍼티(Backing Property) 기법을 사용한다. _emails
라는 프로퍼티는 값을 지정하고 다른 프로퍼티인 emails
는 _emails
프로퍼티에 대한 읽기 연산을 제공한다. _emails
는 var
인 반면 emails
는 val
이기 때문에 프로퍼티를 2개 사용한다. 이런 기법을 실제로 자주 사용하므로 잘 알아두는 것이 좋다.
표준 라이브러리 함수 lazy
는 위 코드를 훨씬 더 간단하게 사용할 수 있게 해준다.
class Person(val name: String) {
val emails by lazy { loadEmails(this) }
}
위임 프로퍼티 구현
위임 프로퍼티를 예시를 들어 구현해보자. 어떤 객체를 UI에 표시하는 경우 객체가 바뀌면 UI가 자동으로 바뀌어야 한다. 자바에서는 PropertyChangeSupport
와 PropertyChangeEvent
클래스를 사용해 이런 통지를 처리한다.
PropertyChangeSupport
클래스를 리스너의 목록을 관리하고 PropertyChangeEvent
는 이벤트가 들어오면 목록의 모든 리스너에게 이벤트를 통지한다.
필드를 모든 클래스에 추가하고 싶지 않으므로 PropertyChangeSupport
인스턴스를 changeSupport
라는 필드에 저장하고 프로퍼티 변경 리스너를 추적하는 도우미 클래스 PropertyChangeAware
를 만들어보자.
open class PropertyChangeAware {
protected val changeSupport = PropertyChangeSupport(this)
fun addPropertyChangeListener(listener: PropertyChangeListener) {
changeSupport.addPropertyChangeListener(listener)
}
fun removePropertyChangeListener(listener: PropertyChangeListener) {
changeSupport.removePropertyChangeListener(listener)
}
}
Person
클래스를 작성해보자.
class Person(
val name: String, age: Int, salary: Int
) : PropertyChangeAware() {
var age: Int = age
set(newValue) {
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)
}
}
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
/*
Property age changed from 34 to 35
Property salary changed from 2000 to 2100
*/
이제 Person
클래스의 세터 코드의 중복을 제거해보자. 프로퍼티의 값을 저장하고 필요에 따라 통지를 보내주는 클래스를 작성한다.
class ObservableProperty(
val propertyName: String, var propertyValue: Int,
val changeSupport: PropertyChangeSupport
) {
fun getValue(): Int = propertyValue
fun setValue(newValue: Int) {
val oldValue = propertyValue
propertyValue = newValue
changeSupport.firePropertyChange(propertyName, 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 propertyValue: Int,
val changeSupport: PropertyChangeSupport
) {
operator fun getValue(person: Person, property: KProperty<*>): Int = propertyValue
operator fun setValue(person: Person, property: KProperty<*>, newValue: Int) {
val oldValue = propertyValue
propertyValue = newValue
changeSupport.firePropertyChange(property.name, oldValue, newValue)
}
}
이전 코드랑 비교하면 다음과 같은 차이가 있다.
getValue
와setValue
함수에operator
변경자가 붙는다.getValue
와setValue
는 프로퍼티가 포함된 객체(Person
)와 프로퍼티를 표현하는 객체(KProperty
)를 받는다.KProperty
인자를 통해 프로퍼티 이름을 전달받을 수 있다.
이제 Person
클래스를 코틀린의 위임 프로퍼티 기능을 이용하여 간단하게 만들 수 있다.
class Person(
val name: String, age: Int, salary: Int
) : PropertyChangeAware() {
var age: Int by ObservableProperty(age, changeSupport)
var salary: Int by ObservableProperty(salary, changeSupport)
}
위임 프로퍼티 컴파일 규칙
위임 프로퍼티는 어떻게 동작할까? 먼자 아래와 같은 위임 프로퍼티가 있는 클래스가 있다고 가정하자.
class C {
var prop: Type by MyDelegate()
}
val c = C()
컴파일러는 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 호출 코드를 생성해준다.
이런 메커니즘은 단순하지만 활용도가 많은데, 프로퍼티 값이 저장된 장소를 바꿀 수 있고(맵, DB, Cookie 등) 프로퍼티를 읽거나 쓸 때 벌어질 일을 변경할 수도 있다(값 검증, 변경 통지 등).