@[toc]
DSL(領域特定語言)是Kotlin所帶來的強大語法特性之一罕伯,也是Java中所不存在的功能梨与,JetBrain也基于DSL開發(fā)出了眾多的開源庫鸣驱,Kotlin的開發(fā)者可以使用DSL來重構許多已有的代碼吏口,甚至有可能做到徹底拋棄HTML,XML雕擂,SQL等代碼的地步啡邑。
現(xiàn)代編程語言已經越來越向自然語言靠攏,因此學習使用一個語法特性并非難事井赌,所以本文將延續(xù)本專題的風格:“理論先行”谤逼,重點在于詳細講解DSL在Kotlin中的實現(xiàn)原理贵扰,以及如果我們使用DSL怎樣去構建一套合理的API,而如何去使用已有的DSL庫流部,這個根據(jù)不同的開發(fā)需求因人而異戚绕,本文會簡單介紹,但不是重點枝冀;文章將會先解釋到底什么是DSL舞丛,再來詳細講解DSL的兩大原理——帶接收者的lambda和invoke約定,最后簡單介紹一些好用的基于DSL的開源庫果漾。
DSL的簡單介紹
文章開頭我們就已經提到瓷马,DSL是領域特定語言的英文縮寫。那到底什么是領域特定語言跨晴?我們最常使用的領域特定語言就是SQL以及正則表達式,SQL和正則表達式都只能解決它們特定領域內的問題片林,SQL用于數(shù)據(jù)庫操作端盆,而正則表達式則是用來處理文本字符串,它們也都有自己的語法费封,但是你無法使用它們在計算機上編寫完整的程序焕妙;所以它們并不是我們常規(guī)意義上理解的“編程語言”,那些有能力在計算機上編寫幾乎任何程序的編程語言弓摘,諸如焚鹊,Kotlin,Java韧献,Python等等我們有一個專業(yè)術語來定義它們末患,叫做圖靈完備語言,而上面介紹的那些DSL就不是圖靈完備的锤窑。
Kotlin中的DSL
我們如果要在我們編寫的程序中使用DSL璧针,需要把它們保存到一個文本字符串內,然后再通過調用源編程語言的函數(shù)(方法)渊啰,將它們作為參數(shù)傳入進去探橱,然后它們才能得到執(zhí)行,不妨回憶一下我們是如何在Java中使用SQL和正則表達式的绘证;這樣的使用方式最大的缺點就是內嵌在源編程語言中的DSL隧膏,無法在編寫過程中獲得IDE的錯誤提示,也無法在編譯期獲得語法錯誤檢查嚷那,哪怕只是輸錯一個字母胞枕,都將導致運行時的執(zhí)行失敗,因此我們原先使用DSL的方式是不安全的车酣。
既然將DSL作為文本字符串直接內嵌在源編程語言的代碼中是不安全的曲稼,那有沒有可能使用某種方式索绪,讓它們和源編程語言在一定程度上掛鉤,這樣既方便使用贫悄,又能讓它們得到IDE的錯誤提示和編譯期的語法檢查瑞驱?這也是一些編程語言社區(qū)內討論過很多的概念——內部DSL;如果你是inteliJ IDEA的用戶窄坦,其實也早就使用過內部DSL了唤反,當你在編寫Gradle的規(guī)則文件的時候,使用的就是Groovy語言的內部DSL(當然鸭津,目前Gradle文件已經支持使用Kotlin DSL來編寫彤侍。)
使用內部DSL編寫出來的代碼,和它們的源編程語言是一樣的逆趋,比如說Kotlin代碼文件的擴展名是.kt盏阶,而使用Kotlin DSL編寫出來的代碼的文件也是.kt,因為它本質上就是Kotlin代碼闻书,因此它也可以存在于任何.kt文件中和普通Kotlin代碼混編名斟;那普通Kotlin代碼和Kotlin DSL之間到底有什么明顯的界限?實際上并沒有(《Kotlin in Action》的作者在書中說:判斷的標準應該主觀到:“當我看見它的時候魄眉,就知道它是一個DSL”砰盐。),DSL看起來特殊的語法其實是由Kotlin中的兩種語法特性——帶接收者的lambda和invoke約定坑律,來提供支持岩梳,關于這兩者,后文會有具體討論晃择。
Kotlin DSL的例子
我們來舉一個Kotlin DSL的例子冀值,如果我們使用JetBrain構建Android UI的開源庫Anko的話,我們可以用DSL重構一份XML代碼藕各;我們先來看看XML:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="30dp"
android:orientation="vertical" >
<EditText
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="Name"
android:textSize="24sp" />
<EditText
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="Password"
android:textSize="24sp" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Login"
android:textSize="26sp" />
</LinearLayout>
如果換成DSL來編寫如下:
lineatLayout {
orientation = LinearLayout.VERTICAL
padding = dip(30)
editText {
hint = "Name"
textSize = 24f
}.lparam(wrapContent, wrapContent)
editText {
hint = "Password"
textSize = 24f
}.lparam(wrapContent, wrapContent)
button("Login") {
textSize = 26f
}.lparam(wrapContent, wrapContent)
}
即使你不是Android開發(fā)者池摧,也可以輕易的看出兩者的異同,XML中的元素:LinearLayout, EditText激况,Button等的層級嵌套關系和DSL中的完全一至作彤,屬性的賦值也是應有盡有;這說明乌逐,如果你想把當前的一些編寫起來不那么方便的代碼竭讳,遷移到基于Kotlin DSL的庫,大多數(shù)情況下其實學習成本并不高浙踢,實際上變化的只是一些簡單的語法規(guī)則绢慢。
我們在開始下一節(jié)之前,先來看看這一段DSL代碼,做一個簡單的分析胰舆,并提出幾個問題骚露。我可以首先先告訴大家一個結論,linearLayout {}缚窿,editText {}棘幸,button {},這些東西全部都是Kotlin高階函數(shù)倦零,而orientation和padding這些都是Kotlin中的屬性误续;學習過Kotlin的高階函數(shù)的你應該知道,linearLayout {}大括號的內部實際上是一個lambda表達式扫茅,它作為一個參數(shù)蹋嵌,被傳遞給了函數(shù)linearLayout,而在這個lambda表達式的外部葫隙,你是無法引用到orientation和padding屬性的栽烂,同理,在editText {}的lambda表達式的外部恋脚,也是無法引用到hint和textSize屬性的愕鼓。因為orientation是LinearLayout類的屬性,而hint和textSize是EditText類的屬性慧起;這也就說明在這些lambda表達式的內部,持有了一個對這些類型對象的引用册倒;而這樣的lambda表達式就是帶接收者的lambda蚓挤。
深入理解帶接收者的lambda
對象調用其對應的類內部的方法,是所有有面向對象編程經驗的開發(fā)者都知道的原則驻子,但這里要講清楚帶接收者的lambda灿意,還是要從這里講起。我們先來看下面的例子:
class A {
fun function1() {
function2()
}
fun function2() {
// do something...
}
}
// 擴展函數(shù)
fun A.function3() {
function1()
function2()
}
fun main(args: Array<String>) {
val a = A()
a.function1()
a.function2()
a.function3()
}
代碼很基礎崇呵,function1和function2都是A的成員函數(shù)缤剧,在function1中可以直接調用function2,即在同一個類的方法中可以直接調用另一個方法域慷,而在A的外面荒辕,我們則需要創(chuàng)建一個A的對象來調用function1和function2;因為在A的內部犹褒,所有的成員(變量/函數(shù))都持有一個A類型對象的引用抵窒,而在A的外部,在調用這些成員的時候叠骑,我們需要知道調用它的到底是哪一個對象李皇,這是最基本的類和對象之間的關系,我就不再多說了宙枷。但在Kotlin中唯一的例外就是擴展函數(shù)掉房,在擴展函數(shù)中調用其接收者的成員函數(shù)(或屬性)可以直接調用茧跋,這是因為在A的外部調用它的擴展函數(shù),需要一個A的對象卓囚。學過高階函數(shù)和lambda編程后我們都知道瘾杭,函數(shù)和lambda在很多時候可以認為是同一種東西,都可以把它們看作是一種有類型的(類型由參數(shù)類型捍岳,數(shù)量富寿,順序以及返回值類型來確定)可被執(zhí)行,且可以被保存在一個變量中的代碼段锣夹;所以帶接收者的lambda在某些時候可以認為和擴展函數(shù)是等價的(注意页徐,只是某些時候,因為lambda和函數(shù)在被編譯成.class字節(jié)碼以后是不同的银萍,這是另一個話題变勇,這里不再展開了),假如我們要定義一個A類型作為接收者類型且一個Int類型作為參數(shù)贴唇,無返回值的帶接收者的lambda搀绣,就可以像如下這樣定義:
val receiver: A.(Int) -> Until = {
// do something...
}
如果我們要調用執(zhí)行這個lambda:
val a = A()
a.receiver(3)
所以Part 1中介紹的那些諸如linearLayout {},editText {}戳气,button {}這些函數(shù)链患,都是以一個帶接收者的lambda作為參數(shù)的普通內聯(lián)函數(shù),讓我們以editText {}為例來看看它是如何定義的:
inline fun ViewManager.editText(init: (@AnkoViewDslMarker android.widget.EditText).() -> Unit): android.widget.EditText {
return ankoView(`$$Anko$Factories$Sdk25View`.EDIT_TEXT, theme = 0) { init() }
}
inline fun <T : View> ViewManager.ankoView(factory: (ctx: Context) -> T, theme: Int, init: T.() -> Unit): T {
val ctx = AnkoInternals.wrapContextIfNeeded(AnkoInternals.getContext(this), theme)
val view = factory(ctx)
view.init()
AnkoInternals.addView(this, view)
return view
}
看起來有點復雜瓶您,啟示拆開來看其實很簡單麻捻,首先這是一個擴展函數(shù),接收者是ViewManager呀袱,這樣就限制了這個函數(shù)的調用范圍贸毕,即只能在某個父布局中被調用,隨后我們看到參數(shù)init就是一個標準的帶接收者的lambda夜赵,而init在函數(shù)內部調用ankoView函數(shù)的時候又會在它的lambda參數(shù)中被調用明棍,ankoView函數(shù)用來生成一個EditText對象,至于內部的原理寇僧,我們不去分析摊腋,而editText函數(shù)又會將這個EditText對象返回,便于函數(shù)的調用者獲取這個對象的引用嘁傀;最后我們看到歌豺,整個函數(shù)加了inline修飾符,即被聲明成內聯(lián)的心包,這樣就保證了DSL API的執(zhí)行效率类咧,而執(zhí)行init這個帶接收者lambda的ankoView實際上也是ViewManager的擴展函數(shù),而且它也是內聯(lián)的,這里不再做過多的源碼深入痕惋。我們簡單的體驗了一下如何聲明一個DSL API区宇,從Anko來看,實際上就是以下三點:
- 1.使用擴展函數(shù)來限制函數(shù)的調用范圍
- 2.使用帶接收者的lambda來保證API中的嵌套關系
- 3.使用inline修飾符值戳,把這些有l(wèi)ambda表達式作為參數(shù)的函數(shù)聲明成內聯(lián)的來保證執(zhí)行效率
我們這里再詳細說一下第二點议谷。
我們在編寫HTML和XML的時候,其中一點非常重要堕虹,那就是嵌套關系卧晓;這些嵌套關系即保證了這些元素之間的包含和被包含的關系,又保證了HTML或XML的可讀性赴捞;以使用XML來編寫Android UI為例逼裆,如果不使用XML,而是直接編寫Java代碼的話赦政,也是可行的胜宇,但是我們只能使用Java那種從上到下不停new出一個對象,然后用對象不停調用不同方法的辦法來創(chuàng)建UI恢着,當然也是可行的桐愉,但是這幾乎可以說是讓代碼的可讀性瞬間歸零,這樣編寫代碼即容易出錯掰派,后期也幾乎不可維護从诲。但是現(xiàn)在Kotlin有了帶接收者的lambda,我們可以在保留嵌套關系的同時靡羡,使用Kotlin這樣的圖靈完備語言來編寫我們需要的UI盏求,這樣就實現(xiàn)了Part 1中提到的內部DSL的全部優(yōu)點。
函數(shù)式的對象的invoke約定
Kotlin的約定有很多種亿眠,而比如使用便捷的get操作,以及重載運算符等等磅废,invoke約定也僅僅是一種約定而已纳像;我們可以把lambda表達式或者函數(shù)直接保存在一個變量中,然后就像執(zhí)行函數(shù)一樣直接執(zhí)行這個變量拯勉,這樣的變量通常聲明的時候都被我們賦值了已經直接定義好的lambda竟趾,或者通過成員引用而獲取到的函數(shù);但是別忘了宫峦,在面向對象編程中岔帽,一個對象在通常情況下都有自己對應的類,那我們能不能定義一個類导绷,然后通過構造方法來產生一個對象犀勒,然后直接執(zhí)行它呢?這正是invoke約定發(fā)揮作用的地方。
class A(val str: String) {
operator fun invoke() {
println(str)
}
}
fun main(args: Array<String>) {
val a = A("Hello")
a()
}
輸出:Hello
我們只需要在一個類中使用operator來修飾invoke函數(shù)贾费,這樣的類的對象就可以直接像一個保存lambda表達式的變量一樣直接調用钦购,而調用后執(zhí)行的函數(shù)就是invoke函數(shù)。
我們還有另一種方式來實現(xiàn)可調用的對象褂萧,即讓類繼承自函數(shù)類型押桃,然后重寫invoke方法:
class A : (String) -> String {
override fun invoke(str: String): String {
println(str)
return str
}
}
fun main(args: Array<String>) {
val a = A("Hello")
println(a())
}
輸出:Hello
Hello
直接讓一個類繼承自函數(shù)類型,這樣invoke的函數(shù)類型就和繼承的類型一致了导犹,我們也可以像上面那樣直接調用A類的對象唱凯,最終會執(zhí)行invoke函數(shù)。
使用invoke約定可以構建出什么樣的DSL API呢谎痢?在Anko中好像還沒有發(fā)現(xiàn)這樣的例子磕昼,但是在Gradle的構建腳本中這樣的例子就比較常見:
dependencies.compile("junit:junit:4.11")
dependiences {
compile("junit:junit:4.11")
}
dependiences實際上就是一個對象,它既可以直接調用compile方法舶得,又能在它的lambda表達式參數(shù)內調用compile掰烟,可見dependiences也是一個使用了invoke約定的類的對象,而它接收的是一個帶接收者的lambda表達式作為函數(shù)參數(shù)沐批。
帶接收者的lambda和invoke約定是支撐Kotlin DSL的兩大語法特性纫骑,但實際上在Kotlin中眾多的語法糖中,還有許多特性為你設計DSL的優(yōu)雅語法提供了可能九孩,這其中包括了:中輟調用先馆,運算符重載,括號外的lambda等等等等躺彬;我們不妨充分發(fā)散自己的思維煤墙,讓我們使用這些眾多的優(yōu)雅語法構建一個屬于自己的DSL庫,用來解決編程中某一類特定領域的棘手問題宪拥;Json數(shù)據(jù)格式也是一個講究嵌套的數(shù)據(jù)格式仿野,我們能否充分發(fā)揮我們的想象來編寫一個基于DSL的庫,來對Json做點什么呢她君?
那些優(yōu)秀的DSL開源庫
下面介紹的Kotlin DSL開源庫都是Kotlin的親爹JetBrain開發(fā)的脚作,這說明,就目前來看廣大開發(fā)者應該還沒有把DSL的潛力發(fā)揮到極致缔刹,如果您有其它優(yōu)秀的的DSL庫推薦球涛,可以給文章留言。
- 數(shù)據(jù)庫操作:Exposed
Exposed是JetBrain推出的校镐,可以使用DSL代替SQL來操作數(shù)據(jù)庫的開源庫亿扁,項目地址如下:Exposed - 動態(tài)構建Android UI:Anko
Anko也是JetBrain推出的,上文已經提到過了鸟廓;它是一款便于Android開發(fā)者使用Kotlin進行Android開發(fā)的函數(shù)庫从祝,其中襟己,使用DSL動態(tài)構建Android UI只是其中的一部分功能,這個庫的Github地址如下:Anko - 動態(tài)構建HTML布局:kotlinx.html
也是JetBrain官方推出的庫哄褒,用來使用DSL來構建HTML布局稀蟋,從它的包名中含有kotlinx就可以看出來,它的受重視程度高于Anko呐赡,基本上屬于Kotlin官方develop kit中的一部分退客,它的Github地址如下:
kotlinx.html
除此之外,Gradle已經支持使用Kotlin DSL來編寫構建腳本链嘀,使用Gradle的同學萌狂,也不妨立刻開始嘗試。