관리 메뉴

나만을 위한 블로그

[이펙티브 코틀린] 아이템 35. 복잡한 객체를 생성하기 위한 DSL을 정의하라 본문

책/Effective Kotlin

[이펙티브 코틀린] 아이템 35. 복잡한 객체를 생성하기 위한 DSL을 정의하라

참깨빵위에참깨빵_ 2023. 1. 31. 19:23
728x90
반응형

코틀린을 활용하면 DSL(Domain Specific Language)을 직접 만들 수 있다. DSL은 복잡한 객체, 계층 구조를 가진 객체들을 정의할 때 유용하다. DSL을 만드는 건 약간 힘든 일이지만 한 번 만들고 나면 보일러플레이트와 복잡성을 숨기면서 개발자의 의도를 명확하게 표현할 수 있다. 예를 들어 코틀린 DSL은 아래와 같은 형태로 HTML을 표현할 수 있다. 이는 고전적인 HTML, 리액트 HTML 모두에서 활용할 수 있다.

 

body {
    div {
        a("https://kotlinlang.org") {
            target = ATarget.blank
            + "Main site"
        }
    }
    + "Some content"
}

 

다른 플랫폼의 뷰도 이런 형태로 DSL을 써서 만들 수 있다. DSL은 자료 또는 설정을 표현할 때도 활용될 수 있다. 아래 코드는 Ktor를 활용해 만든 API 정의 예시다. 마찬가지로 DSL을 썼다.

 

fun Routing.api() {
    route("news") {
        get {
            val newsData = NewsUseCase.getAcceptedNews()
            call.respond(newsData)
        }
        get("propositions") {
            requireSecret()
            val newsData = NewsUseCase.getPropositions()
            call.respond(newsData)
        }
    }
    // ...
}

 

다음은 코틀린 테스트를 활용해서 테스트 케이스를 정의한 것이다.

 

class MyTests: StringSpec({
    "length should return size of string" {
        "hello".length shouldBe 5
    }
    "startsWith should test for a prefix" {
        "world" should startWith("wor")
    }
})

 

DSL을 활용하면 복잡하고 계층적인 자료구조를 쉽게 만들 수 있다. 참고로 DSL 내부에서도 코틀린이 제공하는 걸 활용할 수 있다. 코틀린의 DSL은 type-safe이므로 여러 유용한 힌트를 활용할 수 있다. 이미 존재하는 코틀린 DSL을 활용하는 것도 좋지만 사용자 정의 DSL을 만드는 법도 알아 두면 좋다.

 

사용자 정의 DSL 만들기

 

사용자 정의 DSL을 만드는 법을 이해하려면 리시버를 쓰는 함수 타입에 대한 개념을 이해해야 한다. 일단 함수 자료형 자체에 대한 개념을 간단하게 알아본다.

함수 타입은 함수로 쓸 수 있는 객체를 나타내는 타입이다. 예를 들어 filter 함수는 predicate에 함수 타입이 활용되고 있다.

 

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

 

함수 타입의 예를 몇 가지 확인한다.

 

  • () -> Unit : 아규먼트를 갖지 않고 Unit을 리턴하는 함수
  • (Int) -> Unit : Int를 아규먼트로 받고 Unit을 리턴하는 함수
  • (Int) -> Int : Int를 아규먼트로 받고 Int를 리턴하는 함수
  • (Int, Int) -> Int : Int 2개를 아규먼트로 받고 Int를 리턴하는 함수
  • (Int) -> () -> Unit : Int를 아규먼트로 받고 다른 함수를 리턴하는 함수. 이 때 다른 함수는 아규먼트로 아무것도 받지 않고 Unit을 리턴
  • (() -> Unit) -> Unit : 다른 함수를 아규먼트로 받고 Unit을 리턴하는 함수. 이 때 다른 함수는 아규먼트로 아무것도 받지 않고 Unit을 리턴

 

함수 타입을 만드는 기본적인 방법은 아래와 같다.

 

  • 람다 표현식
  • 익명 함수
  • 함수 레퍼런스

 

예를 들어 아래와 같은 함수가 있다고 가정한다.

 

fun plus(a: Int, b: Int) = a + b

 

유사 함수(analogical function)는 아래와 같은 방법으로 만든다.

 

val plus1: (Int, Int) -> Int = { a, b -> a + b }
val plus2: (Int, Int) -> Int = fun(a, b) = a + b
val plus3: (Int, Int) -> Int = ::plus

 

위 예시에선 프로퍼티 타입이 지정돼 있으므로 람다 표현식과 익명 함수의 아규먼트 타입을 추론할 수 있다. 반대로 아래처럼 아규먼트 타입을 지정해서 함수 형태를 추론하게 할 수도 있다.

 

val plus4 = { a: Int, b: Int -> a + b }
val plus5 = fun(a: Int, b: Int) = a + b

 

함수 타입은 "함수를 나타내는 객체"를 표현하는 타입이다. 익명 함수는 일반적인 함수처럼 보이지만 이름을 갖고 있지 않다. 람다 표현식은 익명 함수를 짧게 작성할 수 있는 표기법이다.

함수를 나타내는 타입이 있다면 확장 함수의 경우는 어떤가? 확장 함수는 어떻게 표현할 수 있을까?

 

fun Int.myPlus(other: Int) = this + other

 

익명 함수를 만들 땐 일반 함수처럼 만들고 이름만 빼면 된다고 했다. 익명 확장 함수도 이런 방법으로 만들 수 있다.

 

val myPlus2: Int.(Int) -> Int =
    fun Int.(other: Int) = this + other

 

이렇게 함수는 람다식, 구체적으로 리시버를 가진 람다 표현식을 써서 정의할 수 있다. 이렇게 하면 스코프 내부에 this 키워드가 확장 리시버(아래 코드에서 Int 인스턴스)를 참조하게 된다

 

val myPlus3: Int.(Int) -> Int = { this + it }

 

리시버를 가진 익명 확장 함수와 람다 표현식은 아래의 방법으로 호출할 수 있다.

 

  • 일반적인 객체처럼 invoke 메서드 사용
  • 확장 함수가 아닌 함수처럼 사용
  • 일반 확장 함수처럼 사용

 

fun main() {
    1.myPlus(2)
    myPlus2.invoke(1, 2)
    myPlus3(1, 2)
}

 

이처럼 리시버를 가진 함수 타입의 가장 중요한 특징은 this의 참조 대상을 변경할 수 있다는 것이다. this는 apply 함수에서 리시버 객체의 메서드, 프로퍼티를 간단하게 참조할 수 있게 해 주기도 한다.

 

fun main() {
    val user = User().apply { 
        name = "김"
        surname = "철수"
    }
}

inline fun <T> T.apply(block: T.() -> Unit): T {
    this.block()
    return this
}

class User {
    var name: String = ""
    var surname: String = ""
}

 

리시버를 가진 함수 타입은 코틀린 DSL을 구성하는 가장 기본적인 블록이다. 이걸 활용해서 HTML 표를 표현하는 DSL을 만든다.

 

fun createTable(): TableDsl = table {
    tr {
        for (i in 1 .. 2) {
            td {
                +"This is column $i"
            }
        }
    }
}

 

DSL의 앞에 table 함수가 있다. 현재 코드가 톱레벨에 위치하고 별도의 리시버를 갖지 않으므로, table 함수도 톱레벨에 있어야 한다.

함수 아규먼트 안에서 tr을 쓰고 있다. tr 함수는 table 정의 내부에서만 허용돼야 한다. 따라서 table 함수의 아규먼트는 tr 함수를 갖는 리시버를 가져야 한다. 비슷하게 tr 함수의 아규먼트는 td 함수를 갖는 리시버를 가져야 한다.

 

fun table(init: TableBuilder.() -> Unit): TableBuilder {
    // ...
}

class TableBuilder {
    fun tr(init: TrBuilder.() -> Unit) { /*...*/ }
}

class TrBuilder {
    fun td(init: TdBuilder.() -> Unit) { /*...*/ }
}

class TdBuilder

 

언제 써야 하는가?

 

DSL은 정보를 정의하는 방법을 제공한다. DSL은 여러 종류의 정보를 표현할 수 있지만 사용자 입장에선 이 정보가 어떻게 활용되는지 명확하진 않다. DSL은 아래의 것을 표현하는 경우 유용하다.

 

  • 복잡한 자료구조
  • 계층적인 구조
  • 거대한 양의 데이터

 

DSL 없이 빌더 또는 단순하게 생성자만 활용해도 원하는 모든 걸 표현할 수 있다. DSL은 많이 쓰이는 구조의 반복을 제거할 수 있게 해준다. 많이 쓰이는 반복되는 코드가 있고 이를 간단하게 만들 수 있는 별도의 코틀린 기능이 없다면 DSL 사용을 고려해 보는 게 좋다.

반응형
Comments