[Kotlin Tutorial] DSL construction - Chap 11. |
참조 : Kotlin in action
11.1. From APIs to DSLs
-
DSL 을 작성하기 전에 생각해봐야 할 것이 있다. 우리의 (Kotlin?) 궁극적 목표는 가독성과 유지보수성을 최대로 늘리는 것.
그것은 곧 좋은 API 를 설계하는 것으로 이어진다.
그렇다면 API 가 clean 하다는 것은 무슨 의미일까?
1. 사용자가 읽기 좋은 것. 그것은 name 과 concept 을 잘 잡는 것이다.
2. 의미없는 syntax 는 빼고, 최소한의 코드로 code 가 읽기 좋은 것.
-
Kotlin 에서는 clean API 를 위해서 extension function, infix calls, lambda syntax shortcut, operator overloading 등을 제공한다.
-
다른 language 와 마찬가지로 Kotlin 의 DSL 들 역시 fully statically typed 된 형태이다.
-
아래는 DSL 의 예제이다.
val yesterday = 1.days.ago
fun createSimpleTable() = createHTML().
table{
tr{
td{ +”cell” }
}
}
11.1.1. The concept of domain-specific languages
-
가장 대표적인 DSL 은 SQL 과 regular expression 이다.
DSL 은 대부분 declarative 하다.
imperative language 는 operation 을 수행하는 step 을 정확히 묘사하는 것이고, declarative language 는 원하는 결과를 얻기 위한 형태의 명령만 하고, operation 수행은 engine 등에 맡긴다.
-
DSL 은 그 specific domain 에서는 두각을 나타내지만 GPL ( General Purpose Language ) 과 혼재되어 사용하기는 어렵다는 단점이 있다.
보통 다른 file 에 쓰거나 string literal 로 작성해서 호출하는 방식을 사용한다.
그래서 compile time 에 이들의 상호작용을 검증하기가 쉽지 않고, debugging 도 쉽지 않다.
IDE 에서도 잘 지원해주지 않는다.
사용하는 사람은 Syntax 가 달라 배우는 데 시간도 걸리고, 읽는 사람 역시 배움이 필요하다.
DSL 의 장점은 유지하면서 이런 문제를 해결하기 위해서 internal DSLs 컨셉이 최근 인기가 좋다.
11.1.2. Internal DSLs
-
external DSL 은 그 자체의 독자적인 syntax 를 가지고 있는 것을 이야기한다.
internal DSL 은 그 반대 개념으로 GPL 로 쓰여진 것을 이야기한다.
-
Exposed framework 는 database access 를 위한 Kotlin framework 이다.
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
(Country join Customer)
.slice(Country.name, Count(Customer.id))
.selectAll()
.groupBy(Country.name)
.orderBy(Count(Customer.id), isAsc = false)
.limit(1)
아래의 Exposed framework 로 만든 코드는 위와 동일한 SQL query 문을 만든다.
11.1.3. Structure of DSLs
-
DSL 이 일반적인 API 와 다른 점은 structure 나 grammar 가 있다는 것이다.
일반적인 lib 은 method 들로 구성되어 있고, client 는 lib 의 method 를 하나 하나 호출한다.
이러한 호출에는 상속관계의 structure 나 call 간의 유지되는 context 는 없다.
이러한 일반적인 호출을 command-query API 라고 한다.
반대로 DSL 에는 DSL grammar 에 의해 정의된 structure 가 있다.
Kotlin 에서는 이를 보통 nesting lambda 나 chained method call 을 통해 한다.
저것들이 internal DSL 이 된다.
-
DSL structure 의 한 가지 장점은 여러개의 function call 에 대해 한개의 context 를 물고 가도록 한다.
dependencies{ // lambda nesting structure
compile(“junit:jnuit:4.11”)
compile(“com.google.inject:guice:4.1.0”)
}
command-query API 에서는 다음과 같이 call 했어야 한다.
project.dependencies.add(“compile”, “jnuit:jnuit:4.11”)
project.dependencies.add(“compile”, “com.google.inject:guice:4.1.0”)
-
Chained method call 역시 DSL 의 structure 를 구성한다.
str should startWith(“kot”) // infix 와 혼합된 chained method calls @ kotlintest framework
assertTrue(str.startsWith(“kot”))
11.1.4. Building HTML with an internal DSL
-
kotlinx.html lib 를 이용한 코드이다.
fun createSimpleTable() = createHTML().
table{
tr{
td{ + “cell” }
}
}
<table>
<tr>
<td>cell</td>
</tr>
</table>
-
왜 text 로 안 하고 Kotlin 으로 코드를 짜려고 하냐고?
Kotlin version 은 type-safe 하다.
예를 들어 td tag 는 tr tag 안에서만 존재할 수 있다. ( 아니면 compile fail )
그리고 이 녀석은 일반적인 코드이기 때문에 다른 langauge 특성들을 다 적용할 수 있다.
fun createAnotherTable() = createHTML().table{
val numbers = mapOf(1 to “one”, 2 to “two”)
for ((num, string) in numbers){
tr{
td { +“$num” }
td { + string }
}
}
}
11.2. Building structured APIs: Lambdas with receivers in DSLs
-
Lambda with receiver 는 structure 를 만들 수 있게 해주는 Kotlin 의 강력한 feature 이다.
11.2.1. Lambdas with receivers and extension function types
-
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)
여기서의 문제 중 하나는 it 이 반복된다는 것.. 우리는 with 로 이걸 없앨 수 있다는 것을 안다.
-
fun buildString(builderAction: StringBuilder.() -> Unit) : String{
val sb = StringBuilder()
sb.builderAction()
return sb.toString()
}
val s = buildString{
this.append(“Hello, “)
append(“World!”)
}
println(s)
buildString 정의를 보면 일반적인 function type 대신 extension function type 을 사용하도록 바뀌었다.
extension function type 은 function type parameter 중 하나를 꺼내서 앞에다 주고 . 으로 구분해주면 된다.
(StringBuilder) -> Unit 이 StringBuilder.() -> Unit 이 되었다.
여기서 StringBuilder 를 receiver type 이라고 부른다.
그리고 lambda 에 전달되는 value 는 receiver object 가 된다.
왜 extension function type 이냐고?
명시적인 qualifier 없이 external type 의 member 를 접근하는 것이 extension function 과 비슷하기 때문.
그리고 extension function 은 기존 class 에 member 를 추가하는 것임.
결과적으로 extension function 과 lambdas with receiver 모두 receiver object 를 가지며,
function 이 호출할 때 body 에서 접근이 가능하다.
extension function 이기 떄문에 builderAction(sb) 에서 sb.builderAction() 으로 바뀌었다.
-
Lambda 가 receiver 가 있는지 확인해보면 qualifier 없이 호출 할 수 있는지 확인할 수 있다.
StringBuilder.() -> Unit 이라면 StringBuilder 를 qualifier 없이 호출 할 수 있다.
-
실제 lib 에 적용된 buildString function 은 아래와 같다.
fun buildString(builderAction: StringBuilder.() -> Unit): String = StringBuilder().apply(builderAction).toString()
-
apply 는 with 와 동일하지만 lambda 실행 후 receiver object 를 전달한다는 차이가 있다.
inline fun<T> T.apply(block: T.() -> Unit): T{
block()
return this
}
inline fun <T,R> with(receiver:T, block: T.() -> R): R = receiver.block()
val map = mutableMapOf(1 to “one”)
map.apply { this[2] = “two” }
with(map) { this[3] = “three” }
11.2.2. Using lambdas with receivers in HTML builders
-
Kotlin 의 HTML 용 DSL 을 HTML builder 라고 부른다.
Idea 는 Groovy 의 dynamic builder 로부터 왔지만, Kotlin 의 것은 사용성이 더 좋고, 안전하며, 추가적으로 더 매력적인 것들이 있다.
-
fun createSimpleTable() = createHTML().
table{
tr{
td { +”cell” }
}
}
여기서 table, tr, td 는 모두 higher-order function 이다. ( lambda 를 param 으로 받거나 return 하는 녀석 )
이 녀석들은 특별히 그냥 lambda 가 아니라 lambda with receiver 를 받는다.
lambda 를 이용하므로써 name-resolution rule 을 변경한다.
예를 들면 tr function 은 table object 안에 들어있다고 보면 그 구조가 이해가 갈 것이다.
open class Tag
class TABLE : Tag { // utility class 라서 code 에 직접 나오지 않는다. 그래서 모두 대문자 정의
fun tr(init : TR.() -> Unit)
}
class TR : Tag {
fun td(init: TR.() -> Unit)
}
class TD : Tag
-
receiver with lambda 를 사용하지 않으면 아래와 같이 썼어야 한다.
fun createSimpleTable() = createHTML().
table{
(this@table).tr{
(this@tr).td{
+”cell"
}
}
}
cf) lambda 에서의 this 는 가장 가까운 receiver 를 가르킨다.
그러나 this@label 을 통해서 바깥의 receiver 를 지정할 수도 있다.
label 이 없는 경우 function name 으로 접근할 수 있다.
Kotlin 1.1 부터는 @DslMarker annotation 으로 내부에서 바깥쪽의 receiver 접근을 제한할 수도 있다.
11.2.3. Kotlin builders: enabling abstraction and reuse
-
DSL 을 쓰면 extract function 을 해서 reuse 하는 것이 쉽지 않다.
Kotlin + internal DSL 을 사용하면 가능하지롱!
-
<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=“#”>Seperated link</a></li>
</ul>
</div>
fun buildDropdown() = createHTML().
div(classes=“dropdown”){
button(classes=“btn dropdown-toggle”){
+”Dropdown”
span(classes = “caret”)
}
ul(classes = “dropdown-menu”){
li { a(“#”) { +”Action” } }
li { a(“#”) { +”Action” } }
li { role = “separator”; classes=setOf(“divider”) }
li { classes = setOf(“dropdown-header”); +”Header” }
li { a(“#”) { +”Seperated 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 } }
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)
11.3. More flexible block nesting with the "invoke" convention
-
invoke convention 이 있다면 function type 의 object 를 함수로 호출할 수 있다.
이것은 자주 사용되는 패턴은 아니지만 DSL 에서 가끔 유용하게 사용될 수 있다.
11.3.1. The “invoke” convention: objects callable as functions
-
Convention 이라 하면 특별하게 naming 된 함수이며 regular method-call syntax 가 아닌 다른 방식으로 표기하는 것을 지원하는 것을 말한다.
예를 들어 get 을 정의하면 index operator 를 사용할 수 있다.
foo 에 convention 으로 get 을 정의하면 foo[bar] 를 사용할 수 있다.
-
class Greeter(val greeting: String){
operator fun invoke(name: String){
println(“$greeting, $name!”)
}
}
val bavarianGreeter = Greeter(“Servus”)
bavarianGreeter(“Dmitry”)
invoke 는 overload 해서 정의 가능하다. 즉 몇 개의 param 이 들어가도 상관 없다.
11.3.2. The “invoke” convention and functional types
-
lambda 를 호출하는 것은 사실 invoke 를 호출하는 것과 같다.
( lambda() = lambda.invoke() )
lambda 는 inline 되지 않는 한 Function1 과 같은 functional interface 를 구현한 class 가 된다.
그리고 해당 interface 들은 invoke method 를 구현한다.
interface Function2<in P1, in P2, out R>{
operator fun invoke(p1: P1, p2: P2) : R
}
-
labmda 호출하는 것이 invoke 를 호출하는 것과 같다는 것이 왜 중요한가?
이것이 복잡한 lambda 를 여러개의 method 로 나누는 것을 가능하게 한다.
data class Issue{
val id: String, val project: String, val type: String,
val priority: String, val description: String
}
class ImportantIssuePredicate(val project: String) : (Issue) -> Boolean{ // function type base class
override fun invoke(issue: Issue): Boolean{
return issue.project == project && issue.imImporant()
}
private fun Issue.isImportant(): Boolean{
return type == “Bug” && (priority == “Major” || priority == “Critical”)
}
}
val issue1 = Issue(“IDEA-15446”, “IDEA”, “Bug”, “Major”, “Save settings failed”)
val issue2 = Issue(“KT-12183”, “Kotlin”, “Feature”, “Normal”, “Intention: convert several calls”)
val predicate = ImportantIssuePredicate(“IDEA”)
for( issue in listOf(issue1, issue2).filter(predicate)){
println(issue.id)
}
위의 예에서 single lambda 로 두기에는 로직의 규모가 큰 편이어서, 이 녀석을 function type interface 를 구현하는 class 로 만들었다. 그리고 invoke 를 override 했다.
이 방식으로 코드를 더 깔끔히 쓸 수 있고 로직 분리도 할 수 있다.
11.3.3. The “invoke” convention in DSLs: declaring dependencies in Gradle
-
dependencies.compile(“junit:jnit:4.11”)
dependencies{
compile(“jnuit:jnuit:4.11”)
}
첫번째 것은 dependencies variable 의 compile 함수 호출이고,
두번째 것은 dependencies 의 invoke 함수가 호출된 후(lambda 를 param 으로 받는다)의 compile 함수 호출이다.
11.4. Kotlin DSLs in practice
11.4.1. Chaining infix calls: “should” in test frameworks
-
s should startWith(“kot”)
infix fun <T> T.should(matcher: Matcher<T>) = matcher.test(this)
interface Matcher<T> {
fun test(value: T)
}
class startWith(val prefix: String) : Matcher<String>{ // DSL 의 경우 가독성 등을 위해 naming convention 을 깨기도 한다.
override fun test(value: String){
if(value.startsWith(prefix) == false)
throw AssertionError(“String $value does not start with $prefix”)
}
}
-
“kotlin” should start with “kot”
전혀 코틀린 스럽지 않은 이 코드도 코틀린의 DSL 이다.
“kotlin”.should(start).with(“kot”)
object start // single instance
infix fun String.should(x: start): StartWrapper = StartWrapper(this)
class StartWrapper(val value: String){
infix fun with(prefix: String) =
if (value.startsWith(prefix) == false)
throw AssertionError(“String does not start with $prefix: $value”)
}
여기서 start 는 single instance 인데 던져주는 게 이상할 수 있다.
문법을 만들기 위해 희생해야 하는 부분이다. 사용자 입장에서는 쉬울 수 있지만, 제작자 입장에서는 소스를 보기 더 힘들 수 있다.
11.4.2. Defining extensions on primitive types: handling dates
-
val yesterday = 1.days.ago
val tomorrow = 1.days.fromNow
val Int.days: Period // Period 는 JDK 8 이긴 함
get() = Period.ofDays(this)
val Period.ago: LocalDate
get() = LocalDate.now() - this // Period 에 “minus" 라는 이름의 single param 을 받는 함수가 정의되어 있다.
val Period.fromNow: LocalDate
get() = LocalDate.now() + this
11.4.3. Member extension function: internal DSL for SQL
-
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>
// ...
}
object Country : Table(){
val id = integer(“id”).autoIncrement().primaryKey()
val name = varchar(“name”, 50)
}
SchemaUtils.create(Country) // table 만들기
Table class 안의 primaryKey extension 과 autoIncrement extension 해당 class에서만 쓰는 extension 이다.
그래서 이들은 member extension 이라 부른다.
이들은 scope 을 제한한다는 큰 장점을 가진다.
-
member extension 의 단점은 일반 extension 과는 달리 outside 에서 extension 을 추가할 수 없다는 것이다.
-
fun Table.select(where: SqlExpressionBuilder.() -> Op<Boolean>) : Query{ ... }
object SqlExpressionBuilder{
infix fun<T> Column<T>.eq(t: T) : Op<Boolean>
...
}
val result = (Country join Customer)
.select { country.name eq “USA” }
result.forEach{ println( it[Customer.name] ) }
11.4.4. Anko: creating Android UIs dynmaically
-
class AlertDialogBuilder{
fun positiveButton(text: String, callback: DialogInterface.() -> Unit)
fun negativeButton(text: String, callback: DialogInterface.() -> Unit)
...
}
fun Context.alert( message:String, title:String, init:AlertDialogBuilder.() -> Unit )
fun Activity.showAreYouSureAlert(process:() -> Unit){
alert(title = “Are you sure?”, message = “Are you really sure?”){
positiveButton(“Yes”) { process() }
negativeButton(“No”) { cancel() }
}
}
-
verticalLayout{
val email = editText{
hint = “Email"
}
val password = editText{
hint = “Password”
transformationMethod = PasswordTransformationMethod.getInstance()
}
button(“Log In”){
onClick{
login(email.text, password.text)
}
}
}
11.5. Summary
-
Internal DSLs 는 API design pattern 으로 multiple method call 로 구성된 structure를 구성해 더 표현이 좋은 API 를 만드는 것이다.
-
Labmdas with receivers 는 nesting structure 를 만드는 데 좋다.
-
lambda with a receiver 가 취하는 param type 은 extension function type 이다.
그리고 호출하는 function 은 lambda 를 호출할 때 receiver instance 를 전달한다.
-
Kotlin internal DSLs 를 사용하면 external template 이나 markup language 보다 code 재사용성을 높여주고 abstraction 을 만들 수 있다는 장점이 있다.
-
named object 를 infix 의 param 으로 사용하는 것은 DSL 을 영어처럼 읽도록 하는 장점이 있다.
-
primitive type 에서 extension 을 정의할 수 있다.
-
invoke convention 을 사용함으로써 object 를 function 처럼 사용할 수 있다.
-
kotlinx.html 은 HTML page 를 만드는 internal DSL 을 제공한다.
-
kotlintest lib 은 assrtion 기반 unit test 코드를 더 readable 하게 작성하게 도와주는 internal DSL 이다.
-
Exposed lib 은 database 와 연동되는 internal DSL 이다.
-
Anko lib 은 android 개발을 도와주는 녀석이고, UI layout 생성에 대한 internal DSL 도 제공한다.
'프로그래밍 놀이터 > Kotlin, Coroutine' 카테고리의 다른 글
[Kotlin Tutorial] Documenting Kotlin code (0) | 2017.09.20 |
---|---|
[Kotlin Tutorial] Building Kotlin projects (0) | 2017.09.18 |
[Kotlin] Linkage Error 버그? (0) | 2017.09.13 |
[Kotlin Tutorial] Annotation 과 Reflection #2 (0) | 2017.09.12 |
[Kotlin Tutorial] Annotation 과 Reflection #1 - Chap 10. Annotations and reflection (0) | 2017.09.07 |
댓글