Kotlin In Action 7장

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

코틀린에서는 기존 자바 클래스를 코틀린 언어에 적용하기 위함이다.

기존 자바 클래스가 구현하는 인터페이스가 이미 고정돼 있고 코틀린 쪽에서 자바 클래스가 새로운 인터페이스를 구현하게 만들 수는 없다.

반면 확장 함수를 사용하면 기존 자바 클래스에 대해 관례에 따라 이름을 붙임으로써 기존 자바 코드를 수정하지않고 새로운 기능을 쉽게 구현할 수 있다.

산술 연산자 오버로딩

앞서 서술한 코틀린의 관례를 사용하는 방법을 알아보자.

가장 단순한 관례는 산술 연산자다.

자바는 Primitive 타입에 대해서만 산술 연산자를 사용할 수 있고, 추가로 String에 대해 + 연산자를 사용할 수 있다.

그러나 BigInteger같은 클래스에서도 add 메소드가 아닌 + 연산을 사용하는 편이 더욱 직관적이다.

컬렉션도 마찬가지로 += 연산자를 사용할 수 있으면 더 좋을 것이다.

코틀린에서는 이러한 기능이 가능하다.

이항 산술 연산 오버로딩

Point 클래스를 하나 만들자.

이 클래스는 두 점의 X 좌표와 Y 좌표를 각각 더한다. 다음 코드는 + 연산자 구현을 보여준다.

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,20)
println(p1 + p2)
// 결과 : Point(x=40, y=40)

plus 함수 앞에는 반드시 operator 키워드를 명시해야한다.

기본적으로 연산자 오버로딩을 사용할 때는 operator 키워드를 명시함으로써 이 함수가 어떤 관례를 따르고 있음을 명시한다.

확장함수로 만드는 것도 가능하다.

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

오버로딩이 가능한 이항 산술 연산자는 다음과 같다.

함수 이름
a*btimes
a/bdiv
a%bmod(1.1부터 rem)
a+bplus
a-bminus

직접 정의한 함수를 통해 구현하더라도 당연히 연산자 우선순위는 우리가 알고 있는 그것과 동일하게 적용된다.

복합 대입 연산자 오버로딩

plus와 같은 연산자를 오버로딩하면 코틀린은 + 연산뿐 아니라 그와 관련있는 복합 대입 연산자인 +=도 자동으로 지원한다.

만약 += 연산이 객체에 대한 참조를 다른 참조로 바꾸기보다 원래 객체의 내부 상태를 변경하고 싶은 경우가 있을 수 있다.

변경 가능한 컬렉션에 원소를 추가하는 경우가 이 경우일텐데, 이 경우 반환 타입이 Unit인 PlusAssign 함수를 정의하면 코틀린은 += 연산자에 그 함수를 사용한다.

만약 해당 컬렉션이 읽기 전용 컬렉션이라면 +=과 -=는 변경을 적용한 복사본을 반환한다.

단항 연산자 오버로딩

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

+a, -a와 같은 단항 연산자 또한 사용 가능하다.

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

비교 연산자 오버로딩

코틀린에서는 Primitive 타입 뿐만 아니라 모든 객체에 대한 비교 연산을 수행할 수 있다.

eqauls나 compareTo를 호출해야하는 자바와 달리 코틀린에서는 == 비교 연산자를 직접 사용할 수 있어 훨씬 코드를 이해하기 쉽다.

동등성 연산자 : eqauls

우리는 이전에 == 연산자 호출이 eqauls 메소드 호출로 컴파일한다는 사실을 배웠다.

식별자 비교 연산자 ===를 사용해 두 객체간의 참조가 같은지를 비교할 수 있다.

단, ===는 오버로딩할 수 없다.

순서 연산자 : compareTo

자바에서는 정렬이나 최대,최소값을 비교할 때 Comparable 인터페이스를 구현해야했다.

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

또한 인터페이스 안에 있는 compareTo 메소드를 호출하는 관례를 지원한다.

따라서 비교 연산자 <,>,<=, >=는 compareTo 호출로 컴파일된다.

compareTo가 반환하는 값은 Int다.

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

컬렉션을 다룰 때 가장 많이 쓰이는 연산은 인덱스를 사용해 원소를 읽거나 쓰는 연산과

어떤 값이 컬렉션에 속해있는지 검사하는 연산이다.

인덱스를 가져올때는 a[b]와 같이 사용하며(이를 인덱스 연산자라고 부른다.) in 연산자는 원소가 컬렉션이나 범위에 속하는지 검사하거나 컬렉션에 있는 원소를 순회할 때 사용한다.

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

코틀린의 map 컬렉션에서 인덱스 연산자를 사용해 맵에 키/값 쌍을 넣거나 변경할 수 있단느 사실을 우리는 이미 알고 있다.

map[key] = newValue

자 이제 위의 코드가 어떻게 동작하는지 살펴보자.

코틀린에서는 인덱스 연산자도 관례를 따른다.

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

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

당연히 타입은 Int가 아닌 타입도 사용할 수 있다.

또한 만약 2차원 행렬을 표현하는 클래스에는 operator fun get(rowIndex:Int, colIndex:int)를 정의하면 matrix[row,col]로 해당 메소드를 호출하는 것이 가능하다.

이번엔 인덱스에 해당하는 컬렉션 원소를 쓰고 싶은 경우를 살펴보자.

이때는 set이라는 이름의 함수를 정의하면 된다.

기존 Point가 불변 클래스이므로 변경가능한 새로운 클래스인 MutablePoint를 정의해서 사용하자.

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")
    }
}

in 관례

in은 객체가 컬렉션에 들어있는지 검사한다.

이 경우 in 연산자와 대응하는 함수는 contains다.

어떤 점이 사각형 영역에 포함되는지 판단할 때 in 연산자를 사용해 구현해보자.

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
}

until을 통해 열린 범위를 만들고 그 범위 안에 해당 좌표가 포함되는지를 검사한다.

rangeTo 관례

범위를 만들기 위해서는 ..를 사용해야한다.

1..10은 1부터 10까지 모두 포함된 수의 범위를 말한다.

.. 연산자는 rangeTo 함수를 간략하게 표현하는 방법이다.

operator fun <T: Comparable<T>> T.rangeTo(that: T): ClosedRange<T>

이 함수는 범위를 반환하며, 어떤 원소가 그 범위 안에 들어있는지를 in을 통해 검사할 수 있다.

for 루프를 위한 iterator 관례

for 루프는 범위 검사와 마찬가지로 in 연산자를 사용한다.

하지만 이 경우 in의 의미는 조금 다르다.

for(x in list) { ... } 의 경우 list.iterator()를 호출해서 이터레이터를 얻은 다음, 자바와 마찬가지로 그 이터레이터에 대해 hasNextnext 호출을 반복하는 식으로 변환된다.

코틀린에서는 이 또한 관례이므로 iterator 메소드를 확장 함수로 정의할 수 있다.

이러한 성질로 인해 일반 자바 문자열에 대한 for 루프가 가능하다.

operator fun CharSequence.iterator(): CharIterator
for (c in "abc") {}

구조 분해 선언과 component 함수

구조 분해 선언, destructuring declaration을 살펴보자.

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

val p = Point(10,20)
val (x,y) = p

구조 분해 선언은 일반 변수 선언과 비슷해 보인다.

다만 =의 좌변에 여러 변수를 괄호로 묶었다는 점이 다르다.

val (x,y) = p는 다음과 같이 컴파일된다.

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

data 클래스의 주 생성자에 들어있는 프로퍼티에 대해서는 컴파일러가 자동으로 componentN 함수를 만들어준다.

결국 data 클래스이기 때문에 구조분해 선언을 사용할 수 있었다는 것을 알 수 있다.

만약 data 클래스가 아닌 일반 클래스에서 이 함수는 다음과 같이 구현할 수 있다.

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

참고로 컬렉션이나 배열에서도 componentN 함수가 존재한다.

구조 분해 선언과 루프

구조 분해 선언은 다음과 같이 사용할 수 있다.

for((key, value) in map) {
    println("$key -> $value")
}

여기서는 두 가지 관례를 사용한다.

하나는 객체를 이터레이션하는 관례이고, 하나는 구조 분해 선언이다.

코틀린 표준 라이브러리에는 맵에 대한 확장 함수로 iterator가 들어있다.

그 iterator는 맵 원소에 대한 이터레이터를 반환한다. 즉, 자바와는 달리 코틀린에서는 맵을 직접 이터레이션할 수 있다.

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

위임 프로퍼티 (delegated property)는 코틀린이 제공하는 관례에 의존하는 특성 중 독특하면서 강력한 기능을 갖고 있다.

위임 프로퍼티를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다.

예를 들어 프로퍼티는 위임을 사용해 자신의 값을 필드가 아니라 데이터베이스 테이블이나 브라우저 세션, 맵 등에 저장할 수 있다.

위임 프로퍼티 소개

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

위임 프로퍼티의 일반적인 문법은 위와 같다.

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

여기서는 Delegate 클래스의 인스턴스를 위임 객체로 사용한다.

by 뒤에 있는 식을 계산해서 위임에 쓰일 객체를 얻는다.

다음과 같이 컴파일러는 숨겨진 도우미 프로퍼티를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다.

class Foo {
    private val delegate = Delegate()
    var p: Type
    set(value: Type) = delegate.setValue(..., value)
    get() = delegate.getValue(...)
}

프로퍼티 위임 관례를 따르는 Delegate 클래스는 getVaule와 setValue 메소드를 제공해야한다.

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

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

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

lazy initialization은 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제 그 부분의 값이 필요한 경우 초기화 할 때 흔히 쓰이는 패턴이다.

예를 들어보자.

person 클래스가 자신이 작성한 이메일의 목록을 제공한다고 가정하자.

이메일은 데이터베이스에 들어있고 불러오려면 시간이 오래 걸린다.

그래서 이메일 프로퍼티의 값을 최초로 사용할 때 단 한 번만 이메일을 데이터베이스로 가져오고 싶다.

이제 데이터베이스에서 이메일을 가져오는 loadEmials라는 함수가 있다고 하자.

class Email { ... }
fun loadEmails(person: Person): List<Email> {
    println("${person.name}의 이메일을 가져옴)
    return listOf(...)
}

다음은 이메일을 불러오기 전에는 null을 저장하고 불러온 다음에는 이메일 리스트를 저장하는 _emails 프로퍼티를 추가해서 지연 초기화를 구현한 클래스를 보여준다.

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

하지만 이 코드는 어딘가 지저분하고 복잡해보인다.

이 때 위임 프로퍼티를 사용하면 훨씬 더 깔끔한 코드를 얻을 수 있다.

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

lazy 함수는 코틀린 관례에 맞는 시그니처 getValue 메소드가 들어있는 객체를 반환한다.

lazy 함수는 기본적으로 스레드 안전하다.

lazy를 by와 함께 사용함으로써 위임 프로퍼티를 만들 수 있었다.

위임 프로퍼티 컴파일 규칙

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

val c = C()

위의 코드를 컴파일하면 컴파일러는 다음과 같은 코드를 생성한다.

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

다시 말해 컴파일러는 모든 프로퍼티 접근자 안에 getValue와 setValue 호출 코드를 생성해준다.

프로퍼티 값을 맵에 저장

자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때 위임 프로퍼티를 사용할 수 있다.

그런 객체를 확장 가능한 객체라고 부르기도 한다.

아래는 클래스 내부에 맵을 정의하고 해당 맵에 값을 꺼내오는 예제 코드이다.

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

    val name: String
    get() = _attributes["name"]!! // 수동으로 꺼낸다.
}

이 경우 위임 프로퍼티를 사용하면 아주 쉽게 사용할 수 있다.

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

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

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

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

객체 프로퍼티를 저장하거나 변경하는 방법을 바꿀 수 있으면 프레임워크를 개발할 때 유용하다.

데이터베이스에 User라는 테이블이 있고 그 테이블에는 name이라는 문자열 타입의 컬럼과 age라는 정수 타입의 열이 있다고 하자.

User와 Users라는 클래스를 코틀린에서 정의한다. 그리고 데이터베이스에 들어있는 모든 사용자 Entity를 User 클래스를 통해 가져오고 저장할 수 있다.

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

class User(id: EntityID) : Entity(id) {
    var name: String by Users.name
    var age: Int by Users.age
}

Users 객체는 데이터베이스 테이블을 표현한다.

데이터베이스 전체에 단 하나만 존재해야는 테이블을 표현해야하므로 Users를 싱글턴으로 선언했다.

객체의 프로퍼티는 테이블 컬럼을 표현한다.

User의 상위 클래스인 Entity 클래스는 데이터베이스 컬럼을 엔터티의 속성 값으로 연결해주는 매핑이 있다.

Reference

Kotlin In Action (드미트리 제메로프, 스베트라나 이사코바)



© 2022. by minkuk

Powered by minkuk