본문 바로가기
Kotlin

Kotlin in Action #7. 연산자 오버로딩과 기타 관례

by jayden-lee 2021. 8. 16.
728x90
'Kotlin in Action' 책을 학습하고 정리한 내용입니다.

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

언어 기능을 타입에 의존하는 자바와 달리 코틀린은 함수 이름을 통한 관례에 의존한다는 점이 특징이다. 이러한 관례를 선택한 이유는 기존 자바와 호환성을 맞춰야 하는데, 코틀린 쪽에서 자바 인터페이스를 변경할 수 없기 때문이다. 코틀린은 기존 자바 코드를 바꾸지 않음에도 새로운 기능을 부여할 수 있다.

산술 연산자 오버로딩

코틀린에서 관례를 사용하는 가장 단순한 예는 산술연산자이다. 자바에서는 원시 타입과 String 타입에 대해서만 + 연산자를 사용할 수 있다. 코틀린에서는 다른 클래스에 대해서도 산술연산자를 사용할 수 있게 기능을 정의할 수 있다.

이항 산술 연산 오버로딩

Plus data class를 생성해서 + 연산자 오버로딩 한 예제이다. plus 함수를 정의할 때, 반드시 operator  키워드를 붙여야 한다. + 연산자를 사용한 코드는 p1.plus(p2)으로 컴파일 된다.

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(1, 2)
val p2 = Point(5, 6)
println(p1 + p2) // Point(x=6, y=8)

 

코틀린은 프로그래머가 직접 연산자를 만들어서 사용할 수 없고, 언어에서 미리 정해둔 연산자만 오버로딩 할 수 있다. 클래스에서 정의해야 하는 이름이 연산자 별로 정의 되어 있다.

 

 

복합 대입 연산자 오버로딩

+=, -= 등의 연산자를 복합 대입(compound assignment) 연산자라고 부른다. plus 연산자를 오버로딩 하면, 코틀린은 +=도 자동으로 함께 지원한다.

plus 연산자를 사용하게 되면 반환되는 객체 참조는 바뀌어서 나오게 된다. 새로운 객체를 생성해서 반환하기 때문이다. 객체 참조가 바뀌는 것이 아닌 원래 객체의 상태만 변경하는 것도 가능하다.

val numbers = ArrayList<Int>()
numbers += 22
println(numbers[0]) // 22

 

반환 타입이 Unit인 PlusAssign 함수를 정의하면, 코틀린은 += 연산자에 그 함수를 사용한다. +=는 plus, plusAssign 양쪽으로 컴파일 할 수 있다. 클래스가 두 개를 모두 동시에 정의하면 컴파일러는 오류를 낸다.

plus. : 새로운 객체를 반환
plusAssign : 기존 객체를 사용하면서 상태만 변경

코틀린 표준 라이브러리는 컬렉션에 대해 두 가지 접근법을 제공한다. +와 -는 항상 새로운 컬렉션을 반환하며, +=와 += 연산자는 객체 상태를 변경한다.

val list = arrayListOf(1, 2)
list += 3
    
val newList = list + listOf(4, 5)
    
println(list == newList) // false

 

단항 연산자 오버로딩

단항 연산자도 이항 연산자와 동일하다. 미리 정해진 이름의 함수(멤버, 확장 함수)로 선언하면서 operator로 표시하면 된다.

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

val p = Point(1, 5)
println(-p) // Point(x=-1, y=-5)

 

비교 연산자 오버로딩

코틀린은 모든 객체에 대해 비교 연산을 수행할 수 있다. 자바는 equals, compareTo를 호출해야 했던 것들을 코틀린에서는 == 비교 연산자를 직접 사용할 수 있어서 비교 코드가 더 이해하기 쉽다.

동등성 연산자 equals

코틀린은 ==  연산자 호출을 equals 메서드 호출로 컴파일 한다. 그리고 내부에서 인자가 null인지 검사하므로 null이 될 수 있는 값에도 비교 연산자를 사용할 수 있다.

a == b -> a?.equals(b) ?: (b == null)

 

data class는 컴파일러가 자동으로 equals를 생성해준다. Point 클래스의 직접 equals를 구현한다면 다음과 비슷한 코드가 생성된다.

class Point(val x: Int, val y: Int) {
    override fun equals(obj: Any?) : Boolean {
        if (obj === this) return true // (1) identity equals (식별자 비교)
        if (obj !is Point) return false // (2)
        return obj.x == x && obj.y == y // (3)
    }
}

println(Point(1, 2) == Point(1, 2)) // true
println(Point(1, 2) != Point(4, 5)) // true
println(null == Point(3, 2)) // false

 

Any 클래스에 equals에는 operator가 붙어 있지만, 오버라이드하는 메서드 앞에는 operator를 붙이지 않아도 상위 클래스의 operator가 지정된다. 그리고 != 호출이 equals 메서드 호출로 바뀌게 되며, 반환 값을 반전시켜 돌려준다.

 

순서 연산자 compareTo

자바에서 순서를 비교하는 클래스는 Comparable 인터페이스를 구현해야 한다. Comparable 인터페이스에 있는 compareTo 메서드는 두 객체를 비교해서 정수를 반환한다. 코틀린에서도 똑같은 인터페이스를 지원한다. 인터페이스에 있는 compareTo 메서드를 호출하는 관례를 제공한다.

a >= b -> a.compareTo(b) >= 0

 

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

컬렉션을 다룰 때 가장 많이 사용하는 연산은 인덱스를 사용해 원소를 읽거나 쓰는 연산과 어떤 값이 컬렉션에 포함되어 있는지 검사하는 연산이다.

인덱스로 원소에 접근 get과 set

인덱스 연산자를 사용해 원소를 읽는 연산은 get 연산자 메서드로 변환되고, 원소를 쓰는 연산은 set 연산자 메서드로 변환된다.

인덱스로 원소에 접근해서 읽는 get 메서드

operator fun Point.get(index: Int) : Int {
    return when(index) {
        0 -> x
        1 -> y
        else -> throw IndexOutOfBondsException("Invalid coordinate $index")
    }
}

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

 

인덱스로 원소에 접근해서 쓰는 set 메서드

operator fun MutablePoint.set(index: Int, value: Int) {
    when(index) {
        0 -> x = value
        1 -> y = value
        else -> throw IndexOutOfBondsException("Invalid coordinate $index")
    }
}

val p = MutablePoint(10, 20)
p[1] = 50
println(p) // MutablePoint(x=10, y= 50)

 

in 관례

in은 객체가 컬렉션에 들어있는 검사를 한다. in 연산자에 대응하는 함수는 contains 이다.

operator fun Rectangle.contains(p: Point) : Boolean {
    // 비교 해서 boolean 반환하는 코드
    return true
}

val rect = Rectangle(Point(10, 20), Point(50, 50))
println(Point(20, 30) in rect) // true

 

in의 우항에 있는 객체는 contains 메서드의 수신 객체가 되고, in의 좌항에 있는 객체는 contains 메서드에 인자로 전달된다.

 

rangeTo 관례

범위를 만들려면 .. 구문을 사용해야 한다. 1..10은 1부터 10까지 모든 수가 들어있는 범위를 나타낸다. .. 연산자는 rangeTo 함수를 간략하게 표현하는 방법이다.

start..end -> start.rangeTo(end)

 

어떤 클래스가 Comparable 인터페이스를 구현하면 rangeTo를 정의할 필요가 없다. 비교 가능한 원소로 이루어진 범위를 쉽게 만들 수 있다.

val now = LocalDate.now()
val vacation = now..now.plusDays(10)
println(now.plusWeeks(1) in vacation) // true

 

for 루프를 위한 iterator 관례

for 루프는 범위 연산자와 똑같이 in 연산자를 사용한다. 이 경우 in의 의미는 다르다. for (x in list) {} 와 같은 코드는 list.iterator()를 호출해서 이터레이터를 얻고 나서 자바와 마찬가지로 hasNext와 next 호출을 반복하는 식으로 변환된다.

 

구조 분해 선언과 component 함수

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

val p = Point(10, 20)
val (x, y) = p // 자바스크립트, c#에 있는 것과 동일한 기능인듯

 

구조 분해 선언은 일반 변수 선언과 비슷해보이지만, =의 좌변에 여러 변수를 괄호로 묶었다. 내부에서 구조 분해 선언은 다시 관례를 사용한다. 구조 분해 선언의 각 변수를 초기화 하기 위해 componentN이라는 함수를 호출한다.

val (a, b) = p

val a = p.component1()
val b = p.component2()

 

data class 주 생성자에 들어있는 프로퍼티는 컴파일러가 자동으로 componentN 함수를 만들어준다. 데이터 클래스가 아닌 일반 클래스에 대해서도 component 함수를 구현할 수 있다.

class Point(val x: Int, val y: Int) {
    operator fun component1() = x
    operator fun component2() = y
}

 

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

위임 프로퍼티를 사용하면 값을 뒷받침하는 필드에 더 복잡한 방식으로 동작하는 프로퍼티를 쉽게 구현할 수 있다. 프로퍼티는 위임을 사용해서 값을 필드가 아니라 DB, Session, Map 등에 저장할 수 있다.

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

 

위임 프로퍼티(delegated property) 소개

by 뒤에 있는 식을 계산해서 위임에 사용할 객체를 얻는다. 프로퍼티 위임 객체가 따라야 하는 관례를 따르는 모든 객체를 위임에 사용할 수 있다. 위임 관례에 따라 Delegate는 getValue, setValue를 제공해야 한다.

class Foo {
    val p: Type by Delegate()
}
class Delegate {
    operator fun getValue() { }
    operator fun setValue(.., value: Type) { }
}

 

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

지연 초기화(lazy initialization)는 객체의 일부분을 초기화하지 않고 미뤘다가 실제로 그 부분의 값이 필요할 경우 초기화 할 때 사용하는 패턴이다.

class Person(val name: String) {
    private var _emails = List<Email>? = null
    val emails: List<Email>
        get() {
            if (_emails == null) {
                _emails = loadEmails(this)
            }
            return _emails!!
        }
}

val p = Person("Alice")
println(p.emails) // 최초로 emails 읽을 때 단 한번만 이메일 정보를 가져온다.

 

위 코드에서는 뒷받침하는 프로퍼티(backing property)라는 기법을 사용했다. _emails 프로퍼티는 값을 저장하고, 다른 프로퍼티인 emails는 _emails라는 프로퍼티에 대한 읽기 연산을 제공한다. 이 구현은 쓰레드에 안전하지 않고 언제나 정상 동작한다고 보장할 수 없다. 위임 프로퍼티를 사용하면 코드가 훨씬 더 간단해진다.

lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메서드가 들어있는 객체를 반환한다. lazy 함수의 인자는 값을 초기화 할 때 호출할 람다이다. lazy는 쓰레드에 안전하다.

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

 

위임 프로퍼티 컴파일 규칙

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

val c = C()

 

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

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

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

 

프로퍼티 값을 map에 저장

Person 클래스를 설계할 때 필수 값인 이름만 정의하고 나머지 부가정보는 사람마다 다르기 때문에 동적으로 정의할 수 있게 map 객체를 사용한 예제 코드이다.

class Person {
    private val _attributes = hashMapOf<String, String>()
	
    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }

    val name: String by _attributes // 위임 프로퍼티 맵 사용
}

 

프레임워크에서 위임 프로퍼티 사용

Users는 테이블에 해당하고, User는 테이블에 들어있는 데이터를 표현한다고 하면 위임 프로퍼티를 사용해서 구현하면 다음과 같다.

object Users : IdTable() {
    val name = varchar("name", length = 50).index()
    val age = integer("age")
}

/*
 * User 엔티티 속성은 위임 프로퍼티이며, Users의 컬럼 객체를 위임 객체로 사용했다.
 */
class User(id: EntityId) : Entity(id) {
    var name: String by Users.name
    var age: Int by Users.age
}

 

댓글