用 Kotlin 實作 DSL — 以 JSON 為範例

Andy Lu
10 min readMay 8, 2023
Image by Tom from Pixabay

近年來,聲明式程式語言(Declarative programming)逐漸的出現在現代程式語言中,其中不泛包括了 Flutter、Jetpack Compose…,使用聲明式程式語言,我們實作時考慮的就會是怎麼去完成(What to do),而不是如何去完成(How to do)。

Domain Specific Language (DSL) ,中文翻譯為領域特定語言。是專門為了要解決特定領域而設計的程式語言,它不是專指特定的程式語言。將所要使用的領域程式碼用抽象的方式來表達,以該領域最原始的使用方式,而不是所使用程式語言的使用方式,其目的就是要讓該領域的程式碼能夠更容易被了解、更能夠清楚表明其目的,讓開發者能夠更快速的掌握該程式的意圖,而不被程式語言的語法所迷惑。 換句話說,我們能使用 Kotlin 將程式碼抽象成領域特定語言,將如何去完成包裝成怎麼去完成。

如下方的 JSON 內容,如果我們能夠使用更為接近的程式碼來編寫,讀到這段程式碼的人就能更清楚知道這是一段 JSON。

{
"a":"123",
"b":"456"
}

若是在 Kotlin 程式碼裡使用底下的方式建立 JSON,會比原生所提供的方式更容易理解,

jsonObject{  
item("a" to "123")
item("b" to "456")
}

下面為使用 Android SDK 內所提供的 org.json.JSONObject 來產生相同的物件:

import org.json.JSONObject

val jsonObject = JSONObject()
jsonObject.put("a", "123")
jsonObject.put("b", "456")

因 Kotlin 的語言特性,讓我們可以自行創建 DSL ,要在 Kotlin 中寫 DSL ,我們只需要使用到 Lambda ExpressionExtension FunctionFunction literals with receiver 三個特性。

以下我們快速的介紹這三個特性:

Lambda Expression(Lambda 表達式)

在 Kotlin 中,函式的參數除了一般的類別外,還可以以匿名函式的方式來表示,而這麼一來,我們就可以將要執行的內容由外部輸入,而不需要冗餘的宣告,如在 Collection 內的眾多高階函式,如 filter

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
return filterTo(ArrayList<T>(), predicate)
}

filter 的參數我們可以發現,其型別是 (T) -> Boolean ,表示這個匿名函式的輸入參數是 T,而回傳值則是 Boolean

前面提到,我們不需要冗餘的宣告就可以使用,所以 filter 函式可以以下面的方式呼叫:

val numbers = listOf(1, 2, 3, 4, 5)  
val filteredList = numbers.filter { it > 3 }

我們只需要在函式的最後方使用大括弧 { } 將運算式包起來,那麼這個運算式就會傳入 filter 函式內,而不需寫多餘的程式碼。

❗️ 能夠在函式的後方直接使用大括弧的前提是,匿名函式必須是最後一個參數才可以。

熟悉 Kotlin 的朋友一定對 Collection 不陌生,因為使用 Collection 我們就可以使用 串串大法,我們從 filter 函式的回傳值就可以得知,因為它的回傳值是 List ,所以同樣又包含了那些高階函式,所以也就可以無限制的串接下去囉。

Extension Function (擴充函式)

第二項要介紹的是 Extension Function,使用它我們就可以直接替類別加入函式,而不需更改原本的類別,這是一項非常有用的功能,當我們建立擴充函式之後,在我們 Package 裡的相同類別都能夠直接擁有該函式。 譬如說,若我們的程式很常用 filter 函式來過濾 List 的值,但是過濾的方式都固定,或許我們就可以自行建立一個 Extension Function。假如我們經常需要過濾偶數,如果使用原本的 filter 函式,我們會寫成這個樣子。

val numbers = listOf(1, 2, 3, 4, 5)
val evenNumbers = numbers.filter { it % 2 == 0 }

只要能夠整除 2,就是偶數。

若將這個功能使用 Extension Function 改寫:

fun List<Int>.filterEvenNumbers(): List<Int> = this.filter { it % 2 == 0 }

ℹ️ 宣告方法:在函式名稱前面加上要加上此功能的類別,並在函式裡面使用 this 來取代輸入值。

當我們使用 List<Int> 時,就可以輕鬆呼叫 filterEvenNumbers() 來取得我們要的結果。

val evenNumbers = numbers.filterEvenNumbers()

使用 Extension Function 的另一個好處是,使用端便不需要在意其底層實作的細節,反之我們只需要知道有這個方法可以用即可。而這也就是 Kotlin 眾多高階函式所要帶給我們的優勢。

Lambda with Receiver (具有 Context 接收器的匿名函式)

前面知道 Lambda Expression 作為函式的參數時,我們可以直接在呼叫函式的時候才建立匿名函式,並在該函式內呼叫傳入的匿名函式。不過,有時候我們需要傳入的匿名函式是屬於某個類,換句話說,我們只能使用該類別的函式。

聽起來很饒舌,對吧。

看以下的範例:

fun list(values: List<Int>,
input: List<Int>.() -> List<Int>
): List<Int> {
return input.invoke(values)
}

ℹ️ 呼叫方法:在匿名函式的前面加上要使用的類別,並在類別後方加上 . ,如上方範例的 List<Int>.() -> List<Int>

那麼,我們建立了這個看起來怪怪的函式之後,我們該怎麼使用呢? 呼叫方式如下,因為這個 list 函式的最後一個參數是 Lambda Expression,所以我們可以在 list 函式後方直接使用大括弧將要執行的內容包起來。

val numbers = listOf(1, 2, 3, 4, 5)
list(numbers) { filter { it % 2 == 0 } }

而在這個大括弧裡面,我們就能夠使用所有屬於 List<Int> 的函式,例如上方範例的 filter

將 Extension Function 的參數使用 Lambda with Receiver 的方法來改寫

到目前為止,都只是介紹單獨的功能,接下來,採用組合技(Extension Function + Lambda with Receiver),讓我們的呼叫更漂亮。 將前面範例改成使用 Extension Function,會是怎麼樣呢?

fun List<Int>.list(input: List<Int>.() -> List<Int>): List<Int> {
return input.invoke(this)
}

我們將輸入值提出,讓這個函式成為 List<Int> 的 Extension Function,如此一來,使用 list 函式時,就不需要先使用小括弧 ( ) 將輸入值傳入,而是可以直接在我們的輸入後方直接串 list 函式

所以前面的範例就可以改寫成:

val numbers = listOf(1, 2, 3, 4, 5)
numbers.list { filter { it % 2 == 0 } }

DSL 實戰 — JSON

在 Android SDK 中,包含了 org.json 函式庫,我們可以使用這個函式庫建立 JSONObject 以及 JSONArray。 若有一個 JSONObject,裡面包含了兩組元素:"a":"123" , "b":"123"。 內容如下:

{
"a":"123",
"b":"456"
}

如果採用 Android SDK 提供的 org.jsonJSONObject 類來產生這個 JSONObject,程式碼如下:

val jsonObject = JSONObject()
jsonObject.put("a", "123")
jsonObject.put("b", "456")

JSONObject() 類中,可使用 put() 分別帶入 key-value,如此就能將內容存入。

改用 DSL 實作 JSONObject

那麼,若我們希望能夠在 Kotlin 內部以 DSL 的方式來實作,也就是說我們可以使用近似於原本建立的方式來產生 JSONObject。

實作範例程式碼如下:

fun jsonObject(builder: JSONObject.() -> Unit): JSONObject {  
val jsonObject = JSONObject()
jsonObject.builder()
return jsonObject
}

實際使用:

jsonObject{  
put("a", "123")
put("b", "456")
}

從上方的範例我們可以發現,使用 DSL 風格來改寫,使得呼叫 jsonObject 建立 JSONObject 會與原本 JSON 的格式又更像了,我們可以在加以改造一下。 使用 item() 函式取代 put() ,並將輸入的變數改以 Pair 替代。

我們目的是想要使得建立 JSONObject 可以使用下面的方式來建立,不知道你們有沒有想到該怎麼建立呢?

jsonObject{  
item("a" to "123")
item("b" to "456")
}

Extension Function

沒錯,在這邊我們可以使用 Extension Function 來達到這個結果。 -> 我們針對 JSONObject 類別加上 item 函式,並使用 Pair 作為其輸入參數。

fun JSONObject.item(pair: Pair<String, Any>): JSONObject =
this.put(pair.first, pair.second)

利用 Lambda with Receiver 以及 Extension Function 兩項功能,我們就能夠將 Android SDK 的 JSONObject 以 DSL 的方式改造,進而使其成為與原本的 JSON 格式更相近的呼叫方式。我們在使用的時候,就可以以更直覺得方式來調用。

結論

DSL 是一個蠻抽象的主題,一開始在學習的時候有點不知道它是什麼東西,但是在 Kotlin 的世界裡 DSL 可謂是無所不在。 為什麼 DSL 會在 Kotlin 中到處都看得到呢?我認為有兩點原因,一、因為 Kotlin 的語言特性,所以使得在 Kotlin 內編寫 DSL 變得可能了,二、因為 DSL 是使用原特定領域的語言方式來呈現該內容,所以就減少了學習的門檻,任何人都能夠輕鬆的了解其內容。 而在 Kotlin 內,我們可以依照自己的需求建立 DSL,只需要三項語言特性 Lambda Expression、 Extension Function 及 Lambda with Receiver 即可完成。 在本篇文章中,介紹了如何在 Kotlin 實現 DSL 風格的 JSONObject,作為練習,你們可以自行練習如何在 Kotlin 中建立 DSL 風格的 JSONArray。

參考:

--

--

Andy Lu

Android/Flutter developer, Kotlin Expert, like to learn and share.