devseop08 님의 블로그

[OOP] 4. 연산자 오버로딩 본문

Language/Kotlin

[OOP] 4. 연산자 오버로딩

devseop08 2025. 7. 14. 04:33
  • 언어의 어떤 기능과 미리 정해진 이름의 함수를 연결해주는 기법을 코틀린에서는 관례라고 부른다.
  • 코틀린이 지원하는 여러 관례와 그 관례의 사용법을 살펴본다.
  • 언어의 기능을 타입에 의존하는 자바와 달리 코틀린은 관례에 의존한다.
  • 기존 자바 클래스가 구현하는 인터페이스는 이미 고정돼 있고 코틀린 쪽에서 자바 클래스가 새로운 인터페이스를 구현하게 만들 수는 없다.
  • 확장 함수 메커니즘을 이용하면, 기존 클래스에 새로운 메서드를 추가할 수 있다.
  • 따라서 기존 자바 클래스에 대해 확장 함수를 구현하면서 관례에 따라 이름 붙이면 기존 자바 코드를 바꾸지 않아도 새로운 기능을 쉽게 부여할 수 있다.
  • 9.1 산술 연산자를 오버로딩해서 임의의 클래스에 대한 연산을 더 편리하게 만들기
  • 코틀린에서 관례를 사용하는 가장 단순한 예는 산술 연산자다. 자바에서는 기본 타입에 대해서만 산술 연산자를 사용할 수 있고, 추가로 String 값에 대해 + 연산자를 사용 가능
  • 다른 클래스에서도 산술 연산자가 유용한 경우들이 있다.
    • ex) BigInteger 클래스에서 add 메서드를 명시적으로 호출하기 보다는 + 연산을 사용하는 편이 더 낫다

9.1.1 plus, times, divide 등: 이항 산술 연산 오버로딩

// plus 연산자 구현하기
data classs Point(val x: Int, val y: Int){
    operator fun plust(other: Point){
        return Point(x + other.x, y + other.y)
    }
}

fun main(){
    val p1 = Point(10, 20);
    val p2 = Point(30, 40);
    println(p1 + p2);
    // Point(x=40, y=60)
}
  • plus 함수 앞에 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
a + b plus
a - b minus
// 두 피연산자의 타입이 다른 연산자 정의
operator fun Point.times(scale: Doouble): Point { // 피연산자 타입: Point와 Double
    return Poinit((x * scale).toInt(), (y * scale).toInt())
}

fun main() {
    val p = Point(10, 20)
    println(p * 1.5)
    // Point(x=15, y=30)
}
  • 코틀린 연산자가 자동으로 교환법칙을 지원하지는 않는다.
  • 사용자가 p * 1.5외에 1.5 * p라고도 쓸 수 있어야 한다면 기존 연산자 함수에서 파라미터 타입의 순서가 바뀐 또다른 연산자 함수 operator fun Double.times(p: Point): Point를 더 정의해야 한다.
  • 연산자 반환 타입이 꼭 두 피연산자 중 하나와 일치해야만 하는 것도 아니다.
// 결과 타입이 피연산자 타입과 다른 연산자 정의하기
operator fun Char.times(count: Int): String {
    return toString().repeat(count)
}

fun main() {
    println('a' * 3)
    // aaa
}
  • 일반 함수와 마찬가지로 operator 함수도 오버로딩할 수 있다.
  • 이름은 같지만 파라미터 타입이 서로 다른 연산자 함수를 여럿 만들 수 있다.

9.1.2 연산을 적용한 다음에 그 결과를 바로 대입: 복합 대입 연산자 오버로딩

  • plus와 같은 연산자를 오버로딩하면 코틀린은 + 연산자 뿐만 아니라 그와 관련 있는 연산자인 +=와 같은 복합 대입 연산자도 자동으로 함께 지원한다.
fun main() {
    var point = Point(1, 2) // += 연산자를 쓰려면 var로 선언돼야 한다.
    point += Point(3, 4) // point = point + Point(3, 4)
    println(point)
    // Point(4, 6)
}
  • 경우에 따라 += 연산이 객체에 대한 참조를 다른 참조로 바꾸기보다 원래 객체의 내부 상태를 변경하게 만들고 싶을 때가 있는데
  • 대표적으로 변경 가능한 컬렉션에 원소를 추가하려는 경우가 있다.
fun main() {
    val numbers = mutableListOf<Int>()
    numbers += 42
    println(numbers[0])
    // 42
}
  • 반환 타입이 Unit인 plusAssign 함수를 정의하면서 operator로 표시하면 코틀린은 += 연산자에 그 함수를 적용한다.
  • 다른 복합 대입 연산자 함수도 minusAssign, timesAssign 등의 이름을 사용한다.
  • 코틀린 표준 라이브러리는 변경 가능한 컬렉션에 대해 plusAssign을 정의한다.
operator fun <T> MutableCollection<T>.plussAssign(element: T) {
    this.add(element)
}
  • 이론적으로는 코드에 있는 +=를 plus와 plusAssign 양쪽으로 컴파일할 수 있지만
  • 어떤 클래스가 이 두 함수를 모두 정의하고 둘 다 +=에 사용 가능한 경우 컴파일러는 오류를 보고한다.
  • plus와 plusAssign 연산을 동시에 정의하지 말라.
  • 클래스가 변경 불가능하다면 plus와 같이 새로운 값을 반환하는 연산만을 추가해야 하고
  • 빌더와 같이 변경 가능한 클래스를 설계한다면 plusAssign이나 그와 비슷한 연산만을 제공하도록 해야한다.
  • 코틀린 표준 라이브러리에서 +, - 연산은 항상 새로운 컬렉션을 반환하며
  • +=와 -= 연산자는 항상 변경 가능한 컬렉션에 작용해 메모리에 있는 객체 상태를 변화시킨다.
  • 또한 읽기 전용 컬렉션에서 +=와 -=는 변경을 적용한 새로운 복사본을 반환한다.
fun main() {
    val list = mutableListOf(1, 2) // 변경 가능한 컬렉션 참조 list
    list += 3 // list가 참조하는 객체의 상태(내용)을 변경한다.
    val newList = list + listOf(4, 5) // + 는 두 리스트의 모든 원소를 포함하는 새로운
                                      // 리스트를 반환한다.
    println(list)
    // [1, 2, 3]
    println(newList)
    // [1, 2, 3, 4, 5]
}

9.1.3 피연산자가 1개 뿐인 연산자: 단항 연산자 오버로딩

  • 코틀린은 하나의 값에만 작용하는 단항 연산자도 제공한다.
operator fun Point.unaryMinus(): Point { // 단항 부호 반전 함수는 파라미터가 없다.
    return Point(-x, -y)
}

fun main() {
    val p = Point(10, 20)
    println(-p)
    // Point(x=-10, y =-20)
}
  • 단항 연산자를 오버로딩하기 위해 사용하는 함수는 인자를 취하지 않는다.
함수 이름
+a unaryPlus
-a unaryMinus
!a not
++a , a++ inc
--a, a-- dec
  • inc나 dec 함수를 정의해 증가/감소 연산자를 오버로딩하는 경우 컴파일러는 일반적인 값에 대한 전위와 후위 증가/감소 연산자와 같은 의미를 제공한다.
// BigDecimal 타입에 대한 전위/후위 증가 연산자 오버로딩
operator fun BigDecimal.inc() = this + BigDecimal.ONE

fun main() {
    var bd = BigDecimal.ZERO
    println(bd++)
    // 0
    println(bd)
    // 1
    println(++bd)
    // 2
}

9.2 비교 연산자를 오버로딩해서 객체들 사이의 관계를 쉽게 검사

  • 코틀린에서는 산술 연산자와 마찬가지로 기본 타입 값뿐 아니라 모든 객체에 대해 비교 연산(==, !=, >, < 등)을 수행할 수 있다.
  • equals나 compareTo를 호출해야 하는 자바와 달리, 코틀린에서는 ==비교 연산자를 직접 사용할 수 있어 비교 코드가 equals나 compareTo를 사용한 코드보다 더 간결하며 이해하기 쉽다.
  • 9.2.1 동등성 연산자: equals
  • 코틀린은 == 연산자 호출을 equals 메서드 호출로 컴파일한다.
  • != 연산자를 사용하는 식도 equals 호출로 컴파일되는데 비교 결과를 뒤집은 값을 결괏값으로 사용하도록 컴파일된다.
  • == 와 !=는 내부에서 인자가 null인지 검사하므로 다른 연산과 달리 널이 될 수 있는 값에도 적용할 수 있다.
  • a == b => a?.equals(b) : (b == null) (equals를 호출하는 a가 널이 아닌 경우에만 equals 호출)
// equals 메서드 구현하기
class Point(val x: Int, val y: Int) {
    override fun equals(obj: Any?): Boolean { // Any에 정의된 메서드를 오버라이드
        if(obj === this) return true // 최적화: 파라미터가 이 식의 this와 동일한 
                                     // 객체인지 살펴본다.
        if(obj !is Point) return false // 파라미터 타입 검사
        return obj.x == x && obj.y == y // Point 타입으로 스마트 캐스트 
    }
}
fun main() {
    println(Point(10, 20) == Point(10, 20)) // true
    println(Point(10, 20) != Point(5, 5)) // true, equals 반환값 반전시켜 돌려준다.
    println(null == Point(1, 2)) // false
}
  • 코틀린의 === 연산자는 자바의 == 연산자와 같다.(동일성 비교)
  • === 연산자는 자신의 두 피연산자가 서로 같은 객체를 가리키는지 비교한다.
  • equals를 구현할 때 ===를 사용해 자신과의 비교를 최적화하는 경우가 많으며 ===를 오버로딩할 수는 없다.
  • equals 함수에는 override가 붙어있는데, 다른 연산자 오버로딩 관례와 달리 equals는 Any에 이미 정의된 메서드이므로 operator가 아닌 override가 필요하다.
  • equals가 Any에 정의된 메서드이기 때문에 동등성 비교를 모든 코틀린 객체에 대해 적용할 수 있다.
  • Any의 equals에는 operator가 붙어있지만 그 메서드를 오버라이드하는 하위 클래스의 메서드 앞에는 operator 변경자를 붙이지 않아도 자동으로 상위 클래스의 operator 지정이 적용된다.
  • Any에서 상속받은 equals가 확장 함수보다 우선 순위가 높기 때문에 equals를 확장 함수로 정의할 수는 없다.

9.2.2 순서 연산자: compareTo(<, >, <=, >=)

  • 자바에서 정렬이나 최댓값, 최솟값 등 값을 비교해야 하는 알고리즘에 사용할 클래스는 Comparable 인터페이스를 구현해야 한다.
  • Comparable에 들어있는 compareTo 메서드는 한 객체와 다른 객체의 크기를 비교해 정수로 나타내준다.
  • 하지만 자바에는 이 메서드를 짧게 호출할 수 있는 방법이 없다. <> 등의 연산자로는 기본 타입의 값만 비교 가능하다. 다른 모든 타입의 값에는 element1.compareTo(element2)를 명시적으로 사용해야 한다.
  • 코틀린도 똑같은 Comparable 인터페이스를 지원한다.
  • 코틀린은 Comparable 인터페이스 안에 있는 compareTo 메서드를 호출하는 관례를 제공
  • 비교 연산자(<, >, <=, >=)를 사용하는 코드를 compareTo 호출로 컴파일한다.
  • compareTo가 반환하는 값은 Int다.
  • 비교 연산자 컴파일 형식
a >= b => a.compareTo(b) >= 0
a <= b => a.compareTo(b) <= 0
a > b  => a.compareTo(b) > 0 
a < b  => a.compareTo(b) < 0
  • compareTo 메서드 구현하기
class Person(
    val firstName: String, val lastName: String
): Comparable<Person> {
    override fun compareTo(other: Person): Int {
        return compareValuesBy(this, other, Person::lastName, Person::firstName)
    }
}

fun main() {
    val p1 = Person("Alice", "Smith")
    val p2 = Person("Bob", "Johnson")
    println(p1 < p2) // false
}
  • Person 객체가 Comparable 인터페이스를 구현했기 때문에, Person 객체를 코틀린 뿐 아니라 자바 쪽의 컬렉션 정렬 메서드 등에도 사용할 수 있다.
  • equals와 마찬가지로 Comparable의 compareTo에도 operator 변경자가 붙어있으므로 하위 클래스에서 오버라이드 할 때 함수 앞에 operator를 붙일 필요가 없다.
  • equals와 마찬가지로 상속받은 compareTo가 확장 함수보다 우선 순위가 높으므로 compareTo를 확장 함수로 정의할 수는 없다.
  • 코틀린 표준 라이브러리의 compareValuesBy 함수를 사용해 compareTo를 쉽고 간결하게 정의할 수 있다.
  • compareValuesBy 함수는 두 객체와 여러 비교 함수를 인자로 받는데, 첫 번째 비교 함수에 두 객체를 넘겨 두 객체가 같다는 결과(0)가 나오면 두 번째 비교 함수를 통해 두 객체를 비교한다.
  • compareValuesBy는 이런 식으로 두 객체의 대소를 알려주는 0이 아닌 값이 처음 나올 때까지 인자로 받은 함수를 차례로 호출해 두 값을 비교하며, 모든 함수가 0을 반환하면 0을 그대로 반환한다.
  • 각 비교 함수는 프로퍼티/메서드 참조일 수 있다.
  • Comparable 인터페이스를 구현하는 모든 자바 클래스를 코틀린에서는 간결한 연산자 구문으로 비교할 수 있다. => String을 간결한 비교 연산자로 비교 가능
  • 코틀린 컴파일러는 관례를 따르지만 자바 컴파일러는 타입을 따른다. => 자바에서 간단한 비교 연산자로 비교가 가능한 것은 기본 타입
  • 비교 연산자를 자바 클래스에 대해 사용하기 위해 특별히 확장 메서드를 만들거나 할 필요는 없다.
fun main() {
    println("abc" < "bac")
    // true
}

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

  • 컬렉션을 다룰 때 가장 많이 쓰는 연산: 인덱스를 사용해 원소를 읽거나 쓰는 연산, 어떤 값이 속해 있는지 검사하는 연산
  • 이 모든 연산을 연산자 구문으로 사용할 수 있다.
  • 인덱스를 사용해 원소를 설정하거나 가져오고 싶을 때는 a[b]라는 식을 사용(인덱스 접근 연산자)
  • in 연산자는 원소가 컬렉션이나 범위에 속하는지 검사하거나 컬렉션에 있는 원소를 이터레이션할 때 사용

9.3.1 인덱스로 원소 접근: get과 set

  • 코틀린에서는 맵에 접근할 때 자바 배열 원소에 접근할 때처럼 각괄호([])를 사용한다.
val value = map[key]

mutableMap[key] = newValue
  • 코틀린에서는 인덱스 접근 연산자도 관례를 따른다.
  • 인덱스 접근 연산자를 사용해 원소를 읽는 연산은 get 연산자 메서드로, 원소를 쓰는 연산은 set 연산자 메서드로 변환된다.
  • Map과 MutableMap 인터페이스에는 get, set 메서드가 이미 들어있다.
  • 직접 만든 클래스에 get, set 메서드를 추가하기
// get 관례 구현하기: 확장 함수 이용
operator fun Point.get(index: Int): Int {
    return when(index) {
        0 -> x
        1 -> y
        else -> 
            throw IndexOutOfBoundsException("Invalid coordinate $index")
    }
}

fun main() {
    val p = Point(10, 20)
    println(p[1])
    // 20
}
x[a] => x.get(a)
  • get 메서드의 파라미터로 Int가 아닌 타입도 사용할 수 있다.(맵 인덱스 연산의 경우 get의 파라미터 타입은 앱의 키 타입과 같은 임의의 타입이 될 수 있다.)
  • 또한 여러 파라미터를 사용하는 get을 정의할 수도 있다.(오버로딩! operator 키워드: 연산자 오버로딩)
  • 2차원 행렬이나 배열을 표현하는 클래스에
    operator fun get(rowIndex: Int, colIndex: Int)를 정의하면 matrix[row, col]로 그get 메서드를 호출할 수 있다.
x[a, b] => x.get(a, b)
  • 컬렉션 클래스가 다양한 키 타입을 지원해야 한다면 다양한 파라미터 타입에 대해 오버로딩한 get 메서드를 여럿 정의할 수도 있다.
  • 비슷하게 인덱스에 해당하는 컬렉션 원소를 각괄호를 사용해 쓰고 변경하는, 즉 set하는 함수를 정의할 수도 있다.
  • Point 클래스는 불변 클래스이므로 이런 메서드르 정의하는 것이 무의미
  • 가변 클래스에 관례를 따르는 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 IndexOufOfBoundsException("Invalid coordinate $index")
    }
}

fun main() {
    val p = MutablePoint(10, 20)
    p[1] = 42
    println(p)
    // MutablePoint(x=10, y=42)    
}
  • 대입에 인덱스 연산자를 사용하려면 set이라는 이름의 함수를 정의해야 한다.
  • set이 받는 마지막 파라미터 값은 대입 연산자의 오른쪽, 나머지 파라미터 값은 인덱스 연산자 안에 순서대로 들어간 값에 해당한다.
x[a, b] = c => x.set(a, b ,c)

9.3.2 어떤 객체가 컬렉션에 들어있는지 검사: in 관례

  • 컬렉션이 지원하는 다른 연산자로는 in이 있다. in 은 객체가 컬렉션에 들어있는지 검사한다.
  • 이 때 대응하는 함수는 contains다.
data class Rectangle(val upperLeft: Point, val lowerRight: Point)

operator fun Rectangle.contains(p: Point): Boolean {
    return p.x in upperLeft.x..<lowerRight.x &&
           p.y in upperLeft.y..<lowerRight.y
}

fun main() {
    val rect = Rectangle(Point(10,20), Point(50,50))
    println(Point(20, 30) in rect)
    // true
    println(Point(5, 5) in rect)
    // false
}
  • in 오른쪽에 있는 객체는 contains 메서드의 수신 객체가 되고 in의 왼쪽에 있는 객체는 contains 메서드에 인자로 전달된다.
a in c => c.contains(a)

9.3.3 객체로부터 범위 만들기: ragneTo와 rangeUntil 관례

  • 범위를 만들려면 .. 구문을 사용해야 한다. 1..10은 1부터 10까지의 모든 수가 들어있는 범위를 말한다.
  • 범위 연산자 ..는 rangeTo 함수 호출을 간략하게 표현하는 방법이다.
start..end = start.rangeTo(end)
  • rangeTo 함수는 범위를 반환한다
  • 직접 만든 클래스에 대해서도 .. 연산자를 정의할 수 있다.
  • 하지만 클래스가 Comparable 인터페이스를 구현한다면 rangeTo 함수를 따로 정의할 필요가 없다.
  • 코틀린 표준 라이브러리를 통해 비교 가능한 원소로 이뤄진 범위를 쉽게 만들 수 있다.
  • 코틀린 표준 라이브러리에는 모든 Comparable 객체(Comparable를 구현한 모든 클래스 타입의 모든 객체)에 대해 적용 가능한 확장 함수 rangeTo가 정의되어 있다.
  • Ranges.kt 파일에 정의된, Comparable 인터페이스를 구현한 모든 객체에 적용할 수 있는 확장 함수 rangeTo
// Ranges.kt
public operartor fun <T: Comparable<T>> T.rangTo(that: T):ClosedRange<T> =
                                                    ComparableRange(this, that)

  • Comparable 인터페이스를 구현한 LocalDate에서 .. 연산자로 확장 함수 rangeTo 호출하기

import java.time.LocalTime

fun main() {
    val now = LocalDate.now()
    val vacation = now..now.plusDays(10)
    println(now.plusWeeks(1) in vacation)
    // true
}
  • now..now.plusDays(10)이라는 식은 컴파일러에 의해 now.rangeTo(now.plusDays(10))으로 변환된다.
  • rangeTo 함수는 LocalDate 클래스의 멤버는 아니며 Comparable에 대한 확장 함수를 정의함으로 인해 멤버인 것처럼 추가된 것이다.
  • Int, Long, Double과 같은 원시 타입의 클래스는 .. 연산자에 대해 오버로딩한(operator 선언한) rangeTo 함수를 멤버 함수로 정의해놓았다.
  • 그런데 클래스의 멤버 함수는 클래스에 대한 확장 함수보다 우선 순위가 높기 때문에 원시 타입 객체로부터 rangeTo 함수 호출 시
  • Comparable 인터페이스를 구현한 모든 클래스에 대한 확장 함수 rangeTo가 아닌 원시 타입 클래스의 멤버 함수 rangeTo가 호출된다.
  • 하지만 사실, 실제로 range 함수가 호출이 되는 것이 아니라 컴파일 타임 때 Int 타입의 range 함수에 구현된 로직이 인라이닝되고 이것이 그대로 실행되는 것이다.(Int와 같은 원시 타입의 rangeTo 함수 등은 코틀린 표준 라이브러리 개발 단계에서 특별한 처리를 하여 배포한다.)

  • 원시 타입 클래스의 멤버 함수 rangeTo 함수의 반환 타입이 IntRange이고 IntRange는 Iterable 인터페이스를 구현하기 때문에
  • IntRange 타입의 객체는 코틀린 표준 라이브러리에서 정의한, Iterable 인터페이스 구현한 모든 클래스들에 대한 확장 함수 forEach를 호출할 수 있다.
여기서 잠깐! Int 타입의 rangeTo 함수는 왜 구현부가 비어있는 걸까?
그 이유는 바로 코틀린 Int 타입은 코틀린 표준 라이브러리에 속하는데 코틀린 표준 라이브러리에서 코틀린 Int 클래스의 멤버 함수들을 구현할 때 @InlineOnly 어노테이션을 사용하고 inline 선언했기 때문이다! => 배포판을 보는 일반 개발자는 실제 그 함수의 구현부를 보지 못한다.
  • @InlineOnly 어노테이션에 관한 이야기
    • 코틀린에서 @InlineOnly 어노테이션은 내부 구현 목적으로 사용되는 특수한 어노테이션이며,
    • 일반 사용자(개발자)가 사용하는 용도는 거의 없다.
    • 주로 코틀린 표준 라이브러리에서 inline 함수의 본문만을 노출하고 외부에서 직접 호출하는 것을 방지하기 위해 사용됩니다
  • @InlineOnly 기본 개념
    • 목적: 해당 함수는 반드시 inline 되어야 하며, 바이트코드 상 함수 호출로 남아있지 않도록 강제
    • 결과: 호출하는 쪽에서 무조건 인라이닝되며, 리플렉션으로 호출하거나 참조할 수 없다.
    • 실제로 함수 바디만 컴파일되고 함수 자체는 클래스 파일에 존재하지 않게 된다.
  • 사용 예시 (표준 라이브러리 내부 코드 기준)
@InlineOnly
inline fun IntRange.contains(value: Int): Boolean {
    return value in this
}
  • 이런 식의 코드는 Kotlin 표준 라이브러리 내부에서만 사용되고, @InlineOnly를 붙이면 이 함수는 반드시 인라인되어야 하며, JVM 바이트코드로 컴파일될 때 함수 정의가 사라진다.
  • 사용 시 주의점
    • @InlineOnly 함수는 외부에서 직접 호출하거나 참조할 수 없습니다.
    • 이 어노테이션을 붙인 함수는 반드시 inline이어야 합니다. 그렇지 않으면 컴파일 오류가 발생합니다.
    • 리플렉션 사용이 불가능합니다 (예: Function::class 참조 불가).
    • 대부분의 경우 직접 사용할 필요는 없습니다. → 일반 개발자는 inline 만으로도 충분합니다.
  • 실제와 배포 버전의 차이 (확장 개념)
    • Kotlin의 표준 라이브러리 코드를 GitHub 등에서 보면 @InlineOnly가 붙은 함수의 구현이 보일 수 있지만, 배포된 .class 파일이나 .kt 소스에서는 이 구현이 사라진 것처럼 보일 수 있습니다. 이유는 다음과 같습니다:
      • @InlineOnly는 함수 자체를 .class에 넣지 않음
      • 인라인 되는 부분만 호출 위치에 포함되므로 .class나 디컴파일된 소스에서는 구현이 누락된 것처럼 보일 수 있음

fun main() {
    val n = 9
    (0..n).forEach{ print(it)}
    //0123456789 
}
  • rangeTo 연산자는 다른 산술 연산자보다 우선 순위가 낮다. 하지만 혼동을 피하기 위해 괄호로 인자를 감싸주면 더 좋다.
fun main() {
    val n = 9
    println(0..(n + 1))
    //0..10
}
  • rangeTo 연산자와 비슷하게 rangeUntil 연산자 ..<는 열린 범위를 만든다
  • 열린 범위는 상계를 포함하지 않는다.
fum main() {
    (0..<9).forEach{ print(it) } 
    // 012345678
}

9.3.4 자신의 타입에 대해 루프 수행

  • 코틀린의 for 루프는 범위 검사와 똑같이 in 연산자를 사용한다.
  • 하지만 이 경우 in의 의미는 단순 in 연산과는 다르다.
  • for(x in list) {...}와 같은 문장은 list.iterator()를 호출해서 이터레이터를 얻은 다음,
  • 자바와 마찬가지로 그 이터레이터에 대해 hasNext와 next 호출을 반복하는 식으로 변환한다.
  • 코틀린에서는 이 또한 관례이므로 iterator 메서드를 확장 함수로 원하는 대로 정의해두고 for 루프를 사용할 수 있다.
  • 이런 성질로 인해 자바 문자열에 대한 for 루프가 가능하다.
  • 코틀린 표준 라이브러리는 String의 상위 클래스인 CharSequence에 대한 iterator 확장 함수를 제공하고 있다.

9.4 component 함수를 사용해 구조 분해 선언 제공

  • 구조 분해 선언 시의 관례
  • 구조 분해를 사용하면 복합적인 값을 분해해서 여러 지역 변수를 한꺼번에 초기화할 수 있다.
fun main() {
    val p = Point(10, 20)
    val (x, y) = p
    println(x) // 10
    println(y) // 20
}
  • 내부에서 구조 분해 선언은 관례를 사용한다.
  • 구조 분해 선언의 각 변수를 초기화하고자 componentN이라는 함수를 호출한다.
  • 여기서 N은 구조 분해 선언에 있는 변수 위치에 따라 붙는 번호다.
val (a, b) = p 
=> val a = p.component1()  
=> val b = p.component2() 
  • data 클래스의 주 생성자에 들어있는 프로퍼티에 대해서는 컴파일러가 자동으로 componentN 함수를 만들어준다.
  • 데이터 클래스 타입이 아닌 클래스에서 component 함수 구현
class Point(val x: Int, val y:Int) {
    operator fun component1() = x
    operator fun component2() = y
}
  • 구조 분해 선언은 함수에서 여러 값을 반환할 때 유용하다.
  • 여러 값을 한 꺼번에 반환해야 하는 함수가 있다면 반환해야 하는 모든 값이 들어갈 데이터 클래스를 정의하고 함수의 반환 타입을 그 데이터 클래스로 바꾼다.
  • 이 상태에서 구조 분해 선언 구문을 사용하면 이런 함수가 반환하는 값을 쉽게 풀어 여러 변수에 넣을 수 있다.
// 구조 분해 선언을 이용하여 여러 값 반환하기
data class NameComponents(val name: String, val extension: String)

fun splitFilename(fulllName: String): NameComponents {
    val result = fullName.split('.', limit = 2)
    return NameComponents(result[0], result[1])
}

fun main() {
    val (name, ext) = splitFilename("example.kt")
    println(name) // example
    println(ext)  // kt
}
  • 배열이나 컬렉션에도 componentN 함수가 확장 함수로 선언돼 있다.

data class NameComponents(val name: String, val extension: String)

fun splitFilename(fullName: String): NameComponents {
    val (name, extension) = fullName.split('.', limit=2)
    return NameComponents(name, extension)
}
  • 물론 무한히 componentN을 선언할 수는 없으므로 이런 구문을 무한정 사용할 수는 없다.
  • 코틀린 표준 라이브러리에서는 맨 앞의 다섯 원소에 대한 componentN을 제공한다.

9.4.1 구조 분해 선언과 루프

// 구조 분해 선언을 사용해 맵 이터레이션 
fun printEntries(map: Map<String, String>) {
    for((key, value) in map) {
        println("$key -> $value")
    }
}

fun main() {
    val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
    printEntries(map)
    // Oracle -> Java
    // JetBrains -> Kotlin
}
  • 코틀린 표준 라이브러리에는 맵에 대한 확장 함수로 iterator가 들어있다.
  • 그 iterator는 맵 항목에 대한 이터레이터를 반환한다.
  • 자바와 달리 코틀린에서는 맵을 직접 이터레이션할 수 있다.
  • 또한 코틀린 라이브러리는 Map.Entry에 대한 확장 함수로 component1과 component2를 제공한다.
  • 실제 앞의 루프는 다음과 동등한 코드로 컴파일된다.
for(entry in map.etries) {
    val key = entry.component1()
    val value = entry.component2()
    ...
}
  • 람다가 data class나 맵 같은 복합적인 값을 파라미터로 받을 때도 구조 분해 선언을 쓸 수 있다.
map.forEach{
    (key, value) -> println("$key -> $value")
}

9.4.2 _ 문자를 사용해 구조 분해 값 무시

  • 컴포넌트가 여럿 있는 객체에 대해 구조 분해 선언을 사용할 때는 변수 중 일부가 필요 없을 경우가 있다.
data class Person(
    val firstName: String, 
    val lastName: String,
    val age: Int,
    val city: String
)

fun introducePerson(p: Person){
    val (firstName, lastName, age, city) = p
    println("This is $firstName, aged $age")
}
  • 이 경우 정의된 localName과 city 지역 변수는 코드에 아무 값도 제공하지 않는다.
  • 이들은 쓰이지 않으면서 함수의 본문에서 코드를 지저분하게 한다.
  • 전체 객체를 구조 분해해야만 하는 것은 아니기 때문에 구조 분해 선언에서 뒤쪽의 구조 분해 선언을 제거할 수는 있다.
val (firstName, lastName, age) = p
  • lastName 선언을 없앨 때는 약간 다른 방식을 써야 한다.
  • val (firstName, age)와 같이 아예 없앨 수는 없다.
  • 이런 경우를 처리할 수 있게 코틀린은 _ 문자를 쓸 수 있게 해준다.
  • lastName을 _로 바꾸고 마지막 city를 제거하면 된다.
fun introducePerson(p: Person){
    val (firstName, _, age) = p
    println("This is $firstName, aged $age")
}

코틀린 구조 분해의 한계와 단점

  • 코틀린 구조 분해 선언 구현은 위치에 의한 것이다.
  • 구조 분해의 결과가 대입될 변수의 이름은 중요하지 않다. 오직 위치, 순서에 따른다.
  • 이로 인해 리팩터링을 하면서 데이터 클래스의 프로퍼티 순서를 변경하면 문제가 발생할 수 있다.
data class Person(
    val lastName: String, 
    val firsttName: String,
    val age: Int,
    val city: String
)

val (firstName, lastName, age, cituy) = p
  • 이런 동작은 구조 분해 선언이 작은 컨테이너 클래스나 장차 변경될 가능성이 아주 작은 클래스에 대해서만 유용하다는 것을 의미한다.
  • 복잡한 엔티티에 대해 구조 분해 사용을 가능한 한 피해야 한다.
  • 이 문제에 대한 잠재적인 해법은 이름 기반 구조 분해를 도입하는 것이다.
  • 9.5 프로퍼티 접근자 로직 재활용: 위임 프로퍼티
  • 위임 프로퍼티를 사용하면 값을, 뒷받침하는 필드에 단순히 저장하는 것하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 , 접근자 로직을 매번 재구현할 필요 없이 쉽게 구현할 수 있다.
  • 프로퍼티는 위임을 사용해 자신의 값을 필드가 아니라 데이터베이스 테이블이나 브라우저, 세션, 맵 등에 저장할 수 있다.
  • 이런 특성의 기반에는 위임이 있다.
  • 위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업을 처리하도록 맡기는 디자인 패턴을 말한다.
  • 작업을 처리하는 도우미 객체위임 객체라고 말한다.
  • 클래스 위임을 통해 구현한 데코레이팅 패턴을 프로퍼티에 적용해서 접근자 기능을 도우미 객체, 위임 객체가 수행하도록 위임한다는 것이다.
  • 9.5.1 위임 프로퍼티의 기본 문법과 내부 동작
  • 프로퍼티에 위임을 적용하는 일반적인 문법
var p: Type by Delegate()
  • p 프로퍼티는 접근자 로직을 다른 객체, Delegate 타입 객체에 위임한다.
  • Delegate 클래스의 인스턴스를 위임 객체롤 사용한다.
  • by 뒤에 있는 식을 계산해 위임에 쓰일 객체를 얻는 것이다.
  • 프로퍼티 위임 객체가 따라야 하는 모든 관례를 따르는 모든 객체를 위임에 사용할 수 있다.
class Foo {
    var p: Type by Delegate()
}
  • 컴파일러는 숨겨진 도우미 프로퍼티(private 가시성)를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다.
  • p 프로퍼티는 바로 그 위임 객체에게 자신의 작업을 위임한다.
  • 감춰진 도우미 프로퍼티 이름을 delegate라고 가정했을 때 컴파일러가 프로퍼티의 위임을 변환하는 형식
class Foo {
    private val delegate = Delegate()
    var p: Type
        get() = delegate.getValue(/*...*/)
        set(value: Type) = delegate.setValue(/*...*/, value)
}
  • 프로퍼티 위임 관례에 따라 Delegate 클래스는 getValue와 setValue 메서드를 제공해야 한다.
  • var로 선언된 변경 가능한 프로퍼티를 위임하는 경우에만 위임 객체에서 setValue를 호출하여 사용할 수 있다.
  • 추가로 위임 객체는 선택적으로 provideDelegate 함수 구현을 제공할 수도 있다.
  • provideDelegate 함수는 위임 객체 최초 생성 시 검증 로직을 수행하거나 위임이 인스턴스화 되는 방식을 변경할 수 있다.
class Delegate {
    operator fun getValue(/*...*/) {/*...*/} // 게터를 구현하는 로직
    operator fun setValue(/*...*/, value: Type) {/*...*/} // 세터를 구현하는 로직
    operator fun provideDelegete(/*...*/): Delegate {/*...*/} // 위임 객체 생성, 제공
} 
class Foo {
    var p: Type by Delegate() // by 키워드는 프로퍼티와 위임 객체를 연결한다.
}
fun main() {
    val foo = Foo() // 위임 클래스 Delegate에 provideDelegete 함수가 있으면 위임 객체를
                    // 생성해 감춰진 도우미 프로퍼티에 대입해준다.
    val oldValue = foo.p // 내부에서 delegate.getValue(...) 호출
    foo.p = newValue // 내부에서 delegate.setValue(..., newValue) 호출
}

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

  • 지연 초기화는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 경우 초기화할 때 흔히 쓰이는 패턴이다.
  • 초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 지연 초기화 패턴을 사용할 수 있다.
  • 데이터베이스에 사람들 각자의 이메일 리스트가 저장돼있고 사람을 나타내는 Person 클래스에 이메일 목록 emails 인스턴스 변수가 선언된 상태에서
  • Person 클래스 타입의 객체를 생성할 때 바로 emails를 초기화하는 것이 아니라 객체가 생성된 후 Person 클래스 타입의 객체가 emails를 최초로 필요로 하여 데이터베이스에서 해당 사람의 이메일 목록을 불러올 때 단 한 번만 초기화를 해주고 싶다고 해보자.(이메일 목록을 지연 초기화를 하고 싶다는 것이다.)
  • 우선, 이메일을 불러오는 함수 loadEmails
class Email { ... }
fun loadEmails(person: Person): List<Email> {
    println("Load emails for ${person.name}")
    return listOf(/*...*/)
}
  • 일단 뒷받침하는 프로퍼티를 이용해서 지연 초기화를 구현할 수 있다.
  • 실제로 읽히고 사용될 emails 프로퍼티와는 별개로
  • emails 최초 상태를 저장하고 emails 프로퍼티가 읽히고 사용되기 전에 먼저 상태를 저장해놓는 _emails 프로퍼티(뒷받침하는 프로퍼티)를 추가해서 지연 초기화를 구현한다.
  • _emails의 최초 상태는 null이다.
class Person(val name: String) {
    private var _emails: List<Email>? = null

    val emails: List<Email> 
        get() {
            if(_emails == null){
                _emails = loadEmails(this)
            }
            return _emails!! // 널 아님 단언: 앞에서 널 검사 통과함 보장
        }
}

fun main() {
    val p = Person("Alice")
    p.emails
    // Load emails for Alice
    p.emails
}
  • 위에 설명한 것처럼 뒷받침하는 프로퍼티라는 기법을 사용했다.
  • _emails라는 프로퍼티는 값을 저장하고 다른 프로퍼티인 emails는 _emails라는 프로퍼티에 대한 읽기 연산을 제공한다.
  • 두 프로퍼티의 타입이 서로 달라서 프로퍼티를 2개 모두 사용해야 한다.
  • 이들의 이름은 간단한 관례를 사용한다.
  • 클래스에 같은 개념을 표현하는 프로퍼티가 2개 있을 때 비공개 프로퍼티 앞에 밑줄을 붙이며, 공개 프로퍼티에는 아무것도 붙이지 않는다.
  • 뒷받침하는 프로퍼티 기법을 사용해서 지연 초기화를 구현하는 것에는 약간의 문제가 있다.
  • 지연 초기화해야 하는 프로퍼티가 많아지면 코드가 어떻게 될지 생각해보자.
  • 더군다나 지연 초기화가 원래 의도한대로 작동할 거라고 보장할 수도 없다.
  • 현재 구현은 스레드 안전하지 않기 때문이다.
  • 두 스레드가 동시에 Person 객체의 emails에 접근할 때 _emails 프로퍼티는 두 스레드에 대해 동기화되지 못하기 때문에 비용이 많이 드는 loadEmails 함수가 두 번 호출될 수 있다. 이것은 원래 의도한 최초로 emails에 접근할 때 단 한 번만 loadEmails 함수를 호출하여 초기화하기로 한 의도와는 맞지 않다.
  • 이런 상태에서 무수히 많은 스레드가 동시에 하나의 Person 객체에게 접근해 emails를 읽으려고 한다고 하면, 비용이 많이 드는 loadEmails 함수가 무수히 많이 중복하여 호출될 수 있는 것이다.
  • 위임 프로퍼티를 통한 지연 초기화는 뒷받침하는 프로퍼티를 이용한 지연 초기화 시의 코드가 길어질 우려와 스레드 동기화 문제를 해결해준다.
  • 단, 스레드 동기화 문제를 해결을 위해선 감춰진 도우미 프로퍼티에 대입되는 위임 객체는 lazy라는 코틀린 표준 라이브러리에서 제공하는 함수를 통해 얻어내야 한다.
class Person(val name: String) {
    val emails by lazy { loadEmails(this) } // 위임 프로퍼티
}
  • lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메서드가 들어있는 객체를 반환한다.
  • 코틀린 표준 라이브러이에 lazy 함수가 반환하는 객체의 타입 Lazy< T >에 대해서 getValue가 확장 함수로 선언돼있다.
public actual fun <T> lazy(initializer:() -> T): Lazy<T>
    = SynchronizedLazyImpl(initializer, lock)
@InlineOnly
public inline operator fun <T> Lazy<T>.getValue(
    thisRef: Any?, property: KProperty<*>): T = value
  • 따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다.
  • lazy 함수의 인자는 값을 초기화할 때 호출할 람다다.
  • lazy 함수 함수가 반환하는 객체는 기본적으로 스레드 안전한다.
  • 동기화에 사용할 락을 lazy 함수에 전달할 수도 있고, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy 함수가 동기화를 생략하게 할수도 있다.
// lock을 인자로 받는 코틀린 표준 라이브러리의 lazy 함수
public actual fun <T> lazy(lock: Any?, initializer:() -> T): Lazy<T>
    = SynchronizedLazyImpl(initializer, lock)

9.5.3 위임 프로퍼티 구현

  • 어떤 객체의 프로퍼티가 바뀔 때마다 해당 객체의 리스너(Observer)에게 객체의 프로퍼티가 어떻게 변경됐는지를 전달하여 리스너(Observer)가 이에 따라 특정 행동을 하도록 요청하고자 한다.
  • 이런 경우를 옵저버블이라고 부른다. 코틀린이 옵저버블을 어떻게 구현할 수 있는지 살펴보자.
  • Observable 클래스는 Observer들의 리스트를 관리한다. notifyObservers가 호출되면 옵저버는 등록된 모든 Observer의 onChange 함수를 통해 프로퍼티의 이전 값과 새 값을 전달한다.
open class Observable {
    val observers = mutableListOf<Observer>()
    fun notifyObservers(propName: String, oldValue: Any?, newValue: Any?){
        for(obs in observers) {
            obs.onChange(propName, oldValue, newValue)
        }
    }
}
  • Observer는 onChange 메서드에 대한 구현만 제공하면 되므로 함수형 인터페이스로 선언
fun interface Observer {
    fun onChange(name: String, oldValue: Any?, newValue: Any?)
}
  • 프로퍼티 변경 알림을 위임없이 직접 구현(field 키워드를 사용, 뒷받침하는 필드에 접근하는 방법)
class Person(val name: String, age: Int, salary: Int): Observable() {
    var age: Int = age
        set(newValue) {
            val oldValue = field
            field = newValue // 뒷받침하는 필드 접근
            notifyObservers(
                "age", oldValue, newValue
            )
        }

    var salary: Int = salary
        set(newValue){
            val oldValue = field
            field = newValue
            notifyObservers(
                "salary", oldValue, newValue
            )
        } 
}
fun main() {
    val p = Person("Seb", 28, 1000)
    p.observers += Observer { 
        propName, oldValue, newValue -> println(
            """
            Property $propNmae changed from $oldValue to $newValue!
            """.trimIndent()
        )
    }
    p.age = 29
    // Property age changed from 28 to 29!
    p.salary = 1500
    // Property salary changed from 1000 to 1500!
}
  • age또는 salary 프로퍼티의 값을 저장하고 필요에 따라 저장된 age또는 salary 프로퍼티의 값을 읽거나 age또는 salary 프로퍼티의 값을 변경하고 변경 통지를 보내는 클래스 추출
class ObservableProperty(
    val propName: String,
    var propValue: Int, 
    val observable: Observable
){
    fun getValue(): Int = propValue
    fun setvalue(newValue: Int){
        val oldValue = propValue
        propValue = newValue
        notifyObservers(propName, oldValue, newValue)
    }
}
  • ObservableProperty 클래스로 위임 패턴 구현
class Person(val name: String, age: Int, salary: Int): Observable() {
    val _age = ObservableProperty("age", age, this)
    var age: Int
        get() = _age.getValue()
        set(newValue: Int) = _age.setValue(newValue)

    val _salary = ObservableProperty("salary", salary, this)
    var salary: Int
        get() = _salary.getValue()
        set(newValue: Int) = _salary.setValue(newValue)
}
  • 코틀린의 위임이 실제로 작동하는 방식과 비슷해졌다.
  • by 키워드를 이용해서 실제 위임 객체를 생성해 위임 프로퍼티로 위임 패턴을 구현해보자
  • 우선, ObservableProperty 클래스에서 getValue 메서드와 setValue 메서드의 시그니처를 위임 관례에 맞게 수정해야 한다.
import kotlin.reflect.KProperty

class ObservableProperty(var propValue: Int, val observable: Observable) {
    operator fun getValue(thisRef: Any?, prop: KProperty<*>):Int = propValue
    operator fun setValue(thisRef: Any?, prop: KProperty<*>, newValue: Int){
        val oldValue = propValue
        propValue = newValue
        observable.notifyObservers(prop.name, oldValue, newValue)
    }
}
  • thisRef : 바로 설정하거나 읽을 프로퍼티가 들어있는 인스턴스
  • prop: 프로퍼티를 표현하는 객체
  • KProperty 인자를 통해 프로퍼티 이름을 전달받으므로 기존 ObservableProperty 클래스 주 생성자에서 name 프로퍼티를 없앤다.
  • by 키워드를 사용해 위임 프로퍼티 구현
class Person(val name: String, age: Int, salary: Int): Observable() {
    var age by ObservableProperty(age, this) // 위임 프로퍼티 
    var salary by ObservableProperty(salary, this) // 위임 프로퍼티
}
  • by 키워드를 사용해 위임 객체를 지정하면 이전 예제에서 직접 코드를 작성해야 했던 여러 작업을 코틀린 컴파일러가 자동으로 처리해준다.
  • 코틀린은 위임 객체를 감춰진 프로퍼티에 저장하고 주 객체의 프로퍼티를 읽거나 쓸 때마다 위임 객체의 getValue와 setValue를 호출해준다.
  • 관찰 가능한 프로퍼티의 로직을 직접 작성하는 대신, 즉 위임 관례를 따르는 위임 클래스를 직접 구현하는 대신, 코틀린 표준 라이브러리의 Delegates.observable 함수를 이용해 ObservableProperty와 동일하게 위임 관례를 따르는 위임 클래스의 객체를 생성해줄 수도 있다.
  • 대신 Delegates.observable 함수를 이용해 위임 객체 생성 시, setValue 함수에서 위임 객체의 상태값을 변경하는 것 외에 추가로 할 행동을 람다로 전달해줘야 한다.
import kotlin.properties.Delegates

class Person(val name: String, age: Int, salary: Int): Observable() {
    private val onChange = {
        property: KProperty<*>, oldValue: Any?, newValue: Any? ->
            notifyObservers(property.name, oldValue, newValue)
    }

    var age by Delegates.observable(age, onChange)
    var salary by Delegates.observable(salary, onChange)
}
  • 다른 관례들과 마찬가지로 getValue와 setValue 함수 모두 객체 안에 정의된 메서드이거나 확장 함수일 수 있다.

9.5.4 위임 프로퍼티는 커스텀 접근자가 있는 감춰진 프로퍼티로 변환된다.

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 호출 코드를 생성해준다는 것이다.
  • 이러한 메커니즘은 단순하지만 활용성이 없다.
  • 프로퍼티 값이 저장될 장소를 바꿀 수도 있고 프로퍼티를 읽거나 쓸 때 벌어질 일을 변경할 수도 있다.

9.5.5 맵에 위임해서 동적으로 애트리뷰트 접근

// 값을 맵에 저장하는 프로퍼티 정의
class Person {
    private val _attributes = mutableMapOf<String, String>()

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

    var name: String
        get() = _attribute["name"]!!
        set(value){
            _attribute["name"] = value
        }
}

fun main() {
    val p = Person()
    val data = mapOf("name" to "Seb", "company" to "JetBrains")
    for((attrName, value) in data)
        p.setAttribute(attrName, value)
    println(p.name)
    // Seb
    p.name = "Sebastian"
    println(p.name)
    // Sebastian
}
// 값을 맵에 저장하는 위임 프로퍼티 구현
class Person {
    private val _attributes = mutableMapOf<String, String>()

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

    var name: String by _attributes
}
  • 이런 코드가 작동하는 이유는 표준 라이브러리가 Map과 MutableMap 인터페이스에 대해 getValue와 setValue 확장 함수를 제공하기 때문이다.

9.5.6 실전 프레임워크가 위임 프로퍼티를 활용하는 방법

  • 위임 프로퍼티를 이용하면 객체의 프로퍼티를 저장하거나 변경하는 방법을 개발자가 원하는 대로 정의하고 구현할 수 있는 구조의 프레임워크를 만들 수 있다.
  • User 클래스 객체의 이름과 나이를 데이터베이스로부터 읽어오고 데이터베이스에 쓸 수 있도록 위임 프로퍼티를 구현해보자
  • User 클래스 정의
class User(id: EntityId): Entity(id) {
    val name: String
    val age: Int
}
  • User 객체의 속성과 매핑되는 컬럼을 프로퍼티로 갖는 IdTable 클래스 타입의 객체 Users 정의
  • 객체 식으로 정의된 싱글턴 객체이다.
object Users: IdTable()  {
    val name: Column<String>  = varchar("name", 50).index()
    val age: Column<Int> = integer("age")
}
  • User 클래스 name 프로퍼티의 접근자(get, set) 로직을 Users 객체 Column< String > 타입의 name 프로퍼티에 정의된 객체(varchar("name", 50).index()가 반환한 컬럼 객체)를 이용하여 구현할 수 있다.
  • 즉, Users 객체의 Column< String > 타입의 name 프로퍼티를 User 클래스 name 프로퍼티의 접근자 로직 구현을 위한 위임 객체로 사용할 수 있다는 것이다.
  • 마찬가지로 User 클래스 age 프로퍼티의 접근자 로직 또한 Users 객체의 이미 정의된
    Column< Int > 타입의 age 프로퍼티를 위임 객체로 하여 구현할 수 있다.
  • 그러기 위해선 우선, Column 클래스의 확장 함수로 getValue, setValue 함수를 정의하여야 하고, 이 함수는 위임 객체 관례에 따른 시그니처 요구 사항을 만족해야 한다.
operator fun <T> Column<T>.getValue(o: Entity, desc: KProperty<*>): T{
    // 데이터베이스에서 컬럼 값 가져오기(Entity 타입의 o 안에 id가 들어있다. 이를 이용함)
}
operator fun <T> Column<T>.setValue(o: Entity, desc: KProperty<*>, value: T){
    // 데이터베이스에서 컬럼 값 변경하기(Entity 타입의 o 안에 id가 들어있다. 이를 이용함)
}
  • 최종적으로 by 키워드를 이용해 User 클래스의 name 프로퍼티와 age 프로퍼티를 각각 Column 타입 객체(Users.name과 Users.age)를 위임 객체로 하는 위임 프로퍼티로 만들면 된다.
class User(id: EntityId): Entity(id) {
    val name: String by Users.name
    val age: Int by Users.age
}

'Language > Kotlin' 카테고리의 다른 글

어노테이션과 리플렉션  (1) 2025.09.23
[OOP] 5. 제네릭스  (2) 2025.07.21
[API] 3. 고차 함수  (2) 2025.07.14
[API] 2. 컬렉션과 시퀀스  (1) 2025.07.07
[OOP] 3. 기본 타입, 컬렉션, 배열  (1) 2025.06.09