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

akim·2023년 1월 5일
1

Kotlin in Action

목록 보기
8/12
post-thumbnail

6장까지는 코틀린을 코틀린답게 쓰는 방법을 알아봤다. 코틀린의 특성을 알고, 코드를 이해하고, 활용할 수 있도록 말이다. 6장까지를 잘 마쳤다면 코틀린을 기존 라이브러리나 API와 함께 사용할 수 있게 되었을 것이다.

7장부터는 지금까지 익힌 코틀린을 활용해 API를 선언하고 추상화를 정의하는 방법을 배우며, 코틀린 언어의 더 깊은 특성을 다뤄본다.


7장에서는 컨벤션에 대해 배운다. 번역본 책에서는 주로 관례로 표현하지만 우리가 흔히 아는 그 컨벤션을 말하는 것이 맞다. 코틀린은 연산자 오버로딩과 프로퍼티 위임 등의 기법에 관례를 사용한다.

언어 기능을 타입에 의존하는 자바와 달리 코틀린은 함수 이름을 통한 관례에 의존한다. 코틀린에서 이런 관례를 채택한 이유는 기존 자바 클래스를 코틀린 언어에 적용하기 위함이다.

기존 자바 클래스가 구현하는 인터페이스는 이미 고정돼 있고 코틀린 쪽에서 자바 클래스가 새로운 인터페이스를 구현하게 만들 수는 없다. 반면 확장 함수를 사용하면 기존 클래스에 새로운 메서드를 추가할 수 있다.

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


산술 연산자 오버로딩

코틀린에서 관례를 사용하는 가장 단순한 예는 산술 연산자다.

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

그러나 다른 클래스에서도 산술 연산자가 유용한 경우가 있다. 예를 들어 BigInteger 클래스를 다룬다면 add 메서드를 명시적으로 호출하기 보다는 + 연산을 사용하는 편이 더 낫다.

코틀린에서는 그런 일이 가능하다 !

지금부터 어떻게 클래스에 대한 일반 산술 연산자를 정의할 수 있는지 살펴보자.

1. 이항 산술 연산 오버로딩

연산자를 오버로딩하는 함수 앞에는 꼭 operator가 있어야 한다.
이 키워드를 붙임으로써 어떤 함수가 관례를 따르는 함수임을 명확히 할 수 있다.

data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point { // "plus" 라는 이름의 연산자 함수를 정의한다.
        return Point(x + other.x, y + other.y) // 좌표를 성분별로 더한 새로운 점을 반환한다.
    }
}

위 코드를 보면 plus 함수 앞에 operator 키워드가 붙어있는 것을 확인할 수 있다.

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

만약 operator 가 없는데 실수로 관례에서 사용하는 함수 이름을 쓰고 우연히 그 이름에 해당하는 기능을 사용한다면 operator modifier is required ... 오류를 통해 이름이 겹쳤다는 사실을 알고 문제를 해결할 수 있다.

또한 연산자를 멤버 함수로 만드는 대신 아래와 같이 확장 함수로 정의할 수도 있다.

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

1-2. 코틀린의 산술 연산자 오버로딩 규칙

  • 프로그래머가 직접 연산자를 만들어 사용할 수는 없으며, 언어에서 미리 정해둔 연산자만 오버로딩할 수 있다.
  • 관례에 따르기 위해 클래스에서 정의해야 하는 이름이 연산자별로 정해져 있다.
  • 직접 정의한 함수를 통해 구현하더라도 연산자 우선순위는 언제나 표준 숫자 타입에 대한 연산자 우선순위와 같다.
  • 연산자를 정의할 때 두 피연산자(연산자 함수의 두 파라미터)가 같은 타입일 필요는 없다.
  • 코틀린 연산자가 자동으로 교환법칙을 지원하지는 않는다.
  • 연산자 함수의 반환 타입이 꼭 두 피연산자 중 하나와 일치해야만 하는 것은 아니다.
  • 일반 함수와 마찬가지로 operator 함수도 오버로딩할 수 있다. (따라서 이름은 같지만 파라미터 타입이 서로 다른 연산자 함수를 여럿 만들 수 있다.)

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

함수 이름
a * btimes
a / bdiv
a % bmod
a + bplus
a - bminus

2. 복합 대입 연산자 오버로딩

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

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

point += Point(3, 4)point = point + Point(3, 4) 라고 쓴 식과 같다. 너무 당연한 얘기를

물론 변수가 변경 가능한 경우에만 복합 대입 연산자를 사용할 수 있다.

경우에 따라 += 연산이 객체에 대한 참조를 다른 참조로 바꾸기보다 원래 객체의 내부 상태를 변경하게 만들고 싶을 때가 있다. 변경 가능한 컬렉션에 원소를 추가하는 경우가 대표적인 예다.

반환 타입이 UnitplusAssign 함수를 정의하면 코틀린은 += 연산자에 그 함수를 사용한다. 다른 복합 대입 연산자 함수도 비슷하게 minusAssign , timesAssign 등의 이름을 사용한다.

코틀린 표준 라이브러리는 변경 가능한 컬렉션에 대해 plusAssign 을 정의한다.

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

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

어떤 클래스가 이 두 함수를 모두 정의하고 둘 다 += 에 사용 가능한 경우 컴파일러는 오류를 보고한다. 일반 연산자를 사용하면 이를 해결할 수 있다.

다른 방법으로 varval 로 바꿔서 plusAssign 적용이 불가능하게 할 수도 있다. 하지만 일반적으로 새로운 클래스를 일관성 있게 설계하는 게 가장 좋다.

plusplusAssign 연산을 동시에 정의하지 말라.

클래스가 앞에서 본 Point 처럼 변경 불가능하다면 plus 와 같이 새로운 값을 반환하는 연산만을 추가해야 한다.

빌더와 같이 변경 가능한 클래스를 설계한다면 plusAssign 이나 그와 비슷한 연산만을 제공하라.

코틀린 표준 라이브러리는 컬렉션에 대해 두 가지 접근 방법을 함께 제공한다.

  • +- 는 항상 새로운 컬렉션을 반환한다.
  • +=-= 는 항상 변경 가능한 컬렉션에 적용해 메모리에 있는 객체 상태를 변화시킨다.
  • 읽기 전용 컬렉션에서 +=-= 는 변경을 적용한 복사본을 반환한다. (따라서 var 로 선언한 변수가 가리키는 읽기 전용 컬렉션에만 +=-= 를 적용할 수 있다.)

3. 단항 연산자 오버로딩

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

미리 정해진 이름의 함수를 (멤버나 확장 함수로) 선언하면서 operator 로 표시하면 된다.

operator fun Point.unaryMinus(): Point { // 단항 minus 함수는 파라미터가 없다.
    return Point(-x, -y) // 좌표에서 각 성분의 음수를 취한 새 점을 반환한다.
}

단항 연산자를 오버로딩하기 위해 사용하는 함수는 인자를 취하지 않는다.

오버로딩 가능한 단항 연산자

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

비교 연산자 오버로딩

코틀린에서는 산술 연산자와 마찬가지로 원시 타입 값뿐 아니라 모든 객체에 대해 비교 연산을 수행할 수 있다. equlscompareTo 를 호출해야 하는 자바와 달리 코틀린에서는 == 비교 연산자를 사용할 수 있어서 비교 코드가 더 간결하며 이해하기 쉽다.

지금부터는 이렇게 비교 연산자를 지원하는 관례를 살펴보자.

1. 동등성 연산자: equals

4장에서 동등성에 대해 다루며 코틀린이 == 연산자 호출을 equals 메서드 호출로 컴파일한다는 사실을 배웠다. 사실 이는 특별한 경우가 아니고 지금껏 설명한 여러 관례를 적용한 것에 불과하다.

!= 연산자를 사용하는 식도 equals 호출로 컴파일된다. 물론 당연히 비교 결과를 뒤집은 값을 결과 값으로 사용한다.

==!= 는 내부에서 인자가 널인지 검사하므로 다른 연산과 달리 널이 될 수 있는 값에도 적용할 수 있다.

앞서 계속 살펴봤던 Point 클래스를 다시 생각해보자.
이 클래스의 경우 data 라는 표시가 붙어있으므로 컴파일러가 자동으로 equals 를 생성해준다. 하지만 직접 equals 를 구현한다면 아래와 비슷해진다.

class Point(val x: Int, val y: Int) {
    override fun equals(obj: Any?): Boolean { // Any에 정의된 메서드를 오버라이딩한다.
        if (obj === this) return true // 최적화: 파라미터가 "this" 와 같은 객체인지 살펴본다.
        if (obj !is Point) return false // 파라미터 타입을 검사한다.
        return obj.x == x && obj.y == y // Point로 스마트 캐스트해서 x와 y 프로퍼티에 접근한다.
    }
}

위 코드에서는 식별자 비교 연산자 === 를 사용해 equals 의 파라미터가 수신 객체아 같은지 살펴본다.

식별자 비교 연산자는 자바의 == 연산자와 같다. 따라서 === 는 자신의 두 피연산자가 서로 같은 객체를 가리키는지 (원시 타입인 경우 두 값이 같은지) 비교한다.

equals 를 구현할 때는 === 를 사용해 자기 자신과의 비교를 최적화하는 경우가 많다.

1-2. 코틀린의 동등성 연산자 오버로딩 규칙

  • === 를 오버로딩할 수 없다.
  • 다른 연산자 오버로딩 관례와 달리 equalsAny 에 정의된 메서드이므로 override 가 필요하다.
  • Anyequals 에는 operator 가 붙어있지만 그 메서드를 오버라이드하는 (하위 클래스) 메서드 앞에는 operator 변경자를 붙이지 않아도 자동으로 상위 클래스 operator 지정이 적용된다.
  • Any 에서 상속받은 equals 가 확장 함수보다 우선 순위가 높기 때문에 equals 를 확장 함수로 정의할 수는 없다는 사실에 유의하라.

2. 순서 연산자: compareTo

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

코틀린도 똑같은 Comparable 인터페이스를 지원한다. 개다가 코틀린은 이 인터페이스 안에 있는 compareTo 메서드를 호출하는 관례를 제공한다.

따라서 비교 연산자(< , >, <=, >=)는 compareTo 호출로 컴파일된다.
또한 compareTo 가 반환하는 값은 Int 다.

Person 클래스를 통해 compareTo 메서드를 구현하는 방법을 살펴보자.

앞서 본 equals 와 마찬가지로 compareTo 에도 operator 변경자가 붙어있으므로 하위 클래스의 오버라이딩 함수에 operator를 붙일 필요가 없다.

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이 아닌 값이 처음 나올 때까지 인자로 받은 함수를 차례로 호출해 두 값을 비교하며 모든 함수가 0을 반환하면 최종적으로 0을 반환한다.

이때, 필드를 직접 비교하면 코드는 좀 더 복잡해지지만 비교 속도는 훨씬 더 빨라진다는 사실을 기억해야 한다.


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

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

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

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

앞서 계속 다뤘던 Point 클래스에 이런 메서드를 추가해보자.

operator fun Point.get(index: Int): Int { // "get" 연산자 함수를 정의한다.
    return when(index) { // 주어진 인덱스에 해당하는 좌표를 찾아 반환한다.
    	0 -> x
    	1 -> y
    	else ->
    		throw IndexOutOfBoundsException("Invalid coordinate $index")
}

위 코드와 같이 get 이라는 메서드를 만들고 operator 변경자를 붙이기만 하면 된다. 이후 p[1] 이라는 식은 pPoint 타입인 경우 위에서 정의한 get 메서드로 변환된다.

인덱스에 해당하는 컬렉션 원소를 쓰고 싶을 때는 set 이라는 이름의 함수를 정의하면 된다. 그러나 Point 클래스는 불변 클래스이므로 set 이 의미가 없다. 대신 변경 가능한 점을 표현하는 다른 클래스를 만들어서 예제로 사용해보자.

data class MutablePoint(var x: Int, var y: Int)
operator fun MutablePoint.set(index: Int, value: Int) { // "set" 이라는 연산자 함수를 정의한다.
    when(index) { // 주어진 인덱스에 해당하는 좌표를 변경하여 반환한다.
    	0 -> x = value
        1 -> y = value
        else ->
			throw IndexOutOfBoundsException("Invalid coordinate $index")
}

set 이 받는 마지막 파라미터 값은 대입문의 우항에 들어가고, 나머지 파라미터 값은 인덱스 연산자 [ ] 에 들어간다.


2. in 관례

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

어떤 점이 사각형 영역에 들어가는지 판단할 때 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 && // 범위를 만들고 "x" 좌표가 그 범위 안에 있는지 검사한다.
           p.y in upperLeft.y until lowerRight.y // "untli" 함수를 사용해 열린 범위를 만든다.
}

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

열린 범위 는 끝 값을 포함하지 않는 범위를 말하며, until 을 이용해 구현한다.

  • 10..20 이라는 식을 사용해 일반적인(닫힌) 범위를 만들면 10 이상 20 이하인 범위가 생긴다.
  • 10 until 20 으로 만드는 열린 범위는 10 이상 19 이하인 범위로, 20은 포함되지 않는다.

3. rangeTo 관례

일반적으로 범위를 만들려면 .. 구문을 사용해야 한다. .. 연산자는 rangeTo 함수를 간략하게 표현하는 방법이다.rangeTo 함수는 범위를 반환하며, 이 연산자를 아무 클래스에서나 정의할 수 있다.

하지만 어떤 클래스가 Comparable 인터페이스를 구현하면 rangeTo 정의할 필요가 없다. 코틀린 표준 라이브러리에는 모든 Comparable 객체에 대해 적용 가능한 rangeTo 함수가 들어있다.

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

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

유의할 점

  • rangeTo 연산자는 다른 산술 연산자보다 우선순위가 낮긴 하나 혼동을 피하기 위해 괄호로 인자를 감싸주면 더 좋다.

  • 0..n.forEach { } 와 같은 식은 컴파일할 수 없다. 앞서 말했듯 범위 연산자는 우선 순위가 낮아서 범위의 메서드를 호출하려면 (0..n).forEach { } 와 같이 범위를 괄호로 둘러싸야 한다.


4. for 루프를 위한 iterator 관례

2장에서 다뤘듯이 코틀린의 for 루프는 범위 검사와 똑같이 in 연산자를 사용한다. 하지만 이때의 in 의 의미는 다르다.

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

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

코틀린 표준 라이브러리는 String 의 상위 클래스인 CharSequence 에 대한 iterator 확장 함수를 제공한다.


예를 들어 날짜에 대해 이터레이션하는 다음 코드를 살펴보자.
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)
            }
        }

여기서 범위 타입에 대한 iterator 메서드를 어떻게 정의하는지 살펴보자.

앞서 살펴본 rangeTo 라이브러리 함수는 ClosedRange 의 인스턴스를 반환한다.
위 코드에서 CloseRange<LocalDate> 에 대한 확장 함수 iterator 를 정의했기 때문에 LocalDate 의 범위 객체를 for 루프에 사용할 수 있다.


구조 분해 선언과 component 함수

4장에서 데이터 클래스에 대해 설명할 때 데이터 클래스의 특성 중 몇 가지를 나중에 설명한다고 했다. 이제 코틀린의 관례 원리를 이해했으므로 관례를 사용한 마지막 특성인 구조 분해 선언 에 대해 알아보자.

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

fun main(args: Array<String>) {
    val p = Point(10, 20)
    val (x, y) = p // x와 y를 선언한 다음에 p의 여러 컴포넌트로 초기화한다.
    println(x)
    println(y)
}

구조 분해 선언은 일반 변수 선언과 비슷해 보인다. 다만 = 의 좌변에 여러 변수를 괄호로 묶었다는 점이 다르다.

내부에서 구조 분해 선언은 다시 관례를 사용한다.

구조 분해 선언의 각 변수를 초기화하기 위해 componentsN 이라는 함수를 호출한다. 여기서 N 은 구조 분해 선언에 있는 변수 위치에 따라 붙는 번호다. data 클래스의 주 생성자에 들어있는 프로퍼티에 대해서는 컴파일러가 자동으로 componentsN 함수를 만들어준다.

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

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

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

물론 무한히 componentN 을 선언할 수는 없으므로 이런 구문을 무한정 사용할 수는 없다. 그럼에도 여전히 컬렉션에 대한 구조 분해는 유용하다.

코틀린 표준 라이브러리에서는 맨 앞의 다섯 원소에 대한 componentN 을 제공한다.

표준 라이브러리의 PairTriple 클래스를 사용하면 함수에서 여러 값을 더 간단하게 반환할 수 있다.

이 둘은 그 안에 담겨있는 원소의 의미를 말해주지는 않으므로 경우에 따라 가독성이 떨어질 수는 있지만, 직접 클래스를 작성할 필요가 없으므로 코드는 더 단순해진다.

1. 구조 분해 선언과 루프

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

특히 맵의 원소에 대해 이터레이션할 때 구조 분해 선언이 유용하다.

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

위의 예제는 두 가지 코틀린 관례를 활용한다. 하나는 객체를 이터레이션하는 관례고, 다른 하나는 구조 분해 선언이다.

코틀린 표준 라이브러리에는 맵에 대한 확장 함수로 iterator 가 들어있다. 그 iterator 는 맵 원소에 대한 이터레이터를 반환한다. 따라서 자바와 달리 코틀린에서는 맵을 직접 이터레이션할 수 있다.

또한 코틀린 라이브러리는 Map.Entry 에 대한 확장 함수로 component1component2 를 제공한다.


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

7장에서 마지막으로 다룰 내용은 코틀린이 제공하는 관례에 의존하는 특성 중에 독특하면서도 강력한 기능인 위임 프로퍼티 다.

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

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

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

4절에서 클래스 위임에 대해 다룰 때 이 패턴을 이미 살펴본 바 있다.

이 절에서는 그 패턴을 프로퍼티에 적용해서 접근자 기능을 도우미 객체가 수행하게 위임한다. 도우미 객체를 직접 작성할 수도 있지만 더 나은 방법은 코틀린 언어가 제공하는 기능을 활용하는 것이다.

1. 위임 프로퍼티 소개

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

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

이때 p 프로퍼티는 접근자 로직을 다른 객체에게 위임한다. 여기서는 Delegate 클래스의 인스턴스를 위임 객체로 사용한다. by 뒤에 있는 식을 계산해서 위임에 쓰일 객체를 얻는다. 프로퍼티 위임 객체가 따라야하는 관례를 따르는 모든 객체를 위임에 사용할 수 있다.

class Foo {
	private val delegate = Delegate () // 컴파일러가 생성한 도우미 프로퍼티다.
	var p: Type // "p" 프로퍼티를 위해 컴파일러가 생성한 접근자는 "delegate"의 getValue와 setValue 메서드를 호출한다.
	set (value: Type) = delegate. setValue (..., value)
	get () = delegate.getValue (...)
}

위와 같이 컴파일러는 숨겨진 도우미 프로퍼티를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다. p 프로퍼티는 바로 그 위임 객체에게 자신의 작업을 위임한다.

설명을 편하게 하기 위해 이 감춰진 프로퍼티 이름을 delegate 라고 하자.

프로퍼티 위임 관례를 따르는 Delegate 클래스는 getValuesetValue (변경 가능한 프로퍼티의 경우) 메서드를 제공해야 한다. 관례를 사용하는 다른 경우와 마찬가지로 getValuesetValue 는 멤버 메서드이거나 확장 함수일 수 있다. 두 메서드의 파라미터를 생략하고 Delegate 클래스를 단순화하면 아래와 같다.

class Delegate {
	operator fun getValue (...) { ... } // gelValue는 게터를 구현하는 로직을 담는다.
	operator fun setValue (..., value: Type) { ... } // selValue 메서드는 세터를 구현하는 로직을 담는다.
}

class Foo {
	var p: Type by Delegate () // "by" 키워드는 프로퍼티와 위임 객체를 연결한다.
}

foo.p 는 일반 프로퍼티처럼 쓸 수 있고, 일반 프로퍼티 같아 보인다. 하지만 실제로 p 의 게터나 세터는 Delegate 타입의 위임 프로퍼티 객체에 있는 메서드를 호출한다.


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

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

초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때 마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 지연 초기화 패턴을 사용할 수 있다.

class Person (val name: String) {
	private var emails: List <Email>? = null
	val emails: List‹Email>
		get () {
			if (_emails = null) {
				_emails = loadEmails (this)
            }
			return _emails!! // 저장해 둔 데이터가 있으면 그 데이터를 반환한다.
       }
}

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

여기서는 뒷받침하는 프로퍼티 라는 기법을 사용한다.

_emails 라는 프로퍼티는 값을 저장하고, 다른 프로퍼티인 emails_emails 라는 프로퍼티 값에 대한 읽기 연산을 제공한다. _emails 는 널이 될 수 있는 타입인 반면 emails 는 널이 될 수 없는 타입이므로 프로퍼티를 두 개 사용해야 한다.

하지만 이런 코드를 만드는 일은 약간 성가시다.

지연 초기화해야하는 프로퍼티가 많아지면 코드가 어떻게 될까? 게다가 위 구현은 스레드 안전하지 않아서 언제나 제대로 자동한다고 말할 수도 없다. 코틀린은 이보다 더 나은 해법을 제공한다.

위임 프로퍼티를 사용하면 위 코드가 훨씬 더 간단해진다. 위임 프로퍼티는 데이터를 저장할 때 쓰이는 뒷받침하는 프로퍼티와 값이 오직 한 번만 초기화됨을 보장하는 게터 로직을 함께 캡슐화준다. 예제와 같은 경우를 위한 위임 객체를 반환하는 표준 라이브러리 함수가 바로 lazy 다.

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

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

lazy 함수의 인자는 값을 초기화할 때 호출할 람다다. lazy 함수는 기본적으로 스레드 안전하다. 하지만 필요에 따라 동기화에 사용할 락을 lazy 함수에 전달할 수도 있고, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy 함수가 동기화를 하지 못하게 막을 수도 있다.


3. 위임 프로퍼티 구현

어떤 객체의 프로퍼티가 바뀔 때마다 리스너에게 변경 통지를 보내고 싶다. 이런 기능이 유용할 때가 많다. 예를 들어 어떤 객체를 UI에 표시하는 경우 객체가 바뀌면 자동으로 UI도 바뀌어야 한다. 이 경우 코틀린에서는 위임 프로퍼티를 이용해 기능을 구현할 수 있다.

4. 위임 프로퍼티 컴파일 규칙

이번에는 위임 프로퍼티가 어떤 방식으로 동작하는지 정리해보자.

아래와 같은 위임 프로퍼티가 있는 클래스가 있다고 가정하자.

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) = <delegates>.setValue(this, <property>, value)
}

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

5. 프로퍼티 값을 맵에 저장

자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때 위임 프로퍼티를 활용하는 경우가 자주 있다. 그런 객체를 확장 가능한 객체 라고 부르기도 한다.

예를 들어 연락처 관리 시스템에서 연락처별로 임의의 정보를 저장할 수 있게 허용하는 경우를 살펴보자. 시스템에 저장된 연락처에는 특별히 처리해야 하는 일부 필수 정보 (이름 등)가 있고, 사람마다 달라질 수 있는 추가 정보가 있다.
그린 시스템을 구현하는 방법 중에는 정보를 모두 맵에 저장하되 그 맵을 통해 처리 하는 프로퍼티를 통해 필수 정보를 제공하는 방법이 있다.

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

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

profile
학교 다니는 개발자

0개의 댓글