摘要:Kotlin 沒有 lazy 關鍵字,通過屬性代理實現只讀屬性的延遲求值,而 Scala 和 Swift 則通過 lazy 關鍵字來做到這一點。Swift 同樣可以通過傳入函數的形式來支持函數參數的延遲求值,可以通過 @autoclosure 來簡化調用過程中參數的寫法,這一點其實從形式上與 Scala 的傳名參數類似。

“懶”是程序員最優秀的品質之一,程序也是如此。

Kotlin 當中的 Lazy 想必大家都已經非常熟悉了,它其實承載的功能就是變量的延遲求值。今天我們同樣來對比一下其他語言對於類似功能的實現。

最近在探索相同特性在不同語言中實現的對比的文章寫作思路,如果大家覺得有收穫,別忘了點個贊讓我感受一下;如果覺得這思路有問題,歡迎評論留言提建議 ~~

Kotlin 的延遲求值

Kotlin 最初亮相的時候,基於屬性代理實現的 Lazy 就是最吸引人的特性之一。只有使用時纔會初始化,這個看上去簡單的邏輯,通常我們在 Java 當中會寫出來非常囉嗦,延遲初始化也經常因爲各種原因變成“忘了”初始化,導致程序出現錯誤。

這一切在 Kotlin 當中變得非常簡單。Kotlin 的 Lazy 通過屬性代理來實現,並沒有引入額外的關鍵字,這一點似乎非常符合 Kotlin 的設計哲學(就像其他語言的協程都喜歡 async/await 關鍵字,而 Kotlin 只有一個 suspend 關鍵字就承載了及其複雜的邏輯一樣):

[Kotlin]

val lazyValue by lazy { 
    complicatedComputing()
}

除了可以用於變量聲明,Lazy 也同樣適用於函數傳參,這一點非常重要,我們來看個例子:

[Kotlin]

fun assertAllTrue(vararg conditions: Lazy<Boolean>): Boolean {
    return conditions.all { it.value }
}

assertAllTrue 這個函數的目的是判斷所有參數的條件都爲真,因此如果其中有一個爲假,那麼後面的條件就不用計算了,這個邏輯類似於我們常見的 && 運算中的邏輯短路。代碼中,it.value 的 it 是 Lazy<Boolean> 類型,value 是 Lazy 的屬性,我們可以通過這個屬性來觸發 Lazy 邏輯的運算,並且返回這個結果 —— Lazy 用作屬性代理時邏輯也是如此。

接下來我們做下實驗,首先定義兩個函數用於提供條件值並通過打印輸出來判斷其是否被執行:

[Kotlin]

fun returnFalse() = false.also { println("returnFalse called.") }
fun returnTrue() = true.also { println("returnTrue called.") }

接下來我們調用 assertAllTrue 來看看會發生什麼:

[Kotlin]

val result = assertAllTrue(lazy { returnFalse() }, lazy { returnTrue() })
println(result)

輸出結果:

returnFalse called.
false

不意外吧?我們還可以模擬 || 再實現一個類似的函數:

[Kotlin]

fun assertAnyTrue(vararg conditions: Lazy<Boolean>): Boolean {
    return conditions.any { it.value }
}

只要有一個爲真就立即返回 true,後面的條件就不再計算了。大家可以自己試試給它傳幾個參數之後看看能得到什麼結果。

簡單來說,Kotlin 的 Lazy 是一個很普通的類,它可以承載 Kotlin 當中各種對於延遲計算的需求的實現,用在屬性定義上時借用了屬性代理的語法,用作函數參數時就使用高階函數 lazy 來構造或者直接傳入函數作爲參數即可。

除了使用 Lazy 包裝真實的值來實現延遲求值,我們當然也可以使用函數來做到這一點:

[Kotlin]

fun assertAllTrue(vararg conditions: () -> Boolean): Boolean {
    return conditions.all { it.invoke() }
}

這種情況下,我們傳入的參數就是一個函數,延遲計算的意圖也更加明顯:

[Kotlin]

val result = assertAllTrue({ returnFalse() }, ::returnTrue, ::returnFalse)

對於符合參數類型要求的 returnTrue 和 returnFalse 這兩個函數,我們既可以直接傳入函數引用,也可以構造一個 Lambda 表達式來包裝對它們的調用。傳入函數作爲參數來實現延遲計算是最基本的手段,其他語言的處理也無非就是在此基礎上增加一些友好的語法,後面我們在 Scala 和 Swift 部分就可以看到。

Scala 的延遲求值

在 Scala 當中 lazy 是個關鍵字。而相比之下,在 Kotlin 當中我們提到 Lazy 是指類型,提到 lazy,則是指構造 Lazy 對象的高階函數。

Kotlin 當中的 Lazy 用在定義屬性時,只支持只讀屬性或變量上(也就是 val 修飾的屬性或變量),這一點 Scala 的用法比較類似,下面是一個比較無聊的例子,不過倒是能說明問題:

[Scala]

def timeConsumingWork(): Unit ={
    ...
}

...

lazy val stopTime = System.currentTimeMillis()
val startTime = System.currentTimeMillis()

timeConsumingWork()
println(stopTime - startTime)

我們想要統計下 timeConsumingWork 這個函數的調用耗時,stopTime 雖然先調用,但因爲有 lazy 修飾,實際上等號右面的表達式 System.currentTimeMillis() 並沒有立即執行,反而是後定義的 startTime 因爲沒有被 lazy 修飾而立即計算出值。所以這個程序還真能基本正確地輸出 timeConsumingWork 函數執行的耗時。

哇,這樣看起來 Scala 使用 lazy 關鍵字定義屬性的語法比起 Kotlin 要簡單多了哎!不過換個角度,乍一看明明有一行代碼放在前面卻沒有立即執行是不是會很怪呢?如果一時間沒有注意到 lazy 關鍵字,代碼閱讀起來還真是有點兒令人迷惑呢。

我們接着看看函數參數延遲求值的情況。在 Scala 當中同樣存在高階函數,因此我們幾乎可以依樣畫葫蘆寫出 assertAllTrue 的 Scala 實現:

[Scala]

def assertAllTrue(conditions: (() => Boolean)*): Boolean = {
    conditions.forall(_.apply())
}

其中 () => Boolean 就是 Scala 中返回值爲 Boolean 類型的函數類型,後面的 * 表示這是個變長參數;函數體當中我們對所有的條件進行遍歷,並在 forall 當中調用 apply 來求出對應 condition 的值,這裏的 forall 相當於 Kotlin 當中的 all,apply 相當於 Kotlin 當中函數的 invoke。

用法如下:

[Scala]

val result = assertAllTrue(returnFalse, returnTrue, () => returnFalse())

注意到我們既可以直接把函數名作爲值傳入,這類似於 Kotlin 當中傳入函數引用的做法,最後一個參數 () => returnFalse() 則是定義了一個 Lambda 表達式來包裝到 returnFalse 函數的調用。

Hmmm,這麼看起來跟 Kotlin 真是一模一樣啊。

非也非也。Scala 的函數參數除了可以傳遞值以外,還有一種叫做傳名參數,即僅在使用時纔會觸發求值的參數。我們還是以前面的 assertAllTrue 爲例:

[Scala]

def assertBothTrue(left: => Boolean, right: => Boolean): Boolean = {
    left && right
}

可惜的是,Scala 的傳名參數不支持變長參數,所以例子有點兒縮水,不過不影響說明問題。

函數體內的最後一行就是函數的返回值,所以 left && right 的值就是 assertBothTrue 的返回值了;而 left 和 right 的參數類型長得有點兒奇怪,如果說它是 Boolean 吧,可它的類型前面還有個 => ,說它是函數類型吧, => 前面也沒有參數呀,而且用起來跟 Boolean 類型的變量看起來也沒什麼兩樣 —— 對嘍,這就是傳名參數,只有訪問時纔會計算參數的值,訪問的方式與普通的變量沒有什麼區別,不過每次訪問都會重新計算它的值,這一點又與函數的行爲相同。

接下來我們看下怎麼使用:

[Scala]

val result = assertBothTrue(returnFalse(), returnTrue())
println(result)

我們看到傳參時也沒什麼特別之處,直接傳就好了,與我們通常的認知的不同之處在於,assertBothTrue 調用時不會立即對它的參數求值,所以其實這樣看起來確實不太直觀(這大概是 Kotlin 設計者最不喜歡 Scala 的地方了。。)。

整體比較起來,Scala 對延遲求值做了語言級別的正式支持,因此語法上更省事兒,有些情況下代碼顯得也更自然。

哦,對了,例子縮水的問題其實也是有辦法解決的,哪有 Scala 解決不了的問題呢。。。:)

[Scala]

implicit class BooleanByName(value: => Boolean) {
    def valueByName: Boolean = value
}

def assertAllTrue(conditions: BooleanByName*): Boolean = {
    conditions.forall(_.valueByName)
}

思路也簡單,既然 Scala 不支持把傳名參數聲明爲變長參數,那麼我們就換個其他類型,巧就巧在 Scala 還支持類型隱式轉換,所以定義一個 BooleanByName 即可,這樣我們調用 assertAllTrue 傳的參數就可以是 Boolean 類型的表達式,編譯器會幫我們自動轉換爲 BooleanByName 類型丟給 assertAllTrue 函數。BooleanByName 中的 valueByName 是一個函數,Scala 當中對於不修改類內部狀態的無參函數通常聲明成沒有括號的樣子,這樣的函數調用時如同訪問屬性一樣( 如代碼中的 _.valueByName ),這在 Kotlin 當中的等價寫法就是一個沒有 backingfield 的只讀屬性的 getter。

Swift 的延遲求值

最近比較喜歡 Swift,因爲跟 Kotlin 長得像啊。不過隨着瞭解的深入,發現二者雖然看起來很像,但用起來差異太大了,至少在延遲求值這個語法特性的設計上,Swift 形式上更像 Scala。

Swift 的 lazy 也是一個關鍵字,可以修飾類的屬性,不過它不支持修飾局部變量,因此我們只能:

[Swift]

class LazyDemo {
    lazy var value = complicatedComputing()
    
    func complicatedComputing() -> Int {
        ... 
    }
}

不難想到,只要第一次訪問 value 時,complicatedComputing 纔會被調用。從延遲求值的角度來講與 Scala 是沒什麼差別的,不過大家仔細看會發現我們聲明屬性時用的是 var,也就是說 value 是可變的,這與 Scala、Kotlin 都不一樣。更有趣的是,如果我們希望 value 是隻讀的,將它的聲明改爲 lazy let value = ... ,Swift 編譯器會抱怨說 lazy 只能修飾 var。

納尼?你們這些語言的設計者是怎麼回事,意見居然這麼不統一?

其實 Swift 當中對於變量的讀寫有更嚴格的設計,這一點從 struct 與 class 的差異就可見一斑。而 lazy 之所以只能修飾 var,原因也很簡單,聲明的時候 value 雖然還沒有初始化,但在後續訪問的時候會觸發求值,因此存在聲明之後再賦值的邏輯。Hmmm,這個賦值行爲從語言運行的角度來講確實如此,可是這個邏輯不應該對開發者是透明的麼,爲什麼要讓開發者操心這麼多?

當然,如果想要保護 lazy 修飾的屬性的寫權限,可以考慮私有化 setter:

[Swift]

private(set) lazy var value = ...

但類內部仍然可以修改 value 的值,所以這個方法的作用也很有限。

接下來看下 Swift 當中函數參數的延遲求值。不難想到,我們將函數作爲參數傳入就可以實現這一點:

[Swift]

func assertAllTrue(_ conditions: () -> Bool ...) -> Bool {
    conditions.allSatisfy { condition in condition() }
}

大體上寫法與 Kotlin 類似,不過有幾個細節我們來解釋下。

  • 參數 conditions 前面的下劃線,一般語言的參數都只有參數名,也就是 conditions,Swift 還有一個參數標籤的概念,用於函數調用時指定(其實我們在 Kotlin 當中調用函數時也可以在參數前加參數名,但作爲位置參數時不強制),用下劃線可以省略掉這個標籤。
  • () -> Bool 表示 Swift 當中的函數類型,這與 Kotlin 的寫法基本一致,後面的 … 則表示這個參數爲變長參數。
  • { condition in condition() } 是 Swift 當中的 Lambda (在 Swift 當中稱爲 Closure,其實是一個東西),完整的寫法是 { (condition: () -> Bool) in condition() } ,不難看出,in 是用來分隔參數列表和表達式體的,condition 是參數,它的類型是 () -> Bool

好,那我們下面調用一下這個函數試試看:

[Swift]

let result = assertAllTrue({ returnFalse() }, returnTrue, returnFalse)

第一個參數使用 Lambda 表達式包裝對 returnFalse 函數的調用;後面的兩個參數直接使用函數名傳入,這類似於 Kotlin 當中的函數引用的用法。結果不言而喻。

這麼看來 Swift 也可以通過傳入函數來實現延遲求值。有了前面 Scala 的經驗,我們就不免要想,函數參數延遲求值的寫法上能否進一步簡化呢?答案是能,通過 @autoclosure 來實現。不過不巧的是 @autoclosure 也不支持變長參數(嗯??這句話好像在哪兒聽到過?),所以我們的例子就又縮水成了下面這樣:

[Swift]

func assertBothTrue(_ left: @autoclosure () -> Bool, _ right: @autoclosure () -> Bool) -> Bool {
    left() && right()
}

那調用時有什麼不一樣呢?

[Swift]

let result = assertBothTrue(returnFalse(), returnTrue())

我們直接傳入表達式,Swift 會幫我們用 {} 把它包裝起來,換句話說,參數裏面的 returnFalse 和 returnTrue 這兩個函數只有用到的時候纔會被調用。

簡單總結一下,Swift 通過 lazy 關鍵字來實現類屬性的延遲求值,這一點寫法上雖然與 Scala 很像,但只能修飾類或結構體的成員,而且是可讀寫的成員;Swift 同樣可以通過傳入函數的形式來支持函數參數的延遲求值,可以通過 @autoclosure 來簡化調用過程中參數的寫法,這一點其實從形式上與 Scala 的傳名參數類似。

再來一個有趣的例子

當語言設計地足夠靈活,基於已有的語法經常也能造出“新特性”,接下來我們就造一個。

常見的語言當中都有 while 循環,爲什麼沒有 whileNot 呢?聰明的我們想到了這一點,於是就開始造語法了。先來看看 Kotlin 怎麼實現:

[Kotlin]

fun whileNot(condition: () -> Boolean, action: () -> Unit) {
    if(!condition()) {
        action()
        whileNot(condition, action)
    }
}

用法:

[Kotlin]

var i = 10
whileNot({ i < 0 }){
    println(i)
    i -= 1
}

輸出就是 10 9 … 0

Scala 呢?

[Scala]

def whileNot(condition: => Boolean)(action: => Unit): Unit = {
    if (!condition) {
        action
        whileNot(condition)(action)
    }
}

爲了能讓第二個參數用 { ... } 以類似於 Kotlin 的方式傳入,我們用柯里化的方式聲明瞭這個函數,來瞧瞧用法:

[Scala]

var i = 10
whileNot(i < 0) {
    println(i)
    i -= 1
}

矮?是不是有那味了?這看着跟 while 已經沒差了。

下面是 Swift 的實現:

[Swift]

func whileNot(_ condition: @autoclosure () -> Bool, _ action: () -> Void) {
    if !condition() {
        action()
        whileNot(condition(), action)
    }
}

我似乎已經感覺到了那味兒~

[Swift]

var i = 10
whileNot(i < 0) {
    print(i)
    i -= 1
}

怎麼樣,Swift 造出來的 whileNot 也幾乎可以以假亂真了。

看來真的只有你家 Kotlin “稍遜一籌” 啊,條件那裏還必須加個 {} ,沒有語法糖可以將這個去掉。不過,(咳咳,官方口吻)Kotlin 一向不喜歡偷偷摸摸的,我們必須要保留 {} 讓你一眼就能看出來那是個函數,而不像某些語言搞得那麼曖昧。

其實吧,單從這個例子的角度來講,函數的參數類型聲明還是挺清楚的,現在 IDE 這麼牛逼,所以支持一下這樣的特性算不算違反 Kotlin 的設計原則其實也不一定,不過目前看來這種不痛不癢的小特性還是算了吧,跨平臺纔是最牛逼的,加油 Kotlin,我等着 Android Studio 5.0 寫 iOS 呢(zZZ)。

小結

總結一下:

  1. Kotlin 沒有 lazy 關鍵字,通過屬性代理實現只讀屬性的延遲求值,而 Scala 和 Swift 則通過 lazy 關鍵字來做到這一點
  2. Kotlin 和 Scala 對於屬性的延遲求值只支持只讀屬性,Swift 只支持可變屬性
  3. Kotlin 和 Scala 的延遲求值還支持局部變量,Swift 不支持。
  4. 他們仨都支持通過傳入函數的方式來實現函數參數的延遲求值。
  5. Scala 和 Swift 對函數參數延遲求值在語法上有更友好的支持,前者通過傳名參數,後者通過 @autoclosure。
  6. Kotlin 是唯一一個通過其他特性順帶支持了一下延遲求值的,這很符合 Kotlin 設計者的一貫做法((⊙o⊙)…)。

如果大家想要快速上手 Kotlin 或者想要全面深入地學習 Kotlin 的相關知識,可以關注我基於 Kotlin 1.3.50 全新制作的新課,課程第一版曾幫助3000多名同學掌握 Kotlin,這次更新迴歸內容更精彩:

掃描二維碼或者點擊鏈接 《Kotlin 入門到精通》 即可進入課程

Android 工程師也可以關注下《破解Android高級面試》,這門課涉及內容均非淺嘗輒止,除知識點講解外更注重培養高級工程師意識,目前已經有近 1000 位同學在學習:

掃描二維碼或者點擊鏈接 《破解Android高級面試》 即可進入課程

相關文章