Kotlin-簡約之美-進階篇(十六):DSL原理解析

@[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的同學萌狂,也不妨立刻開始嘗試。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末怀泊,一起剝皮案震驚了整個濱河市茫藏,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌霹琼,老刑警劉巖务傲,帶你破解...
    沈念sama閱讀 211,743評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異枣申,居然都是意外死亡售葡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,296評論 3 385
  • 文/潘曉璐 我一進店門忠藤,熙熙樓的掌柜王于貴愁眉苦臉地迎上來挟伙,“玉大人,你說我怎么就攤上這事模孩〖饫” “怎么了?”我有些...
    開封第一講書人閱讀 157,285評論 0 348
  • 文/不壞的土叔 我叫張陵榨咐,是天一觀的道長介却。 經常有香客問我,道長块茁,這世上最難降的妖魔是什么齿坷? 我笑而不...
    開封第一講書人閱讀 56,485評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮龟劲,結果婚禮上,老公的妹妹穿的比我還像新娘轴或。我一直安慰自己昌跌,他們只是感情好,可當我...
    茶點故事閱讀 65,581評論 6 386
  • 文/花漫 我一把揭開白布照雁。 她就那樣靜靜地躺著蚕愤,像睡著了一般答恶。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上萍诱,一...
    開封第一講書人閱讀 49,821評論 1 290
  • 那天悬嗓,我揣著相機與錄音,去河邊找鬼裕坊。 笑死包竹,一個胖子當著我的面吹牛,可吹牛的內容都是我干的籍凝。 我是一名探鬼主播周瞎,決...
    沈念sama閱讀 38,960評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼饵蒂!你這毒婦竟也來了声诸?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 37,719評論 0 266
  • 序言:老撾萬榮一對情侶失蹤退盯,失蹤者是張志新(化名)和其女友劉穎彼乌,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體渊迁,經...
    沈念sama閱讀 44,186評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡慰照,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,516評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了宫纬。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片柠傍。...
    茶點故事閱讀 38,650評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖楣导,靈堂內的尸體忽然破棺而出闰歪,到底是詐尸還是另有隱情,我是刑警寧澤蝌蹂,帶...
    沈念sama閱讀 34,329評論 4 330
  • 正文 年R本政府宣布噩斟,位于F島的核電站,受9級特大地震影響孤个,放射性物質發(fā)生泄漏剃允。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,936評論 3 313
  • 文/蒙蒙 一齐鲤、第九天 我趴在偏房一處隱蔽的房頂上張望斥废。 院中可真熱鬧,春花似錦给郊、人聲如沸牡肉。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,757評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽统锤。三九已至毛俏,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間饲窿,已是汗流浹背煌寇。 一陣腳步聲響...
    開封第一講書人閱讀 31,991評論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留逾雄,地道東北人阀溶。 一個月前我還...
    沈念sama閱讀 46,370評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像嘲驾,于是被迫代替她去往敵國和親淌哟。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,527評論 2 349

推薦閱讀更多精彩內容