[Kotlin in Action] part11_DSL 만들기
DSL(영역 특화 언어 Domain Specific Language)을 사용해서 표현력 좋고 코틀린다운 API를 설계하는 방법
코틀린 DSL 설계는 코틀린 언어의 여러 특성을 활용한다.
1. 수신 객체 지정 람다 - 코드 블록에서 변수가 가리키는 대상을 결저앟는 방식 변경 가능
2. invoke 관례 - DSL 코드 안에서 람다와 프로퍼티를 유연하게 대입 가능
API에서 DSL로
목표: 코드의 가독성과 유지 보수성 향상 -> 인터페이스, 클래스의 API를 살펴봐야 한다
=> 모든 개발자는 클래스 간의 상호작용을 이해하기 쉽고 명확하게 표현할 수 있어야 한다.
깔끔한 API를 작성할 수 있게 돕는 코틀린 기능
-> 확장 함수, 중위 함수 호출, 람다 구문에 사용할 수 있는 it 등의 문법적 편의, 연산자 오버로딩
먼저 DSL이 무엇인지 살펴보자
영역 특화 언어라는 개념
DSL은 범용프로그래밍과 달리 더 선언적이어서 원하는 결과를 기술하고, 그 결과를 달성하기 위한 세부실행은 엔진에 맡긴다. 따라서 범용 프로그래밍보다 훨씬 효율적이다.
-> DSL의 한 가지 단점: 범용 언어로 만든 호스트 애플리케이션과 조합하기 어려움, 그렇다고 DSL 프로그램을 별도의 파일이나 문자열 리터럴로 저장하면 여러 작업이 어려워지는 경우가 많다.
=> 내부 DSL 로 문제를 해결할 수 있다.
내부 DSL
범용 언어로 작성된 프로그램의 일부. 범용 언어와 동일한 문법을 사용한다.
범용 언어로 작성하면 그렇지 않은 언어로 작성된 프ㅗ그램과 동일한 프로그램이 생성되고 실행된다. 하지만 범용 언어로 작성하면 결과 집합을 코틀린 언어로 변환하기 위해 노력할 필요가 없다.
DSL의 구조
DSL과 일반적인 API 간의 경계는 없다. 그러나 다른 API에는 존재하지 않지만 DSL에는 존재하는 특징이 한 가지 있다. 구조 또는 문법이 그 특징이다.
▶︎ 전형적인 라이브러리
- 여러 메서드로 이뤄지고 클라이언트는 그런 메서드를 한 번에 하나씩 호출하며 사용한다.
- 함수 호출 시퀀스는 아무런 구조가 없다.
- 한 호출과 다른 호출 간에는 아무런 맥락이 없다.
-> 명령-질의 API
▶︎ DSL
- DSL의 메서드 호출은 DSL 문법에 의해 정해진다
- 코틀린 DSL은 보통 람다를 중첩시키거나 메서드 호출을 연쇄시키는 방식으로 구조를 만든다.
- 여러 함수를 조합해서 연산을 만든다.
- 함수 이름은 보통 동사(groupBy, orderBy) 역할을 하고
- 함수 인자는 명사(Country.name) 역할을 한다.
장점: 같은 문맥을 함수 호출 시마다 반복하지 않고도 재사용할 수 있다.
내부 DSL로 HTML만들기
kotlin.html 라이브러리에서 가져온 API로 html 페이지를 생성하기
fun createSimpleTable() = createHTML().
table {
tr {
td { +"cell" }
} }
<table> <tr>
<td>cell</td>
</tr>
</table>
fun createAnotherTable() = createHTML().table {
val numbers = mapOf(1 to "one", 2 to "two")
for ((num, string) in numbers) {
} }
tr {
td { +"$num" }
td { +string }
}
<table> <tr>
<td>1</td>
<td>one</td>
</tr>
<tr>
<td>2</td>
<td>two</td>
</tr>
</table>
구조화된 API 구축: DSL에서 수신 객체 지정 DSL사용
수신 객체 지정 람다는 구조화된 API를 만들 때 도움이 되는 강력한 코틀린 기능이다.
수신 객체 지정 람다와 확장 함수 타입
buildString 함수를 통해 코틀린이 수신 객체 지정 람다를 어떻게 구현하는지 살펴보자.
-> buildString 함수를 사용하면 한 StringBuilder 객체에 여러 내용을 추가할 수 있다.
fun buildString(
builderAction: (StringBuilder) -> Unit
): String {
val sb = StringBuilder()
builderAction(sb)
return sb.toString()
}
>>> val s = buildString {
... it.append("Hello, ")
... it.append("World!")
... }
>>> println(s)
Hello, World!
이 코드를 더 편하게 사용하려면 람다를 수신 객체 지정 람다로 바꾸는 방법이 있다.
람다의 인자 중 하나에게 수신 객체라는 상태를 부여하면 이름과 마침표를 명시하지 않아도 그 인자의 멤버를 바로 사용할 수 있다.
fun buildString(
builderAction: StringBuilder.() -> Unit
) : String {
val sb = StringBuilder()
sb.builderAction()
return sb.toString()
}
>>> val s = buildString {
... this.append("Hello, ")
... append("World!")
... }
>>> println(s)
Hello, World!
ㄴ 람다 안에서 it을 사용하지 않아도 된다. it.append() 대신 append()를 사용한다.
또한 파라미터 타입을 선언할 때 확장 함수 타입 extension function type을 사용했다.
(StringBuilder) -> Unit 를 StringBuilder.() -> Unit 으로 바꿨다.
여기서 . 앞에 있는 타입을 수신 객체 타입이라고 부른다.
-> 왜 확장 함수일까?
확장 함수나 수신 객체 지정 람다에서는 모두 함수(람다)를 호출할 때 수신 객체를 지정해야만 하고, 함수 (람다) 본문 안에서는 그 수신 객체를 특별한 수식자 없이 사용할 수 잇기 때문이다.
-> 수신 객체 지정 람다를 변수에 저장하는 코드
val appendExcl : StringBuilder.() -> Unit =
{ this.append("!") }
>>> val stringBuilder = StringBuilder("Hi")
>>> stringBuilder.appendExcl()
>>> println(stringBuilder)
Hi!
>>> println(buildString(appendExcl))
!
수신 객체 지정 람다를 HTML 빌더 안에서 사용
HTML 빌더 : HTML을 만들기 위한 코틀린 DSL
-> 타입 안전한 빌더의 대표적인 예
-> 객체 계층 구조를 선언적으로 정의할 수 있음
-> 코틀린 HTML빌더가 어떻게 작동할까
fun createSimpleTable() = createHTML().
table {
tr {
td { +"cell" }
} }
ㄴ table, tr, td는 고차 함수로 수신 객체 지정 람다를 인자로 받는다.
각 수신 객체 지정 람다가 이름 결정 규칙을 바꾸는 방식
=> 간단한 HTML 빌더 구현
open class Tag(val name: String) {
private val children = mutableListOf<Tag>()
protected fun <T : Tag> doInit(child: T, init: T.() -> Unit) {
child.init()
}
fun table(init: TABLE.() -> Unit) = TABLE().apply(init)
class TABLE : Tag("table") {
fun tr(init: TR.() -> Unit) = doInit(TR(), init)
}
class TR : Tag("tr") {
fun td(init: TD.() -> Unit) = doInit(TD(), init)
}
class TD : Tag("td")
fun createTable() =
table {
tr {
td {
} }
}
>>> println(createTable())
<table><tr><td></td></tr></table>
children.add(child)
override fun toString() =
"<$name>${children.joinToString("")}</$name>"
코틀린 빌더: 추상화와 재사용을 가능하게 하는 도구
내부 DSL을 사용하면 일반 코드와 마찬가지로 반복되는 내부 DSL 코드 조각을 새 함수로 묶어서 재사용할 수 있음.
부트스트랩 라이브러리(http://getbootstrap.com)에서 제공하는 드롭다운 메뉴가 있는 HTML 페이지를 코틀린 빌더를 통해 생성
<div class="dropdown">
<button class="btn dropdown-toggle">
Dropdown
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="#">Action</a></li>
<li><a href="#">Another action</a></li>
<li role="separator" class="divider"></li>
<li class="dropdown-header">Header</li>
<li><a href="#">Separated link</a></li>
</ul> </div>
코틀린 html을 사용하면 위의 코드를 만들기 위해 아래와 같이 작성할 수 있다.
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" } }
} }
div, button은 모두 일반 함수라서 반복되는 로직을 별도의 함수로 분리하면 코드를 더 읽기 쉽게 만들 수 있다.
invoke 관례를 사용한 더 유연한 블록 중첩
invoke 관례를 사용하면 객체를 함수처럼 호출할 수 있다.
invoke 관례: 함수처럼 호출할 수 있는 객체
관례는 특별한 이름이 붙은 함수를 일반 메서드 호출 구문으로 호출하지 않고 더 간단한 다른 구문으로 호출할 수 있게 지원하는 기능이다.
invoke는 get과 달리 각괄호 대신 괄호를 사용한다.
operater 변경자가 붙은 invoke 메서드를 정의하면 클래스의 객체를 함수처럼 호출할 수 있다.
invoke 메서드는 원하는대로 파라미터 개수나 타입을 지정할 수 있다.
여러 파라미터 타입을 지원하기 위한 오버로딩도 가능하다.
class Greeter(val greeting: String) {
operator fun invoke(name: String) {
println("$greeting, $name!")
}
}
>>> val bavarianGreeter = Greeter("Servus")
>>> bavarianGreeter("Dmitry")
Servus, Dmitry!
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 {
return issue.project == project && issue.isImportant()
}
private fun Issue.isImportant(): Boolean {
return type == "Bug" &&
(priority == "Major" || priority == "Critical")
} }
>>> val i1 = Issue("IDEA-154446", "IDEA", "Bug", "Major",
... "Save settings failed")
>>> val i2 = Issue("KT-12183", "Kotlin", "Feature", "Normal",
... "Intention: convert several calls on the same receiver to with/apply")
>>> val predicate = ImportantIssuesPredicate("IDEA")
>>> for (issue in listOf(i1, i2).filter(predicate)) {
... println(issue.id)
... }
IDEA-154446
ㄴ 람다 본문에서 따로 분리해낸 메서드가 영향을 끼치는 영역을 최소화할 수 있다는 장점이 있다.
DSL의 invoke 관례: 그레이들에서 의존관계 정의
class DependencyHandler {
fun compile(coordinate: String) {
println("Added dependency on $coordinate")
}
operator fun invoke(
body: DependencyHandler.() -> Unit) {
Defines a regular command API
Defines “invoke” to support the DSL API
body() }
}
>>> val dependencies = DependencyHandler()
“this” becomes a receiver of the body function: this.body()
>>> dependencies.compile("org.jetbrains.kotlin:kotlin-stdlib:1.0.0")
Added dependency on org.jetbrains.kotlin:kotlin-stdlib:1.0.0
>>> dependencies {
... compile("org.jetbrains.kotlin:kotlin-reflect:1.0.0")
>>> }
Added dependency on org.jetbrains.kotlin:kotlin-reflect:1.0.0
- dependencies를 함수처럼 호출하면 람다를 인자로 넘기게 된다.
- 람다의 타입은 확장함수 타입이다.
- 지정한 수신 객체 타입은 DependencyHandler이다.
- invoke 메서드는 이 수신 객체 지정 람다를 호출한다.
- invoke 메서드가 DependencyHandler의 메서드이므로 이 메서드 내부의 this는 DependencyHandler 객체이다.
- 이 타입의 객체를 따로 명시하지 않아도 compile()을 호출할 수 있다.
실전 코틀린 DSL
중위 호출 연쇄: 테스트 프레임워크의 should
- 깔끔한 구문은 내부 DSL의 특징 중 하나이다.
- 깔끔하게 만드려면 코드의 기호 수를 줄여야 함
원시 타입에 대한 확장 함수 정의: 날짜 처리
자바 8의 java.time API와 코틀린을 사용해서 1.days.ago, 1.days.fromNow 와 같은 API를 구현해보자
val Int.days: Period
get() = Period.ofDays(this)
val Period.ago: LocalDate
get() = LocalDate.now() - this
val Period.fromNow: LocalDate
get() = LocalDate.now() + this
>>> println(1.days.ago)
2016-08-16
>>> println(1.days.fromNow)
2016-08-18
멤버 확장 함수: SQL을 위한 내부 DSL
- 클래스 안에서 확장 함수와 확장 프로퍼티를 선언
- 정의한 확장 함수나 확장 프로퍼티는 그들이 선언된 클래스의 멤버인 동시에 그들이 확장하는 다른 타입의 멤버이기도 함
- 이러한 함수나 프로퍼티를 멤버 확장(Member Extensions)이라고 부른다.
- 멤버 확장으로 정의해야 하는 이유는 메서드가 적용되는 범위를 제한하기 위함
- 대신 제한된 범위로 인해 멤버 확장은 확장성이 떨어짐
안코: 안드로이드 UI를 동적으로 생성하기
fun Activity.showAreYouSureAlert(process: () -> Unit) {
alert(title = "Are you sure?",
message = "Are you really sure?") {
positiveButton("Yes") { process() }
negativeButton("No") { cancel() }
} }
fun Activity.showAreYouSureAlert(process: () -> Unit) {
alert(title = "Are you sure?",
message = "Are you really sure?") {
positiveButton("Yes") { process() }
negativeButton("No") { cancel() }
} }
}
}