Post

Kotlin in Action 2판 9장 연산자 오버로딩과 다른 관례

Kotlin의 관례(Convention): 연산자 오버로딩, 구조 분해 선언, 위임 프로퍼티

Kotlin in Action 2판 9장 연산자 오버로딩과 다른 관례

9장 연산자 오버로딩과 다른 관례

코틀린에는 사용자가 정의한 함수를 언어 기능이 호출하는 관례(convention) 가 있는데 이를 통해 특정 이름을 가진 함수의 존재만으로 언어 기능을 사용할 수 있음

9 장에서 다루는 내용

  • 연산자 오버로딩
  • 관례 함수 이름과 동작 원리
  • 위임 프로퍼티(Delegated Property)

9.1 산술 연산자를 오버로드해서 임의의 클래스에 대한 연산을 더 편리하게 만들기

코틀린에서는 관례(convention) 에 따라 특정 이름의 함수를 정의하면, 해당 함수를 연산자처럼 사용할 수 있음. 자바에서는 기본 타입에 대해서만 산술 연산이 가능하지만, 코틀린은 사용자 정의 클래스에도 산술 연산자를 오버로드할 수 있음.

대표적인 예제로 Point 클래스를 사용하여 +, * 연산을 구현함.


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

리스트 9.1 plus 연산자 구현하기

1
2
3
4
5
6
7
8
9
data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}

operator fun Point.plus(other: Point): Point {
    return Point(x + other.x, y + other.y)
}
  • + 연산자를 plus 함수로 오버로딩한 예제임
  • 반드시 operator 키워드를 붙여야 관례에 따른 연산자 오버로딩이 가능함
  • 내부적으로 a + ba.plus(b) 로 변환되어 호출됨
  • 클래스 내부가 아닌 외부 확장 함수로도 연산자 오버로딩이 가능함
1
2
3
4
5
fun main() {
    val p1 = Point(10, 20)
    val p2 = Point(30, 40)
    println(p1 + p2) // Point(x=40, y=60)
}

두 피연산자의 타입이 다른 연산자 정의하기

1
2
3
operator fun Point.times(scale: Double): Point {
    return Point((x * scale).toInt(), (y * scale).toInt())
}
1
2
3
4
fun main() {
    val p = Point(10, 20)
    println(p * 1.5) // Point(x=15, y=30)
}
  • PointDouble 간의 곱셈을 오버로딩한 예제임
  • 두 피연산자의 타입이 달라도 연산자 오버로딩 가능
  • 단, 1.5 * p 같은 교환법칙은 자동으로 지원되지 않음, 별도로 정의해야 함

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

plus와 같은 연산자를 오버로딩하면, 코틀린은 +뿐 아니라 복합 대입 연산자 +=도 자동 지원함. 이를 복합 대입(compound assignment) 연산자라고 함.

1
2
3
4
5
fun main() {
    var point = Point(1, 2)
    point += Point(3, 4)
    println(point) // Point(x=4, y=6)
}
  • plusAssign 함수를 오버로딩하면 += 연산자가 해당 함수로 번역됨

+= 연산은 plus 또는 plusAssign 중 하나로 컴파일됨
두 함수 모두 정의되어 있는 경우 컴파일 오류 발생

1
2
3
a += b  
// → a = a + b  또는  
// → a.plusAssign(b)

불변 객체에는 plus, 변경 가능한 객체에는 plusAssign을 사용하는 것이 일관된 설계임


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

단항 연산자(unary operator) 도 이항 연산자와 마찬가지로
operator 키워드와 미리 정해진 함수 이름을 사용하여 오버로딩 가능함

단항 산술 연산자 정의하기

1
2
3
4
5
6
7
8
operator fun Point.unaryMinus(): Point {
    return Point(-x, -y)
}

fun main() {
    val p = Point(10, 20)
    println(-p) // Point(x=-10, y=-20)
}
  • -pp.unaryMinus() 로 변환됨

표 9.2 오버로딩할 수 있는 단항 연산자

함수 이름
+aunaryPlus()
-aunaryMinus()
!anot()
++a, a++inc()
--a, a--dec()

증가 연산자 정의하기

1
2
3
4
5
6
7
8
9
10
import java.math.BigDecimal

operator fun BigDecimal.inc() = this + BigDecimal.ONE

fun main() {
    var bd = BigDecimal.ZERO
    println(bd++) // 0
    println(bd)   // 1
    println(++bd) // 2
}
  • ++ 연산자도 inc() 함수로 오버로딩 가능
  • 후위, 전위 증가 매커니즘 제공
  • 자바와 동일한 의미를 제공함

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

코틀린에서는 ==, !=, <, >, <=, >=비교 연산자도 오버로딩이 가능함.
내부적으로는 equals, compareTo 함수를 호출하며, 관례 기반 오버로딩 원칙이 동일하게 적용됨.


9.2.1 동등성 연산자: equals

== 연산자는 내부적으로 equals 호출로 컴파일됨.
!= 연산자도 마찬가지로 equals 호출 후 결과를 반전시켜 사용함.

1
a == b    a?.equals(b) ?: (b == null)

🔍 두 피연산자 중 하나가 null일 경우에도 안전하게 동작하도록 변환됨


equals 메서드 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
13
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
    }
}

fun main() {
    println(Point(10, 20) == Point(10, 20)) // true
    println(Point(10, 20) != Point(5, 5))   // true
    println(null == Point(1, 2))            // false
}
  • ===참조 동등성, ==구조 동등성 비교임
  • equalsAny에 정의된 함수이므로 override 키워드가 필요함
  • operator 키워드는 생략 가능 (상위 클래스에 정의되어 있으므로 자동 상속됨)
  • 확장 함수로 equals를 정의할 수 없음 — 항상 멤버 함수여야 함 (Any로 상속받은 equals가 우선순위가 높기 떄뮨)

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

코틀린은 자바의 Comparable 인터페이스를 그대로 사용함 <, >, <=, >= 연산자는 내부적으로 compareTo 호출로 변환됨

compareTo 메서드 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
}
  • compareValuesBy 함수는 여러 기준을 순서대로 비교할 수 있도록 도와줌
  • 0과 비교하는 코드로 컴파일됨 -> a >= b | a.compareTo(b) >= b

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

컬렉션이나 범위에 대해 사용하는 여러 연산자도 특정 함수 이름의 관례를 따름.
대표적으로 [], in, .., for 루프 등이 있으며, 이 연산자들을 사용할 수 있도록
get, set, contains, rangeTo, iterator 등의 함수를 오버로딩할 수 있음.


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

[] 연산자를 사용해 값을 읽거나 쓸 수 있음.
get 함수는 값을 읽을 때, set 함수는 값을 쓸 때 호출됨.

get 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
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
}
  • get(index) 함수 정의로 p[1] 같은 인덱스 접근이 가능해짐
  • 즉 대충 코드 봤을 때 알다시피 이런 느낌으로 get 구현 가능

set 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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")
    }
}

fun main() {
    val p = MutablePoint(10, 20)
    p[1] = 42
    println(p) // MutablePoint(x=10, y=42)
}
  • set(index, value) 함수 정의로 p[1] = 42 처럼 대입 가능함
  • 대입식 x[a] = b는 내부적으로 x.set(a, b)로 변환됨

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

in 연산자를 사용해 객체가 컬렉션이나 범위에 포함되는지 검사할 수 있음.
이는 내부적으로 contains 함수 호출로 변환됨.

contains 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
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
}

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

.. 연산자를 사용하면 내부적으로 rangeTo 함수 호출로 변환됨.
이는 Comparable 인터페이스를 기반으로 동작함.

1
2
3
4
5
val start = LocalDate.now()
val end = start.plusDays(10)
val vacation = start..end  // start.rangeTo(end)
println(LocalDate.now().plusWeeks(1) in vacation) // true

  • rangeToClosedRange<T> 타입의 범위를 반환함
  • rangeTo는 다른 연산자보다 우선순위가 낮기 때문에 괄호 사용 권장임

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

구조 분해 선언은 여러 값을 한 번에 변수로 분해해 할당하는 문법임.
코틀린에서는 이 기능도 관례에 기반하며, componentN() 함수를 호출하는 방식으로 동작함.

1
2
3
4
val p = Point(10, 20)
val (x, y) = p
println(x) // 10
println(y) // 20

위 코드는 다음과 같이 컴파일됨:

1
2
val x = p.component1()
val y = p.component2()

구조 분해 선언을 위한 componentN 함수

  • data classcomponent1, component2 등의 함수를 자동으로 생성함
  • 일반 클래스에서는 직접 정의해야 함
1
2
3
4
class Point(val x: Int, val y: Int) {
    operator fun component1() = x
    operator fun component2() = y
}
  • 함수 앞에는 반드시 operator 키워드가 필요함

리스트 9.14 구조 분해 선언을 사용해 여러 값 반환하기

1
2
3
4
5
6
7
8
9
10
11
12
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])
}

fun main() {
    val (name, ext) = splitFilename("example.kt")
    println(name) // example
    println(ext)  // kt
}
  • 구조 분해 선언은 함수 반환값을 명확하게 분리해서 처리할 때 유용함
  • Pair, Triple 등을 사용해도 되지만, 의미 있는 데이터 클래스를 사용하는 것이 더 표현력이 좋음

컬렉션에 대해 구조 분해 선언 사용하기

1
2
3
4
fun splitFilename(fullName: String): NameComponents {
    val (name, extension) = fullName.split('.', limit = 2)
    return NameComponents(name, extension)
}
  • List, Array 등에도 componentN 함수가 정의되어 있어 구조 분해 선언 사용 가능함
  • 코틀린 표준 라이브러리는 최대 5개까지의 component 함수를 제공함 (필자는 처음 안 사실)

9.4.1 구조 분해 선언과 루프

구조 분해 선언을 사용해 맵 이터레이션하기

1
2
3
4
5
6
7
8
9
10
11
12
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
}

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

구조 분해 선언에서 일부 값이 필요 없을 때는 _(언더스코어) 를 사용하여 무시할 수 있음.

1
2
3
4
5
6
7
8
9
10
11
data class Person(
    val firstName: String,
    val lastName: String,
    val age: Int,
    val city: String,
)

fun introducePerson(p: Person) {
    val (firstName, _, age) = p
    println("This is $firstName, aged $age.")
}
  • 필요하지 않은 값은 _에 대입하면 컴파일러가 무시함
  • 변수 이름은 중요하지 않고, 순서에 따라 componentN 함수가 호출됨

구조 분해 선언의 한계

  • 위치 기반 매핑이기 때문에, 변수 이름은 의미가 없고 순서가 중요함
  • 데이터 클래스의 프로퍼티 순서가 바뀌면 기존 구조 분해 코드가 잘못 작동할 수 있음
1
val (firstName, lastName, age, city) = p
  • 위 선언은 내부적으로 component1, component2, component3, component4 호출로 변환됨
  • 구조 분해는 작은 컨테이너 클래스에 국한해서 사용하는 것이 안전
  • 이러한 문제를 막기 위해 이름 기반 구조 분해 선언이 업데이트 될 수 있다는 점이 책에도 나와있었음

🔍 즉 복잡한 엔티티에서는 구조 분해 사용을 지양하는 것이 좋음


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

위임 프로퍼티(delegated property) 는 프로퍼티의 getter/setter 로직을 위임 객체(delegate object) 에 위임함.
이를 통해 데이터 저장 위치(예: DB, 세션, 맵 등)를 바꾸거나, 부가 로직(검증, 알림 등)을 쉽게 붙일 수 있음.


9.5.1 위임 프로퍼티의 기본 문법과 내부 동작

1
var p: Type by Delegate()
  • Delegate 객체는 getValue, setValue 함수를 구현해야 함
  • 컴파일러는 감춰진 delegate 필드를 생성하고, 이를 통해 접근자 로직을 위임함
1
2
3
4
class Delegate {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): Type { ... }
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Type) { ... }
}
  • 선택적으로 provideDelegate 함수도 구현 가능
  • 모든 위임 메서드에는 operator 키워드가 필요함

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

lazy 함수 사용

1
2
3
class Person(val name: String) {
    val emails by lazy { loadEmails(this) }
}
  • lazy는 첫 접근 시 한 번만 초기화되는 값을 반환하는 스레드 안전 위임 객체

9.5.3 위임 프로퍼티 구현

예시는 너무 길어서 Observable 정의 같은 부분은 생략

프로퍼티 변경 통지를 클래스로 구현

1
2
3
4
5
6
7
8
9
class ObservableProperty(var propValue: Int, val observable: Observable) {
    operator fun getValue(thisRef: Any?, prop: KProperty<*>) = propValue

    operator fun setValue(thisRef: Any?, prop: KProperty<*>, newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        observable.notifyObservers(prop.name, oldValue, newValue)
    }
}

위임 적용

1
2
3
4
class Person(val name: String, age: Int, salary: Int): Observable() {
    var age by ObservableProperty(age, this)
    var salary by ObservableProperty(salary, this)
}
  • 컴파일러가 getter/setter 내부에 getValue, setValue 호출 코드를 생성해줌
  • 중복된 notify 로직 제거 가능

표준 라이브러리 사용: Delegates.observable

표준 라이브러리를 사용하면 옵저버블한 변수 로직을 작성하지 않아도 됨

1
2
3
4
5
6
7
8
9
10
import kotlin.properties.Delegates

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

    var age by Delegates.observable(age, onChange)
    var salary by Delegates.observable(salary, onChange)
}
  • Delegates.observable 함수는 변경 시 호출될 람다를 인자로 받음

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

1
2
3
class C {
    var prop: Type by MyDelegate()
}
  • 위 코드는 다음처럼 컴파일됨:
1
2
3
4
5
6
7
class C {
    private val <delegate> = MyDelegate()

    var prop: Type
        get() = <delegate>.getValue(this, <property>)
        set(value) = <delegate>.setValue(this, <property>, value)
}
  • <delegate>는 숨겨진 필드
  • <property>KProperty 객체로 컴파일 타임에 자동 생성됨

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

동적으로 정의되는 속성을 맵에 저장하면서도 정적 프로퍼티처럼 사용할 수 있음

맵을 사용하는 프로퍼티 위임 예제

1
2
3
4
5
6
7
8
9
class Person {
    private val _attributes = mutableMapOf<String, String>()

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

    val name: String by _attributes
}
  • name 프로퍼티는 내부적으로 _attributes["name"] 을 사용해 값을 읽음
  • getValue/setValue 확장 함수가 Map, MutableMap 인터페이스에 대해 정의되어 있기에 위임 가능

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

책에서 예시로 든 Exposed 라는 ORM 프레임워크에서는 데이터베이스 테이블과 엔티티 간의 매핑을 위임을 통해 간결하게 구현함.

위임 프로퍼티를 사용한 데이터베이스 칼럼 접근

1
2
3
4
5
6
7
8
9
object Users : IdTable() {
    val name: Column<String> = varchar("name", 50).index()
    val age: Column<Int> = integer("age")
}

class User(id: EntityID) : Entity(id) {
    var name: String by Users.name
    var age: Int by Users.age
}
  • Users는 싱글턴 객체이며 DB 테이블에 해당함
  • UserEntity를 상속하며, 각 프로퍼티는 Users 객체의 컬럼을 위임 받음
  • Column<T> 클래스는 getValue / setValue 함수를 통해 위임 관례를 구현함
1
2
3
4
5
6
7
operator fun <T> Column<T>.getValue(o: Entity, desc: KProperty<*>): T {
    // DB에서 값 읽기
}

operator fun <T> Column<T>.setValue(o: Entity, desc: KProperty<*>, value: T) {
    // DB에 값 쓰기
}

9장 요약

  • 연산자 오버로딩: plus, times, compareTo, equals 등 연산자 대응 함수 정의로 사용자 정의 타입에서도 연산자 사용 가능
  • 비교 연산자: ==equals, <compareTo 로 컴파일됨
  • 컬렉션 관례: get, set, contains 정의 시 [], in 연산자 사용 가능
  • 구조 분해 선언: componentN 함수 기반. data class는 자동 생성
  • 위임 프로퍼티:
    • getValue, setValue 함수로 로직 위임
    • by lazy {} → 지연 초기화
    • Delegates.observable() → 변경 감지
    • by map → 동적 속성 처리
    • 프레임워크에서 DB, JSON 등 다양한 응용 가능
This post is licensed under CC BY 4.0 by the author.