【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】
Future 與 Future,有什麼區別?
在 Dart 2 的諸多升級中(除了更好的靜態檢查、運行時類型安全、可選的 new/const、核心函式庫改進等等),一個值得一提的改進是 void 的正式化,使其更加實用且更不容易出錯。這在異步程式設計中尤其清晰,您可以為異步函數編寫 Future<void>
,這些函數在工作完成時不返回結果。在此之前,您可能使用了 Future<Null>
,所以我們經常被問到的問題是:Future<void>
和 Future<Null>
之間有什麼區別?我應該使用哪一個,以及何時使用?
由於我的目標是提供有用的資訊,我將從 TLDR 開始。
TL;DR:99.99% 的情況下,您應該使用 void 類型。
我還建議您現在就在您的專案中啟用兩個與 void 相關的lint 規則:
- prefer_void_to_null: 幫助您改掉輸入幾乎過時的 Null 的習慣。
- void_checks: 為您提供可能更直觀的 void 語義,即使它們對於安全程式碼並非絕對必要。
本文的其餘部分並非輕鬆閱讀。它結合了歷史、邊緣案例和類型理論。我接下來要寫的就像試圖解釋單子,但我會盡力而為,希望您能跟上。
快速 Future 小知識
為了展示閱讀本文的價值,我為您提出了一個小知識問題。
假設我們將自己限制在兩個 Future - 一個 Future<User>
和一個 Future<Null>
。以下程式碼行會做什麼?
1 | // 這段程式碼在您的編輯器中會失敗嗎? |
1 | // 這段程式碼在運行時會失敗嗎? |
如果我們改用 await 呢?
1 | User u = await Future<Null>.value(null); |
暫停以產生戲劇性效果
答案是,除非您禁用了隱式向下轉型,否則這四行程式碼都不會在您的編輯器中顯示任何錯誤。沒錯,使用 Future<Null>
並假裝它是它不是的東西,例如 Future<User>
,是 100% 合法的。Future<void>
就不是這樣!
同樣不直觀的是,第二行和第四行在運行時會失敗(以這種方式丟棄 Future 的結果是不「安全」的),而其他行則靜默地成功。
這些行為應該會讓您感到不安。但別擔心!您已經知道解決方案:使用 Future<void>
。我將分析為什麼這種不直觀的行為會這樣工作。
void 現在是如何工作的
我一直在思考本文的最佳順序。這裡有很多概念需要解釋。但是,我會盡量按照從最有用的資訊到最不有用的資訊的順序進行。因此,由於 void 在 Dart 2 中比 Null 更有用,因此從 Dart 2 void 語義開始是有意義的。
Dart 中 void 背後的基本思想源於 void 值不應使用的目標。
1 | void f() {} |
希望這就是您期望發生的!到目前為止,一切都很好。
值得注意的是,實現這個目標並且將 void 作為一個相當普通的類型公開會有點奇怪:
1 | f(void argument) { |
這就是 Dart 2 中我們新的「廣義 void」開始變得酷炫和強大的地方……儘管有時會讓人感到困惑。
我向您提出一個問題……我們可以傳遞什麼類型的值作為 f 的參數?
1 | f(void argument) { … } |
答案是……任何東西!因為我們不允許您在函數 f 中使用 x 的值,所以我們可以自信地說 x 可以取的任何值都不會導致任何運行時錯誤。
1 | f(1); // 與以下程式碼沒有區別 |
如果我們根據 void 是一個不會被使用的值的思想推導出 void 的最佳語義,這就是我們得到的結果。這意味著,它是一個可以用任何東西填充的值。它就像一個真空:一個沒有輸出的輸入。
現在,在某些情況下,某些東西在運行時總是沒有錯誤,但仍然應該導致靜態錯誤。這是一個 lint 的絕佳案例!事實上,這就是 void_checks lint 背後的思想。它會尋找您將 null 以外的任何東西傳遞到 void 位置的地方,我鼓勵團隊啟用它。它並非健全性所必需的,但是將 null 以外的任何東西傳遞到 void 位置仍然可能是一個意外,lint 會為您標記它。
由於所有這些都基於基礎類型理論,因此 void 可以與 Dart 的每個部分都很好地配合使用,即使在利用類型推斷的 Future<void>
上下文中的延續也是如此:
1 | Future<void>().then((x) { |
1 | // 是的,這也是一個錯誤: |
此時,普通開發人員可能已經了解了有效使用 void 所需知道的一切。
然而,如果您仍在繼續閱讀,還有一些更有趣的東西。
即使啟用了 void_checks,也不能保證 void 位置包含 null。以下列覆寫為例:
1 | class A { |
我們不想讓此覆寫非法,因為它是安全的、有用的,並且與 Dart 1 相比是一個重大變更。因此,我們不得不接受 void 位置可能包含任何值。我們也不能「優化掉」A.f() 返回的值,因為在運行時它可能是一個 B!
相反,我們有一個聰明的選擇,即讓 void 成為 Object 的姊妹類型。畢竟,它可以包含任何值,並且所有值都是物件。這不是我們設計的東西,它只是現實。認識到現實,我們就可以利用它。
透過使 void 成為 Object 的同級,我們沒有任何要求 void 值完全未使用。我們盡力讓 void 保持自身,但為了向後兼容性,我們特意在一些地方放鬆了這些限制:
1 | dynamic f() => voidFn(); // 這是合法的 |
這些是對於 Object 合法的特殊情況,為了使 Dart 2 更順利地推出,我們也讓它對 void 合法。
使 void 成為 Object 的同級也意味著 void 可以用作類型參數,而不會使編譯後的輸出膨脹(對於 C++ 使用者來說,沒有「模板特化」)。這種減少有助於保持 Web 和 Flutter 應用程式的小巧,但代价是允許以下操作:
1 | <void>[1, 2, 3].toString(); // 這是合法的,並列印 [1, 2, 3] |
最後,將參數類型指定為 void 可能看起來沒有用,尤其是因為它是 Object 的一種形式。然而,void 值可以傳遞到 void 位置:
1 | f(void x) { … } |
這對於排序很有用,例如在模擬返回 void 的方法時,Mockito 會使用它。(將上面的程式碼中的 f 替換為 when 以獲得更接近的近似值。)
總而言之:
- void 類型是 Object 的同級。
- 幾乎總是,void 物件不能被使用。
- 標記為 void 的東西實際上可以是任何東西。
- 任何東西都可以「丟棄」到標記為 void 的位置,而 lint void_checks 限制了這種行為。
- void 值可以傳遞到其他 void 位置。
底部類型
在我開始討論 Null 之前,我們必須先討論「底部」類型。
這是一個類型理論中自然發生的類型,它有一個簡短的學術定義和一些實際應用。
如果您因為它太奇怪了而停止閱讀本節,那就是我最初 TLDR 的有力證據。除非您希望您的程式碼同樣奇怪,否則您可能需要 void。現在讓我們探索這個奇怪的兔子洞,看看它有多深,好嗎?
底部類型是所有類型的子類型。用更簡單的面向對象術語來說,這意味著它是一個人。還是一輛車。還是一種動物。還有來自每個程式的所有其他類型。
如果這聽起來很荒謬,那是因為它確實如此。我喜歡將它視為一個「佔位符」類型。但是「荒謬的」或「虛構的」類型也是一個合理的稱呼。然而,它被稱為底部類型,因為它是類型層次結構的底部,在電腦科學中,它是顛倒的。¯_(ツ)_/¯。正式地,它可以用符號 ⊥ 表示,您可能也認識到它是「假」的符號。
如果您試圖想像一個既是人又是車又是動物的值,您將無法想到任何東西。令人驚訝的是,這就是 ⊥ 的實際用途的來源!
如果我編寫一個永不返回的函數會怎樣?有兩種簡單的方法可以做到這一點:
1 | loopForever() { |
1 | alwaysThrow() { |
這兩個函數的最佳返回類型是什麼?
這取決於情況。因為函數永不返回,所以返回類型並不重要。您可以使用任何類型 - 即使是荒謬的底部類型,它可以在各種語言中以各種方式使用。
C++ 將其稱為 noreturn。Rust 有 !,Scala 有 Nothing。但我最喜歡的例子來自 Haskell,它有一個非常常用的 undefined 函數,它會中止程式。它返回,您猜對了,底部類型。
如果我們假設 Dart 中有一個 undefined 函數,就像 Haskell 中的那樣,它在被調用時只拋出一個異常並返回 ⊥,那麼它看起來像這樣:
1 | Foo foo = cond ? val : undefined(); |
在示例用法行中,當 cond 為真時,程式可以安全地運行並儲存 val。而當 cond 為假時,程式將在 undefined() 期間拋出異常。將 undefined() 的結果「儲存」到 foo 中是安全的,無論 foo 的類型如何,因為該儲存永遠不會真正發生!
undefined() 這裡沒有返回任何東西。但這裡的教訓不是我們可以讓 foo 為空……而是底部類型像一個空的承諾一樣是空的。它比空還空。它永遠不會發生。
我必須小心地說明的一點是,您可以在實踐中從這些函數中返回 void,並且根據用法,通常應該返回 void。通常,像 return loopForever()
這樣的程式碼更有可能是一個錯誤,而不是一個有用的模式。然而,您可以自行選擇。
底部類型也適用於唯讀空列表。在 Dart 中,List 是協變的,因此允許將 List<int>
用作 List<Object>
。如果您試圖將 String 放入該 List<Object>
中,運行時檢查會為您捕獲該錯誤並拋出錯誤。
這意味著,如果我們建立一個 ⊥ 列表,我們就無法在其中放入任何東西,但我們可以將其視為任何東西的列表:
1 | List<int> intList = <⊥>[]; |
當您查看所謂的底部類型的「逆變」位置時,還會有更多有趣的案例。
假設我們定義了一個具有 ⊥ 參數的函數:
1 | void f(⊥ x) {} |
這幾乎與 undefined() 示例相反。我們不是聲明了一個永不返回的函數,而是聲明了一個無法被調用的函數!沒有實際值可以賦值給該參數 x。您不能傳入 Person,因為它也不是 Car,並且您不能傳入 Car,因為它也不是 Person。您唯一可以傳入的就是荒謬的類型本身:
1 | f(undefined()); |
但是正如我們之前所討論的,undefined() 永不返回,所以在這種情況下,f() 仍然永遠不會被實際調用!
將參數類型指定為 ⊥ 可能看起來沒有用,但它具有深奧的價值,因為所有可以被調用的函數都是不能被調用的函數的子類型。(想一想:一個可以被調用的函數不一定要被調用。如果一個函數沒有被調用,它就不會產生運行時錯誤。)
如果您還在繼續閱讀,請深呼吸,並拍拍自己的背。
具體來說,將任何 Function(X)
轉換為 Function(⊥)
是安全的,對於任何 X 都是如此。這比使用 Dart 的包羅萬象的 Function 類型更好,因為它更具體。
例如,您可以將任何一元函數儲存在一個欄位中,並動態調用它,以繞過靜態錯誤,如果您犯了錯誤,則用運行時錯誤代替它們:
1 | Function(⊥) f; |
1 | // 123 作為 f 的參數的有效性在運行時檢查 |
這是一個在緊要關頭幫助您的小技巧。
現在我們可以討論 Null 了。
Dart 2 中的 Null
概念上的「底部」類型(所有類型的子類型)存在於 Dart 中,但這並不是全部。這樣的值也存在於 Dart 2 中!在 Dart 中,我們稱它為 Null,而該值就是 - 您猜對了 - null。
由於我可以使任何東西都為 null(/Null),所以在 Dart 中荒謬的類型並不那麼荒謬。這使得它有點複雜。
注意:值 null 當然不是 Car,也不是 Person。我們確實收到了對 Dart 中非空類型的請求。因此,如果我們確實對 Dart 進行了這樣的更改,我們將需要一個新的底部類型,可能命名為 Nothing 之類的名稱,那時它將是一個更真實的底部類型。
Null 不僅具有與底部類型相同的所有荒謬用法,而且還有一個逃生出口。如果您真的需要從一個不能返回的函數中返回,或者調用一個不能被調用的函數,我們會給您一個出路!您可以將 null 傳遞進去!老實說,如果您從整體上看,我們不這樣做會有點不公平。
但是,對於一個原本簡單的聲明,它確實給出了很多警告:
1 | Null nothing() => null; |
Null 是 foo() 最特定的類型,所以它是一個合乎邏輯的選擇,也是一個良好的起點。如果您在其上調用不存在的方法,分析器也會善意地警告您:
1 | nothing().x; // 錯誤!Null 上沒有成員 x |
但这可能会造成安全的假象。
1 | nothing().toString(); // 沒有錯誤:Null 定義了 toString() |
1 | // 對於 f(x) 中的所有參數類型都可接受, |
如果您的目標是讓 nothing() 成為