KotlinInAction/DSL 만들기

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

webmaster 2022. 8. 21. 11:10
728x90

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

 
fun main() {
    val s = buildString {
        it.append("Hello, ") //it는 StringBuilder 인스턴스
        it.append("World")
    }
    println(s)
}

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

 

  • 람다 본문에 매번 it 사용해 StringBuilder 인스턴스를 참조해야 하는 단점이 있다(it. 을 일일이 넣지 않고 싶다)
  • 람다의 인자 중 하나에게 수신 객체 상태를 부여 하면, 이름과 마침표를 명시하지 않아도 된다(it. 생략 가능 → append()만 사용)
수신 객체 지정 람다 사용
fun main() {
    val s = buildString {
        this.append("Hello, ") //this 키워드는 StringBuilder 인스턴스를 가리킨다.
        append("World")//this를 생략해도 묵시적으로 StringBuilder 인스턴스가 수신 객체로 취급
    }
    println(s)
}
fun buildString(
    builderAction: StringBuilder.() -> Unit //수신 객체가 있는 함수 타입의 파라미터 선언
): String{
    val sb = StringBuilder()
    sb.builderAction() //StringBuilder 인스턴스를 람다의 수신객체로 넘긴다
    return sb.toString()
}
  • buildString에게 수신 객체 지정 람다를 인자로 넘기기 때문에 람다안에서 it 키워드 제거
  • 명확히는 this.append() 로 써야 하지만, this. 은 생략 가능
  • 파라미터 타입 선언은 확장 함수 타입을 사용했다
    • (StringBuilder) → Unit을 StringBuilder.() → Unit로 변경
    • . 앞에 오는 타입을 수신 객체 타입, 람다에 전달되는 그런 타입 객체를 수신 객체라고 한다.

수신 객체의 확장함수를 사용

왜 확장 함수 타입일까? 확장 함수의 본문에서는 확장 대상 클래스에 정의된 메서드를 클래스 내부에서 호출하듯 할 수 있다. 확장 함수나 수신 객체 지정 람다에서는 모두 함수를 호출할 때, 수신 객체를 지정해야 하고, 함수 본문에서는 수신 객체를 수식자 없이 사용할 수 있다.

EX) StringBuilder 인스턴스를 builderAction(sb) 구문으로 전달할 수 있지만, 수신 객체 지정 람다를 사용해 sb.builderAction()으로 전달할 수 있다.

→ builderAction은 StringBuilder 클래스 안에 정의된 함수가 아니며, sb는 확장 함수를 호출할 때와 동일한 구문으로 호출할 수 있는 함수 타입 인자일 뿐이다.

수신 객체 지정 람다를 변수에 저장하기
fun main() {
    val appendExcl: StringBuilder.() -> Unit = {//appendExcl은 확장 함수 타입의 값
        this.append("!")
    }
    val stringBuilder = StringBuilder("Hi")
    stringBuilder.appendExcl() //appendExcl을 확장함수처럼 호출
    println(stringBuilder)
    println(buildString(appendExcl))//appendExcl을 인자로 넘길 수 있다.
}
  • 소스코드상 수신 객체 지정 람다는 일반 람다와 똑같아 보이는 것을 주의하자
  • 함수 시그니처를 보면 람다에 수신 객체 여부와, 어떤 타입의 수신 객체를 요구하는지 알 수 있다.
표준 라이브러리의 buildString
fun buildString(builderAction: StringBuilder.() -> Unit): String =
    StringBuilder().apply(builderAction).toString()
 
  • apply 함수는 인자로 받은 람다나 함수를 호출하면서 자신의 수신 객체를 람다나 묵시적 수신 객체로 사용한다
apply, with
inline fun T.apply(block: T.() -> Unit): T {
    block() //this.block() 과 같다. apply의 수신객체를 수신객체로 지정해 람다(block)를 호출
    return this //수신 객체 반환
}
inline fun <T, R> with(receiver: T, block: T.() -> R): R =
    receiver.block()//람다를 호출해 얻은 결과를 반환
  •  apply, with 모두 자신이 제공받은 수신 객체로, 확장 함수 타입의 람다를 호출
  • apply는 수신 객체 타입에 대한 확장 함수로 선언됐기 때문에 수신객체의 메서드처럼 불리며, 수신객체를 묵시적인자(this)로 받는다.
  • with는 수신 객체를 첫번째 파라미터로 받으며, 람다를 호출해 얻은 결과를 반환한다.
사용 예제(apply, with)
fun main() {
    val map = mutableMapOf(1 to "one")
    map.apply { this[2] = "two"}
    with(map) {
        this[3] = "three"
    }
    println(map)
}
  • apply는 수신 객체를 다시 반환 하지만, with는 람다를 호출해 얻은 결과를 반환한다

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

HTML 빌더 : HTML을 만들기 위한 코틀린 DSL(타입 안전한 빌더의 대표적인 예)

HTML 빌더를 사용해 table 만들기
fun createSimpleTable() = createHTML()
    .table {
        tr {   
            td { +"cell" }
        }
    }
  • 각 수신 객체 지정 람다가 이름 결정 규칙을 바꾼다.
    • table 함수에 넘겨진 람다에서는 tr을 사용해 HTML 태그를 그리지만, 그 밖에서는 tr이라는 함수를 찾을 수 없다.
    • td 또한 tr안에만 존재한다.
  • 각 블록의 이름 결정 규칙은 람다의 수신 객체에 의해 결정된다.
HTML 빌더를 위한 태그 클래스 정의
open class Tag
class TABLE : Tag {
    fun tr(init : TR.() -> Unit) //tr 함수는 TR 타입을 수신 객체로 받는 람다를 인자로 받는다
}
class TR : Tag {
    fun td(init : TD.() -> Unit) //td 함수는 TD 타입을 수신 객체로 받는 람다를 인자로 받는다
}
class TD : Tag
  •  TABLE, TR, TD 모두 HTML 생성 코드에 나타나면 안되는 유틸리티 클래스다.(이름을 모두 대문자로 만들어 일반 클래스와 분류)
    • 모두 Tag를 확장
  • tr, td 메서드의 init 파라미터를 보면, 모두 확장함수인 것을 알 수 있다
    • 각 메서드에 전달할 람다의 수신 객체 타입을 TR, TD로 지정한다.
HTML 빌더 호출의 수신 객체를 명시
fun createSimpleTable() = createHTML()
    .table {
        (this@table).tr { //this@table의 타입은 TABLE 이다
            (this@tr).td {  //this@tr의 타입은 TR이다
                +"cell" // 묵시적 수신 객체로 this@td을 사용할 수 있고 그 타입은 TD다
            }
        }
    }
  •  수신 객체를 묵시적으로 정하고, this 참조를 쓰지 않아도 되면, 빌더 문법이 간단해지고, 전체적인 구문이 HTML구문과 비슷해 진다.
  • 수신 객체 지정 람다가 다른 수신 객체 지정 람다안에 들어가게 된다면, 내부 람다에서 외부람다에 정의된 수신객체를 사용할 수 있다.
    • EX) td 함수의 인자 람다에서 this@table, this@tr, this@td를 사용할 수 있다
    • 코틀린 1.1 부터 @DslMarker 애노테이션을 사용해 중첩된 람다에서 외부 람다의 수신 객체를 접근하지 못하게 제한할 수 있다.
간단한 HTML 빌더의 전체 구현
open class Tag(val name: String) {
    private val children = mutableListOf<Tag>() //모든 중첩 태그를 저장한다

    protected fun <T : Tag> doInit(child: T, init: T.() -> Unit) {
        child.init()//자식 태그를 초기화한다
        children.add(child)//자식 태그에 대한 참조를 저장한다
    }

    override fun toString() =
        "<$name>${children.joinToString("")}</$name>"//결과 HTML을 문자열로 반환한다
}

fun table(init: TABLE.() -> Unit) = TABLE().apply(init)

class TABLE : Tag("table") {
    fun tr(init: TR.() -> Unit) = doInit(TR(), init) //TR태그 인스턴스를 새로 만들고, 초기화한 다음 TABLE 태그의 자식으로 등록한다
}
class TR : Tag("tr") {
    fun td(init: TD.() -> Unit) = doInit(TD(), init)//TD태그의 새 인스턴스를 만들어서 TR 태그의 자식으로 등록한다
}
class TD : Tag("td")

fun createTable() = table {
    tr {
        td { }
    }
}

fun main() {
    println(createTable())
}
  • table 함수는 TABLE 태그의 새 인스턴스를 만들고 그 인스턴스를 초기화하고 반환한다.
    • table 함수에 전달된 init 람다를 호출
    • table에 전달된 init 람다에는 tr 함수 호출이 들어 있다.
    • TABLE().tr{ ...} 이라고 쓴 것처럼 TABLE 인스턴스를 수신 객체로 호출한다.
  • 각 태그에는 자식들에 대한 참조를 저장하는 리스트가 들어 있으며, 주어진 태그를 초기화하고, 바깥쪽 태그의 자식으로 추가하는 로직이 공통으로 들어가야한다.
    • 이런 기능을 Tag라는 상위 클래스로 뽑아 doInit 이라는 멤버로 만들었다.
    • doInit은 자식 태그에 대한 참조를 저장하는 일과, 인자로 전달받은 람다를 호출하는 책임을 진다.
  • 여러 태그들은 doInit() 메서드만 호출하면 된다.
  • 모든 태그에는 중첩 태그를 저장하는 리스트가 있어, 자기 이름을 태그 안에 넣고, 자식 태그를 재귀적으로 문자열로 바꿔서 합친 다음에 닫는 태그를 추가하는 방식으로 적절히 자기 자신을 문자열로 표현한다.
HTML 빌더를 사용해 태그를 동적으로 생성
fun createSimpleAnotherTable() = table {
    for(i in 1..2) {
        tr { //tr이 호출될 때마다 매번 새 TR 태그가 생기고, TABLE 자식으로 등록된다.
            td { }
        }
    }
}

fun main() {
    println(createSimpleAnotherTable())
}
  • 태그 생성 함수가 자신이 새로 생성한 태그를 부모 태그가 가진 자식 목록에 추가한다는 점의 유의하자!

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

내부 DSL을 사용하면 반복되는 내부 DSL 코드 조각을 새 함수로 묶어서 재사용이 가능하다.

부트스트랩 라이브러리를 활용한 드롭다운 목록 추가

부트스트랩 라이브러리를 사용해 드롭다운 메뉴를 HTML에 추가하기
<div class="dropdown">
    <button class="btn dropdown-toggle">
      Dropdown
    </button>
    <ui class="dropdown-menu">
      <li><a href="#">Action</a></li>
      <li><a href="#">Another Action</a></li>
      <li role="separator" class="dropdown-divider"></li>
      <li class="dropdown-header">Header</li>
      <li class="dropdown-item"><a href="#">Separated link</a></li>
    </ui>
  </div>
HTML 빌더를 사용해 드롭다운 메뉴 만들기
fun main() {
    println(buildDropdown())
}

fun buildDropdown() = createHTML().div(classes = "dropdown") {
    button(classes = "btn dropdown-toggle") {
        +"Dropdown"
        span(classes = "caret")
    }
    ul(classes = "dropdown-menu") {
        li { a("#") { +"Action" } }
        li { a("#") { +"Another Action" } }
        li { role = "separator"; classes = setOf("divider") }
        li { classes = setOf("dropdown-header"); +"Header" }
        li { a("#") { +"Separated link" } }
    }
}
 

도우미 함수를 활용해 드롭다운 메뉴 만들기
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 } }
  • 첫 번째 파라미터는 href에 들어갈 주소, 두 번째 파라미터에는 메뉴 원소 이름이 들어간다.
  • dropdown 메뉴 목록에 li { a(href) { +name } }라는 원소를 새로 추가한다.
  • li가 UL 클래스의 확장 함수이기 때문에, UL클래스의 확장 함수로 구현할 수 있다
  • 모든 UL태그 안에서 item을 호출할 수 있고, item함수는 항상 Li 태그를 추가해 준다.
divider, dropdownHeader, dropdown, dropdownButtonMenu 확장함수
fun UL.divider() = li { role = "separator"; classes = setOf("divider") }

fun UL.dropdownHeader(text: String) = li { classes = setOf("dropdown-header"); +text }

fun DIV.dropdownMenu(block: UL.() -> Unit) = ul("dropdown-menu", block)

fun DIV.dropdownButton(block: BUTTON.() -> Unit) =
    button(classes = "btn dropdown-toggle") {
        block()
        span(classes = "caret")
    }
  •  divider, dropdownHeader 는 item과 비슷한 방식으로 확장함수를 정의하여 li태그를 없앨 수 있다.
  • dropdownMenu는 "dropdown-menu" CSS 클래스가 지정되 ul태그를 만들는데, 인자로 태그 내용을 채워 넣는 수신 객체 지정 람다를 받는다
  • ul{...} 블록을 dropdownMenu{ ...} 으로 바꿔서 호출할 수 있는것이다.
    • ul과 dropdownMenu의 수신 객체 지정 람다 내부에서는 수신 객체가 같다.
    • dropdownMenu는 UL을 적용한 확장 함수 타입의 람다를 인자로 받기 때문에 람다 안에서는 UL.item 등을 특별한 지시자 없이 사용할 수 있다.
  • dropdownButton 도 비슷한 방식으로 구현된다
dropdown 메뉴를 만드는 최상위 함수
fun TagConsumer<String>.dropdown(
    block: DIV.() -> Unit
): String = div("dropdown", block) = "caret")
  •  TagConsumer 추상 클래스를 수신객체 타입으로 사용해 HTML 여러 태그에서 사용하게 dropdown을 구현 한다.
728x90