跳到主內容

理解空安全

作者:Bob Nystrom
2020 年 7 月

空安全是我們自 Dart 2.0 中用健全的靜態型別系統取代原有的不健全可選型別系統以來,對 Dart 所做的最大改變。當 Dart 首次推出時,編譯時空安全是一個罕見的特性,需要冗長的介紹。如今,Kotlin、Swift、Rust 和其他語言都有各自的解決方案,以應對一個已經變得非常常見的問題。 下面是一個示例

dart
// Without null safety:
bool isEmpty(String string) => string.length == 0;

void main() {
  isEmpty(null);
}

如果您在沒有空安全的情況下執行此 Dart 程式,它會在呼叫 .length 時丟擲 NoSuchMethodError 異常。null 值是 Null 類的例項,而 Null 沒有“length” getter。執行時故障很糟糕。這在 Dart 這種旨在使用者裝置上執行的語言中尤為如此。如果伺服器應用程式發生故障,您通常可以在任何人注意到之前重啟它。但當 Flutter 應用程式在使用者手機上崩潰時,使用者會很不高興。當您的使用者不高興時,您也不會高興。

開發人員喜歡 Dart 這樣的靜態型別語言,因為它們使型別檢查器能夠在編譯時(通常直接在 IDE 中)發現程式碼中的錯誤。您越早發現錯誤,就能越早修復它。當語言設計者談論“修復空引用錯誤”時,他們指的是豐富靜態型別檢查器,以便語言可以檢測出上述嘗試在可能為 null 的值上呼叫 .length 這樣的錯誤。

這個問題沒有唯一的正確解決方案。Rust 和 Kotlin 都有其各自的方法,這些方法在各自語言的上下文中是合理的。本文件將詳細闡述我們對 Dart 的解決方案。它包括對靜態型別系統的更改以及一系列其他修改和新語言特性,讓您不僅能夠編寫空安全程式碼,而且希望能享受這個過程。

本文件很長。如果您想了解更精簡的內容,只包含上手所需知識,請從概覽開始。當您準備好更深入地理解並有時間時,請回到這裡,以便您瞭解語言如何處理 null、我們為何這樣設計以及如何編寫符合習慣、現代的空安全 Dart 程式碼。(劇透警告:最終它與您今天編寫 Dart 程式碼的方式驚人地相似。)

語言處理空引用錯誤的各種方式各有優缺點。以下原則指導了我們的選擇

  • 程式碼應預設為安全。 如果您編寫新的 Dart 程式碼且不使用任何明確不安全的特性,它在執行時絕不會丟擲空引用錯誤。所有可能的空引用錯誤都會被靜態捕獲。如果您希望將某些檢查推遲到執行時以獲得更大的靈活性,您可以這樣做,但您必須透過使用程式碼中明確可見的特性來選擇這樣做。

    換句話說,我們不是給您一件救生衣,然後讓您自己記住每次下水時都要穿上。相反,我們給您一艘不會沉的船。您會保持乾燥,除非您自己跳下船。

  • 空安全程式碼應該易於編寫。 大多數現有的 Dart 程式碼在執行時是正確的,並且不會丟擲空引用錯誤。您喜歡您現在 Dart 程式的樣子,我們也希望您能夠繼續以這種方式編寫程式碼。安全性不應該要求犧牲可用性、向型別檢查器妥協,或者需要顯著改變您的思維方式。

  • 生成的空安全程式碼應完全健全。 “健全性”在靜態檢查的上下文中對不同的人有不同的含義。對我們而言,在空安全的上下文中,這意味著如果一個表示式的靜態型別不允許 null,那麼該表示式的任何可能執行都不會評估為 null。語言主要透過靜態檢查提供此保證,但也可能涉及一些執行時檢查。(不過,請注意第一條原則:這些執行時檢查發生的任何位置都將由您選擇。)

    健全性對於使用者信心至關重要。一艘大部分時間都浮在水面上的船,不會讓您有信心駕駛它去闖蕩公海。但它對於我們勇敢的編譯器駭客也同樣重要。當語言對程式的語義屬性做出嚴格保證時,這意味著編譯器可以執行基於這些屬性為真的最佳化。對於 null 而言,這意味著我們可以生成更小的程式碼,消除不必要的 null 檢查,以及更快的程式碼,無需在呼叫方法之前驗證接收者是否非 null

    一個注意事項:我們只保證完全空安全的 Dart 程式的健全性。Dart 支援包含新版空安全程式碼和舊版遺留程式碼的混合版本程式。在這些混合版本程式中,空引用錯誤仍可能發生。在混合版本程式中,您可以在空安全的部分獲得所有的靜態安全優勢,但在整個應用程式實現空安全之前,您無法獲得完整的執行時健全性。

請注意,消除 null 並非目標。null 本身沒有問題。相反,能夠表示值的缺失是非常有用的。將對特殊“缺失”值的支援直接內建到語言中,使得處理缺失變得靈活且可用。它支撐著可選引數、便捷的 ?. 空感知運算子以及預設初始化。導致問題的不是 null 本身,而是 null 出現在您不期望的地方

因此,透過空安全,我們的目標是讓您對 null 在程式中的流向擁有控制權洞察力,並確保它不會流向會導致崩潰的地方。

型別系統中的可空性

#

空安全始於靜態型別系統,因為所有其他功能都依賴於它。您的 Dart 程式中包含一個完整的型別宇宙:像 intString 這樣的基本型別,像 List 這樣的集合型別,以及您和您使用的包定義的所有類和型別。在空安全之前,靜態型別系統允許 null 值流入任何這些型別的表示式中。

在型別理論的術語中,Null 型別被視為所有型別的子型別

Null Safety Hierarchy Before

允許對某些表示式執行的操作集(getter、setter、方法和運算子)由其型別定義。如果型別是 List,您可以呼叫其 .add()[]。如果是 int,您可以呼叫 +。但 null 值沒有定義這些方法中的任何一個。允許 null 流入其他型別的表示式意味著任何這些操作都可能失敗。這實際上是空引用錯誤的核心——每次失敗都源於嘗試在 null 上查詢它不具有的方法或屬性。

非空型別和可空型別

#

空安全透過改變型別層次結構從根本上消除了這個問題。Null 型別仍然存在,但它不再是所有型別的子型別。相反,型別層次結構看起來像這樣

Null Safety Hierarchy After

由於 Null 不再是子型別,因此除特殊 Null 類之外,任何型別都不允許使用 null 值。我們已使所有型別預設不可為空。如果您有一個 String 型別的變數,它將始終包含一個字串。至此,我們已經修復了所有空引用錯誤。

如果我們認為 null 完全沒用,我們就可以到此為止了。但 null 很有用,所以我們仍然需要一種處理它的方法。可選引數是一個很好的說明性案例。考慮以下空安全 Dart 程式碼

dart
// Using null safety:
void makeCoffee(String coffee, [String? dairy]) {
  if (dairy != null) {
    print('$coffee with $dairy');
  } else {
    print('Black $coffee');
  }
}

這裡,我們希望允許 dairy 引數接受任何字串或 null 值,但不能是其他任何值。為了表達這一點,我們透過在底層基礎型別 String 的末尾加上 ?,將 dairy 賦予一個可空型別。從底層來看,這實際上是定義了底層型別和 Null 型別的聯合。因此,如果 Dart 擁有功能齊全的聯合型別,String? 將是 String|Null 的簡寫。

使用可空型別

#

如果您有一個可空型別的表示式,您可以使用其結果做什麼?由於我們的原則是預設安全,所以答案是不多。我們不能讓您在其上呼叫底層型別的方法,因為如果值為 null,這些方法可能會失敗

dart
// Hypothetical unsound null safety:
void bad(String? maybeString) {
  print(maybeString.length);
}

void main() {
  bad(null);
}

如果我們允許您執行它,它就會崩潰。我們唯一能安全地讓您訪問的方法和屬性是底層型別和 Null 類都定義的方法和屬性。這只是 toString()==hashCode。所以您可以將可空型別用作 Map 鍵,儲存在 Set 中,與其他值進行比較,並在字串插值中使用它們,但僅此而已。

它們如何與非空型別互動?將空型別傳遞給預期可空型別的東西總是安全的。如果一個函式接受 String?,那麼傳遞一個 String 是允許的,因為它不會導致任何問題。我們透過使每個可空型別成為其底層型別的超型別來建模這一點。您也可以安全地將 null 傳遞給預期可空型別的東西,因此 Null 也是每個可空型別的子型別

Nullable

但反過來,將可空型別傳遞給預期底層非空型別的東西是不安全的。期望 String 的程式碼可能會在該值上呼叫 String 方法。如果您將 String? 傳遞給它,null 可能會流入並導致失敗

dart
// Hypothetical unsound null safety:
void requireStringNotNull(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  String? maybeString = null; // Or not!
  requireStringNotNull(maybeString);
}

這個程式不安全,我們不應該允許它。然而,Dart 一直存在一種稱為隱式向下轉型的東西。例如,如果您將一個 Object 型別的值傳遞給一個期望 String 的函式,型別檢查器會允許它

dart
// Without null safety:
void requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString);
}

為了保持健全性,編譯器會在 requireStringNotObject() 的引數上靜默插入一個 as String 轉型。該轉型可能會失敗並在執行時丟擲異常,但在編譯時,Dart 認為這是可以的。由於非空型別被建模為可空型別的子型別,隱式向下轉型將允許您將 String? 傳遞給預期 String 的東西。允許這樣做將違反我們預設安全的原則。因此,在空安全中,我們完全移除了隱式向下轉型。

這使得對 requireStringNotNull() 的呼叫會產生編譯錯誤,這正是您想要的。但這也意味著所有隱式向下轉型都將成為編譯錯誤,包括對 requireStringNotObject() 的呼叫。您必須自行新增顯式向下轉型

dart
// Using null safety:
void requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString as String);
}

我們認為這是一個總體上很好的改變。我們的印象是,大多數使用者從未喜歡隱式向下轉型。特別是,您可能以前因此受過挫

dart
// Without null safety:
List<int> filterEvens(List<int> ints) {
  return ints.where((n) => n.isEven);
}

發現錯誤了嗎?.where() 方法是惰性的,因此它返回一個 Iterable,而不是 List。這個程式可以編譯,但隨後在執行時會丟擲異常,因為它嘗試將該 Iterable 轉型為 filterEvens 宣告返回的 List 型別。隨著隱式向下轉型的移除,這會變成一個編譯錯誤。

我們說到哪了?對了,好的,所以就好像我們把您程式中的型別宇宙分成了兩半

Nullable and Non-Nullable types

有一個非空型別區域。這些型別允許您訪問所有有趣的方法,但絕不能包含 null。然後,有一個與之平行的所有相應可空型別家族。這些型別允許 null,但您對它們能做的事情不多。我們允許值從非空一側流向可空一側,因為這樣做是安全的,但反方向則不行。

這看起來可空型別基本上沒什麼用。它們沒有方法,而且您無法擺脫它們。別擔心,我們有一整套功能可以幫助您將值從可空一半移到另一半,我們很快就會講到。

頂部和底部

#

本節內容有些深奧。除非您對型別系統感興趣,否則可以跳過大部分內容,只閱讀最後兩點。想象一下您程式中的所有型別,它們之間有表示子型別和超型別關係的邊。如果您將其繪製出來,就像本文件中的圖表一樣,它將形成一個巨大的有向圖,其中像 Object 這樣的超型別位於頂部附近,而像您自己的型別這樣的葉子類位於底部附近。

如果該有向圖在頂部匯聚到一個點,存在一個單一型別作為超型別(直接或間接),則該型別稱為頂型別。同樣,如果底部有一個奇怪的型別是所有型別的子型別,則您擁有一個底型別。(在這種情況下,您的有向圖是一個格。

如果您的型別系統具有頂型別和底型別,這很方便,因為這意味著型別級別操作(如最小上界,型別推斷用它來根據條件表示式的兩個分支的型別確定其型別)總能產生一個型別。在空安全之前,Object 是 Dart 的頂型別,而 Null 是其底型別。

由於 Object 現在是不可空的,它不再是頂型別。Null 不是它的子型別。Dart 沒有命名的頂型別。如果您需要一個頂型別,您會想要 Object?。同樣,Null 不再是底型別。如果它仍然是,那麼所有東西都將是可空的。相反,我們添加了一個名為 Never 的新底型別

Top and Bottom

實際上,這意味著

  • 如果您想表示允許任何型別的值,請使用 Object? 而不是 Object。實際上,使用 Object 變得相當不尋常,因為該型別意味著“可以是除這個奇怪地被禁止的值 null 之外的任何可能值”。

  • 在極少數需要底型別的情況下,請使用 Never 而不是 Null。這對於表示函式永不返回以幫助可達性分析特別有用。如果您不知道是否需要底型別,那麼您可能不需要。

確保正確性

#

我們將型別宇宙劃分為可空和非空兩部分。為了保持健全性以及我們“除非您主動要求,否則在執行時絕不會遇到空引用錯誤”的原則,我們需要保證 null 絕不會出現在非空一側的任何型別中。

擺脫隱式向下轉型並移除 Null 作為底型別,涵蓋了型別在程式中透過賦值以及從引數到函式呼叫引數的主要流動點。null 可能潛入的主要剩餘位置是變數首次建立時以及函式退出時。因此,還會出現一些額外的編譯錯誤

無效返回

#

如果一個函式有非空返回型別,那麼透過該函式的每一條路徑都必須到達一個返回值的 return 語句。在空安全之前,Dart 對缺失返回語句的處理相當寬鬆。例如

dart
// Without null safety:
String missingReturn() {
  // No return.
}

如果您分析過這個,您會得到一個溫和的提示可能您忘記了返回,但如果沒有,也沒什麼大不了的。那是因為如果執行到達函式體的末尾,Dart 會隱式返回 null。由於每種型別都是可空的,所以技術上這個函式是安全的,即使它可能不是您想要的。

有了健全的非空型別,這個程式就完全錯誤且不安全了。在空安全下,如果一個具有非空返回型別的函式沒有可靠地返回值,您將收到編譯錯誤。我所說的“可靠地”,是指語言會分析函式中的所有控制流路徑。只要它們都返回了東西,它就滿足了。這個分析相當智慧,所以即使是這個函式也是可以的

dart
// Using null safety:
String alwaysReturns(int n) {
  if (n == 0) {
    return 'zero';
  } else if (n < 0) {
    throw ArgumentError('Negative values not allowed.');
  } else {
    if (n > 1000) {
      return 'big';
    } else {
      return n.toString();
    }
  }
}

我們將在下一節更深入地探討新的流分析。

未初始化的變數

#

當您宣告一個變數時,如果您沒有給它一個顯式初始化器,Dart 會將變數預設初始化為 null。這很方便,但如果變數的型別是非空的,則顯然完全不安全。因此,我們必須對非空變數進行更嚴格的規定

  • 頂層變數和靜態欄位宣告必須有初始化器。 由於這些變數可以在程式的任何地方訪問和賦值,編譯器無法保證變數在使用前已被賦予值。唯一安全的選項是要求宣告本身就有一個初始化表示式,該表示式產生正確型別的值

    dart
    // Using null safety:
    int topLevel = 0;
    
    class SomeClass {
      static int staticField = 0;
    }
  • 例項欄位必須在宣告時有初始化器、使用初始化形參或在建構函式的初始化列表中進行初始化。 這聽起來有很多術語。下面是示例

    dart
    // Using null safety:
    class SomeClass {
      int atDeclaration = 0;
      int initializingFormal;
      int initializationList;
    
      SomeClass(this.initializingFormal)
          : initializationList = 0;
    }

    換句話說,只要在您到達建構函式體之前欄位已經有值,就可以了。

  • 區域性變數是最靈活的情況。一個非空區域性變數不需要有初始化器。這完全沒問題

    dart
    // Using null safety:
    int tracingFibonacci(int n) {
      int result;
      if (n < 2) {
        result = n;
      } else {
        result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
      }
    
      print(result);
      return result;
    }

    規則僅為:區域性變數在使用前必須被明確賦值 為此,我們也可以依賴我之前提及的新流分析。只要到達變數使用的每條路徑都首先對其進行初始化,該使用就是可以的。

  • 可選引數必須有預設值。 如果您沒有為可選的位置引數或命名引數傳遞實參,那麼語言會用預設值填充它。如果您沒有指定預設值,預設預設值是 null,而如果引數的型別是非空的,這就不行了。

    所以,如果您希望一個引數是可選的,您需要使其可空,或者指定一個有效的非 null 預設值。

這些限制聽起來很繁瑣,但實際上並沒有那麼糟糕。它們與圍繞 final 變數的現有限制非常相似,您很可能多年來一直在使用它們而沒有真正注意到。此外,請記住,這些限制僅適用於非空變數。您總是可以將型別設為可空,然後獲得預設初始化為 null

即便如此,這些規則確實會造成摩擦。幸運的是,我們有一套新的語言特性來潤滑那些新限制會拖慢您速度的最常見模式。不過,首先,是時候討論流分析了。

流分析

#

控制流分析在編譯器中已經存在多年。它大部分對使用者是隱藏的,並用於編譯器最佳化,但一些新語言已經開始使用相同的技術來實現可見的語言特性。Dart 已經透過型別提升的形式具備了一些流分析能力

dart
// With (or without) null safety:
bool isEmptyList(Object object) {
  if (object is List) {
    return object.isEmpty; // <-- OK!
  } else {
    return false;
  }
}

請注意,在標記行上,我們可以對 object 呼叫 isEmpty。該方法定義在 List 上,而不是 Object 上。這之所以有效,是因為型別檢查器會檢視程式中的所有 is 表示式和控制流路徑。如果某個控制流構造的主體僅在變數上的某個 is 表示式為真時才執行,那麼在該主體內部,變數的型別就會“提升”為被測試的型別。

在此示例中,if 語句的 then 分支僅在 object 實際包含列表時執行。因此,Dart 將 object 的型別從其宣告的 Object 型別提升為 List。這是一個方便的功能,但它相當有限。在空安全之前,以下功能上相同的程式無法執行

dart
// Without null safety:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty; // <-- Error!
}

同樣,您只有在 object 包含列表時才能到達 .isEmpty 呼叫,因此這個程式是動態正確的。但是型別提升規則不夠智慧,無法識別 return 語句意味著第二個語句只有在 object 是列表時才能被執行。

對於空安全,我們已經將這種有限的分析在幾個方面進行了大大增強。

可達性分析

#

首先,我們修復了長期以來的抱怨,即型別提升在處理提前返回和其他不可達程式碼路徑方面不夠智慧。在分析函式時,它現在會考慮 returnbreakthrow 以及任何可能導致函式提前終止執行的方式。在空安全下,這個函式

dart
// Using null safety:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty;
}

現在完全有效。由於 if 語句會在 object 不是 List 時退出函式,Dart 會在第二個語句中將 object 提升為 List。這是一個非常好的改進,對許多 Dart 程式碼都有幫助,甚至與可空性無關的程式碼。

Never 型別用於不可達程式碼

#

您還可以編寫這種可達性分析。新的底部型別 Never 沒有值。(什麼值同時是 Stringboolint?)那麼,一個表示式的型別為 Never 意味著什麼呢?這意味著該表示式永遠無法成功完成評估。它必須丟擲異常、中止,或者以其他方式確保期望該表示式結果的周圍程式碼永遠不會執行。

事實上,根據語言規定,throw 表示式的靜態型別是 NeverNever 型別在核心庫中宣告,您可以將其用作型別註解。也許您有一個輔助函式,可以更容易地丟擲某種型別的異常

dart
// Using null safety:
Never wrongType(String type, Object value) {
  throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}

您可以這樣使用它

dart
// Using null safety:
class Point {
  final int x, y;

  Point(this.x, this.y);

  Point operator +(Object other) {
    if (other is int) return Point(x + other, y + other);
    if (other is! Point) wrongType('int | Point', other);

    print('Adding two Point instances together: $this + $other');
    return Point(x + other.x, y + other.y);
  }

  // toString, hashCode, and other implementations...
}

此程式分析無誤。請注意,+ 方法的最後一行訪問了 other 上的 .x.y。它已被提升為 Point,即使該函式沒有任何 returnthrow 語句。控制流分析知道 wrongType() 的宣告型別是 Never,這意味著 if 語句的 then 分支必須以某種方式中止。由於最終語句只有在 otherPoint 時才能到達,因此 Dart 會將其提升。

換句話說,在您自己的 API 中使用 Never 可以擴充套件 Dart 的可達性分析。

明確賦值分析

#

我在區域性變數部分簡要提到了這一點。Dart 需要確保非空區域性變數在使用前始終被初始化。我們使用明確賦值分析來儘可能靈活地處理這一點。語言會分析每個函式體,並透過所有控制流路徑跟蹤區域性變數和引數的賦值。只要變數在到達其任何使用的每條路徑上都被賦值,該變數就被認為是已初始化的。這允許您宣告一個沒有初始化器的變數,然後使用複雜的控制流在之後對其進行初始化,即使該變數具有非空型別。

我們還使用明確賦值分析來使 final 變數更加靈活。在空安全之前,如果您需要以某種有趣的方式初始化區域性變數,使用 final 可能會很困難

dart
// Using null safety:
int tracingFibonacci(int n) {
  final int result;
  if (n < 2) {
    result = n;
  } else {
    result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
  }

  print(result);
  return result;
}

這將是一個錯誤,因為 result 變數是 final 但沒有初始化器。藉助空安全下更智慧的流分析,此程式是正常的。分析可以判斷 result 在每個控制流路徑上都恰好被明確初始化一次,因此標記變數為 final 的約束條件得到了滿足。

空檢查時的型別提升

#

更智慧的流分析有助於大量 Dart 程式碼,即使是與可空性無關的程式碼。但我們現在進行這些更改並非巧合。我們將型別劃分為可空集和非空集。如果您有一個可空型別的值,您實際上無法用它任何有用的事情。在值 null 的情況下,這種限制是好的。它防止您的程式崩潰。

但是,如果值不是 null,那麼能夠將其移到非空一側,以便您可以在其上呼叫方法會很好。流分析是區域性變數和引數(以及 Dart 3.2 開始的私有 final 欄位)實現此目的的主要方式之一。我們已經擴充套件了型別提升,使其也能識別 == null!= null 表示式。

如果您檢查一個可空型別的區域性變數以檢視它是否不為 null,Dart 會將該變數提升為底層非空型別

dart
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments != null) {
    result += ' ' + arguments.join(' ');
  }
  return result;
}

這裡,arguments 具有可空型別。通常,這會禁止您在其上呼叫 .join()。但由於我們在一個 if 語句中守護了該呼叫,該語句檢查以確保該值不為 null,Dart 會將其從 List<String>? 提升為 List<String>,並允許您在其上呼叫方法或將其傳遞給預期非空列表的函式。

這聽起來像是一個相當小的事情,但是這種基於流的空檢查型別提升是使大多數現有 Dart 程式碼在空安全下工作的原因。大多數 Dart 程式碼在執行時是正確的,並且透過在呼叫方法之前檢查 null 來避免丟擲空引用錯誤。新的空檢查流分析將這種動態正確性轉化為可證明的靜態正確性。

當然,它也與我們為可達性所做的更智慧的分析協同工作。上述函式同樣可以寫成

dart
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments == null) return result;
  return result + ' ' + arguments.join(' ');
}

該語言在哪些型別的表示式會導致型別提升方面也更智慧。顯式的 == null!= null 當然有效。但使用 as 進行的顯式轉型、賦值或字尾 ! 運算子(我們將在後面介紹)也會導致型別提升。總的目標是,如果程式碼在執行時是正確的,並且可以合理地進行靜態推斷,那麼分析就應該足夠智慧以完成此任務。

請注意,型別提升最初僅適用於區域性變數,而從 Dart 3.2 開始也適用於私有 final 欄位。有關使用非區域性變數的更多資訊,請參閱使用可空欄位

不必要程式碼警告

#

擁有更智慧的可達性分析並瞭解 null 在程式中的流向,有助於確保您新增程式碼來處理 null。但我們也可以利用同樣的分析來檢測您不需要的程式碼。在空安全之前,如果您編寫了類似這樣的程式碼

dart
// Using null safety:
String checkList(List<Object> list) {
  if (list?.isEmpty ?? false) {
    return 'Got nothing';
  }
  return 'Got something';
}

Dart 無法知道那個空感知 ?. 運算子是否有用。它所知道的是,您可能會向函式傳遞 null。但在空安全 Dart 中,如果您已經用現在非空的 List 型別標註了該函式,那麼它就知道 list 永遠不會是 null。這意味著 ?. 將永遠不會做任何有用的事情,您應該直接使用 .

為了幫助您簡化程式碼,現在靜態分析足夠精確,可以檢測到不必要的程式碼,我們為此添加了警告。在非空型別上使用空感知運算子,甚至進行像 == null!= null 這樣的檢查,都會被報告為警告。

當然,這與非空型別提升也相關。一旦變數被提升為非空型別,如果您再次冗餘地檢查其 null 性,您將收到警告

dart
// Using null safety:
String checkList(List<Object>? list) {
  if (list == null) return 'No list';
  if (list?.isEmpty ?? false) {
    return 'Empty list';
  }
  return 'Got something';
}

您在此處的 ?. 上會收到警告,因為在它執行時,我們已經知道 list 不可能為 null。這些警告的目標不僅僅是清理無意義的程式碼。透過移除不必要null 檢查,我們確保剩餘的有意義的檢查能夠突出顯示。我們希望您能夠檢視您的程式碼並看到 null 在哪裡流動。

使用可空型別

#

我們現在已將 null 納入可空型別集合。透過流分析,我們可以安全地讓一些非 null 值跳過柵欄進入非空一側,在那裡我們可以使用它們。這是一個很大的進步,但如果我們就此止步,所產生的系統仍然會非常受限。流分析僅對區域性變數、引數和私有 final 欄位有幫助。

為了儘量恢復 Dart 在空安全之前所擁有的靈活性——並在某些地方超越它——我們還有一些其他的新特性。

更智慧的空感知方法

#

Dart 的空感知運算子 ?. 比空安全出現得早得多。執行時語義規定,如果接收者是 null,則跳過右側的屬性訪問,表示式計算結果為 null

dart
// Without null safety:
String notAString = null;
print(notAString?.length);

它不會丟擲異常,而是列印“null”。空感知運算子是使 Dart 中可空型別可用的一種很好的工具。雖然我們不能讓您在可空型別上呼叫方法,但我們可以且確實允許您在其上使用空感知運算子。空安全之後的程式版本是

dart
// Using null safety:
String? notAString = null;
print(notAString?.length);

它與之前的功能完全相同。

然而,如果您在 Dart 中使用過空感知運算子,您可能在方法鏈中使用它們時遇到過煩惱。假設您想檢視一個可能不存在的字串的長度是否為偶數(我知道這不是一個特別實際的問題,但請理解我的意思)

dart
// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);

儘管這個程式使用了 ?.,它仍然在執行時丟擲異常。問題在於 .isEven 表示式的接收者是其左側整個 notAString?.length 表示式的結果。該表示式計算結果為 null,因此我們嘗試呼叫 .isEven 時會得到一個空引用錯誤。如果您在 Dart 中使用過 ?.,您可能已經透過艱難的方式瞭解到,在使用它一次之後,您必須將空感知運算子應用於鏈中每個屬性或方法

dart
String? notAString = null;
print(notAString?.length?.isEven);

這很煩人,但更糟糕的是,它會混淆重要資訊。考慮一下

dart
// Using null safety:
showGizmo(Thing? thing) {
  print(thing?.doohickey?.gizmo);
}

給您一個問題:Thing 上的 doohickey getter 能返回 null 嗎?它看起來可能會,因為您正在對結果使用 ?.。但也有可能第二個 ?. 只是為了處理 thingnull 的情況,而不是 doohickey 的結果。您無法判斷。

為了解決這個問題,我們借鑑了 C# 在相同功能設計上的一個巧妙想法。當您在方法鏈中使用空感知運算子時,如果接收者評估結果為 null,那麼方法鏈的其餘部分將完全短路並跳過。這意味著如果 doohickey 具有非空返回型別,那麼您可以也應該這樣寫

dart
// Using null safety:
void showGizmo(Thing? thing) {
  print(thing?.doohickey.gizmo);
}

事實上,如果您不這樣做,您會在第二個 ?. 上收到一個不必要的程式碼警告。如果您看到這樣的程式碼

dart
// Using null safety:
void showGizmo(Thing? thing) {
  print(thing?.doohickey?.gizmo);
}

那麼您就確切地知道這意味著 doohickey 本身具有可空返回型別。每個 ?. 對應著一條可以將 null 引入方法鏈的唯一路徑。這使得方法鏈中的空感知運算子既更簡潔又更精確。

與此同時,我們還添加了另外幾個空感知運算子

dart
// Using null safety:

// Null-aware cascade:
receiver?..method();

// Null-aware index operator:
receiver?[index];

沒有空感知函式呼叫運算子,但您可以這樣寫

dart
// Allowed with or without null safety:
function?.call(arg1, arg2);

非空斷言運算子

#

使用流分析將可空變數移到非空型別一側的妙處在於,這樣做是可證明安全的。您可以在先前可空的變數上呼叫方法,而不會放棄非空型別的任何安全性或效能。

但是,許多可空型別的有效用法無法以滿足靜態分析的方式被證明是安全的。例如

dart
// Using null safety, incorrectly:
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok()
      : code = 200,
        error = null;
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  @override
  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${error.toUpperCase()}';
  }
}

如果您嘗試執行此程式碼,您會在呼叫 toUpperCase() 時收到編譯錯誤。error 欄位是可空的,因為它在成功的響應中不會有值。透過檢查該類,我們可以看到在 errornull 時我們從不訪問 error 訊息。但這需要理解 code 的值與 error 的可空性之間的關係。型別檢查器無法看到這種關聯。

換句話說,我們作為程式碼的人類維護者知道在程式碼使用 error 時它不會是 null,我們需要一種方法來斷言這一點。通常,您使用 as 轉型來斷言型別,在這裡您也可以這樣做

dart
// Using null safety:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${(error as String).toUpperCase()}';
}

error 轉型為非空 String 型別,如果轉型失敗,將丟擲執行時異常。否則,它會給我們一個非空字串,我們可以在其上呼叫方法。

“去除可空性”的場景非常常見,因此我們有了一種新的簡寫語法。字尾感嘆號 (!) 會將其左側的表示式轉換為其底層非空型別。因此,上述函式等效於

dart
// Using null safety:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${error!.toUpperCase()}';
}

這個單字元的“感嘆號運算子”在底層型別冗長時特別方便。僅僅為了從某個型別中去除一個 ? 而不得不寫 as Map<TransactionProviderFactory, List<Set<ResponseFilter>>> 會非常惱人。

當然,像任何轉型一樣,使用 ! 會導致靜態安全性損失。為了保持健全性,該轉型必須在執行時進行檢查,並且可能會失敗並丟擲異常。但您可以控制這些轉型插入的位置,並且始終可以透過檢視您的程式碼來發現它們。

late 變數

#

型別檢查器無法證明程式碼安全性的最常見地方是頂層變數和欄位。這裡有一個例子

dart
// Using null safety, incorrectly:
class Coffee {
  String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

void main() {
  var coffee = Coffee();
  coffee.heat();
  coffee.serve();
}

在這裡,heat() 方法在 serve() 之前被呼叫。這意味著 _temperature 在使用之前將初始化為非空值。但靜態分析無法確定這一點。(對於像這樣一個簡單的例子,也許有可能,但對於嘗試跟蹤類的每個例項狀態的通用情況來說是難以解決的。)

由於型別檢查器無法分析欄位和頂層變數的使用,它有一個保守的規則,即非空欄位必須在其宣告處(或例項欄位的建構函式初始化列表中)進行初始化。因此,Dart 會對這個類報告編譯錯誤。

您可以透過將欄位設為可空,然後在使用時使用非空斷言運算子來修復此錯誤

dart
// Using null safety:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature! + ' coffee';
}

這很好用。但這向類的維護者發出了一個令人困惑的訊號。透過將 _temperature 標記為可空,您暗示 null 對於該欄位是一個有用、有意義的值。但這不是意圖。_temperature 欄位絕不應該在其 null 狀態下被觀察到。

為了處理延遲初始化狀態的常見模式,我們添加了一個新的修飾符 late。您可以像這樣使用它

dart
// Using null safety:
class Coffee {
  late String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

請注意,_temperature 欄位具有非空型別,但未初始化。此外,在使用時也沒有顯式的非空斷言。您可以將 late 的語義應用於幾種模型,但我將其視為這樣:late 修飾符意味著“在執行時而不是編譯時強制執行此變數的約束”。它幾乎就像“late”這個詞描述了何時強制執行變數的保證。

在這種情況下,由於欄位沒有明確初始化,每次讀取欄位時,都會插入一個執行時檢查,以確保它已被賦值。如果未賦值,則丟擲異常。將變數賦予 String 型別意味著“您不應該看到我除了字串之外的任何值”,而 late 修飾符意味著“在執行時驗證這一點”。

在某些方面,late 修飾符比使用 ? 更“神奇”,因為欄位的任何使用都可能失敗,並且在使用位置沒有文字可見的提示。但是,您必須在宣告處編寫 late 才能獲得此行為,我們相信在那裡看到該修飾符對於可維護性來說已經足夠明確。

作為回報,您將獲得比使用可空型別更好的靜態安全性。因為該欄位的型別現在是非空的,所以嘗試將 null 或可空的 String 賦值給該欄位將導致編譯錯誤。late 修飾符允許您延遲初始化,但仍然禁止您將其視為可空變數。

惰性初始化

#

late 修飾符還有其他一些特殊功能。這可能看起來自相矛盾,但您可以在具有初始化器的欄位上使用 late

dart
// Using null safety:
class Weather {
  late int _temperature = _readThermometer();
}

當您這樣做時,初始化器會變為惰性的。它不會在例項構造後立即執行,而是被推遲,並在第一次訪問該欄位時惰性執行。換句話說,它的工作方式與頂層變數或靜態欄位上的初始化器完全相同。當初始化表示式開銷大且可能不需要時,這會很方便。

在例項欄位上使用 late 會為您帶來額外的優勢。通常,例項欄位初始化器無法訪問 this,因為在所有欄位初始化器完成之前,您無法訪問新物件。但對於 late 欄位,情況不再如此,因此您可以訪問 this,呼叫方法或訪問例項上的欄位。

late final 變數

#

您還可以將 latefinal 結合使用

dart
// Using null safety:
class Coffee {
  late final String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

與普通的 final 欄位不同,您無需在宣告時或建構函式初始化列表中初始化該欄位。您可以在執行時稍後對其進行賦值。但是,您只能對其賦值一次,並且這一事實會在執行時進行檢查。如果您嘗試多次賦值——例如同時呼叫這裡的 heat()chill() ——第二次賦值將丟擲異常。這是建模最終被初始化並隨後不可變的狀態的絕佳方式。

換句話說,新的 late 修飾符與 Dart 的其他變數修飾符結合使用,涵蓋了 Kotlin 中 lateinit 和 Swift 中 lazy 的大部分功能空間。如果您想要一些區域性惰性求值,甚至可以在區域性變數上使用它。

必需的命名引數

#

為了保證您永遠不會看到帶有非空型別的 null 引數,型別檢查器要求所有可選引數要麼具有可空型別,要麼具有預設值。如果您想要一個帶有非空型別且沒有預設值的命名引數怎麼辦?那將意味著您要求呼叫者始終傳遞它。換句話說,您想要一個命名但非可選的引數。

我用下表可視化了各種 Dart 引數

             mandatory    optional
            +------------+------------+
positional  | f(int x)   | f([int x]) |
            +------------+------------+
named       | ???        | f({int x}) |
            +------------+------------+

由於不明原因,Dart 長期以來一直支援這個表格的三個角落,但卻留下了“命名+強制”的組合為空白。隨著空安全,我們填補了這一空白。您可以透過在引數前放置 required 來宣告一個必需的命名引數

dart
// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}

這裡,所有引數都必須按名稱傳遞。引數 ac 是可選的,可以省略。引數 bd 是必需的,必須傳遞。請注意,必需性與可空性無關。您可以擁有可空型別的必需命名引數,以及非空型別的可選命名引數(如果它們有預設值)。

這是我認為無論是否涉及空安全,都能讓 Dart 變得更好的另一個特性。它只是讓語言感覺對我來說更加完整。

抽象欄位

#

Dart 的一個巧妙特性是它遵循所謂的統一訪問原則。用人類語言來說,這意味著欄位與 getter 和 setter 無法區分。Dart 類中的“屬性”是計算還是儲存,這是一個實現細節。因此,在使用抽象類定義介面時,通常會使用欄位宣告

dart
abstract class Cup {
  Beverage contents;
}

目的是使用者只實現該類,而不擴充套件它。欄位語法只是編寫 getter/setter 對的更短方式

dart
abstract class Cup {
  Beverage get contents;
  set contents(Beverage);
}

但 Dart 並不知道這個類永遠不會被用作具體型別。它將 contents 宣告視為一個真實的欄位。不幸的是,該欄位是不可空的,並且沒有初始化器,因此您會收到編譯錯誤。

一個解決方案是使用顯式抽象 getter/setter 宣告,就像第二個示例中那樣。但這有點冗長,因此在空安全中,我們還添加了對顯式抽象欄位宣告的支援

dart
abstract class Cup {
  abstract Beverage contents;
}

這與第二個示例的行為完全相同。它只是聲明瞭一個具有給定名稱和型別的抽象 getter 和 setter。

使用可空欄位

#

這些新功能涵蓋了許多常見模式,使得處理 null 在大多數情況下相當輕鬆。但即便如此,我們的經驗是可空欄位仍然可能很困難。在可以將欄位設為 late 且非空的情況下,您就成功了。但在許多情況下,您需要檢查欄位是否有值,這就需要使其可空,以便您可以觀察到 null

同時是私有和 final 的可空欄位能夠進行型別提升(除非有某些特殊原因)。如果由於某種原因您無法將欄位設為私有和 final,您仍然需要一個變通方法。

例如,您可能會期望這能正常工作

dart
// Using null safety, incorrectly:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  void checkTemp() {
    if (_temperature != null) {
      print('Ready to serve ' + _temperature + '!');
    }
  }

  String serve() => _temperature! + ' coffee';
}

checkTemp() 內部,我們檢查 _temperature 是否為 null。如果不是,我們就訪問它並最終在其上呼叫 +。不幸的是,這是不允許的。

基於流的型別提升只能應用於同時為私有和 final 的欄位。否則,靜態分析無法證明在您檢查 null 的點和您使用它的點之間,欄位的值沒有改變。(考慮在病態情況下,欄位本身可能被子類中的 getter 覆蓋,該 getter 在第二次呼叫時返回 null。)

因此,由於我們關心健全性,公共和/或非 final 欄位不會被提升,並且上述方法無法編譯。這很煩人。在像這裡這樣的簡單情況下,最好的辦法是在欄位使用時加上 !。這看起來是多餘的,但這或多或少是 Dart 今天的工作方式。

另一個有用的模式是先將欄位複製到區域性變數,然後使用該區域性變數

dart
// Using null safety:
void checkTemp() {
  var temperature = _temperature;
  if (temperature != null) {
    print('Ready to serve ' + temperature + '!');
  }
}

由於型別提升確實適用於區域性變數,這現在可以正常工作了。如果您需要更改值,請記住將其儲存回字段,而不僅僅是區域性變數。

有關處理這些以及其他型別提升問題的更多資訊,請參閱修復型別提升失敗

可空性和泛型

#

像大多數現代靜態型別語言一樣,Dart 擁有泛型類和泛型方法。它們與可空性以一些看似反直覺但一旦您思考其含義就會變得有意義的方式進行互動。首先是“這個型別是可空的嗎?”不再是一個簡單的“是”或“否”的問題。考慮

dart
// Using null safety:
class Box<T> {
  final T object;
  Box(this.object);
}

void main() {
  Box<String>('a string');
  Box<int?>(null);
}

Box 的定義中,T 是可空型別還是非空型別?如您所見,它可以例項化為任何一種。答案是 T 是一個潛在可空型別。在泛型類或方法的體內,潛在可空型別具有可空型別非空型別的所有限制。

前者意味著您不能在其上呼叫除 Object 上定義的少數方法之外的任何方法。後者意味著您必須在使用該型別引數的任何欄位或變數之前初始化它們。這可能會使型別引數變得相當難以使用。

在實踐中,會出現一些模式。在集合類中,型別引數可以例項化為任何型別,您只需處理這些限制。在大多數情況下,就像這裡的例子一樣,這意味著要確保您在需要使用時能夠訪問型別引數型別的值。幸運的是,集合類很少在其元素上呼叫方法。

在您無法訪問值的地方,您可以將型別引數的使用設定為可空

dart
// Using null safety:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);
}

請注意 object 宣告上的 ?。現在該欄位具有顯式可空型別,因此將其保留為未初始化狀態是可以的。

當您將型別引數型別設為可空,例如這裡的 T? 時,您可能需要去除其可空性。正確的方法是使用顯式的 as T 轉型,而不是 ! 運算子

dart
// Using null safety:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);

  T unbox() => object as T;
}

如果值為 null! 運算子總是會丟擲異常。但是,如果型別引數已例項化為可空型別,那麼 null 對於 T 來說是一個完全有效的值

dart
// Using null safety:
void main() {
  var box = Box<int?>.full(null);
  print(box.unbox());
}

此程式應無錯誤執行。使用 as T 可以實現這一點。使用 ! 會丟擲異常。

其他泛型型別有一些邊界,限制了可以應用的型別引數的種類

dart
// Using null safety:
class Interval<T extends num> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty => max <= min;
}

如果邊界是非空的,那麼型別引數也是非空的。這意味著您將受到非空型別的限制——您不能將欄位和變數保持未初始化狀態。這裡的示例類必須有一個初始化這些欄位的建構函式。

作為對該限制的回報,您可以呼叫型別引數型別的值上宣告在其邊界上的任何方法。然而,擁有非空邊界確實會阻止您的泛型類的使用者使用可空型別實參例項化它。這對於大多數類來說可能是一個合理的限制。

您還可以使用可空邊界

dart
// Using null safety:
class Interval<T extends num?> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty {
    var localMin = min;
    var localMax = max;

    // No min or max means an open-ended interval.
    if (localMin == null || localMax == null) return false;
    return localMax <= localMin;
  }
}

這意味著在類的內部,您可以靈活地將型別引數視為可空,但同時也有可空性的限制。您不能對該型別的變數呼叫任何方法,除非您首先處理其可空性。在本例中,我們將欄位複製到區域性變數中,並檢查這些區域性變數是否為 null,以便流分析在我們將它們用於 <= 之前將其提升為非空型別。

請注意,可空邊界不會阻止使用者使用非空型別例項化該類。可空邊界意味著型別實參可以是可空的,而不是必須是可空的。(事實上,如果您不編寫 extends 子句,型別引數的預設邊界是可空邊界 Object?。)沒有辦法要求一個可空型別實參。如果您希望型別引數的使用可靠地是可空的並隱式初始化為 null,您可以在類的主體內部使用 T?

核心庫變更

#

語言中還有一些其他細微的調整,但它們都是次要的。例如,沒有 on 子句的 catch 的預設型別現在是 Object 而不是 dynamic。switch 語句中的貫穿分析也使用了新的流分析。

對您真正重要的其餘變化都在核心庫中。在我們踏上空安全大冒險之前,我們曾擔心,如果不大量破壞現有程式碼,就無法使我們的核心庫實現空安全。事實證明並沒有那麼糟糕。確實有幾個重大變化,但總的來說,遷移進展順利。大多數核心庫要麼不接受 null 並自然地轉向非空型別,要麼接受 null 並優雅地使用可空型別來處理。

不過,也有幾個重要的角落需要注意

Map 索引運算子是可空的

#

這並非真正的更改,而更多是一個需要了解的事實。Map 類的索引運算子 [] 如果鍵不存在則返回 null。這意味著該運算子的返回型別必須是可空的:V? 而不是 V

我們本可以將該方法更改為在鍵不存在時丟擲異常,然後為其提供一個更易於使用的非空返回型別。但是,根據我們的分析,使用索引運算子並檢查 null 以檢視鍵是否不存在的程式碼非常常見,約佔所有使用情況的一半。破壞所有這些程式碼將使 Dart 生態系統陷入混亂。

相反,執行時行為保持不變,因此返回型別必須是可空的。這意味著您通常不能立即使用 map 查詢的結果

dart
// Using null safety, incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.

這會在您嘗試在可空字串上呼叫 .length 時產生編譯錯誤。在您知道鍵存在的情況下,您可以透過使用 ! 來告知型別檢查器

dart
// Using null safety:
var map = {'key': 'value'};
print(map['key']!.length); // OK.

我們曾考慮向 Map 新增另一個方法來為您完成這項工作:查詢鍵,如果未找到則丟擲異常,否則返回一個非空值。但該叫什麼呢?沒有哪個名稱會比單個字元的 ! 更短,也沒有哪個方法名會比在呼叫點直接看到帶有內建語義的 ! 更清晰。因此,訪問 Map 中已知存在元素的慣用方式是使用 []!。您會習慣的。

無未命名 List 建構函式

#

List 上的未命名建構函式會建立一個給定大小的新列表,但不會初始化任何元素。如果您建立了一個非空型別的列表然後訪問一個元素,這將對健全性保證造成一個非常大的漏洞。

為避免這種情況,我們已完全移除了該建構函式。在空安全程式碼中呼叫 List() 是一個錯誤,即使使用可空型別也是如此。這聽起來很可怕,但實際上,大多數程式碼都使用列表字面量、List.filled()List.generate() 或透過轉換其他集合來建立列表。對於您想要建立某種型別的空列表的邊緣情況,我們添加了一個新的 List.empty() 建構函式。

建立完全未初始化列表的模式在 Dart 中一直顯得格格不入,現在更是如此。如果您的程式碼因此而中斷,您始終可以透過使用許多其他生成列表的方式來修復它。

無法為非空列表設定更大的長度

#

這鮮為人知,但 List 上的 length getter 也有一個對應的 setter。您可以將長度設定為更短的值來截斷列表。您也可以將其設定為更長的長度,以未初始化的元素填充列表。

如果您對非空型別的列表這樣做,那麼當您稍後訪問那些未寫入的元素時,您將違反健全性。為了防止這種情況,length setter 將在執行時丟擲異常,當且僅當列表具有非空元素型別並且您將其設定為更長的長度時。截斷所有型別的列表仍然可以,並且您可以增長可空型別的列表。

如果您定義自己的列表型別並擴充套件 ListBase 或應用 ListMixin,這將產生一個重要的後果。這兩種型別都提供了一個 insert() 的實現,該實現以前透過設定長度來為插入的元素騰出空間。這將在空安全下失敗,因此我們更改了 ListMixinListBase 共享)中 insert() 的實現,改為呼叫 add()。如果您希望能夠使用該繼承的 insert() 方法,您的自定義列表類應該提供一個 add() 的定義。

迭代前後無法訪問 Iterator.current

#

Iterator 類是用於遍歷實現 Iterable 型別的元素的“可變遊標”類。在訪問任何元素之前,您應該呼叫 moveNext() 以前進到第一個元素。當該方法返回 false 時,您已到達末尾,沒有更多元素。

以前,如果您在第一次呼叫 moveNext() 之前或迭代結束後呼叫 current,它會返回 null。在空安全下,這將要求 current 的返回型別為 E? 而不是 E。這反過來意味著每次元素訪問都需要一個執行時 null 檢查。

考慮到幾乎沒有人會以那種錯誤的方式訪問當前元素,這些檢查將是無用的。相反,我們已將 current 的型別設為 E。由於在迭代之前或之後可能存在該型別的值,因此如果您在不應該呼叫它時呼叫它,我們已將迭代器的行為定義為未定義。大多數 Iterator 的實現都會丟擲 StateError

總結

#

這是關於空安全所有語言和庫更改的非常詳細的介紹。內容很多,但這是一項相當大的語言變革。更重要的是,我們希望達到一個讓 Dart 仍然感覺連貫和可用的程度。這不僅需要改變型別系統,還需要改變其周圍的許多其他可用性特性。我們不希望空安全感覺像是後來硬加上去的。

核心要點如下

  • 型別預設非空,透過新增 ? 變為可空。

  • 可選引數必須是可空的或具有預設值。您可以使用 required 使命名引數成為必需引數。非空的頂層變數和靜態欄位必須具有初始化器。非空的例項欄位必須在建構函式體開始之前初始化。

  • 空感知運算子之後的方法鏈如果接收者為 null 則會短路。新增了空感知級聯 (?..) 和索引 (?[]) 運算子。字尾非空斷言“感嘆號”運算子 (!) 將其可空運算元轉換為底層非空型別。

  • 流分析允許您安全地將可空的區域性變數和引數(以及 Dart 3.2 開始的私有 final 欄位)轉換為可用的非空變數。新的流分析還具有更智慧的型別提升、缺失返回、不可達程式碼和變數初始化規則。

  • late 修飾符允許您在某些情況下使用非空型別和 final,而這些情況在沒有它時可能無法實現,代價是執行時檢查。它還為您提供了惰性初始化的欄位。

  • List 類已更改以防止未初始化的元素。

最後,一旦您吸收了所有這些內容並將您的程式碼帶入空安全的世界,您將獲得一個編譯器可以最佳化且程式碼中每個可能發生執行時錯誤的地方都可見的健全程式。我們希望您覺得這值得為之努力。