본문 바로가기
kotlin

11. DSL 만들기

by AsCE_hyunseung 2022. 4. 28.

11. DSL 만들기

  • invoke 관계를 사용하면 DSL 코드 안에서 람다와 프로퍼티 대입을 더 유연하게 조합할 수 있다.

11.1 API에서 DSL로

  • 궁극적인 목표는 코드의 가독성과 유지 보수성을 좋게 유지하는 것이다.
  • 상호작용을 이해하기 쉽고 명확하게 표현할 수 있게 만들어야 프로젝트를 계속 유지 보수할 수 있다.

깔끔한 api의 특징

  • 이름과 개념을 잘 선택하면 클라이언트가 해당 코드에 대해 어떤 일이 벌어질지 명확하게 이해할 수 있다.
  • 코드가 간결해야한다. 불필요한 구문이나 번잡한 준비 코드가 가능한 한 적어야 한다.
  • 코틀린 DSL은 간결한 구문을 제공하는 기능과 그런 구문을 확장해서 여러 메소드 호출을 조함으로써 구조를 만들어내는 기능에 의존한다.
  • 코틀린 언어의 다른 특성과 마찬가지로 코틀린 DSL도 온전히 컴파일 시점에 타입이 정해진다.

11.1.1 영어 특화 언어라는 개념

DSL(영역 특화 언어)

  • ex) SQL, 정규식 등등
  • 스스로 제공하는 기능을 제한함으로써 오히려 더 효율적으로 목표를 달성할 수 있게한다.
  • 압축적인 문법을 사용함으로써 DSL은 범용 언어를 사용하는 경우보다 특정 영역에 대한 연산을 더 간결하게 기술할 수 있다.
  • DSL은 범용 프로그래밍 언어와 달리 더 선언적이다.

선언적 언어

  • 원하는 결과를 기술하기만 하고, 그 결과를 달성하기 위해 필요한 세부 실행은 언어를 해석하는 엔진에 맡긴다.

DSL의 단점

  • 자체 문법이 있기 떄문에 다른 언어의 프로그램 안에 직접 포함시킬 수가 없다
  • 따라서 DSL로 작성한 프로그램을 다른 언어에서 호출하려면 DSL 프로그램을 별도의 파일이나 문자열 리터럴로 저장해야한다.
  • 이런식으로 DSL을 저장하면
    • 컴파일 시점에 검증이 힘듦
    • DSL 프로그램 디버깅 힘듦
    • DSL 코드 작성을 돕는 IDE 기능 제공 어려움
  • 이런 단점을 해결하기 위해 내부 DSL이라는 개념이 나왔다.

11.1.2 내부 DSL

  • 독립적인 문법 구조를 가진 외부 DSL과는 반대로 내부 DSL은 범용 언어로 작성된 프로그램의 일부다.
  • 범용 언어와 동일한 문법을 사용한다.
  • DSL의 핵심 장점을 유지하면서 주 언어를 특별한 방법으로 사용하는 것이다.
  • 외부 DSL
SELECT Country.name, Count(Customer.id)
    FROM Country
    JOIN Customer
        ON Country.id = Customer.country_id
GROUP BY Country.name
ORDER BY COUNT(Customer.id) DESC
    LIMIT 1
  • 내부 DSL
  • 코드는 어떤 구체적인 과업을 달성하기 위한 것이지만, 범용 언어의 라이브러리로 구현된다.
(Country join Customer)
        .slice(Country.name, Count(Customer.id))
        .selectAll()
        .groupBy(Country.name)
        .orderBy(Count(Customer.id), isAsc = false)
        .limit(1)

11.1.3 DSL의 구조

  • 다른 API에는 존재하지 않지만 DSL에만 존재하는 특징은 구조 or 문법이다.
  • API를 명령 - 질의 API라고 부른다. 반대로 DSL의 메소드 호출은 DSL 문법에 의해 정해지는 더 커다란 구조에 속한다.
  • 코틀린 DSL에서는 보통 람다를 중첩 시키거나 메소드 호출을 연쇄시키는 방식으로 구조를 만든다.
  • 메소드를 조합해서 만든 질의는 질의에 필요한 인자를 메소드 호출 하나에 모두 다 넘기는 것보다 훨씬 더 읽기 쉽다.

DSL 구조의 장점

  • 같은 문맥을 함수 호출 시마다 반복하지 않고도 재사용할 수 있다는 점이다.

코틀린 DSL 예시

dependencies {
    compile(",,,")
    compile(",,,")
}

명령 - 질의 API 예시

  • 코틀린 DSL 코드보다 중복이 많다는걸 알 수 있다.
project.dependencies.add("compile", ",,,")
project.dependencies.add("compile", ",,,")

11.1.4 내부 DSL로 HTML 만들기

  • 코틀린 코드로 HTML을 만들려는 이유
    • 코틀린 버전은 타입 안전성을 보장한다.

11.2 구조화된 API 구축: DSL에서 수신 객체 지정 DSL 사용

  • 수신 객체 지정 람다는 구조화된 API를 만들 때 도움이 되는 강력한 코틀린 기능이다.

11.2.1 수신 객체 지정 람다와 확장 함수 타입

fun buildString(
    builderAction: (StringBuilder) -> Unit // 함수 타입인 파라미터를 정의한다.
): String {
    val sb = StringBuilder()
    builderAction(sb) // 람다 인자로 StringBuilder 인스턴스를 넘긴다.
    return sb.toString()
}

fun main() {
    val s = buildString {
        it.append("Hello, ") // it 은 StringBuilder 인스턴스를 가리킨다.
        it.append("World!")
    }
    println(s) // Hello, World!–
}
  • 수신 객체 지정 람다로 바꾸면, it을 붙이지 않고 간단하게 호출할 수 있다.
  • buildString에게 수신 객체 지정 람다를 인자로 넘기기 때문에 람다 안에서 it을 사용하지 않아도 된다.
fun buildString(
    builderAction: StringBuilder.() -> Unit // 수신 객체가 있는 함수 타입의 파라미터를 선언한다.
): String {
    val sb = StringBuilder()
    sb.builderAction() // 람다의 수신 객체로 StringBuilder 인스턴스를 넘긴다.
    return sb.toString()
}

fun main() {
    val s = buildString {
        this.append("Hello, ") // "this" 키워드는 StringBuilder 인스턴스를 가리킨다.
        append("World!") // "this" 를 생략해도 암묵적으로 StringBuilder 인스턴스가 수신 객체로 취급된다.
    }
    println(s) // Hello, World!
}
  • 파라미터 타입을 선언할 때 일반 함수 타입 대신 확장 함수 타입을 사용했다.
  • (StringBuilder)를 수신 객체 타입이라 부르고, 람다에 전달되는 그런 타입의 객체를 수신 객체라고 부른다.
// before
fun buildString(
    **builderAction: (StringBuilder) -> Unit**
): String {
    ,,,
}

// after
fun buildString(
    **builderAction: StringBuilder.() -> Unit**
): String {
		,,,
}
  • buildString 함수(수신 객체 지정 람다)의 인자는 확장 함수 타입(builderAction)의 파라미터와 대응한다.
  • 호출된 람다 본문 안에서는 수신 객체(sb)가 묵시적 수신 객체(this)가 된다.
buildString { this.append("!") }

fun buildString(builderAction: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.builderAction()
}

11.2.2 수신 객체 지정 람다를 HTML 빌더 안에서 사용

  • HTML을 만들기 위한 코틀린 DSL을 HTML 빌더라고 부른다.
  • 빌더를 사용하면 객체 계층 구조를 선언적으로 정의할 수 있다.
  • 코틀린 빌더는 타입 안전성을 보장한다.
fun createSimpleTable() = createHTML().
	table { 
    tr { // == (this@table).tr
      td { +"cell" } // == (this@tr).td
    }
  }
  • 사용된 table, tr, td 는 고차 함수로 수신 객체 지정 람다를 인자로 받는 형태이다.
  • 각 수신 객체 지정 람다가 이름 결정 규칙을 바꾸는 방식으로 되어있다.
    • table 함수에 넘겨진 람다에서는 tr 함수를 사용해 <tr> HTML 태그를 먼들 수 있지만 그 람다 밖에서는 tr 함수를 찾을 수 없다.
    • table에 전달된 수신 객체는 TABLE 이라는 특별한 타입이며, 그 안에 tr 메소드 정의가 있다.
  • 수신 객체 지정 람다가 다른 수신 객체 지정 람다 안에 들어가면 내부 람다에서 외부 람다에 정의된 수신 객체를 사용할 수 있다
  • 하지만 코틀린 1.1 부터는 @DslMarker 어노테이션을 사용해 중첩된 람다에서 외부 람다의 수신 객체를 접근하지 못하게 제한할 수 있다.

11.2.3 코틀린 빌더: 추상화와 재사용을 가능하게 하는 도구

  • 내부 DSL을 사용하면 일반 코드와 마찬가지로 반복되는 내부 DSL 코드 조각을 새 함수로 묶어서 재사용할 수 있다.
  • 부트스트랩 라이브러리에서 제공하는 드롭다운 메뉴가 있는 HTML 페이지를 코틀린 빌더를 통해 생성해본다.
fun dropdownExample() = createHTML().dropdown {
  dropdownButton { +"Dropdown" }
  dropdownMenu {
    item("#", "Action")
    item("#", "Another action")
    divider()
    dropdownHeader("Header")
    item("#", "Separated link")
  }
}
fun UL.item(href: String, name: String) = li { a (href) { +name } }
  • item 함수
    • href 주소(첫번째 파라미터)와 메뉴 원소의 이름(두번째 파라미터)로 구현되어 있다.
    • li { a (href) { +name } }라는 원소를 새로 추가한다.
    • 해당 함수를 UL 태그의 확장 함수로 구현하여 li 태그를 추가할 수 있도록 구현한다.
      • 이렇게 item 함수를 정의하고 나면 모든 UL 태그 안에서 item을 호출할 수 있다.

 

11.3 invoke 관례를 사용한 더 유연한 블록 중첩

  • invoke 관례를 사용하면 객체를 함수처럼 호출할 수 있고, 함수처럼 호출할 수 있는 객체를 만드는 클래스를 정의할 수 있다.

11.3.1 invoke 관계: 함수처럼 호출할 수 있는 객체

  • bavarianGreeter("Dmitry") 는 bavarianGreeter.invoke(”Dmitry”)로 컴파일된다.
  • 미리 정해둔 이름을 사용한 메소드를 통해서 긴 식 대신 더 짧고 간결한 식을 쓸 수 있게 해준다.
class Greeter(val greeting: String) {
    operator fun invoke(name: String) { // invoke 메소드를 정의한다.
        println("$greeting, $name")
    }
}

fun main() {
    val bavarianGreeter = Greeter("Servus")
    bavarianGreeter("Dmitry") // Greeter 인스턴스를 함수처럼 호출한다. 
    // Servus, Dmitry
}
  • 오버로딩한 invoke가 있는 클래스의 인스턴스를 함수처럼 사용할 때는 오버로딩한 여러 시그니처를 모두 다 활용할 수 있다.

11.3.2 invoke 관례와 함수형 타입

  • 인라인하는 람다를 제외한 모든 람다는 함수형 인터페이스를 구현하는 클래스로 컴파일된다.
  • 각 함수형 인터페이스 안에는 그 인터페이스 이름이 가리키는 개수만큼 파라미터를 받는 invoke 메소드가 들어있다.
interface Function2<in P1, in P2, out R> {
    operator fun invoke(p1: P1, p2: P2): R
}
  • 람다를 함수처럼 호출하면 이 관례에 따라 invoke 메소드 호출로 변환된다.
    • 복잡한 람다를 여러 메소드로 분리하면서도 분리 전 람다처럼 외부에서 호출할 수 있는 객체를 만들 수 있다.
  • 함수 타입을 확장하면서 invoke() 메소드를 오버라이딩하는 예제
data class Issue (
    val id: String, val project: String, val type: String,
    val priority: String, val description: String
)

class ImportantIssuesPredicate(val project: String)
    : (Issue) -> Boolean { // 함수 타입을 부모 클래스로 사용
    override fun invoke(issue: Issue): Boolean { // invoke 메소드 구현
        return issue.project == project && issue.isImportant()
    }
    
    private fun Issue.isImportant(): Boolean {
        return type == "Bug" &&
                (priority == "Major" || priority == "Critical")
    }
}

11.3.3 DSL의 invoke 관례: 그레이들에서 의존관계 정의

  • 그레이들에서의 dependencies 객체는 DependencyHandler 클래스의 인스턴스다
  • DependencyHandler 클래스 구현(simple ver)
class DependencyHandler {
    fun compile(coordinate: String) {
        println("add dependency on $coordinate")
    }

    operator fun invoke(body: DependencyHandler.() -> Unit) {
        body()
    }
}
  • 이 경우에는 compile 메소드를 직접 호출한다
dependencies.compile("라이브러리1") 
  • 아래와 같은 호출은 invoke 메소드를 호출하게끔 변환된다
//before
dependencies {
    compile("라이브러리2")
}

//after
dependencies.invoke({
    this.compile("라이브러리2")
})

11.4 실전 코틀린 DSL

11.4.1 중위 호출 연쇄: 테스트 프레임워크의 should

  • 코틀린 테스트 DSL에서 메소드 호출 연쇄시키기
// 중위 호출
"kotlin" should start with "kot"

// 중위 호출을 일반 메소드 호출로 변경
"kotlin".should(start).with("kot")
  • should 함수 중에는 start 객체를 파라미터 타입으로 사용하는 특별한 오버로딩 버전이 있다.
  • should 함수는 중간 래퍼 객체를 돌려주는데, 이 래퍼 객체 안에는 중위 호출 가능한 with 메소드가 들어있다.
object start

infix fun String.should(x: start): StartWrapper = StartWrapper(this)

class StartWrapper(val value: String) {
    infix fun with(prefix: String) =
        if (!value.startsWith(prefix))
            throw AssertionError("string does not with $prefix: $value")
        else Unit
}

11.4.2 원시 타입에 대한 확장 함수 정의: 날짜 처리

val Int.days: Period
    get() = Period.ofDays(this) // this는 상수의 값을 가리킨다
val Period.ago: LocalDate
    get() = LocalDate.now() - this // 연산자 구문을 사용해서 LocalDate.minus를 호출한다.
val Period.fromNow: LocalDate
    get() = LocalDate.now() + this // 연산자 구문을 사용해서 LocalDate.plus를 호출한다.
  • LocalDate라는 JDK 클래스에는 코틀린 -, + 연산자 관례와 일치하는 minus, plus 메소드가 들어있다
// 
@Override
    public LocalDate minus(TemporalAmount amountToSubtract) {
        if (amountToSubtract instanceof Period) {
            Period periodToSubtract = (Period) amountToSubtract;
            return minusMonths(periodToSubtract.toTotalMonths()).minusDays(periodToSubtract.getDays());
        }
        Objects.requireNonNull(amountToSubtract, "amountToSubtract");
        return (LocalDate) amountToSubtract.subtractFrom(this);
    }

@Override
    public LocalDate plus(TemporalAmount amountToAdd) {
        if (amountToAdd instanceof Period) {
            Period periodToAdd = (Period) amountToAdd;
            return plusMonths(periodToAdd.toTotalMonths()).plusDays(periodToAdd.getDays());
        }
        Objects.requireNonNull(amountToAdd, "amountToAdd");
        return (LocalDate) amountToAdd.addTo(this);
    }

11.4.3 멤버 확장 함수: SQL을 위한 내부 DSL

  • 클래스 안에서 확장 함수와 확장 프로퍼티를 선언하는 것.
    • 이렇게 정의한 확장 함수나 확장 프로퍼티는 그들이 선언된 클래스의 멤버인 동시에 그들이 확장하는 다른 타입의 멤버이기도 하다.
    • 이런 함수나 프로퍼티를 멤버 확장이라 부른다.
  • 각 칼럼의 속성을 지정하는 방법을 살펴보면, 이때 멤버 확장이 쓰인다.
  • primaryKey(), autoIncrement() 는 Table 클래스의 멤버다
  • 멤버 확장으로 정의하는 이유는 메소드가 적용되는 범위를 제한하기 위해서다.
class Table {
    fun integer(name: String): Column<Int>
    fun varchar(name: String, length: Int): Column<String>

		fun <T>Column<T>.primaryKey(): Column<T>
    fun Column<Int>.autoIncrement(): Column<Int>
    // ...
}

val id = integer("id").autoIncrement().primaryKey()
  • 확장 함수의 다른 장점은 수신 객체 타입을 제한하는 기능이다.
  • autoIncrement를 할 수 있는 컬럼은 정수 타입이어야한다.
fun Column<Int>.autoIncrement(): Column<Int>

11.5 요약

  • 수신 객체 지정 람다는 람다 본문 안에서 메소드를 결정하는 방식을 재정의함으로써 여러 요소를 중첩시킬 수 있는 구조를 만들 수 있다.
  • invoke 관례를 사용하면 객체를 함수처럼 다룰 수 있다.