7장 연산자 오버로딩과 기타 관례

KOTLIN IN ACTION 7장을 요약한 내용입니다.

산술 연산자 오버로딩

코틀린에서 관례를 사용하는 가장 단순한 예는 산술 연산자다. 자바에서는 원시 타입에 대해서만 산술 연산자를 사용할 수 있고, 추가로 String에 대해 + 연산자를 사용할 수 있다.

이항 산술 연산 오버로딩

Point에서 지원하고픈 첫 번째 연산은 두 점을 더하는 연산이다.

// case 1. 연산자를 자체 함수로 정의하기
data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}

fun main(args: Array<String>) {
    val p1 = Point(10, 20)
    val p2 = Point(30, 40)
    println(p1 + p2)
}

// case 2. 연산자를 확장 함수로 정의하기
data class Point(val x: Int, val y: Int)

operator fun Point.plus(other: Point): Point {
    return Point(x + other.x, y + other.y)
}

operator 변경자를 추가해 plus 함수를 선언하고 나면 + 기호로 두 Point 객체를 더할 수 있다.

오버로딩 가능한 이항 산술 연산자

연산자를 정의할 때 두 피연산자가(연산자 함수의 두 파라미터)가 같은 타입일 필요는 없다. 또는 연산자 함수의 반환 타입이 꼭 두 피연산자 중 하나와 일치해야만 하는 것도 아니다.

data class Point(val x: Int, val y: Int)

// case 1. 두 피연산자가 다른 연산자 정의
operator fun Point.times(scale: Double): Point {
    return Point((x * scale).toInt(), (y * scale).toInt())
}

// case 2. 반환 타입이 피연산자와 다른 연산자 정의
operator fun Char.times(count: Int): String {
    return toString().repeat(count)
}

복합 대입 연산자 오버로딩

+=, -= 등의 연산자는 복합 대입(compound assignment)연산자라 불린다.

>>> var point = Point(1, 2)
>>> point += Point(3, 4)
>>> println(point)
Point(x=4, y=6)

코틀린 표준 라이브러리는 변경 가능한 컬렉션에 대해plusAssign을 정의하며, 앞의 예제는 그 plusAssign을 사용한다.

operator fun <T> MutableCollection<T>.plusAssign(element: T) {
		this.add(element)
}

이론적으로코드에 있는 +=를 plus와 plusAssign 양쪽으로 컴파일할 수 있다. 어떤 클래스가 이 두 함수를 모두 정의하고 둘 다 +=에 사용 가능한 경우 컴파일러는 오류를 보고한다.

단항 연산자 오버로딩

단항 연산자 오버로딩하는 절차도 이항 연산자와 마찬가지다.

data class Point(val x: Int, val y: Int)

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

fun main(args: Array<String>) {
    val p = Point(10, 20)
    println(-p)
}

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

비교 연산자 오버로딩

동등성 연산자: equals

코틀린이 == 연산자 호출을 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
    }
}

fun main(args: Array<String>) {
    println(Point(10, 20) == Point(10, 20))
    println(Point(10, 20) != Point(5, 5))
    println(null == Point(1, 2))
}

순서 연산자: compareTo

자바에서 정렬이나 최댓값, 최솟값 등 값을 비교해야 하는 알고리즘에 사용할 클래스는 Comparable 인터페이스를 구현해야 한다. Comparable에 들어있는 compareTo 메소드는 한 객체와 다른 객체의 크기를 비교해 정수로 나타내준다. 하지만 자바에는 이 메소드를 짧게 호출할 수 있는 방법이 없다.

코틀린도 똑같은 Comparable 인터페이스를 지원한다.

class Person(
        val firstName: String, val lastName: String
) : Comparable<Person> {

    override fun compareTo(other: Person): Int {
        return compareValuesBy(this, other,
            Person::lastName, Person::firstName)
    }
}

이 코드는 코틀린 표준 라이브러리의 compareValuesBy 함수를 사용해 compareTo를 쉽고 간결하게 정의할 수 있다.

fun main(args: Array<String>) {
    println("abc" < "bac")
}

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

in 관례

컬렉션이 지원하는 다른 연산자로는 in이 있다. In은 객체가 컬렉션에 들어있는지 검사한다. 그런 경우 in 연산자와 대응하는 함수는 contains다.

data class Point(val x: Int, val y: Int)

data class Rectangle(val upperLeft: Point, val lowerRight: Point)

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

fun main(args: Array<String>) {
    val rect = Rectangle(Point(10, 20), Point(50, 50))
    println(Point(20, 30) in rect)
    println(Point(5, 5) in rect)
}

rangeTo 관례

범위를 만들려면 .. 구문을 사용해야 한다. 예를 들어 1..10은 1부터 10까지 모든 수가 들어있는 범위를 가리킨다.

fun main(args: Array<String>) {
    val n = 9
    println(0..(n + 1))
    (0..n).forEach { print(it) }
}

for 루프를 위한 iterator 관례

코틀린에서는 iterator 메소드를 확장 함수로 정의할 수 있다. 이런 성질로 인해 일반 자바 문자열에 대한 for 루프가 가능하다.

operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
        object : Iterator<LocalDate> {
            var current = start

            override fun hasNext() =
                current <= endInclusive

            override fun next() = current.apply {
                current = plusDays(1)
            }
        }

fun main(args: Array<String>) {
    val newYear = LocalDate.ofYearDay(2017, 1)
    val daysOff = newYear.minusDays(1)..newYear
    for (dayOff in daysOff) { println(dayOff) }
}

구조 분해 선언과 component 함수

구조 분해를 사용하면 복합적인 값을 분해해서 여러 다른 변수를 한꺼번에 초기화할 수 있다.

data class Point(val x: Int, val y: Int)

fun main(args: Array<String>) {
    val p = Point(10, 20)
    val (x, y) = p
    println(x)
    println(y)
}

구조 분해 선언은 함수에서 여러 값을 반환할 때 유용하다. 여러 값을 한꺼번에 반환해야 하는 함수가 있다면 반환해야 하는 모든 값이 들어갈 데이터 클래스를 정의하고 함수의 반환 타입을 그 데이터 클래스로 바꾼다. 구조 분해 선언 구문을 사용하면 이런 함수가 반환하는 값을 쉽게 풀어서 여러 변수에 넣을 수 있다.

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(args: Array<String>) {
    val (name, ext) = splitFilename("example.kt") // 구조 분해 선언 구문을 사용해 데이터 클래스프를 푼다. 
    println(name)
    println(ext)
}

표준 라이브러리의 Pair나 Triple 클래스를 사용하면 함수에서 여러 값을 더 간단하게 반환할 수 있다. Pair나 Triple은 그 안에 담겨있는 원소의 의미를 말해주지 않으므로 경우에 따라 가독성이 떨어질 수 있는 반면, 직접 클래스를 작성할 필요가 없으므로 코드는 더 단순해진다.

구조 분해 선언과 루프

함수 본문 내의 선언문뿐 아니라변수 선언이 들어갈 수 있는 장소라면 어디든 구조 분해 선언을 사용할 수 있다.

fun printEntries(map: Map<String, String>) {
    for ((key, value) in map) {
        println("$key -> $value")
    }
}

fun main(args: Array<String>) {
    val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
    printEntries(map)
}

이 간단한 예제는 두 가지 코틀린 관례를 활용한다. 하나는 객체를 이터페이션하는 관례고, 다른 하나는 구조 분해 선언이다. 또한 코틀린 라이브러리는 Map.Entry에 대한 확장 함수로 component1과 component2를 제공한다.

for (entry in map.entries) {
		val key = entry.component1()
		val value = entry.component2()
}

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

위임 프로퍼티(delegated property)를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다. 또한 그 과정에서 접근자 로직을 매번 재구현할 필요도 없다.

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

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

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

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 // 최초로 emails를 읽을 때 단 한번만 이메일을 가져온다. 
    p.emails
}

이런 코드를 만드는 일은 약간 성가시다. 지연 초기화해야 하는 프로퍼티가 많아지면 코드가 어떻게 될까? 게다가 이 구현은 스레드 안전하지 않아서 언제나 제대로 작동한다고 말할 수도 없다. 위임 프로퍼티를 사용하면 훨씬 더 간편해진다.

class Person(val name: String) {
    val emails by lazy { loadEmails(this) }
}

lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메소드가 들어있는 객체를 반환한다. 따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다.

요약

  • 코틀린에서는 정해진 이름의 함수를 오버로딩함으로써 표준 수학 연산자를 오버로딩할 수 있다. 하지만 직접 새로운 연산자를 만들 수는 없다.

  • 비교 연산자는 equals와 compareTo 메소드로 변환된다.

  • 클래스에 get, set, contains라는 함수를 정의하면 그 클래스의 인스턴스에 대해 []와 in 연산을 사용할 수 있고, 그 객체를 코틀린 컬렉션 객체와 비슷하게 다룰 수 있다.

  • 미리 정해진 관례를 따라 tangeTo, iterator 함수를 정의하면 범위를 만들거나 컬렉션과 배열의 원소를 이터레이션할 수 있다.

  • 구조 분해 선언을 통해 한 객체의 상태를 분해해서 여러 변수에 대입할 수 있다. 함수가 여러 값을 한꺼번에 반환해야 하는 경우 구조 분해가 유용하다. 데이터 클래스에 대한 구조 분해는 거저 사용할 수 있지만, 커스텀 클래스의 인스턴스에서 구조 분해를 사용하려면 componentN 함수를 정의해야 한다.

  • 위임 프로퍼티를 통해 프로퍼티 값을 저장하거나 초기화하거나 읽거나 변경할때 사용하는 로직을 재활용할 수 있다. 위임 프로퍼티는 프레임워크를 만들 때 아주 유용하다.

  • 표준 라이브러리 함수인 lazy를 통해 지연 초기화 프로퍼티를 쉽게 구현할 수 있다.

  • Delegates.observable 함수를 사용하면 프로퍼티 변경을 관찰할 수 있는 관찰자를 쉽게 추가할 수 있다.

  • 맵을 위임 객체로 사용하는 위임 프로퍼티를 통해 다양한 속성을 제공하는 객체를 유연하게 다룰 수 있다.

Last updated