[Kotlin In Action] 7. 연산자 오버로딩과 기타 관례

[Kotlin In Action] 7. 연산자 오버로딩과 기타 관례

Convention(컨벤션/관례)

: Kotlin에서 어떤 언어 기능과 미리 정해진 이름의 함수를 연결해주는 기법 예) 어떤 클래스 안에 plus라는 이름의 메서드를 정의하면, 그 클래스 인스턴스에 대해 + 연산자를 사용할 수 있다.

기존 자바 클래스에 대해 확장 함수를 구현하면서 관례에 따라 이름을 붙이면 기존 자바 코드를 바꾸지 않아도 새로운 기능을 쉽게 부여할 수 있다.

#1. 산술 연산자 오버로딩

이항 산술 연산자 오버로딩

data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
		return Point(x + other.x, y + other.y)
}
>>> val p1 = Point(10, 20)
>>> val p2 = Point(30, 40)
>>> println(p1 + p2) // plus 함수가 호출된다.
Point(x=40, y=60

연산자를 오버로딩하는 함수 앞에는 꼭 operator가 있어야 한다. operator가 없는데 관례에서 사용하는 함수 이름을 쓴다면 “operator modifier is required…” 오류가 발생한다.

외부 함수의 클래스에 대한 연산자를 정의할 때는 관례를 따르는 이름의 확장 함수로 구현하는 게 일반적인 패턴이다.

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

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

함수 이름
a * b times
a / b div
a % b rem
a + b plus
a - b minus

연산자 우선순위는 표준 숫자 타입에 대한 연산자 우선순위와 같다.

자바를 코틀린에서 호출하는 경우, 함수 이름이 코틀린의 관례에 맞아 떨어지기만 하면 항상 연산자 식을 사용해 그 함수를 호출할 수 있다.
자바에서는 따로 operator 표시를 할 수 없으므로 이름과 파라미터 개수만 맞으면 된다.

연산자를 정의할 때 두 파라미터가 같은 타입일 필요는 없다.

operator fun Point.times(scale: Double): Point {
    return Point((x * scale).toInt(), (y * scale).toInt())
}

// >>> val p = Point(10, 20)
// >>> println(p * 1.5)
// Point(x=15, y=30)

코틀린 연산자가 자동으로 교환 법칙(a op b == b op a 인 성질)을 지원하지는 않는다. -> 사용자가 p * 1.5외에 1.5 * p라고도 쓸 수 있어야 한다면, 대응하는 연산자 함수인 operator fun Double.times(p: Point): Point 함수도 정의해야 한다.

연산자 함수의 반환 타입 역시 두 파라미터와 다른 타입으로 구현 가능하다.

operator fun Char.times(count: Int): String {
    return toString().repeat(count)
}

// >>> println('a' * 3)
// aaa

일반 함수와 마찬가지로 연산자 함수도 오버로딩 가능하다. -> 이름은 같지만 파라미터 타입이 서로 다른 연산자 함수를 여럿 만들 수 있다.

복합 대입 연산자 오버로딩

+=, -= 같은 연산자를 복합 대입 연산자라 부른다.

반환 타입이 UnitplusAssign 함수를 정의하면 코틀린은 += 연산자에 그 함수를 사용한다. (minusAssign, timesAssign 등도 마찬가지)

이론적으로는 +=plusplusAssign 양쪽으로 컴파일할 수 있다.

어떤 클래스가 두 함수를 모두 정의하고, 둘 다 +=에 사용 가능한 경우, 컴파일 에러가 발생한다. 변수를 val로 바꿔서 plusAssign 적용이 불가능하게 만들어 해결할 수도 있지만, plusplusAssign을 동시에 정의하지 말고 일관성있게 설계하는 것이 좋다.

컬렉션 접근 방법

  1. +-는 항상 새로운 컬렉션을 반환한다.
  2. +=-=는 항상 변경 가능한 컬렉션에 작용하여, 메모리에 있는 객체 상태를 변화시킨다.
  3. 읽기 전용 컬렉션에서 +=-=는 변경을 적용한 복사본을 반환한다. -> var로 선언한 변수가 가리키는 읽기 전용 컬렉션에만 +=/-=를 적용할 수 있다.

단항 연산자 오버로딩

예) --, ++

operator fun Point.unaryMinus(): Point { //파라미터가 없다.
    return Point(-x, -y)
}

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

단항 산술 연산자

함수 이름
+a unaryPlus
-a unaryMinus
!a not
++a, a++ inc
--a, a-- dec

#2. 비교 연산자 오버로딩

동등성 연산자: equals

코틀린에서는 == / != 연산자 호출을 equals 메서드 호출로 컴파일한다. == / !=는 내부에서 인자가 null인지 검사하므로 다른 연산과 달리 nullable 값에도 적용할 수 있다.

a == b 비교를 처리할 때 a가 null이 아닌 경우에만 a.equals(b)를 호출한다. a가 null이면 b가 null인 경우에만 true가 된다.

data class는 컴파일러가 자동으로 equals를 생성해주며, 일반 클래스 또는 data class의 equals를 직접 구현할 수도 있다.

식별자 비교는 === 연산자를 사용하며, 자바의 == 연산과 같이 서로 같은 객체를 가리키는지(primitive type은 두 값이 같은지) 비교한다. ===는 오버로딩할 수 없다.

equals는 다른 연산자 오버로딩 관례와 달리 operator가 아닌 override가 붙어 있다. equalsAny에 정의된 메서드이므로 override가 필요하며, Any의 equals에는 operator가 붙어있다. -> 메서드를 오버라이드하는 하위 클래스의 메서드 앞에는 operator를 붙이지 않아도, 자동으로 상위 클래스의 operator 지정이 적용된다.

Any에서 상속받은 equals가 확장 함수보다 우선순위가 높기 때문에 equals는 확장 함수로 정의할 수 없다.

순서 연산자: compareTo

자바에서 값을 비교해야 하는 경우 Comparable 인터페이스를 구현해야 한다. 코틀린도 Comparable 인터페이스를 지원하며, 비교 연산자(<, >, <=, >=)를 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)
	  }
}

코틀린 표준 라이브러리에서 제공하는 compareValuesBy 함수를 이용하면 compareTo를 쉽고 간결하게 정의할 수 있다. compareValuesBy는 두 객체와 여러 비교 함수를 인자로 받는다. 첫 번째 비교 함수에 두 객체를 넘겨 같지 않으면 그 결과를 즉시 반환하고, 같으면(0) 두 번째 비교 함수로 비교한다. 이런 식으로 모든 함수가 0을 반환하면 0을 반환한다. 각 비교 함수는 람다_프로퍼티 참조_메서드 참조가 될 수 있다.

  • 성능 관점) 필드를 직접 비교하면 코드는 복잡해지지만 비교 속도는 훨씬 빨라진다.

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

인덱스로 원소에 접근: get / set

인덱스 연산자를 사용해 원소를 읽는 연산은 get 연산자 메서드로 변환되고, 원소를 쓰는 연산은 set 연산자 메서드로 변환된다. 메서드의 파라미터는 Int가 아닌 타입도 사용할 수 있다.

in 관례

in은 객체가 컬렉션에 들어있는지 검사하는 연산자이고, contains와 대응된다.

rangeTo 관례

범위를 만들 때 사용하는 .. 구문은 rangeTo 함수를 간략하게 표현하는 방법이다. 어떤 클래스가 Comparable 인터페이스를 구현한다면 rangeTo를 별도 정의없이 사용할 수 있다.

for loop를 위한 iterator 관례

for (x in list) {..}와 같은 코드는 list.iterator()를 호출해서 hasNextnext 호출을 반복하는 식으로 변환된다.

iterator 연산자 함수를 오버로딩하면 해당 클래스의 범위 객체를 for 루프에 사용할 수 있다.

#4. 구조 분해 선언과 component 함수

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

val p = Point(10, 20)
val (x, y) = p // x와 y를 한 번에 초기화한다.
// x = 10, y = 20

구조 분해 선언은 함수에서 여러 값을 반환할 때 유용하다.

구조 분해 선언과 루프

변수 선언이 들어갈 수 있는 곳이면 구조 분해 선언을 사용할 수 있다. 루프 안에서도 구조 분해 선언을 사용할 수 있으며, 특히 맵의 원소에 대해 이터레이션할 때 유용하다.

fun printEntries(map: Map<String, String>) {
    for ((key, value) in map) { //루프 변수에 구조 분해 선언
        println("$key -> $value")
    }
}

//>>> val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
//-> 맵 원소를 만들 때도 to를 사용하면 유용하다.

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

위임 프로퍼티를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다. 예) 프로퍼티는 위임을 통해 자신의 값을 필드가 아닌 데이터베이스 테이블이나 맵 등에 저장할 수 있다.

위임 프로퍼티 소개

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

-> p 프로퍼티는 접근자 로직을 Delegate 객체에게 위임한다.

class Foo {
    private val delegate = Delegate() //컴파일러가 생성한 도우미 프로퍼티
    var p: Type
       set(value: Type) = delegate.setValue(..., value)
       get() = delegate.getValue(...)
}

p 프로퍼티를 위해 컴파일러가 생성한 접근자는 delegate의 getValue / setValue를 호출한다.

프로퍼티 위임 관례에 따르는 Delegate 클래스는 getValue와 setValue 메서드를 제공해야 한다. (setValue는 변경 가능한 프로퍼티만 사용) getValue와 setValue는 멤버 메서드, 확장 함수 모두 가능하다.

class Delegate {
    operator fun getValue(...) { ... }
    operator fun setValue(..., value: Type) { ... }
}

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

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

지연 초기화(lazy initialization)는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 때 초기화하는 패턴이다. 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 사용할 수 있다.

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

lazy함수는 getValue 메서드가 들어있는 객체를 반환한다.

lazy 함수는 기본적으로 thread-safe하지만, 필요에 따라 동기화에 사용할 lock을 lazy 함수에 전달할 수도 있고 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy 함수가 동기화를 하지 못하게 막을 수도 있다.

위임 프로퍼티 컴파일 규칙

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 호출 코드를 생성해준다.

프로퍼티 값이 저장될 장소를 바꿀 수도 있고, 프로퍼티를 읽거나 쓸 때 실행될 동작을 변경할 수도 있다.

Comments