Programming/Kotlin

코틀린(Kotlin) - 연산자 오버로딩과 기타 관례

JunsuKim 2021. 10. 31.
728x90
 

어떤 클래스 안에 plus라는 이름의 특별한 메소드를 정의한다면 그 클래스의 인스턴스에 대해 + 연산자를 사용할 수 있다. 이런 식으로 어떤 언어 기능과 미리 정해진 이름의 함수를 연결해주는 기법을 코틀린에선 관례라고 한다.

 

코틀린에서는 언어 기능을 타입에 의존하는 자바와 달리 관례에 의존한다.

이는 기존 자바 클래스를 코틀린 언어에 적용하기 위해서이다.

기존 자바 클래스가 구현하는 인터페이스는 이미 고정돼 있고, 코틀린에서 자바 클래스가 새로운 인터페이스를 구현하게 만들 수 없다.

하지만 확장 함수를 사용하면 기존 클래스에 새로운 메소드를 추가할 수 있다.

따라서 기존 자바 코드를 바꾸지 않아도 새로운 기능을 쉽게 부여할 수 있게 된다.

산술 연산자 오버로딩

산술 연산자는 코틀린에서 관례를 사용하는 가장 단순한 예이다.

자바에서는 원시 타입에 대해서만 산술 연산자를 사용할 수 있고, String에 대해 + 연산자를 사용할 수 있다.

또한 BigInter 클래스를 사용할 때 add 메소드를 호출하기보다는 +연산을 사용하는 편이 더 낫다.

 

1. 이항 산술 연산 오버로딩

data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}
  • 연산자를 오버로딩하는 함수의 앞에는 operator 키워드를 붙여야 한다.
    -> 이 키워드를 붙임으로써 어떤 함수가 관례를 따르는 함수인지 명확히 할 수 있다.
  • 만약 operator 키워드가 없는데, 관례에서 사용하는 함수의 이름을 쓰고 그 이름에 해당하는 기능을 사용하면 이름이 겹쳤다는 에러가 뜨게 된다.
  • 연산자를 멤버 함수로 만드는 대신 확장 함수로 정의할 수도 있다.
operator fun Point.plus(other: Point): Point {
    return Point(x + other.x, y + other.y)
}
  • 외부 함수의 클래스에 대한 연산자를 정의할 때 관례를 따르는 이름의 확장 함수로 구현하는 게 일반적이다.
  • 코틀린에서는 프로그래머가 직접 연산자를 만들어 사용할 수 없고 언어에서 정해져 있는 연산자만 오버로딩 할 수 있다.
  • 관례에 따르기 위해 클래스에서 정의해야 하는 이름이 연산자 별로 정해져 있다.
함수 이름
a * b times
a / b div
a % b mod(1.1부턴 rem)
a + b plus
a - b minus
  • 직접 정의한 함수를 통해 구현하더라도 연산자 우선순위는 항상 표준 숫자 타입에 대한 연산자 우선순위와 같다.
  • 연산자를 정의할 때 두 피연산자가 같은 타입일 필요 없다.
  • 코틀린 연산자가 자동으로 교환 법칙을 지원하지 않는다.
  • 연산자 함수의 반환 타입이 두 피연산자 중 하나와 일치해야 하는 것도 아니다.
  • 일반 함수처럼 operator 함수도 오버로딩할 수 있다.
    -> 이름은 같지만 파라미터 타입이 서로 다른 연산자 함수를 여럿 만들 수 있음.

 

2. 복합 대입 연산자 오버로딩

var point = Point(1,2)
point +=Point(3,4)
println(point)

// result
Point(x=4, y=6)

코틀린에서 plus와 같은 연산자를 오버로딩하면 +연산자뿐 아니라 그와 관련된 연산자인 +=도 자동으로 지원된다.

이처럼 +=, -= 등의 연산자를 복합 대입 연산자라 한다.

  • x += 4가 x = x + 4인 것처럼 point += Point(3, 4)는 point = point + Point(3, 4)와 같다.
    (변수가 변경 가능한 경우에만 복합 대입 연산자를 사용할 수 있다.)
  • +=연산이 객체에 대한 참조를 다른 참조로 바꾸기는 것 외에 원래 객체의 내부 상태를 변경하게 할 수 있다.
    (변경 가능한 컬렉션에 원소 추가)
    var munbers = ArrayList<Int>()
    numbers += 42
    println(number[0])
    
    // result
    42​
  • 코틀린 표준 라이브러리는 변경 가능한 컬렉션(MutableCollection)에 대해 plusAssign을 정의한다.
    operator fun <T> MutableCollection<T>.plusAssign(element: T){
      this.add(element)
    }​
  • +=를 plus와 plusAssign 양쪽으로 컴파일할 수 있다.
    만약 어떤 클래스가 이 두 함수를 모두 정의하고 둘 다 +=에 사용 가능한 경우 오류가 발생한다. 이를 해결하려면
    일반 연산자를 사용하거나 var을 val로 바꿔 plussAssign 적용이 불가능하게 할 수 있다.
    하지만 새로운 클래스를 일관성 있게 설계하는 것이 가장 좋다.
  • 클래스가 변경 불가능하면 plus와 같이 새로운 값을 반환하는 연산만을 추가해야 한다.
  • 빌더와 같이 변경 가능한 클래스를 설계하면 plusAssign이나 그와 비슷한 연산을 제공하는 것이 좋다.
  • 코틀린 표준 라이브러리는 컬렉션에 대해 두 가지 접근 방법을 제공하며, +와 -는 항상 새로운 컬렉션을 반환한고
    +=, -= 연산자는 항상 변경 가능한 컬렉션에 적용해 메모리에 있는 객체 상태를 변화시킨다.
  • 읽기 전용 컬렉션에서 +=, -=는 변경을 적용한 복사본을 반환한다.
  • 이와 같은 피연산자로는 개별 원소를 사용하거나 원소 타입이 일치하는 다른 컬렉션을 사용할 수 있다.
    val list = arrayListOf(1, 2)
    list += 3
    val newList = list + listOf(4, 5)
    println(list)
    println(newList)
    
    // result
    [1, 2, 3]
    [1, 2, 3, 4, 5]​

3. 단항 연산자 오버로딩

 

operator fun Point.unaryMinus(): Point {
    return Point(-x, -y)
}

>>> val p = Point(10, 20)
>>> println(-p)

// result
Point(x=-10, y=-20)

단항 연산자를 오버로딩하는 절차는 이항 연산자와 유사하다.

미리 정해진 이름의 함수를 선언할 때 앞에 operator 키워드를 붙이면 된다.

함수 이름
+a unaryPlus
-a unaryMinus
!a not
++a, a++ inc
--a, a-- dec
  • 단항 연산자를 오버로딩하기 위해 사용하는 함수는 인자를 취하지 않는다.
  • inc, dec 함수를 정의해 증감 연산자를 오버로딩하는 경우 일반적인 값에 대한 전위와 후위 증감 연산자와 같은 의미를 제공한다.
    operator fun BigDecimal.inc() = this + BigDecimal.ONE
    >>> var bd = BigDecimal.ZERO
    >>> println(bd++)
    >>>println(++bd)
    
    // result
    0
    2​

비교 연산자 오버로딩

산술 연산자와 마찬가지로 원시 타입 값뿐 아니라 모든 객체에 대해 비교 연산을 할 수 있다.

자바에서는 eqauls 또는 compareTo를 호출해야 하지만 코틀린에선 == 비교 연산자를 직접 사용할 수 있어 간결하고 이해하기 쉬운 코드를 만들 수 있다.

 

1. 동등성 연산자: eqauls

  • != 연산자를 사용하는 식도 equals 호출로 컴파일된다.
  • ==와 !-는 내부에서 인자가 널인지 검사하므로 다른 연산과 달리 널이 될 수 있는 값에도 적용할 수 있다.
  • a==b와 같은 비교를 처리할 때 a가 널인지 판단해서 널이 아닌 경우에만 equals를 호출한다.
    a가 널이라면 b도 널이어야 true이다.
  • data class는 컴파일러가 자동으로 equals를 생성해준다.

equals를 직접 구현하면 다음과 같다.

class Point(val x: Int, val y: Int) {
    override fun equals(obj: Any?): Boolean {
        if(obj === this) return true
        if(obj !is Point) return false
        return obj.x == x && obj.y == y
    }
}

식별자 비교 연산자(===)를 사용하여  equals의 파라미터가 수신 객체와 같은지를 본다.

(식별자 비교 연산자는 자바의 ==연산자와 같다.)

=== 연산자는 자신과의 비교를 최적화하는 경우가 많고, 오버로딩을 할 수 없다.

다른 연산자 오버로딩 관례와는 달리 equals는 Any에 정의된 메소드이기에 override가 필요하다.

Any의 eqauls에는 operator가 붙어있지만 그 메소드를 오버라이드하는 메소드 앞에는 operator를 붙이지 않아도 자동으로 상위 클래스의 operator 지정이 적용된다.

또한 Any에서 상속받은 equals가 확장 함수보다 우선순위가 높으므로 equals를 확장 함수로 정의할 수 없다.

 

2. 순서 연산자: compareTo

  • 자바에서 정렬, 최댓값, 최솟값 등을 비교해야 하는 알고리즘에 사용할 클래스는 Comparable 인터페이스를 구현해야 한다.
  • 코틀린에도 Comparable 인터페이스를 지원하는데, compareTo 메소드를 호출하는 관례도 제공한다.
    -> 비교연산자(<, >, <=, >=)는 compareTo 호출로 컴파일된다.

컬렉션과 범위에 대해 쓸 수 있는 관례

1. 인덱스로 원소에 접근: get, set

mutableMap[key] = newValue
  • 코틀린에선 인덱스 연산자도 관례를 따른다.
  • 인덱스 연산자를 사용해 원소를 읽는 연산은 get 연산자 메소드로 변환되고, 쓰는 연산은 set 연산자 메소드로 변환된다.
  • Map과 MutableMap 인터페이스에는 get, set 연산자 메소드가 이미 들어있다.
operator fun Point.get(index: Int): Int {
    return when (index) {
        0 -> x
        1 -> y
        else -> throw IndexOutOfBoundsException("Invalid coordinate $index")
    }
}

>>> val p = Point(10, 20)
>>> println(p[1])
20

위는 get 관례를 구현한 것이다.

  • get 매소드를 만들고 operator 변경자를 붙이면 된다.
  • p[1]이라는 식은 p가 Point 타입인 경우 방금 정의한 get 메소드로 변환된다.
  • get 메소드의 파라미터로 Int가 아닌 타입도 사용할 수 있다.
    ex) 맵의 키 타입
  • 여러 파라미터를 사용하는 get을 정의할 수 있다.
    operator fun get(rowIndex: Int, colIndex: Int) {
        . . . .
    }
    
    // matrix[row, col]로 호출할 수 있다.​
  • 인덱스에 해당하는 컬렉션 원소를 쓰고 싶을 땐 set이라는 이름의 함수를 정의하면 된다.
    data class MutablePoint(var x: Int, var y: Int)
    operator fun MutablePoint.set(index: Int, value: Int) {
        when(index) {
            0 -> x = value
            1 -> y = value
            else -> throw IndexOutOfBoundsException("Invalid coordinate $index")
        }
    }
    
    >>> var p = MutablePoint(10, 20)
    >>> p[1] = 42
    >>> println(p)
    MutablePoint(x=10, y=42)
  • 대입에 인덱스 연산자를 사용하려면 set이라는 이름의 함수를 정의해야 한다.
  • set이 받는 마지막 파라미터 값은 대입문의 우항에 들어가고, 나머지 파라미터 값은 인덱스 연산자([])에 들어간다.

2. in관례

  • in은 객체가 컬렉션에 들어있는지 검사하며 contains 함수와 대응한다.
    data class Rectangle(val upperLeft: Point, val lowerRight: Point)
    operator fun Rectangle.comtains(p: Point): Boolean {
        return p.x in upperLeft.x until lowerRight.x && p.y in upperLeft.y until lowerRight.y
    }
    
    >>> val rect = Rectangle(Point(10, 20), Point(50, 50))
    >>> println(Point(20, 30) in rect)
    >>> println(Point(5, 5) in rect)
    
    true
    false​
  • 위 코드를 보면 in의 우항에 있는 객체는 contains 메소드의 수신 객체가 되고, 좌항에 있는 객체는 contains 메소드에 인자로 전달된다.

3. rangeTo 관례

  • rangeTo 함수는 범위를 반환한다.
  • 어떤 클래스에나 정의할 수 있지만, 그 클래스가 Comparable 인터페이스를 구현하면 rangeTo를 정의할 필요 없다.
  • 범위를 만드는 ..연산자는 rangeTo 함수를 간략하게 표현한 것이다.
  • 코틀린 표준 라이브러리에는 모든 Comparable 객체에 대해 적용 가능한 rangeTo 함수가 들어있다.
    operator fun <T: Comparable<T>> T.rangeTo(that: T): ClosedRange<T>​
  • rangeTo 연산자는 다른 산술 연산자보다 우선순위가 낮다.

 

4. for 루프를 위한 iterator 관례

for(x in list) {
    . . . .
}
  • 위와 같은 문장은 list.iterator()를 호출하여 이터레이터를 얻은 다음, 이터레이터에 대해 hasNext와 next 호출을 반복하는 식으로 변환된다.
  • 코틀린에선 이 또한 관례이므로 iterator 메소드를 확장 함수로 정의할 수 있다.
  • 코틀린 표준 라이브러리는 String의 상위 클래스인 CharSequence에 대한 iterator 확장 함수를 제공한다.
    operator fun CharSequence.iterator(): CharIterator
    >>> for(c in "abc")()​

구조 분해 선언과 component 함수

  • 구조 분해를 사용하면 복합적인 값을 분해해서 여러 다른 별수를 한 번에 초기화할 수 있다.
  • >>> val p - Point(10, 20)
    >>> val (x, y) = p
    >>> println(x)
    >>> println(y)
    
    10
    20
    구조 분해 선언은 일반 변수 선언과 비슷해 보이지만, =의 좌변에 여러 변수를 괄호로 묶는 것이 다르다.
  • 구조 분해 선언의 각 변수를 초기화하기 위해 componentN 함수를 호출하는데, N은 구조 분해 선언에 있는 변수 위치에 따라붙는 번호이다.
  • data 클래스의 주 생성자에 들어있는 프로퍼티에 대해 컴파일러가 자동으로 componentN 함수를 생성한다.
  • 구조 분해 선언은 함수에서 여러 값을 반환할 때 유용하다.
  • 여러 값을 한 번에 반환해야 하는 함수가 있다면 반환해야 하는 모든 값이 들어갈 데이터 클래스를 정의하고 함수의 반환 타입을 그 데이터 클래스로 바꾸는데, 구조 분해 선언 구문을 사용하면 이런 함수가 반환하는 값을 쉽게 풀어 여러 변수에 넣을 수 있다.
    data class NameComponents(val name: String, val extension: String)
    fun splitFilename(fullName: String): NameComponents {
        val result = fullName.split('.', limit = 2)
        return NameComponents(result[0], result[1])
    }
    
    >>> val (name, ext) = splitFilename("example.kt")
    >>> println(name)
    >>> println(ext)
    
    example
    kt​
  • 코틀린 표준 라이브러리에선 맨 앞의 다섯 원소에 대한 componentN을 제공한다. 

 구조 분해 선언과 루프

  • 함수 본문 내의 선언문뿐 아니라 변수 선언이 들어갈 수 있는 곳이라면 어디든 구조 분해 선언을 사용할 수 있다.
    ex) 루프 안에서 구조 분해 사용 가능(맵의 원소에 대해 이터레이션 할 때 유용)
    fun printEntries(map: Map<String, String>) {
        for((key, value) in map) {
            println("$key -> $value")
        }
    }
    
    >>> val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
    >>> printEntries(map)
    Oracle -> Java
    JetBrains -> Kotlin
  • 코틀린 표준 라이브러리에는 맵에 대한 확장 함수로 iterator가 들어있고, 이는 맵 원소에 대한 이터레이터를 반환한다.
    -> 맵을 직접 이터레이션할 수 있다.
  • 코틀린의 표준 라이브러리는 Map.Entry에 대한 확장 함수로 component1과 component2를 제공한다.
    for(entry in map.entries) {
        val key = entry.component1()
        val value = entry.component2()
        . . .
    }​

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

  • 위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업을 처리하게 맡기는 디자인 패턴을 말한다.
  • 작업을 처리하는 도우미 개게를 위임 객체(delegate)라 한다.

1. 위임 프로퍼티

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

위 문법은 위임 프로퍼티의 일반적인 문법이다.

  • p 프로퍼티는 접근자 로직을 다른 객체에 위임하는데, 여기서 Delegate 클래스의 인스턴스를 위임 객체로 사용한다.
  • by 뒤에 있는 식을 계산하여 위임에 쓰일 객체를 얻는다.
  • 프로퍼티 위임 객체가 따라야 하는 관례를 따르는 모든 객체를 위임에 사용할 수 있다.
class Foo {
    private val delegate = Delegate()
    var p: Type
    set(value: Type) = delegate.setValue(..., value)
    get() = delegate.getValue(...)
}
  • 컴파일러는 숨겨진 도우미 프로퍼티를 만들고 그 프로퍼티를 위임 객체와 인스턴스로 초기화한다.
  • p 프로퍼티는 바로 그 위임 객체에 자신의 작업을 위임한다.
class Delegate {
    operator fun getValue(...) { ... }
    operator fun setVakye(..., value: Type) { ... }
}

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

>>> val foo = Foo()
>>> val oldValue = foo.p
>>> foo.p = new Value

이는 Delegate 클래스를 단순화한 것이다.

 

2. 위임 프로퍼티 사용: 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
    val emails: List<Email>
       get() {
           if (_emails == null) {
               _emails = loadEmails(this) 
           }
           return _emails!!
       }
}

여기서는 뒷받침하는 프로퍼티라는 기법이 쓰였다.

  • _emails라는 프로퍼티는 값을 저장하고, 다른 프로퍼티인 emails는 _emails라는 프로퍼티에 대한 읽기 연산을 제공한다.
  • _emails는 널이 될 수 있는 타입인 반면 emails는 널이 될 수 없는 타입이므로 프로퍼티를 두 개 사용해야 한다.
  • 이런 코드를 만드는 일은 성가시고, 스레드 안전하지 않아 위임 프로퍼티를 사용하는 것이 좋다.
class Person(val name: String) {
    val emails by lazy { loadEmails(this) }
}
  • 위임 프로퍼티는 데이터를 저장할 때 쓰이는 뒷받침하는 프로퍼티와 값이 오직 한 번만 초기화됨을 보장하는 게터 로직을 함께 캡슐화해준다.
  • 위임 객체를 반환하는 표준 라이브러리 함수는 lazy이다.
  • lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메소드가 들어있는 객체를 반환한다.
    -> lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다.
  • lazy 함수의 인자는 값을 초기화할 때 호출할 람다이다. 또한 기본적으로 스레드 안전하다.
  • 필요에 따라 동기화에 사용할 락을 lazy 함수에 전달할 수 있으며, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy 함수가 동기화를 하지 못하게 막을 수 있다.

위임 프로퍼티 구현

어떤 객체를 UI에 표시하는 경우 객체가 바뀌면 자동으로 UI도 바뀌어야 한다.

PropertyChangeSupport 클래스는 리스너의 목록을 관리하고 PropertyChangeEvent 이벤트가 들어오면 목록의 모든
리스너에게 이벤트를 통지한다. 

필드를 모든 클래스에 추가하고 싶지는 않으므로 PropertyChangeSupport 인스턴스를 changeSupport라는 필드에 저장하고 프로퍼티 변경 리스너를 추적해주는 작은 도우미 클래스를 만들자.

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

세터 코드의 중복이 많이 보이므로 이를 제거하고 프로퍼티의 값을 저장하고, 필요에 따라 통지를 보내주는 클래스를 추출하자.

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는 프로퍼티가 포함된 객체와 프로퍼티를 표현하는 객체를 파라미터로 받는다.
  • 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 키워드를 사용해 위임 객체를 지정하면 이전 예제에서 직접 코드를 짜야했던 여러 작업을 코틀린 컴파일러가
자동으로 처리해준다.

위임 프로퍼티 컴파일 규칙

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

val c = C()

컴파일러는 MyDelegate 클래스의 인스턴스를 감춰진 프로퍼티에 저장하며 그 감춰진 프로퍼티를 <delegate>라는
이름으로 부른다. 컴파일러는 프로퍼티를 표현하기 위해 KProperty 타입의 객체를 사용하며 이 객체를 <propery>라 한다.

컴파일러는 다음의 코드를 생성한다.

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

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

이 메커니즘은 상당히 단순하지만 흥미로운 활용법이 많다.

프로퍼티 값이 저장될 장소를 바꿀 수 있고, 프로퍼티를 읽거나 쓸 때 벌어질 일을 변경할 수도 있다.

프로퍼티 값을 맵에 저장

자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때 위임 프로퍼티를 활용하는 경우가 자주 있다.

그런 객체를 확장 가능한 객체(expando object)라고 한다.

class Person {
    private val _attributes = hashMapOf<String, String>()

    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }
    val name: String
        get() = _attributes["name"]!!
}

>>> val p = Person()
>>> val data = mapOf("name" to "Dmitry", "company" to "JetBrains")
>>> for((attrName, value) in data)
        p.setAttribute(attrName, value)  
>>> println(p.name)
}

Dmitry

위 코드를 위임 프로퍼티를 활용하여 쉽게 변경할 수 있다. 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와 setValue 확장 함수를 제공하기 때문이다.

getValue에서 맵에 프로퍼티 값을 저장할 때는 자동으로 프로퍼티 이름을 키로 활용한다.

728x90

댓글