개발/코틀린

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

DinoDev 2022. 11. 27. 20:44
728x90
반응형

도메인 특화 언어(Domain-Specific Language, DSL)는 특정 기능이나 영역을 위해 만들어진 언어를 뜻합니다.

연산자 오버로딩

코틀린 내장 연산자에 대해 새로운 기능을 제공하도록 해주는 기능입니다. + 가 수 타입에서는 덧셈 연산이지만 문자열의 경우 연결 연산인고 컬렉션의 경우는 원소를 맨 뒤에 붙이는 연산입니다. 이렇게 + 가 다르게 동작할 수 있는데 연산자 오버로딩이 되어 있기 때문에 가능합니다.

연산자 오버로딩은 operator 키워드를 붙이고 그에 맞는 이름을 붙이면 됩니다.

operator fun String.times(n: Int) = repeat(n)

fun main() {
    println("abc" * 3) // abcabcabc
    println("abc".times(3)) // abcabcabc
}

단항 연산

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

+e e.unaryPlus()
-e e.unaryMinus()
!e e.not()
enum class Color {
    BLACK, RED, GREEN, BLUE, YELLOW, CYAN, MAGENTA, WHITE;

    operator fun not() = when (this) {
        BLACK -> WHITE
        RED -> CYAN
        GREEN -> MAGENTA
        BLUE -> YELLOW
        YELLOW -> BLACK
        CYAN -> RED
        MAGENTA -> GREEN
        WHITE -> BLACK
    }
}

fun main() {
    println(!Color.RED) // CYAN
    println(!Color.CYAN) // RED
}

증가와 감소

증가(++)와 감소(--) 연산자도 피연산자 타입에 대한 파라미터가 없는 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 color = RainbowColor.GREEN
    println(++color) // BLUE
    println(color++) // BLUE
    println(--color) // BLUE
    println(color--) // BLUE
}

이항 연산

이상 연산자 대부분은 왼쪽 피연산자를 수신 객체로, 오른쪽 피연산자를 일반적인 인자로 받습니다.

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)
import kotlin.math.abs

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 {
        return of(sign * r.sign * num * r.num, den * r.den)
    }

    operator fun div(r: Rational): Rational {
        return of(sign * r.sign * num * r.den, den * r.num)
    }

    override fun toString(): String {
        return "${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)
        }
    }
}

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

fun main() {
    // 1/2 - 1/3
    println(r(1, 2) - r(1, 3)) // 1/6

    // 2/3 + (1/3)/2
    println(r(2, 3) - r(1, 3) / r(2)) // 3/6

    // 3/4 * 8/9 / (2/3)
    println(r(3, 4) * r(8, 9) / r(2, 3)) // 1

    // (1/10)*2 - 2/6
    println(r(1, 10) * r(2) - r(2, 6)) // -2/15
}

..나 in/~!in 연산을 override 할 수 있습니다.

private fun Rational.isLessOrEqual(r: Rational): Boolean {
    return 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 {
        return 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
}

<나 >처럼 비교와 관련된 오버로딩 가능 연산자도 있습니다.

a < b a.compareTo(b) < 0
a <= b a.compareTo(b) <= 0
a > b a.compareTo(b) > 0
a >= b a.compareTo(b) >= 0
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): Boolean {
        return 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()를 사용하고 있어서 operator를 붙이지 않습니다. 또한 확장함수도 멤버 함수가 우선순위가 높기 때문에 동작하지 않습니다.

중위 연산

to와 같이 .를 붙이지 않고 함수를 호출하는 형태를 중위연산(infix function)이라고 부릅니다. 중위 연산 함수를 선언하기 위해서는 fun 앞에 infix를 붙입니다.

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

대입

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)
class TreeNode<T>(val data: T) {
    private val _children = arrayListOf<TreeNode<T>>()

    var parent: TreeNode<T>? = null
        private set

    operator fun plusAssign(data: T) {
        val node = TreeNode(data)
        _children += node
        node.parent = this
    }

    operator fun minusAssign(data: T) {
        val index = _children.indexOfFirst { it.data == data }
        if (index < 0) return
        val node = _children.removeAt(index)
        node.parent = null
    }

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

fun main() {
    val tree = TreeNode("root")
    tree += "child 1"
    tree += "child 2"
    println(tree) // root {child 1 {}, child 2 {}}

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

호출과 인덱스로 원소 찾기

호출 관습을 사용하면 값을 함수처럼 호출 식에서 사용할 수 있습니다. 필요한 파라미터와 함께 invoke() 함수를 정의하면 됩니다.

동반 객체에 넣어서 동반 객체를 팩토리로 만드는 방법도 있습니다.

operator fun <K, V> Map<K, V>.invoke(key: K) = get(key)

operator fun Rational.Companion.invoke(num: Int, den: Int = 1) = of(num, den)

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

문자열, 배열, 리스트 등은 []를 통해서 원소에 접근 할 수 있고 코틀린에서는 get과 set 함수로 operator를 오버라이드 할 수 있습니다.

class TreeNode<T>(val data: T) {
    private val _children = arrayListOf<TreeNode<T>>()

    var parent: TreeNode<T>? = null
        private set

    operator fun plusAssign(data: T) {
        val node = TreeNode(data)
        _children += node
        node.parent = this
    }

    operator fun minusAssign(data: T) {
        val index = _children.indexOfFirst { it.data == data }
        if (index < 0) return
        val node = _children.removeAt(index)
        node.parent = null
    }

    operator fun get(index: Int) = _children[index]

    operator fun set(index: Int, node: TreeNode<T>) {
        node.parent?._children?.remove(node)
        node.parent = this
        _children[index].parent = null
        _children[index] = node
    }
}

fun main() {
    val tree = TreeNode("root")
    tree += "child 1"
    tree += "child 2"
    println(tree[1].data) // child 2

    tree[0] = TreeNode("child 3")
    println(tree[0].data) // child 3
}

구조 분해 

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
}

이터레이션

iterator()함수는 Iterator 타입의 이터레이터 인스턴스를 반환합니다.

operator fun <T> TreeNode<T>.iterator() = children.iterator()

fun main() {
    val content = TreeNode("Title").apply {
        addChild("Topic 1").apply {
            addChild("Topic 1.1")
            addChild("Topic 1.2")
        }
        addChild("Topic 2")
        addChild("Topic 3")
    }

    for (item in content) {
        println(item.data)
    }
}

위임 프로퍼티

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

표준 위임들

코틀린 표준 라이브러리에는 다양한 용도에 맞는 위임 들이 구현되어 있습니다.

lazy

lazy() 함수는 다중 스레드 환경에서 지연 계산 프로퍼티의 등조강르 미세하게 제어하기 위해 세 가지 다른 버전을 가지고 있습니다.

LazyThreadSafeMode가 3가지 제공 됩니다.

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

notNull()

notNull은 초기화를 미루면서 널이 아닌 프로퍼티를 정의 할 수 있게 해줍니다.

특히나 primitive 타입은 lateinit을 사용할 수 없습니다.

초기화 하지 않고 사용하면 에러가 발생합니다.

import kotlin.properties.Delegates

var num: Int by Delegates.notNull()
lateinit var num2: Int // 'lateinit' modifier is not allowed on properties of primitive types

fun main() {
    println(num) // Property num should be initialized before get.
}

observable()

observable은 값이 변경 될 때마다 통지를 받을 수 있습니다.

import kotlin.properties.Delegates

var num by Delegates.observable(0) { _, old, new ->
    println("num changed: $old to $new")
}

fun main() {
    num = 10 // num changed: 0 to 10
    num = 20 // num changed: 10 to 20
}

vetoable()

변경 직전에 람다가 호출 되고 true를 반환하면 값이 변경되는 함수입니다.

import kotlin.properties.Delegates

var num by Delegates.vetoable(0) { _, old, new ->
    if (new > old) {
        println("changed: $old to $new")
        true
    } else {
        println("not changed")
        false
    }
}

fun main() {
    num = 10 // changed: 0 to 10
    num = 5 // not changed
    num = 20 // changed: 10 to 20
}

Map에서 값 읽어오기

표준 라이브러리에서는 map에서 값을 읽어오는 기능도 있습니다.

fun main() {
    val map = mapOf("title" to "Laptop", "price" to 999.9)

    val title by map
    val price by map
    println("$title $price") // Laptop 999.9
}

커스텀 위임 만들기 

커스텀 위임을 만들려면 특별한 연산자 함수를 정의하는 타입이 필요합니다. 읽기 함수의 이름은 getValue여야 하고 두 가지 파라미터를 받습니다.

  • receiver: 수신 객체 값이 들어있고, 위임된 프로퍼티의 수신 객체와 같은 타입(또는 상위 타입)이어야 합니다.
  • property: 프로퍼티 선언을 표현하는 리플렉션이 들어있습니다. KProperty<*>이거나 상위 타입이어야 합니다.
import kotlin.reflect.KProperty

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 {
        return 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 <- initializer
    println(harrySmith.fullName) // Harry Smith <- initializer
    println(johnDoe.fullName) // John Doe <- cached
    println(harrySmith.fullName) // Harry Smith <- cached
}

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

 

var 프로퍼티에 커스텀 위임을 하려면 getValue외에 setValue도 같이 정의해야 합니다.

  • recevier: getValue()와 동일합니다.
  • property: getValue()와 동일합니다.
  • newValue: 프로퍼티에 저장할 새 값입니다. 프로퍼티 자체와 같은 타입(또는 상위 타입)이어야 합니다.
import kotlin.reflect.KProperty

class FinalLateinitProperty<in R, T : Any> {
    private lateinit var value: T

    operator fun getValue(receiver: R, property: KProperty<*>): T {
        return 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" // Exception: Property message is already initialized
}

ReadOnlyProperty와 동일하게 이 경우에도 ReadWriteProperty가 있습니다.

 

provideDelegate()를 통해서 위임 인스턴스화를 제어 할 수 있습니다.

import kotlin.reflect.KProperty

@Target(AnnotationTarget.PROPERTY)
annotation class NoCache

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 {
        return cachedValues.getOrPut(receiver) { receiver.initializer() }
    }
}

class CachedPropertyProvider<in R, out T : Any>(
    val initializer: R.() -> T,
) {

    operator fun provideDelegate(receiver: R?, property: KProperty<*>): CachedProperty<R, T> {
        if (property.annotations.any { it is NoCache }) {
            throw IllegalStateException("${property.name} forbids caching")
        }

        return CachedProperty(initializer)
    }
}

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

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

@NoCache val Person.fullName: String by cached {
    if (this != null) "$firstName $familyName" else ""
}

fun main() {
    val johnDoe = Person("John", "Doe")
    println(johnDoe.fullName) // Exception: java.lang.IllegalStateException
}

고차 함수와 DSL

중위 함수를 사용해 플루언트 DSL 만들기

중위 함수를 사용해 플루언트(Fluent) Api를 만드는 방법에 알아봅니다.

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

위와 같은 코드는 읽기 쉬운 코드로 보이는데 이런 방식을 만드는 방법은 아래와 같습니다.

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

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 from: ResultSet<T>,
    private val orderKey: (T) -> K,
) : ResultSet<T> {
    override val items: Sequence<T>
        get() = from.items.sortedBy(orderKey)
}

infix fun <T> From<T>.where(condition: (T) -> Boolean) = Where(this, condition)
infix fun <T, U> From<T>.select(output: (T) -> U) = Select(this, output)
infix fun <T, U> Where<T>.select(output: (T) -> U) = Select(this, output)
infix fun <T, K : Comparable<K>> Select<*, T>.orderBy(orderKey: (T) -> K) = OrderBy(this, orderKey)
fun <T> from(source: Iterable<T>) = From(source)

타입 안전한 빌더 사용하기

@DslMarker

 

728x90
반응형

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

(코틀린) 동시성  (0) 2022.12.11
(코틀린) 자바 상호 운용성  (0) 2022.12.03
(코틀린) 제네릭스  (0) 2022.11.12
(코틀린) 클래스 계층 이해하기  (0) 2022.11.06
(코틀린) 컬렉션  (0) 2022.10.30