본문 바로가기

코틀린

[코틀린] DSL (도메인 특화 언어)

 

안드로이드 개발하는 kancho입니다.

이번 포스팅에서는 코틀린의 DSL(도메인 특화 언어)에 대해 알아보고자 합니다.

'코틀린 완벽 가이드' 책을 참고하였습니다.

 

 

DSL (도메인 특화 언어)

특정 기능이나 영역을 위해 만들어진 언어를 말하며 코틀린으로 도메인 특화 언어와 비슷한 특별한 API를 설계하는 것이다.

단순성이 주된 장점이다.

 

연산자 오버로딩

코틀린 내장 연산자(+, -, *, / 등등)에 대해 새로운 의미를 부여할 수 있는 언어 차원의 기능이다.

연산자를 구현할 때 관습에 따라 operator 키워드와 함께 확장 함수나 멤버 함수를 정의하면 된다.

 

// times라는 이름의 관습을 통해 정의
operator fun String.times(n: Int) = repeat(n)

// 2개의 식은 동일하다
fun main() {
    println("abc" * 3)	// abcabcabc
    println("abc".times(3))
    
    // 원시타입의 경우 컴파일러가 연산을 함수로 호출하지 않고 최적화해서 계산
    val x = 1.plus(2)   // --> 1 + 2
}

 

 

코틀린 연산자들에 대한 관습과 함수 구현에 대해 알아보자.

 

 

1. 단항 연산

오버로딩할 수 있는 +, -, ! 단항 연산자가 있다.

 

+ 는 unaryPlus()
- 는 unaryMinus()
! 는 not()

 

을 사용한다.

 

enum class Color {
    BLACK, RED, GREEN, CYAN, MAGENTA, WHITE;

    // not() 관습
    operator fun not() = when (this) {
        BLACK -> WHITE
        RED -> CYAN
        GREEN -> MAGENTA
        CYAN -> RED
        MAGENTA -> GREEN
        WHITE -> BLACK
    }
}

// 연산자 함수를 확장 함수로 정의
operator fun <T> ((T) -> Boolean).not(): (T) -> Boolean = { !this(it) }

fun isShort(s: String) = s.length <= 4

// not()을 ! 연사자로 표현
fun main() {
    println(!Color.RED)     // CYAN
    println(!Color.CYAN)    // RED
    
    val data = listOf("abc", "abcde", "ABCDE", "aBcD", "ab")
    println(data.count(::isShort))  // 3
    println(data.count(!::isShort)) // 2
}

 

 

2. 증가와 감소

오버로딩할 수 있는 증가(++), 감소(--) 연산자가 있다.

 

++ 는 inc()
-- 는 dec()

 

을 사용한다.

 

이런 함수의 반환 타입은 항상 증가 이후, 이전의 값과 같은 타입이어야 한다.

 

enum class RainbowColor {
    RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET;

    operator fun inc() = values[(ordinal + 1) % values.size]
    operator fun dec() = values[(ordinal + values.size - 1) % values.size]

    companion object {
        private val values = enumValues<RainbowColor>()
    }
}

fun main() {
    // 가변 변수에만 적용 가능
    var indigo = RainbowColor.INDIGO
    // 후위 연산
    println(indigo++)   // INDIGO
    // 전위 연산
    println(++indigo)   // RED
}

 

 

3. 이항 연산

대부분의 이항 연산자를 오버로딩할 수 있다.

이항 연산자 함수의 대부분은 왼쪽 피연산자를 수신 객체로, 오른쪽 피연산자를 일반적인 인자로 받는다.

 

a + b 는 a.plus(b)
a - b 는 a.minus(b)
a * b 는 a.times(b)
a / b 는 a.div(b)
a % b 는 a.rem(b)
a .. b 는 a.rangeTo(b)
a in b 는 b.contains(a)
a !in b 는 !b.contains(a)

 

을 사용한다.

 

기본 산술 연산을 지원하는 간단한 유리수(Rational Number) 클래스

 

class Rational private constructor(
    val sign: Int,
    val num: Int,
    val den: Int
) {
    operator fun unaryMinus() = Rational(-sign, num, den)
    operator fun plus(r: Rational): Rational {
        val gcd = gcd(den, r.den)
        val newDen = den / gcd * r.den
        val newNum = newDen / den * num * sign + newDen / r.den * r.num * r.sign
        val newSign = newNum.sign()

        return Rational(newSign, abs(newNum), newDen)
    }

    operator fun minus(r: Rational) = this + (-r)
    operator fun times(r: Rational): Rational =
        of(sign * r.sign * num * r.num, den * r.den)
    operator fun div(r: Rational): Rational =
        of(sign * r.sign * num * r.den, den * r.num)

    override fun toString(): String =
        "${sign * num}" + if (den != 1) "/$den" else ""

    companion object {
        private fun Int.sign() = when {
            this > 0 -> 1
            this < 0 -> -1
            else -> 0
        }

        private tailrec fun gcd(a: Int, b: Int): Int {
            return if (b == 0) a else gcd(b, a % b)
        }

        fun of(num: Int, den: Int = 1): Rational {
            if (den == 0) throw ArithmeticException("Denominator is zero")

            val sign = num.sign() * den.sign()
            val numAbs = abs(num)
            val denAbs = abs(den)
            val gcd = gcd(numAbs, denAbs)

            return Rational(sign, numAbs/gcd, denAbs/gcd)
        }
    }
}

 

 

연산자 관습을 사용해 Rational 인스턴스의 산술 연산을 편하게 사용한다.

 

fun r(num: Int, den: Int = 1) = Rational.of(num, den)

operator fun Rational.plus(n: Int) = this + Rational.of(n)
operator fun Int.minus(r: Rational) = Rational.of(this) - r

fun main() {
    // 1/2 - 1/3 --> 1/6
    println(r(1, 2) - r(1, 3))
    // 3/4 * 8/9 / (2/3) --> 1
    println(r(3, 4) * r(8, 9) / r(2, 3))
    
    // -1/3 + 2 --> 5/3
    println(r(-1, 3) + 2)
    // 1 - (1/4) * (1, 2)) --> 7/8
    println(1 - r(1, 4) * r(1, 2))
}

 

 

.. 연산자 사용

// 두 유리수 사이의 구간을 나타내는 클래스
class RationalRange(val from: Rational, val to: Rational) {
    override fun toString() = "[$from, $to]"
}

// rangeTo() 함수
operator fun Rational.rangeTo(r: Rational) = RationalRange(this, r)

fun main() {
    println(r(1, 4)..r(1))  // [1/4, 1]
}

 

in / !in 연산

contains() 연산자 함수

 

private fun Rational.isLessOrEqual(r: Rational) =
    sign * num * r.den <= r.sign * r.num * den

class RationalRange(val from: Rational, val to: Rational) {
    override fun toString() = "[$from, $to]"

    // 다른 이항 연산과 인자 방향이 반대
    operator fun contains(r: Rational): Boolean =
        from.isLessOrEqual(r) && r.isLessOrEqual(to)
    
    operator fun contains(n: Int) = contains(r(n))
}

operator fun Rational.rangeTo(r: Rational) = RationalRange(this, r)

fun main() {
    // 1/2 in [1/4, 1]
    println(r(1, 2) in r(1, 4)..r(1))   // true
    // 1 not in [5/4, 7/4]
    println(1 !in r(5, 4)..r(7, 4))     // true
}

 

 

비교 연산자

 

서로 다른 함수에 대응하지 않는다. 대신 compareTo() 함수를 사용해 피연산자 타입에 대한 모든 비교 연산을 구현한다.

compareTo() 함수는 비교 결과를 알려주는 Int 값을 반환한다.

 

a < b 는 a.compareTo(b) < 0
a <= b 는 a.compareTo(b) <= 0
a > b 는 a.compareTo(b) > 0
a >= b a.compareTo(b) >= 0

 

isLessThan 함수를 지우고 일반적인 compareTo 구현으로 변경한 코드이다.

 

operator fun Rational.compareTo(r: Rational): Int {
    val left = sign * num * r.den
    val right = r.sign * r.num * den
    
    return when {
        left < right -> -1
        left > right -> 1
        else -> 0
    }
}

operator fun Rational.compareTo(n: Int) = compareTo(r(n))

operator fun Int.compareTo(r: Rational) = -r.compareTo(this)

class RationalRange(val from: Rational, val to: Rational) {
    override fun toString() = "[$from, $to]"

    operator fun contains(r: Rational) = 
        r >= from && r <= to
    
    operator fun contains(n: Int) = contains(r(n))
}

fun main() {
    println(-1 > r(1, 3))           // false
    println(r(3 / 4) <= r(7  / 8))  // true
}

 

동등성 관련 연산

== , != 를 사용하면 컴파일러는 equals() 함수를 호출해준다.

equals() 구현은 Any 클래스에 정의된 기반 구현을 상속하기 때문에 명시적인 operator 변경자를 붙이지 않아도 된다.

 

코틀린에서는 && 와 || 를 오버로딩할 수 없다. Boolean 값에 대해서만 지원되는 내장 연산이다.

참조 동등성 연산자인 === 나 !== 도 마찬가지이다.

 

 

4. 중위 연산

함수 앞에 infix 변경자를 붙여야 한다. 파라미터가 하나인 멤버나 확장 함수여야 한다.

 

val pair1 = 1 to 2      // 중위 호출
val pair2 = 1.to(2)     // 일반적인 호출

infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

// 논리곱을 표현하는 중위 연산
infix fun <T> ((T) -> Boolean).and(
    other: (T) -> Boolean
): (T) -> Boolean = { this(it) && other(it) }

// 논리합을 표현하는 중위 연산
infix fun <T> ((T) -> Boolean).or(
    other: (T) -> Boolean
): (T) -> Boolean = { this(it) || other(it) }


fun isShort(s: String) = s.length <= 4
fun String.isUpperCase() = all { it.isUpperCase() }

fun main() {
    val data = listOf("abc", "abcde", "ABCDE", "aBcD", "ab")

    println(data.count(::isShort and String::isUpperCase))  // 0
    println(data.count(::isShort or String::isUpperCase))   // 4
}

 

모든 중위 연산의 우선순위는 같다. 

 

!::isShort or String::isEmpty and String::isUpperCase는
(!::isShort or String::isEmpty) and String::isUpperCase와 같다

 

 

5. 대입

+= 와 같은 복합 대입 연산. 가변 컬렉션과 불변 컬렉션에 따라 연산의 동작이 달라진다.

 

fun main() {
    // mutable 변수에 immutable collection. 새로운 객체 생성
    var nums = listOf(1, 2, 3)	// '+=' creates new list under the hood
    nums += 4
    println(nums)    // [1, 2, 3, 4]

    // immutable 변수에 mutable collection. 객체 그대로
    val mutableNums = mutableListOf(1, 2, 3)
    mutableNums += 4
    println(mutableNums)    // [1, 2, 3, 4]
}

 

위와 같은 복합 대입 연산자(augmented assignment operator)에 대해 2가지 관습을 적용할 수 있다.

 

1. 커스텀 복합 대입 함수가 있으면 그 함수를 사용한다. (+= 의 경우 plusAssign() 이 있는지, -= 의 경우 minusAssign() 이 있는지 등)

 

2. plusAssign() 등의 복합 대입 연산자 함수가 없는 경우 복합 대입문을 이행 연산자와 대입을 사용한 연산으로 해석한다. a += b의 경우 plus()가 있으면 a = a.plus(b)로, a-= 의 경우 minus()가 있으면 a = a.minus(b)로 해석한다.

 

3. 복합 대입 연산자의 왼쪽 피연산자가 불변인 경우 변수에 새 값을 대입할 수 없으므로 a = a.plus(b)와 같이 일반 대입문과 이항 연산을 활용한 방식으로 대입문을 해석할 수 없다.

 

a += b 는 a = a.plus(b) 이며 a.plusAssign(b) 이다
a -= b 는 a = a.minus(b) 이며 a.minusAssign(b) 이다
a *= b 는 a = a.times(b) 이며 a.timesAssign(b) 이다
a /= b 는 a = a.div(b) 이며 a.divAssign(b) 이다
a %=b 는 a = a.rem(b) 이며 a.remAssign(b) 이다

 

예를 들어, 연산자(plus())가 있지만 커스텀 대입 연산자 함수는 없는 경우 복합 대입 연산은 단순한 연산식으로 변환된다.

위에서 만든 Rational 객체는 +, - 를 지원하므로 복합 대입문을 사용할 수 있다.

 

fun main() {
    // plusAssign() 복합 대입 연산자 정의는 없어 mutable 변수여야 한다
    var r = r(1, 2) // 1/2
    // r = r + r(1, 3)
    r += r(1, 3)    // 1/2 + 1/3
    println(r)  // 5/6
}

 

 

 

대입 연산자의 왼쪽 피연산자에 대한 커스텀 대입 함수가 있는 경우에는 복합 대입문이 커스텀 대입 함수 호출로 바뀐다.

커스텀 대입 함수의 return 타입은 Unit 이어야 한다.

 

class TreeNode<T>(val data: T) {
    private val _child = arrayListOf<TreeNode<T>>()
    var parent: TreeNode<T>? = null
        private set

    // 복합 대입 연산자 함수
    operator fun plusAssign(data: T) {
        val node = TreeNode(data)
        _child += node
        node.parent = this
    }

    // 복합 대입 연산자 함수
    operator fun minusAssign(data: T) {
        val index = _child.indexOfFirst { it.data == data }
        if (index < 0) return
        val node = _child.removeAt(index)
        node.parent = null
    }

    // -= 동작을 확인하기 위한 이항 연산자 함수
    operator fun minus(data: T): TreeNode<T> = TODO("todo")

    override fun toString(): String =
        _child.joinToString(prefix = "$data {", postfix = "}")
}

fun main() {
    val tree = TreeNode("root")
    tree += "child 1"   // plus() 가 없어도 복합 대입 연산지 적용
    tree += "child 2"
    println(tree)   // root {child 1 {}, child 2 {}}

    tree -= "child 2"
    println(tree)   // root {child 1 {}}
}

 

코틀린 가변 컬렉션 클래스인 리스트나 집합도 모두 불변 컬렉션에서 상속받은 plus() / minus() 함수가 있고 자체적으로 plusAssign() / minusAssign() 도 정의하고 있다.

 

 

6. 호출과 인덱스로 원소 찾기

호출 관습을 사용하면 값을 함수처럼 호출 식에서 사용할 수 있다. invoke() 함수를 사용한다.

함수 타입의 값은 자동으로 invoke() 멤버가 생기지만 커스텀한 invoke()를 정의할 수 있다.

 

// key를 통해 value를 찾는다
operator fun <K, V> Map<K, V>.invoke(key: K) = get(key)

fun main() {
    val map = mapOf("I" to 1, "V" to 5, "X" to 10)
    println(map("V"))   // 5
}

 

또한 invoke 함수를 동반 객체에 넣어 팩토리로 만드는 방법도 있다.

 

// invoke() -> of() -> Rational의 비공개 생성자
operator fun Rational.Companion.invoke(num: Int, den: Int = 1) =
    of(num, den)

fun main() {
    val r = Rational(1, 2)
}

 

문자열, 배열, 리스트 등의 여러 객체에 대한 인덱스 연산자 []를 적용하는 것도 비슷한 관습이다.

 

// Array.kt의 내부 코드 중 get, set 함수 
public operator fun get(index: Int): T
public operator fun set(index: Int, value: T): Unit

fun main() {
    val array = arrayOf(1, 2, 3)
    println(array[0])   // array(get(0))
    array[0] = 10       // array.set(0, 10)
}

 

 

7. 구조 분해

연산자 오버로딩을 사용하면 임의의 타입에 대해 구조 분해를 제공할 수 있다.

파라미터가 없는 componentN()이라는 이름의 컴포넌트 함수를 멤버 함수나 확장 함수로 정의하는 것이다.

N은 1부터 시작하는 정수이다.

구조 분해 선언의 각 변수는 순서에 따라 componentN() 함수의 반환 값과 타입으로 정해진다.

 

operator fun RationalRange.component1() = from
operator fun RationalRange.component2() = to

fun main() {
    val (from, to) = r(1, 3)..r(1, 2)
    println(from)   // 1/3
    println(to)     // 1/2
}

 

데이터 클래스와 리스트, 배열에서도 적용된다.

 

data class A(
    val a1: Int,
    val a2: Int
)

fun main() {
    val (a1, a2) = A(1, 2)
    println("a : $a1, b : $a2")     // a : 1, b : 2

    val numbers = listOf(10, 20, 30, 40, 50)
    val (a, b, c) = numbers
    println("$a, $b, $c")           // 10, 20, 30
}

 

 

8. 이터레이션

for 루프를 적용할 수 있는 타입들은 iterator() 함수를 가지고 있다.

iterator() 함수는 Iterator 타입의 인스턴스를 반환한다.

원하는 타입에 대해 iterator() 함수를 멤버나 확장으로 정의하면 for 루프를 사용할 수 있다.

 

// TreeNode 클래스에 대한 iterator
operator fun <T> TreeNode<T>.iterator() = child.iterator()

fun main() {
    val content = TreeNode("Title").apply {
        addChild("Topic1").apply {
            addChild("Topic 1.1")
        }
        addChild("Topic2")
        addChild("Topic3")
    }
    for (item in content) {
        println(item.data)      // Topic1 Topic2 Topic3
    }
}

 

 

위임 프로퍼티

위임 프로퍼티를 사용하면 간단한 문법 뒤에 커스텀 프로퍼티 접근 로직을 구현할 수 있다.

프로퍼티 위임을 알아두면 사용하기 편한 API와 DSL을 설계할 때 도움이 된다.

위임 프로퍼티 구현도 몇 가지 관습에 따른다.

 

1. 표준 위임들

코틀린 표준 라이브러리에는 몇 가지 위임 구현이 있다.

 

// 지연 계산 프로퍼티
val text by lazy { File("data.txt").readText() }

 

lazy 함수는 멀티 스레드 환경에서 동작하기 위해 3가지 모드를 갖는다.

 

SYNCHRONIZED : 프로퍼티 접근을 동기화한다. 한 번에 한 스레드만 프로퍼티 값을 초기화할 수 있다 (디폴트)
PUBLICATION : 초기화 함수가 여러 번 호출될 수 있지만 가장 처음 도착하는 결과가 프로퍼티 값이 되도록 동기화한다
NONE : 프로퍼티 접근을 동기화하지 않는다. 다중 스레드 환경에서 프로퍼티의 동작을 보장할 수 없다

 

예시

// 메세지가 최대 한 번 출력
val myValue by lazy {
    println("init myValue")
    123
}

// 값은 그대로지만 여러 스레드가 값을 초기화하려고 시도하면서 메세지가 여러번 출력될 수 있다
val pubModeValue by lazy(LazyThreadSafetyMode.PUBLICATION) {
    println("init myValue")
    123
}

fun main() {
    println(myValue)    // init myValue 123
    println(myValue)    // 123

    // NONE 모드는 가장 빠즈고 초기화 코드가 한 스레드에서만 불린다고 확실될 때 유용하다
    val noneModeValue by lazy(LazyThreadSafetyMode.NONE) { 1 + 2 }
    println(noneModeValue)  // 3
}

 

초기화 함수가 예외를 던지면 프로퍼티가 초기화되지 않는다. 프로퍼티에 다시 접근하면 다시 초기화 함수가 호출된다.

 

Delegates 클래스의 멤버를 통해 몇 가지 표준 위임을 사용할 수 있다.

 

public object Delegates {
    // 프로퍼티 초기화를 미루면서 null이 아닌 프로퍼티를 정의할 수 있게 해준다
    public fun <T : Any> notNull(): ReadWriteProperty<Any?, T> = NotNullVar()
    // 프로퍼티 값이 변경될 때 통지를 받을 수 있다
    public inline fun <T> observable(initialValue: T, crossinline onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Unit):
            ReadWriteProperty<Any?, T> =
        object : ObservableProperty<T>(initialValue) {
            override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) = onChange(property, oldValue, newValue)
        }
    public inline fun <T> vetoable(initialValue: T, crossinline onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Boolean):
            ReadWriteProperty<Any?, T> =
        object : ObservableProperty<T>(initialValue) {
            override fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean = onChange(property, oldValue, newValue)
        }
}

 

notNull 함수

 

기본적으로 lateinit 프로퍼티와 같다. 

보통은 lateinit이 더 간결하고 성능이 좋아 lateinit을 사용한다.

primitive 타입은 예외이다. (lateinit 사용 불가)

 

var text: String by notNull()
fun readText() {
    text = readLine()!!
}

fun main() {
    readText()
    println(text)
    
    // text가 초기화되기 전에 사용을 하면 IllegalStateException 발생
    // IllegalStateException : Property text should be initialized before get.
    println(text)
    readText()
}

 

observable 함수

 

초기값과 람다를 인자로 받는다.

프로퍼티 값이 바뀔 때마다 람다가 호출된다.

 

class Person(name: String, val age: Int) {
    var name: String by observable(name) { _, old, new ->
        println("Name Changed : $old to $new")
    }
}

fun main() {
    val person = Person("Kancho", 30)
    person.name = "Harry"       // Name Changed : Kancho to Harry
    person.name = "Vincent"     // Name Changed : Harry to Vincent
    person.name = "Vincent"     // Name Changed : Vincent to Vincent
}

 

vetoable 함수

 

초기값과 Boolean을 반환하는 람다를 인자로 받는다.

프로퍼티 값을 변경하려고 시도할 때마다 값을 변경하기 직전에 람다가 호출된다.

반환 값이 true 이면 실제 값 변경이 이루어진다. false 이면 값이 그대로 남는다.

 

var password: String by vetoable("password") { _, old, new ->
    if (new.length < 8) {
        println("Password should be at least 8 characters long")
        false
    } else {
        println("OK")
        true
    }
}

fun main() {
    password = "passWord"   // OK
    password = "qwerty"     // Password should be at least 8 characters long
}

 

표준 라이브러리는 맵에 프로퍼티 값을 설정하고 읽어올 수 있는 위임 기능도 제공한다.

 

// 프로퍼티 값을 설정하고 읽어올 수 있는 위임기능이 가능한 클래스
class CartItem(data: Map<String, Any?>) {
    val title: String by data
    val price: Double by data
    val quantity: Int by data
}

fun main() {
    val item = CartItem(
        mapOf(
            "title" to "Laptop",
            "price" to 999.9,
            "quantity" to 1
        )
    )
    // 프로퍼티 이름을 key로 사용한다
    println(item.title)     // Laptop
    println(item.price)     // 999.9
    println(item.quantity)  // 1
}

 

 

2. 커스텀 위임

표준 위임만으로 충분하지 않을 때 커스텀 위임을 구현하면 된다.

커스텀 위임을 만들려면 특별한 연산자 함수나 함수를 정의하는 타입이 필요하다.

이 함수들은 프로퍼티 값을 읽고 쓰는 방법을 구현한다.

 

읽기 함수의 이름은 getValue 여야 하고 2가지 파라미터를 받는다.

 

1. receiver : 수신 객체 값이 들어있고 위임된 프로퍼티의 수신 객체와 같은 타입(or 상위)이어야 한다.

2. property : 프로퍼티 선언을 표현하는 리플렉션이 들어있다. KProperty <*> 이거나 상위 타입이어야 한다.

 

두 파라미터의 타입만 중요하다. getValue() 함수의 리턴 타입은 반드시 위임 프로퍼티의 타입과 같아야 한다.

 

프로퍼티 값과 수신 객체를 연관시켜 기억하는 캐시 역할을 하는 위임 클래스

 

class CachedProperty<in R, out T : Any>(val initializer: R.() -> T) {
    private val cachedValues = HashMap<R, T>()

    operator fun getValue(receiver: R, property: KProperty<*>): T =
        cachedValues.getOrPut(receiver) { receiver.initializer() }
}

fun <R, T : Any> cached(initializer: R.() -> T) = CachedProperty(initializer)

class Person(val firstName: String, val familyName: String)

val Person.fullName: String by cached {
    "$firstName $familyName"
}

fun main() {
    val johnDoe = Person("John", "Doe")
    val harrySmith = Person("Harry", "Smith")

    // 수신 객체에 최초 접근. 값을 계산 후 캐시에 담는다
    println(johnDoe.fullName)       // John Doe
    // 수신 객체에 최초 접근. 값을 계산 후 캐시에 담는다
    println(harrySmith.fullName)    // Harry Smith
    // 수신 객체에 재접근. 캐시에서 값을 읽는다
    println(johnDoe.fullName)       // John Doe
    // 수신 객체에 재접근. 캐시에서 값을 읽는다
    println(harrySmith.fullName)    // Harry Smith
}

 

읽기 전용 커스텀 위임을 정의하고 싶다면 ReadOnlyProperty 인터페이스를 사용하면 된다.

 

public fun interface ReadOnlyProperty<in T, out V> {
    /**
     * Returns the value of the property for the given object.
     * @param thisRef the object for which the value is requested.
     * @param property the metadata for the property.
     * @return the property value.
     */
    public operator fun getValue(thisRef: T, property: KProperty<*>): V
}

 

var 프로퍼티에 해당하는 읽고 쓸 수 있는 프로퍼티의 경우, 값을 저장할 때 setValue() 함수도 정의해야 한다.

리턴 타입은 Unit이어야 하며 3가지 파라미터를 받는다.

 

1. receiver : getValue()와 동일

2. property : getValue()와 동일

3. newValue : 프로퍼티에 저장할 새 값. 프로퍼티 자체와 같은 타입(or 상위)이어야 한다.

 

class FinalLateinitProperty<in R, T : Any> {
    private lateinit var value: T
    operator fun getValue(receiver: R, property: KProperty<*>): T = value
    operator fun setValue(
        receiver: R,
        property: KProperty<*>,
        newValue: T
    ) {
        // 초기화를 단 한 번만 허용한다
        if (this::value.isInitialized) throw IllegalStateException(
            "Property ${property.name} is already initialized"
        )
        value = newValue
    }
}

fun <R, T : Any> finalLateInit() = FinalLateinitProperty<R, T>()

var message: String by finalLateInit()

fun main() {
    message = "Hello"
    println(message)    // Hello
    message = "Bye"     // IllegalStateException : property message is already initialized
}

 

 

ReadOnlyProperty 외에도 값을 변경할 수 있는 ReadWriteProperty 인터페이스가 있다.

 

public interface ReadWriteProperty<in T, V> : ReadOnlyProperty<T, V> {
    public override operator fun getValue(thisRef: T, property: KProperty<*>): V
    public operator fun setValue(thisRef: T, property: KProperty<*>, value: V)
}

 

getValue 나 setValue 함수를 멤버 함수로 정의할 수도 있지만 확장 함수로 정의해도 된다.

 

 

3. 위임 표현

런타임에 위임이 어떻게 표현되고 이를 어떻게 접근하는지 살펴보자

런타임에 위임은 별도의 필드에 저장된다. 반면 프로퍼티 자체에 대해서는 접근자가 자동으로 생성된다.

이 접근자는 위임에 있는 적절한 메서드를 호출한다.

 

class Person(val firstName: String, val familyName: String) {
    var age: Int by finalLateInit()
}

 

위 Person 클래스는 아래와 같이 변환된다.

 

public final class Person {
   // $FF: synthetic field
   static final KProperty[] $$delegatedProperties = new KProperty[]{(KProperty)Reflection.mutableProperty1(new MutablePropertyReference1Impl(Person.class, "age", "getAge()I", 0))};
   @NotNull
   private final FinalLateinitProperty age$delegate;
   @NotNull
   private final String firstName;
   @NotNull
   private final String familyName;

   public final int getAge() {
      return ((Number)this.age$delegate.getValue(this, $$delegatedProperties[0])).intValue();
   }

   public final void setAge(int var1) {
      this.age$delegate.setValue(this, $$delegatedProperties[0], var1);
   }
   ...
   ...
}

 

리플렉션 API를 사용하면 getDelegate() 멤버를 통해 얻은 프로퍼티 객체를 이용해 위임에 접근할 수 있다.

위임 인스턴스가 저장된 비공개 필드에 접근하려면 isAccessible = true를 사용해야 한다.

 

class Person(val firstName: String, val familyName: String) {
    val fullName by lazy { "$firstName $familyName" }
}

fun main() {
    val person = Person("Kancho", "Park")

    // KProperty0 : 모든 수신 객체가 엮여 있다
    println(
        person::fullName
            .apply { isAccessible = true }
            .getDelegate()!!::class.qualifiedName
    )   // kotlin.SynchronizedLazyImpl

    // KProperty1 : 수신 객체가 엮여있지 않아 수신 객체를 따로 지정해야 한다
    println(
        Person::fullName
            .apply { isAccessible = true }
            .getDelegate(person)!!::class.qualifiedName
    )   // kotlin.SynchronizedLazyImpl
}

 

위 코드는 아래와 같이 변환된다.

 

public static final void main() {
   Person person = new Person("Kancho", "Park");
   DslKt$main$1 var1 = new DslKt$main$1(person);
   KProperty0 $this$apply = (KProperty0)var1;
   int var3 = false;
   KCallablesJvm.setAccessible((KCallable)$this$apply, true);
   Object var10000 = ((KProperty0)var1).getDelegate();
   Intrinsics.checkNotNull(var10000);
   String var4 = Reflection.getOrCreateKotlinClass(var10000.getClass()).getQualifiedName();
   System.out.println(var4);
   
   KProperty1 var5 = DslKt$main$3.INSTANCE;
   var3 = false;
   KCallablesJvm.setAccessible((KCallable)var5, true);
   // 수신 객체를 따로 지정
   var10000 = var5.getDelegate(person);
   Intrinsics.checkNotNull(var10000);
   var4 = Reflection.getOrCreateKotlinClass(var10000.getClass()).getQualifiedName();
   System.out.println(var4);
}

 

 

 

고차 함수와 DSL

타입 안전한 빌더(type-safe builder)를 통해 DSL을 설계하는 방법을 살펴보자.

 

 

1. 중위 함수를 사용해 fluent DSL 만들기

 

컬렉션 데이터에 대한 질의에 사용할 수 있는 SQL과 비슷한 문법을 사용하는 간단한 DSL

 

결론적으로 아래와 같이 코드를 작성하고 싶은 것이다.

val nums = listOf(2, 8, 9, 1, 3, 6, 5)
val query = from(nums) where { it > 3 } select { it * 2 } orderBy { it }
println(query.items.toList())

 

위와 같은 코드를 만들기 위한 질의는 다음과 같다

 

1. from 절은 대상 컬렉션을 지정한다

2. 다음으로 선택적으로 where 절이 온다. 걸러낼 조건을 기술한다

3. 다음으로 선택적으로 select 절이 온다. 원래 데이터를 출력 값으로 매핑해준다

4. select 절이 있는 경우 원한다면 orderBy 절을 추가할 수 있다. 결과의 순서를 정할 때 사용할 키를 지정한다

 

코드로 살펴보자

 

먼저 질의의 중간 구조를 표현하는 클래스를 정의해보자

// 원소의 sequence를 돌려주는 인터페이스
interface ResultSet<out T> {
    val items: Sequence<T>
}

/**
 * 질의 구성 요소를 표현하는 클래스 정의
 */
class From<out T>(private val source: Iterable<T>) : ResultSet<T> {
    override val items: Sequence<T>
        get() = source.asSequence()
}

class Where<out T>(
    private val from: ResultSet<T>,
    private val condition: (T) -> Boolean
) : ResultSet<T> {
    override val items: Sequence<T>
        get() = from.items.filter(condition)
}

class Select<out T, out U>(
    private val from: ResultSet<T>,
    private val output: (T) -> U
) : ResultSet<U> {
    override val items: Sequence<U>
        get() = from.items.map(output)
}

class OrderBy<out T, in K : Comparable<K>>(
    private val select: ResultSet<T>,
    private val orderKey: (T) -> K
) : ResultSet<T> {
    override val items: Sequence<T>
        get() = select.items.sortedBy(orderKey)
}

 

다음은 DSL의 요구 사항에 맞게 중위 연산자 함수를 정의해보자

 

// from 뒤에 where이 올 수 있다
infix fun <T> From<T>.where(condition: (T) -> Boolean) =
    Where(this, condition)

// from 뒤에 select가 올 수 있다
infix fun <T, U> From<T>.select(output: (T) -> U) =
    Select(this, output)

// where 뒤에 select가 올 수 있다
infix fun <T, U> Where<T>.select(output: (T) -> U) =
    Select(this, output)

// select 뒤에 orderBy가 올 수 있다
infix fun <T, K : Comparable<K>> Select<*, T>.orderBy(
    orderKey: (T) -> K
) = OrderBy(this, orderKey)

 

마지막으로 질의를 시작하는 함수이다.

 

fun <T> from(source: Iterable<T>) = From(source)

 

이제 아래 코드는 컴파일될 수 있다.

fun main() {
    val nums = listOf(2, 8, 9, 1, 3, 6, 5)
    val query = from(nums) where { it > 3 } select { it * 2 } orderBy { it }
    println(query.items.toList())   // [10, 12, 16, 18]
}

 

 

 

2. 타입 안전한 빌더 사용하기

도메인 객체가 다른 도메인 객체 안에 내포되는 계층적 구조를 표현하는 방법이 있다. DSL을 설계할 때 일반적으로 필요한 기능이다.

빌더 함수와 확장 람다를 결합해 계층적인 구조를 선언적인 방식으로 기술할 수 있게 도와준다.

 

아래와 같은 방식으로 UI를 설계할 수 있는 API를 작성해보자.

 

fun main() {
    val form = dialog("Send a message") {
        borderLayout { 
            south = panel { 
                +button("Send")
                +button("Cancel")
            }
            center = panel { 
                verticalBoxLayout { 
                    +filler(0, 10)
                    +panel { 
                        horizontalBoxLayout { 
                            +filler(5, 0)
                            +label("Message: ")
                            +filler(10, 0)
                            +textArea("")
                            +filler(5, 0)
                        }
                    }
                    +filler(0, 10)
                }
            }
        }
    }
    form.size = Dimension(300, 200)
    form.isVisible = true
}

 

기본적으로

UI 컴포넌트를 구성하는 계층적인 구조를 기술하고 BorderLayout, BoxLayout 등의 표준 레이아웃 관리자를 지원한다.

또한 버튼, 텍스트 필드, 패널, 윈도우 등의 공통 컴포넌트를 생성하고 초기화할 수 있는 함수를 제공한다.

 

 

먼저 UI를 기술할 때

 

1. 버튼, 텍스트 필드 등의 간단한 컴포넌트가 필요하다

2. 패널이나 윈도우 등의 컨테이너가 필요하다

3. 컨테이너의 자식 컴포넌트의 위치를 지정하기 위한 레이아웃이 필요하다.

 

버튼, 라벨, 텍스트 필드 함수

fun label(text: String) = JLabel(text)
fun button(text: String) = JButton(text)
fun textArea(text: String) = JTextArea(text)

 

컨테이너의 상태를 유지하기 위한 ContainerBuilder 클래스

내포시킬 컴포넌트를 추가하고 레이아웃을 정의할 수 있다.

class ContainerBuilder(private val container: Container) {
    operator fun Component.unaryPlus() = apply { container.add(this) }

    fun borderLayout(body: BorderLayoutBuilder.() -> Unit) {
        BorderLayoutBuilder(container).body()
    }

    fun horizontalBoxLayout(body: BoxLayoutBuilder.() -> Unit) {
        BoxLayoutBuilder(container, BoxLayout.LINE_AXIS).body()
    }

    fun verticalBoxLayout(body: BoxLayoutBuilder.() -> Unit) {
        BoxLayoutBuilder(container, BoxLayout.PAGE_AXIS).body()
    }
}

 

위 빌더를 사용해 panel과 dialog 함수를 정의할 수 있다.

 

fun panel(body: ContainerBuilder.() -> Unit) = JPanel().apply {
    ContainerBuilder(this).body()
}

fun dialog(
    title: String,
    body: ContainerBuilder.() -> Unit
): JDialog = JDialog().apply {
    this.title = title
    pack()
    defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
    ContainerBuilder(contentPane).body()
}

 

레이아웃을 처리하기 위한 레이아웃 빌더

 

class BoxLayoutBuilder(private val container: Container, direction: Int) {
    init {
        container.layout = BoxLayout(container, direction)
    }

    operator fun Component.unaryPlus() = apply { container.add(this) }

    fun filler(width: Int, height: Int) =
        Box.createRigidArea(Dimension(width, height))
}

 

추가된 자식 컴포넌트를 유지했다가 컨테이너에 연결해주는 프로퍼티를 설정해야 한다.

 

fun constrained(
    container: Container,
    constraint: Any?
) = observable<Component?>(null) { _, _, value ->
    container.add(value, constraint)
}

class BorderLayoutBuilder(container: Container) {
    init {
        container.layout = BorderLayout()
    }

    var north by constrained(container, BorderLayout.NORTH)
    var south by constrained(container, BorderLayout.SOUTH)
    var west by constrained(container, BorderLayout.WEST)
    var east by constrained(container, BorderLayout.EAST)
    var center by constrained(container, BorderLayout.CENTER)
}

 

'코틀린' 카테고리의 다른 글

[코틀린] 코틀린 테스팅  (0) 2022.12.18
[코틀린] 자바와 코틀린의 상호 운용성  (0) 2022.12.04
[코틀린] Annotation  (0) 2022.11.21
[코틀린] 제네릭  (0) 2022.11.13
[코틀린] 상속(Inheritance)  (0) 2022.11.06