Kotlin - DSL(1): 수신 객체 지정 람다
이 포스트에서는 DSL 에 대해 알아본다.
- DSL (Domain-Specific Language, 영역 특화 언어)
- 수신 객체 지정 람다 사용
DSL 을 사용하면 표현력이 좋고 코틀린다운 API 를 설계할 수 있다.
전통적인 API 와 DSL 형식의 API 의 차이에 대해 알아본 후 DB 접근, HTML 생성, 테스트, 빌드 스크립트 작성 등의 여러 작업에 사용할 수 있음을 살펴본다.
소스는 github 에 있습니다.
목차
- 1. API 에서 DSL 로
- 2. 구조화된 API: DSL 에서 수신 객체 지정 DSL 사용
- 참고 사이트 & 함께 보면 좋은 사이트
개발 환경
- 언어: kotlin 1.9.23
- IDE: intelliJ
- SDK: JDK 17
- 의존성 관리툴: Gradle 8.5
1. API 에서 DSL 로
DSL 에 대해 알아보기 전에 해결하는 문제에 대해 알아보자.
궁극적인 목표는 코드의 가독성과 유지 보수성을 좋게 유지하는 것이다.
클래스에 있는 코드 중 대부분은 다른 클래스와 상호 작용한다.
따라서 그런 상호 작용이 일어나는 연결 지저인 인터페이스를 살펴보아야 한다.
즉, 클래스의 API 를 살펴보아야 한다.
라이브러리가 외부 사용자에게 API 를 지원하는 것처럼 애플리케이션 안의 모든 클래스는 다른 클래스에게 자신과 상호 작용할 수 있는 가능성을 제공한다.
이런 상호 작용을 이해하기 쉽고 명확하게 표현할 수 있게 만들어야 프로젝트를 계속 유지 보수할 수 있다.
깔끔한 API 의 의미
- 어떤 일이 벌어질 지 명확하게 이해할 수 있어야 함
- 이름과 개념을 잘 선택하면 이런 목적을 달성할 수 있음
- 코드가 간결해야 함
- 이번 포스트에서 주로 초점을 맞추는 부분임
깔끔한 API 를 작성할 수 있게 돕는 코틀린 기능은 아래와 같은 것들이 있다.
- 확장 함수
- 중위(
infix
) 함수 호출 - 람다 구문에 사용할 수 있는
it
등의 문법적 편의 - 연산자 오버로딩
확장 함수에 대한 좀 더 상세한 내용은
1. 확장 함수 (extension function),
4. 확장 함수와 null 이 될 수 있는 타입,
5. 상속과 확장 을 참고하세요.
아래는 위의 특성이 코드에 있는 문법적인 잡음을 얼마나 줄여주는지 보여준다.
일반 구문 | 간결한 구문 | 사용한 언어 특성 |
---|---|---|
StringUtil.capitalize(s) | s.capitalize() | 확장 함수 |
1.to(“one”) | 1 to “one” | 중위 호출 |
set.add(2) | set += 2 | 연산자 오버로딩 |
map.get(“key”) | map[“key”] | get() 메서드에 대한 관례 |
file.use({ f -> f.read() }) | file.use { it.read() } | 람다를 괄호 밖으로 빼내는 관례 |
sb.append(“yes”) sb.append(“no”) | with (sb) { append(“yes”) append(“no”) } | 수신 객체 지정 람다 |
이 포스트에서는 깔끔한 API 에서 한 걸음 더 나아가 DSL 구축을 도와주는 코틀린 기능을 살펴본다.
코틀린 DSL 은 간결한 구문을 제공하는 기능과 그런 구문을 확장해서 여러 메서드 호출을 조합한 구조를 만들어내는 기능에 의존한다.
그 결과 DSL 은 메서드 호출만을 제공하는 API 에 비해 더 표현력이 풍부해지고 사용하기 편해진다.
1.1. DSL (Domain-Specific Language, 영역 특화 언어)
SQL 과 정규식도 DSL 이다.
이 두 언어는 DB 조작과 문자열 조작이라는 특정 작업에 적합하지만 전체 애플리케이션을 이 두 언어로 작성하는 경우는 없다.
압축적인 문법을 사용함으로써 DSL 은 범용 언어를 사용하는 경우보다 특정 영역에 대한 연산을 더 간결하게 기술할 수 있다.
DSL 의 단점으로는 바로 DSL 을 범용 언어로 만든 애플리케이션과 함께 조합하기가 어렵다는 점이다.
DSL 은 자체 문법이 있기 때문에 다른 언어의 프로그램 안에 직접 포함시킬 수가 없으므로 DSL 로 작성한 프로그램을 다른 언어에서 호출하려면 DSL 프로그램을 별도의 파일이나 문자열 리터럴로 저장해야 한다.
하지만 이런 식으로 DSL 을 저장하면 호스트 프로그램과 DSL 의 상호 작용을 컴파일 시점에 검증하거나, DSL 프로그램을 디버깅하기 어려워진다.
또한, DSL 과 호스트 언어의 문법이 서로 다르므로 두 언어를 함께 배워야 한다.
이런 문제를 해결하면서 DSL 의 다른 이점을 살리는 방법으로 internal DSL (내부 DSL) 개념이 유명해지고 있다.
1.2. internal DSL (내부 DSL)
독립적인 문법 구조를 가진 external DSL 과는 반대로 internal DSL 은 범용 언어로 작성된 프로그램의 일부이며, 범용 언어와 동일한 문법을 사용한다.
따라서 internal DSL 은 완전히 다른 언어가 아니라 DSL 의 핵심 장점을 유지하면서 주 언어를 특별한 방법으로 사용하는 것이다.
external DSL 인 SQL 과 internal DSL 인 Exposed (코틀린으로 작성된 DB 프레임워크) 가 제공하는 DSL 의 예를 살펴보자.
external DSL 인 SQL
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
internal DSL 인 Exposed
(Country join Customer)
.slice(Country.name, Count(Customer.id))
.selectAll()
.groupBy(Country.name)
.orderBy(Count(Customer.id), isAsc = false)
Exposed 의 경우 SQL 질의가 돌려주는 결과가 네이티브 코틀린 객체이므로 따로 변환할 필요가 없다.
따라서 Exposed 를 internal DSL 이라고 부른다.
1.3. DSL 구조
다른 API 에는 존재하지 않지만 DSL 에만 존재하는 특징으로 구조 혹은 문법이 있다.
- command-query API
- 여러 메서드로 이루어지며, 클라이언트는 그런 메서드를 한 번에 하나씩 호출함으로써 라이브러리를 사용함
- 함수 호출 시퀀스에는 아무런 구조가 없으며, 한 호출과 다른 호출 사이에는 아무 맥락도 존재하지 않음
- DSL
- DSL 의 메서드 호출은 DSL 문법에 의해 정해지는 커다란 구조에 속함
- 코틀린 DSL 에서는 보통 람다를 중첩시키거나, 메서드 호출을 연쇄시키는 방식으로 구조를 만듦
- 바로 뒤의 Exposed 도 그런 구조임
- 이런 문법이 있기 때문에 internal DSL 이라고 부를 수 있음
DSL 에서는 여러 함수 호출을 조합해서 연산을 만들며, 타입 검사기는 여러 함수 호출이 바르게 조합되었는지를 검사한다.
DSL 구조의 장점은 같은 문맥을 함수 호출 시마다 반복하지 않고 재사용할 수 있다는 점이다.
Gradle 에서 의존 관계를 정의할 때 사용하는 코틀린 DSL 인 아래 예시를 보자.
더 많은 코드는 kotlin-dsl-samples 를 참고하세요.
// 람다 중첩을 통해 구조를 만듦
dependencies {
compile("junit:junit:4.11")
compile("com.google.inject:guice:4.1.0")
}
아래는 위 코드를 command-query API 로 만든 예시이다.
project.dependencies.add("compile", "junit:junit:4.11")
project.dependencies.add("compile", "com.google.inject:guice:4.1.0")
코틀린 DSL 로 작성할 때보다 코드 중복이 많은 것을 볼 수 있다.
메서드 호출 연쇄는 DSL 구조를 만드는 또 다른 방법이다.
아래는 코틀린을 위한 서드파티 테스트 프레임워크인 코틀린 테스트 kotest 의 예시이다.
코틀린 테스트에 대한 좀 더 상세한 내용은 2. 실전 DSL 을 참고하세요.
// 메서드 호출을 연쇄시켜 구조를 만듦
str should startWith("kot")
아래는 위 코드를 일반 jUnit API 로 만든 예시이다.
assertTrue(str.startWith("kot"))
같은 코드이지만 가독성이 더 좋지 않은 것을 알 수 있다.
1.4. 내부 DSL 로 HTML 생성
아래는 kotlinx.html 라이브러리를 사용하여 cell 이 하나인 표를 만드는 예시이다.
fun createTable() =
document { }.createHTMLTree().table {
// this 는 TABLE
tr {
// this 는 TR
td {
// this 는 TD
+"cell"
}
}
}
위와 같은 구조가 만들어내는 HTML 은 아래와 같다.
<table>
<tr>
<td>cell</td>
</tr>
</table>
implementation("org.jetbrains.kotlinx:kotlinx-html:0.11.0")
createSimpleTable() 는 위의 HTML 이 들어있는 문자열을 반환한다.
직접 HTML 텍스트를 작성하지 않고 코틀린 코드로 HTML 을 만들면 타입 안전성을 보장할 수 있다.
td 를 tr 안에 넣지 않으면 컴파일이 되지 않는다.
더 중요한 것은 이 코드가 일반 코틀린이라는 점이다.
따라서 표를 정의하면서 동적으로 칸을 생성할 수 있다.
2. 구조화된 API: DSL 에서 수신 객체 지정 DSL 사용
DSL 의 정의와 왜 DSL 이 필요한지 알았으므로 DSL 을 작성할 때 코틀린이 어떤 도움이 되는지와, DSL 문법을 만들 때 가장 중요한 역할을 하는 수신 객체 지정 람다에 대해 알아본다.
수신 객체 지정 람다의 레이블에 대해서는 1.10.2.1. 레이블이 붙은
this
를 참고하세요.
수신 객체 지정 람다는 구조화된 API 를 만들 때 도움이 되는 강력한 코틀린 기능이다.
구조가 있다는 점은 일반 API 와 DSL 을 구분하는 중요한 특성이다.
여기서는 수신 객체 지정 람다와 그 기능을 활용하는 DSL 에 대해 알아본다.
2.1. 수신 객체 지정 람다(Lambda with a receiver)와 확장 함수 타입
2.2. apply()
와 1.8. 확장 람다와 사용하는 StringBuilder
와 buildString()
에서 buildString()
, with()
, apply()
표준 라이브러리 함수를 보면서 수신 객체 지정 람다에 대해 간략히 살펴보았다.
여기서는 buildString()
함수를 통해 코틀린이 수신 객체 지정 람다를 어떻게 구현하는지에 대해 알아본다.
buildString()
함수를 사용하면 StringBuilder
객체에 여러 내용을 추가할 수 있다.
람다를 받는 함수를 사용하는 방법은 1.2. 함수 인자로 람다나 함수 참조 전달 을 참고하세요.
2.2.1. 일반 람다를 인자로 받는 customBuildString() 함수 정의
package com.assu.study.kotlin2me.chap11.receiverobjectdsl
// 람다를 인자로 받는 customBuildString() 정의
fun customBuildString(
builderAction: (StringBuilder) -> Unit, // 함수 타입인 파라메터 정의
): String {
val sb = StringBuilder()
// 람다 인자를 StringBuilder 인스턴스로 넘김
builderAction(sb)
return sb.toString()
}
fun main() {
val s =
customBuildString {
// it 은 StringBuilder 인스턴스를 가리킴
it.append("Hello")
it.append("Assu!")
}
// HelloAssu!
println(s)
}
위 코드는 이해하기 쉽지만 사용하기는 편하지 않다.
람다 본문에서 매번 it
을 사용하여 StringBuilder
인스턴스를 참조해야 한다.
람다의 목적이 StringBuilder
를 텍스트로 채우는 것이므로 it.append()
처럼 메서드 이름 앞에 it.
을 일일히 넣지 않고 append()
를 더 간단하게 호출할 수 있으면 더 좋을 것 같다.
그러기 위해선 람다를 수신 객체 지정 람다로 변경해야 한다.
람다의 인자 중 하나에게 수신 객체라는 상태를 부여하면 이름과 마침표를 명시하지 않아도 그 인자의 멤버를 바로 사용할 수 있다.
2.2.2. 수신 객체 지정 람다를 사용한 customBuildString()
package com.assu.study.kotlin2me.chap11.receiverobjectdsl
// 수신 객체 지정 람다를 사용한 customBuildString()
fun customBuildString2(
builderAction: StringBuilder.() -> Unit, // 수신 객체가 있는 함수 타입의 파라메터 선언
): String {
val sb = StringBuilder()
// StringBuilder 인스턴스를 람다의 수신 객체로 넘김
sb.builderAction()
return sb.toString()
}
fun main() {
val s =
customBuildString2 {
// this 는 StringBuilder 인스턴스를 가리킴
this.append("Hello")
// this 를 생략해도 묵시적으로 StringBuilder 인스턴스가 수신 객체로 취급됨
append("Assu!")
}
// HelloAssu!
println(s)
}
customBuildString2() 로 수신 객체 지정 람다를 인자로 넘기기 때문에 람다 안에서 it
를 사용하지 않아도 된다.
완전한 문장은 this.append()
이지만 클래스 멤버 안에서 보통 그렇듯이 모호한 경우가 아니라면 this.
를 명시할 필요가 없다.
customBuildString2() 의 선언을 보자.
파라메터 타입을 선언할 때 일반 함수 타입 대신 확장 함수 타입을 사용하였다.
// 일반 함수 타입
(StringBuilder) -> Unit
// 확장 함수 타입
StringBuilder.() -> Unit
위에서 마침표 .
앞에 있는 StringBuilder
가 수신 객체 타입이고, 람다에 전달되는 그런 타입의 객체를 수신 객체라고 한다.
아래는 수신 객체 타입이 String 이고, 파라메터로 2 개의 Int 를 받으며, Unit 을 반환하는 확장 함수 타입이다.
String.(Int, Int) -> Unit
- 수신 객체 타입: String
- 파라메터 타입: Int, Int
- 반환 타입: Unit
그렇다면 왜 확장 함수 타입을 사용하는 걸까?
확장 함수의 본문에서는 확장 대상 클래스에 정의된 메서드를 마치 그 클래스 내부에서 호출하듯이 사용할 수 있다.
확장 함수나 수신 객체 지정 람다에서는 모두 함수(= 람다) 를 호출할 때 수신 객체를 지정해야만 하고, 함수(= 람다) 본문 안에서는 그 수신 객체를 특별한 수식자없이 사용할 수 있다.
일반 람다를 사용할 때는 StringBuilder
인스턴스를 builderAction(sb) 구문을 사용하여 전달하지만,
수신 객체 지정 람다를 사용할 때는 sb.builderAction() 으로 전달한다.
즉, sb.builderAction() 에서 builderAction() 은 StringBuilder
클래스 안에 정의된 함수가 아니며, StringBuilder
인스턴스인 sb 는 확장 함수를 호출할 때와 동일한 구문으로 호출할 수 있는 함수 타입(= 확장 함수 타입) 의 인자일 뿐이다.
아래는 customBuildString2() 함수의 인자와 파라메터 사이의 대응 관계와 람다 본문이 호출될 때 어떤 수신 객체가 사용되는지에 대한 그림이다.
위 그림을 보면 수신 객체 지정 람다인 customBuildString2() 함수의 인자는 확장 함수 타입인 builderAction 의 파라메터에 대응한다.
호출된 람다 본문 안에서의 수신 객체인 sb 는 묵시적 수신 객체인 this
가 된다.
2.1.3. 수신 객체 지정 람다를 변수에 저장한 customBuildString()
아래처럼 확장 함수 타입의 변수를 정의하여 그 변수를 마치 확장 함수처럼 호출하거나 수신 객체 지정 람다를 요구하는 함수에게 인자로 넘길수도 있다.
package com.assu.study.kotlin2me.chap11.receiverobjectdsl
// 수신 객체 지정 람다를 변수에 저장
// appendExcl 은 확장 함수 타입의 값임
val appendExcl: StringBuilder.() -> Unit = {
// this 는 StringBuilder 임
this.append("Hello")
}
fun main() {
val sb = StringBuilder("Assu")
// appendExcl() 을 확장 함수처럼 호출
sb.appendExcl()
// AssuHello
println(sb)
// appendExcl 을 인자로 넘길 수 있음
println(buildString(appendExcl)) // Hello
}
2.1.4. buildString()
, apply()
, with()
표준 라이브러리의 buildString()
의 구현은 2.2.2. 수신 객체 지정 람다를 사용한 customBuildString() 보다 더 짧다.
builderAction 을 명시적으로 호출하는 대신 builderAction 을 apply()
함수에게 인자로 넘긴다.
buildString()
시그니처
public inline fun buildString(builderAction: StringBuilder.() -> Unit): String {
return StringBuilder().apply(builderAction).toString()
}
apply()
함수는 인자로 받은 람다나 함수(여기서는 builderAction) 를 호출하면서 자신의 수신 객체(여기서는 StringBuilder) 를 람다나 함수의 묵시적 수신 객체로 사용한다.
apply()
와 with()
의 시그니처를 한번 보자.
apply()
시그니처
public inline fun <T> T.apply(block: T.() -> Unit): T {
// this.block() 과 동일함
// apply() 의 수신 객체를 수신 객체로 지정하여 람다(block) 을 호출함
block()
// 수신 객체 반환
return this
}
with()
시그니처
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
// 람다를 호출하여 얻은 결과를 반환
return receiver.block()
}
기본적으로 apply()
와 with()
는 모두 자신이 제공받은 수신 객체로 확장 함수 타입의 람다를 호출한다.
apply()
는 수신 객체 타입에 대한 확장 함수로 선언되었기 때문에 수신 객체의 메서드처럼 불리며, 수신 객체를 묵시적 인자 this
로 받는다.
with()
는 수신 객체를 첫 번째 파라메터로 받는다.
apply()
는 수신 객체를 반환하지만, with()
는 람다를 호출하여 얻은 결과를 반환한다.
결과를 받아서 사용할 필요가 없다면 이 두 함수를 서로 바꿔서 사용할 수 있다.
package com.assu.study.kotlin2me.chap11.receiverobjectdsl
fun main() {
val map = mutableMapOf(1 to "one")
map.apply { this[2] = "two" }
with(map) { this[3] = "three" }
// {1=one, 2=two, 3=three}
println(map)
}
2.2. 수신 객체 지정 람다를 HTML 빌더 안에서 사용
2.1. 수신 객체 지정 람다(Lambda with a receiver)와 확장 함수 타입 에서 수신 객체 지정 람다와 확장 함수 타입에 대해 알아보았다.
이제 이 개념이 DSL 에서 어떻게 사용되는지에 대해 알아본다.
HTML 을 만들기 위한 코틀린 DSL 을 HTML 빌더라고 한다.
HTML 빌더는 타입 안전한 빌더(type-safe builder) 이다.
빌더를 사용하면 객체 계층 구조를 선언적으로 정의할 수 있는데 코틀린 빌더는 타입 안전성까지 보장한다.
1.4. 내부 DSL 로 HTML 생성 에서 본 HTML 빌더 코드를 다시 보자.
fun createTable() =
document { }.createHTMLTree().table {
// this 는 TABLE
tr {
// this 는 TR
td {
// this 는 TD
+"cell"
}
}
}
위 코드는 일반 코틀린 코드이지 특벽한 템플릿 언어같은 것이 아니다.
table(), tr(), td() 등은 모두 평범한 함수이며, 모두 고차 함수로 수신 객체 지정 람다를 인자로 받는다.
각 수신 객체 지정 람다가 이름 결정 규칙을 결정한다.
table() 함수에 넘겨진 람다에서는 tr() 함수를 사용할 수 있지만, 그 람다 밖에서는 tr() 이라는 이름의 함수를 찾을 수 없다.
각 블록의 이름 결정 규칙은 각 람다의 수신 객체에 의해 결정된다.
table() 에 전달된 수신 객체는 TABLE 이라는 특별한 타입이며, 그 안에 tr() 메서드 정의가 있다.
마찬가지로 tr() 함수는 TR 객체에 대한 확장 함수 타입의 람다를 받는다.
2.2.1. HTML 빌더를 위한 태그 클래스 정의
(틀만 잡은 것이므로 컴파일 오류는 나는 상태인 코드임)
package com.assu.study.kotlin2me.chap11.receiverobjectdsl
open class Tag
class TABLE: Tag {
// tr 함수는 TR 타입을 수신 객체로 받는 람다를 인자로 받음
fun tr(init: TR.() -> Unit)
}
class TR: Tag {
// td 함수는 TD 타입을 수신 객체로 받는 람다를 인자로 받음
fun td(init: TD.() -> Unit)
}
class TD: Tag
TABLE, TR, TD 는 모두 HTML 생성 코드에 나타나면 안되는 유틸리티 클래스이므로 이름을 모두 대문자로 하여 일반 클래스와 구분한다.
위 클래스들은 모두 Tag 클래스를 확장하며, 각 클래스 내부에는 자신의 내부에 들어갈 수 있는 태그를 생성하는 메서드가 들어있다.
tr(), td() 의 init 파라메터 타입은 각각 TR.() -> Unit, TD.() -> Unit 으로 모두 확장 함수이다.
이런 확장 함수 타입은 각 메서드에 전달한 람다의 수신 객체 타입을 순서대로 TR 과 TD 로 지정한다.
2.2.2. HTML 빌더 호출의 수신 객체 명시: @DslMarker
이제 각 함수에서 어떤 일이 벌어지는지 더 명확히 보기 위해 모든 수신 객체를 명시해보자.
foo() 함수의 람다가 사용하는 수신 객체에 접근할 때 this@foo
라는 식을 사용한다는 점에 유의하자.
this@foo
에 대한 설명은 1.10.2.1. 레이블이 붙은this
를 참고하세요.
fun createSimpleTable() = createHTML().
table {
(this@table).tr { // this@table 의 타입은 TABLE 임
(this@tr).td { // this@tr 의 타입은 TR 임
+"cell" // 이 본문에서는 묵시적 수신 객체로 this@td 를 사용할 수 있고, 그 타입은 TD 임
}
}
}
빌더에 수신 객체 지정 람다가 아닌 일반 람다를 사용하면 HTML 생성 코드 구문이 매우 난잡해질 것이다.
수신 객체를 묵시적으로 정하고 this
참조를 사용하지 않아도 되면 빌더 문법이 간단해지고 전체적인 구문이 원래의 HTML 구문과 비슷해진다.
위 코드처럼 수신 객체 지정 람다가 다른 수신 객체 지정 람다 안에 들어가면 내부 람다에서 외부 람다에 정의된 수신 객체를 사용할 수 있다.
예를 들어 td() 함수의 인자인 람다 안에서는 this@table, this@tr, this@td 이렇게 3 가지 수신 객체를 사용할 수 있다.
코틀린 1.1 부터는
@DslMarker
애너테이션을 사용하여 중첩된 람다에서 외부 람다의 수신 객체를 접근하지 못하게 막을 수 있음
2.2.3. kotlinx.html
로 테이블 생성
아래는 kotlinx.html
라이브러리에서 정의한 함수를 사용하는 예시이다.
package com.assu.study.kotlin2me.chap11.receiverobjectdsl
import kotlinx.html.dom.createHTMLTree
import kotlinx.html.dom.document
import kotlinx.html.dom.serialize
import kotlinx.html.table
import kotlinx.html.td
import kotlinx.html.tr
fun createTable() =
document { }.createHTMLTree()
.table { // this 는 TABLE
tr { // this 는 TR
td { // this 는 TD
+"cell"
}
}
}
fun main() {
println(createTable().serialize())
}
<table>
<tr>
<td>cell</td>
</tr>
</table>
2.2.4. HTML 빌더 전체 구현
먼저 table() 함수를 정의한다.
fun table(init: TABLE.() -> Unit): TABLE = TABLE().apply(init)
table() 함수는 TABLE 태그의 새로운 인스턴스를 만들고, 그 인스턴스를 초기화 (이 때 apply()
로 table() 함수에 전달된 init 람다 호출) 하고 반환한다.
table() 호출에서 모든 부분을 명시하면 아래와 같다.
table(init = { this.tr { ... } })
tr() 함수는 마치 TABLE().tr { … } 이라고 쓴 것처럼 TABLE 인스턴스를 수신 객체로 호출된다.
tr() 함수를 보자.
fun tr(init: TR.() -> Unit) {
var tr = TR()
tr.init()
children.add(tr)
}
이런 식으로 주어진 태그를 초기화하고, 바깥쪽 태그의 자식으로 추가하는 로직을 거의 모든 태그가 공유하므로 이런 기능은 상위 클래스인 Tag 로 옮긴 후 doInit 이라는 멤버로 만들 수 있다.
doInit() 은 자식 태그에 대한 참조를 저장하는 일과 인자로 전달받은 람다를 호출하는 기능을 한다.
package com.assu.study.kotlin2me.chap11.receiverobjectdsl
import com.assu.study.kotlin2me.chap08.joinToString
open class Tag(val name: String) {
// 모든 중첩 태그를 저장함
private val children = mutableListOf<Tag>()
// 하위 클래스에서만 볼 수 있도록 protected 가시성 변경자 적용
protected fun <T: Tag> doInit(child: T, init: T.() -> Unit): Unit {
child.init() // 자식 태그 초기화
children.add(child) // 자식 태그에 대한 참조 저장
}
override fun toString(): String {
return "<${name}>${children.joinToString("")}</${name}>"
}
}
fun table(init: TABLE.() -> Unit): TABLE = TABLE().apply(init)
class TABLE : Tag("table") {
// tr 함수는 TR 타입을 수신 객체로 받는 람다를 인자로 받음
// TR 태그 인스턴스를 새로 생성하고, 초기화한 다음에 TABLE 태그의 자식으로 등록
fun tr(init: TR.() -> Unit): Unit = doInit(TR(), init)
}
class TR : Tag("tr") {
// td 함수는 TD 타입을 수신 객체로 받는 람다를 인자로 받음
// TD 태그의 새로운 인스턴스를 생성한 후 TR 태그의 자식으로 등록
fun td(init: TD.() -> Unit): Unit = doInit(TD(), init)
}
class TD : Tag("td")
fun customCreateTable() =
table {
tr {
td { }
}
}
fun main() {
// <table><tr><td></td></tr></table>
println(customCreateTable())
}
아래는 HTML 빌더를 사용하여 태그를 동적으로 생성하는 예시이다.
fun createAnotherTable() =
table {
for (i in 1..2) {
tr {
td { }
}
}
}
fun main() {
// <table><tr><td></td></tr><tr><td></td></tr></table>
println(createAnotherTable())
}
수신 객체 지정 람다는 DSL 을 만들 때 매우 유용하다.
수신 객체 지정 람다를 사용하면 코드 블록 내부에서 이름 결정 규칙을 바꿀 수 있으므로 이를 이용하여 API 에 구조를 추가할 수 있다.
참고 사이트 & 함께 보면 좋은 사이트
본 포스트는 드리트리 제메로프, 스베트라나 이사코바 저자의 Kotlin In Action 을 기반으로 스터디하며 정리한 내용들입니다.
- Kotlin In Action
- Kotlin In Action 예제 코드
- Kotlin Github
- 코틀린 doc
- 코틀린 lib doc
- 코틀린 스타일 가이드
- Exposed github
- kotlin-dsl-samples github
- kotest github
- kotlinx.html github