跳到主要內容

瞭解 Null Safety

作者:Bob Nystrom
2020 年 7 月

Null Safety 是 Dart 自從在 Dart 2.0 中以健全的靜態類型系統取代原先不健全的選用性類型系統以來,所做的最大變更。Dart 剛推出時,編譯時期 Null Safety 是一項罕見的功能,需要長時間的介紹。如今,Kotlin、Swift、Rust 和其他語言都有各自的解決方案來應對這個已變得非常常見的問題。以下範例:

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

void main() {
  isEmpty(null);
}

如果您在未啟用 Null Safety 的情況下執行此 Dart 程式,則在呼叫 .length 時會擲回 NoSuchMethodError 例外。null 值是 Null 類別的實例,而 Null 沒有 "length" getter。執行階段失敗很糟糕。這在像 Dart 這樣設計在終端使用者裝置上執行的語言中尤其如此。如果伺服器應用程式失敗,您通常可以在任何人注意到之前重新啟動它。但是,當 Flutter 應用程式在使用者的手機上崩潰時,他們會不高興。當您的使用者不高興時,您也不會高興。

開發人員喜歡像 Dart 這樣的靜態類型語言,因為它們使類型檢查器能夠在編譯時期(通常就在 IDE 中)找到程式碼中的錯誤。您越早找到錯誤,就能越早修正它。當語言設計者談論「修正 Null 參考錯誤」時,他們指的是豐富靜態類型檢查器,使語言能夠偵測到像上述嘗試在可能為 null 的值上呼叫 .length 這樣的錯誤。

這個問題沒有唯一的真正解決方案。Rust 和 Kotlin 都有自己的方法,這些方法在這些語言的背景下很有意義。本文檔將逐步說明我們針對 Dart 的解決方案的所有細節。它包括對靜態類型系統的變更,以及一整套其他的修改和新的語言功能,不僅讓您可以編寫 Null Safety 程式碼,而且希望您能樂在其中。

本文件篇幅較長。如果您想要較短的文件,涵蓋讓您快速上手所需知道的內容,請從總覽開始。當您準備好更深入的瞭解並有時間時,請回到這裡,以便您可以瞭解語言如何處理 null、我們為何如此設計,以及如何編寫慣用的現代 Null Safety Dart 程式碼。(劇透警告:最終結果與您今天編寫 Dart 程式碼的方式非常接近。)

語言解決 Null 參考錯誤的各種方法各有優缺點。以下原則指導了我們所做的選擇:

  • 程式碼預設應為安全。如果您編寫新的 Dart 程式碼,且未使用任何明確不安全的功能,則永遠不會在執行階段擲回 Null 參考錯誤。所有可能的 Null 參考錯誤都會以靜態方式捕獲。如果您想要將其中一些檢查延遲到執行階段以獲得更大的彈性,您可以這樣做,但您必須選擇使用程式碼中可見的某些功能。

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

  • Null Safety 程式碼應易於編寫。大多數現有的 Dart 程式碼在動態上是正確的,並且不會擲回 Null 參考錯誤。您喜歡您現在的 Dart 程式外觀,而且我們希望您能夠繼續以這種方式編寫程式碼。安全性不應要求犧牲可用性、向類型檢查器懺悔,或必須大幅改變您的思考方式。

  • 產生的 Null Safety 程式碼應完全健全。「健全性」在靜態檢查的背景下對不同的人來說意味著不同的事物。對我們而言,在 Null Safety 的背景下,這表示如果運算式具有不允許 null 的靜態類型,則該運算式的任何可能執行都永遠不會評估為 null。語言主要透過靜態檢查來提供此保證,但也可能涉及一些執行階段檢查。(不過,請注意第一個原則:任何發生這些執行階段檢查的地方都將是您的選擇。)

    健全性對於使用者信心很重要。一艘大部分時間都能保持漂浮的船,不會讓您有勇氣在公海上航行。但它對我們勇敢的編譯器駭客也很重要。當語言對程式的語意屬性做出嚴格保證時,這表示編譯器可以執行假設這些屬性為真的最佳化。就 null 而言,這表示我們可以產生更小的程式碼來消除不必要的 null 檢查,以及更快的程式碼,在呼叫方法之前不需要驗證接收器是否為非 null。

    一個注意事項:我們僅保證在完全 Null Safety 的 Dart 程式中的健全性。Dart 支援包含較新的 Null Safety 程式碼和較舊的舊版程式碼的程式。在這些混合版本程式中,Null 參考錯誤仍可能發生。在混合版本程式中,您可以在 Null Safety 的部分獲得所有靜態安全優勢,但在整個應用程式都為 Null Safety 之前,您不會獲得完整的執行階段健全性。

請注意,消除 null 並非目標。Null 本身並沒有錯。相反地,能夠表示值的不存在是非常有用的。直接在語言中建構對特殊「不存在」值的支援,使處理不存在的情況變得彈性且可用。它支援選用性參數、方便的 ?. Null 感知運算子和預設初始化。糟糕的不是 null,而是 null 出現在您不期望它的地方才會造成問題。

因此,透過 Null Safety,我們的目標是讓您掌控和深入瞭解 null 在程式中可能流經的位置,並確定它不會流到可能導致崩潰的地方。

類型系統中的可空性

#

Null Safety 從靜態類型系統開始,因為其他一切都以此為基礎。您的 Dart 程式中包含整個類型宇宙:原始類型(如 intString)、集合類型(如 List),以及您和您使用的套件定義的所有類別和類型。在 Null Safety 之前,靜態類型系統允許值 null 流入這些類型中任何類型的運算式。

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

Null Safety Hierarchy Before

某些運算式上允許的操作集(getter、setter、方法和運算子)由其類型定義。如果類型為 List,您可以在其上呼叫 .add()[]。如果類型為 int,您可以呼叫 +。但是,null 值未定義任何這些方法。允許 null 流入某些其他類型的運算式表示這些操作中的任何一個都可能失敗。這實際上是 Null 參考錯誤的癥結所在——每次失敗都來自嘗試在 null 上查閱它沒有的方法或屬性。

不可空與可空類型

#

Null Safety 透過變更類型階層來從根本上消除該問題。Null 類型仍然存在,但它不再是所有類型的子類型。相反地,類型階層如下所示:

Null Safety Hierarchy After

由於 Null 不再是子類型,因此除了特殊的 Null 類別之外,沒有任何類型允許值 null。我們已將所有類型預設設為不可空。如果您有一個 String 類型的變數,它將永遠包含一個字串。這樣,我們就修正了所有 Null 參考錯誤。

如果我們認為 null 完全沒用,我們可以在此停止。但是 null 很有用,因此我們仍然需要一種處理它的方法。選用性參數是一個很好的說明案例。請考慮以下 Null Safety 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 鍵、將它們儲存在集合中、將它們與其他值進行比較,並在字串插值中使用它們,但僅此而已。

它們如何與不可空類型互動?將不可空類型傳遞給期望可空類型的東西始終是安全的。如果函式接受 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 的東西。允許這樣做會違反我們預設安全的目標。因此,透過 Null Safety,我們完全移除了隱含向下轉型。

這會使呼叫 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)靠近頂部,而葉類別(如您自己的類型)靠近底部。

如果該有向圖在頂部匯聚到一個點,其中有一個類型是超類型(直接或間接地),則該類型稱為頂端類型。同樣地,如果在底部有一個奇怪的類型,它是每個類型的子類型,則您有一個底端類型。(在這種情況下,您的有向圖是格狀結構。)

如果您的類型系統具有頂端類型和底端類型,這會很方便,因為這表示類型層級操作(如最小上限(類型推斷使用它來根據條件運算式的兩個分支的類型來判斷其類型))始終可以產生類型。在 Null Safety 之前,Object 是 Dart 的頂端類型,而 Null 是其底端類型。

由於 Object 現在不可空,因此它不再是頂端類型。Null 不是它的子類型。Dart 沒有命名的頂端類型。如果您需要頂端類型,則需要 Object?。同樣地,Null 不再是底端類型。如果是,則所有內容仍將是可空的。相反地,我們新增了一個名為 Never 的新底端類型。

Top and Bottom

實際上,這表示:

  • 如果您想要指示您允許任何類型的值,請使用 Object? 而不是 Object。實際上,使用 Object 變得非常不尋常,因為該類型表示「可以是任何可能的值,除了這個奇怪地禁止的值 null」。

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

確保正確性

#

我們將類型宇宙分為可空和不可空兩半。為了維持健全性和我們的原則(除非您要求,否則永遠不會在執行階段遇到 Null 參考錯誤),我們需要保證 null 永遠不會出現在不可空端的任何類型中。

擺脫隱含向下轉型並移除 Null 作為底端類型,涵蓋了類型透過程式在指派之間以及從引數流入函式呼叫上的參數中的所有主要位置。null 可能偷偷潛入的主要剩餘位置是變數首次出現時以及您離開函式時。因此,會有一些額外的編譯錯誤:

無效的回傳

#

如果函式具有不可空的回傳類型,則透過函式的每個路徑都必須到達傳回值的 return 陳述式。在 Null Safety 之前,Dart 對於遺失的回傳相當寬鬆。例如:

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

如果您分析了這個,您會得到一個溫和的提示,指出您可能忘記了回傳,但如果沒有,也沒什麼大不了的。這是因為如果執行到達函式主體的末尾,則 Dart 會隱含地傳回 null。由於每個類型都是可空的,因此從技術上來說,此函式是安全的,即使它可能不是您想要的。

使用健全的不可空類型,此程式碼完全錯誤且不安全。在 Null Safety 下,如果具有不可空回傳類型的函式無法可靠地傳回值,您就會收到編譯錯誤。所謂「可靠地」,我的意思是語言會分析透過函式的所有控制流程路徑。只要它們都傳回某些內容,它就會感到滿意。分析非常聰明,因此即使是這個函式也可以:

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 提升為 List 類型,而不是其宣告的類型 Object。這是一個方便的功能,但它相當有限。在 Null Safety 之前,以下功能相同的程式碼無法運作:

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

同樣地,您只有在 object 包含列表時才能到達 .isEmpty 呼叫,因此此程式碼在動態上是正確的。但是,類型提升規則不夠聰明,無法看出 return 陳述式表示只有在 object 是列表時才能到達第二個陳述式。

針對 Null Safety,我們採用了這種有限的分析,並以幾種方式使其更強大

可達性分析

#

首先,我們修正了長期以來被詬病的類型提升在早期返回和其他無法到達的程式碼路徑上不夠聰明的問題。在分析函式時,現在會考慮 returnbreakthrow 以及任何其他可能在函式中提早終止執行的方式。在空值安全機制下,這個函式

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

現在完全有效。由於當 object 不是 List 時,if 語句會退出函式,因此 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 double x, y;

  bool operator ==(Object other) {
    if (other is! Point) wrongType('Point', other);
    return x == other.x && y == other.y;
  }

  // Constructor and hashCode...
}

這個程式分析時沒有錯誤。請注意,== 方法的最後一行存取了 other 上的 .x.y。即使函式沒有任何 returnthrow,它也已被提升為 Point。控制流程分析知道 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 的限制條件已滿足。

Null 檢查的類型提升

#

更智慧的流程分析有助於許多 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 在空值安全機制之前的彈性,並在某些方面超越它,我們還有一些其他新功能。

更聰明的 Null 感知方法

#

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);

非 Null 斷言運算子

#

使用流程分析將可空變數移至不可空世界的好處是,這樣做是可證明安全的。你可以對先前可空的變數呼叫方法,而不會放棄不可空類型的任何安全性或效能。

但許多可空類型的有效用法無法以令人滿意的靜態分析方式證明是安全的。例如

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 欄位是可空的,因為它在成功回應中不會有值。我們可以透過檢查類別看到,當 error 訊息為 null 時,我們永遠不會存取它。但這需要理解 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()}';
}

當底層類型很冗長時,這個單字元的「bang 運算子」特別方便。必須寫成 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 的點和你使用它的點之間沒有變更。(考慮到在病態情況下,欄位本身可能會被子類別中傳回 null 的 getter 覆寫,這是第二次呼叫。)

因此,由於我們關心健全性,因此公共和/或非 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 並優雅地接受具有可空類型的 null

不過,有一些重要的角落

Map 索引運算子可為 Null

#

這實際上不是一個變更,而更像是一件需要知道的事情。Map 類別上的索引 [] 運算子在鍵不存在時傳回 null。這表示該運算子的傳回類型必須是可空的:V? 而不是 V

我們可以變更該方法,使其在鍵不存在時拋出例外,然後為其提供更易於使用的不可空傳回類型。但使用索引運算子並檢查 null 以查看鍵是否不存在的程式碼非常常見,根據我們的分析,約佔所有使用情況的一半。破壞所有這些程式碼會使 Dart 生態系統陷入混亂。

相反,執行階段行為是相同的,因此傳回類型有義務是可空的。這表示你通常無法立即使用地圖查閱的結果

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 新增另一個方法,該方法可以為你執行此操作:查閱鍵,如果找不到則拋出例外,否則傳回不可空值。但要稱之為什麼呢?沒有任何名稱會比單字元 ! 更短,也沒有任何方法名稱會比在呼叫位置看到具有其內建語意的 ! 更清楚。因此,存取地圖中已知存在的元素的慣用方法是使用 []!。你會習慣它的。

沒有未命名的 List 建構子

#

List 上的未命名建構子會建立一個具有給定大小的新清單,但不會初始化任何元素。如果你建立一個不可空類型的清單,然後存取一個元素,這會在健全性保證中戳出一個非常大的漏洞。

為了避免這種情況,我們已完全移除建構子。即使使用可空類型,在空值安全程式碼中呼叫 List() 也是錯誤的。這聽起來很可怕,但在實務中,大多數程式碼都是使用清單常值、List.filled()List.generate() 或作為轉換某些其他集合的結果來建立清單。對於你想要建立某種類型的空清單的邊緣情況,我們新增了一個新的 List.empty() 建構子。

建立完全未初始化的清單的模式在 Dart 中一直感覺格格不入,而現在更是如此。如果你的程式碼因此而中斷,你始終可以使用許多其他方法之一來產生清單,從而修正它。

無法在不可空 List 上設定較大的長度

#

這鮮為人知,但 List 上的 length getter 也具有對應的 setter。你可以將長度設定為較短的值來截斷清單。你也可以將其設定為更長的長度,以使用未初始化的元素填補清單。

如果你使用不可空類型清單執行此操作,則稍後存取這些未寫入的元素時,你會違反健全性。為了防止這種情況,如果(且僅當)清單具有不可空的元素類型你將其設定為更長的長度,則 length setter 將會拋出執行階段例外。截斷所有類型的清單仍然可以,並且你可以擴展可空類型的清單。

如果你定義自己的擴展 ListBase 或套用 ListMixin 的清單類型,則會產生重要的後果。這兩種類型都提供了 insert() 的實作,該實作先前透過設定長度為插入的元素騰出空間。這會在空值安全機制下失敗,因此我們改為變更 ListMixinListBase 共用)中 insert() 的實作以呼叫 add()。如果想要能夠使用該繼承的 insert() 方法,你的自訂清單類別應提供 add() 的定義。

無法在迭代之前或之後存取 Iterator.current

#

Iterator 類別是用於遍歷實作 Iterable 的類型元素的mutable「游標」類別。預期你先呼叫 moveNext(),然後再存取任何元素以推進到第一個元素。當該方法傳回 false 時,你已到達結尾,並且不再有任何元素。

以前,如果你在第一次呼叫 moveNext() 之前或在迭代完成後呼叫 current,則它會傳回 null。在空值安全機制下,這會要求 current 的傳回類型為 E? 而不是 E。反過來,這表示每次元素存取都需要執行階段 null 檢查。

這些檢查機制會是無用的,因為幾乎沒有人會以錯誤的方式存取目前的元素。因此,我們將 current 的類型設為 E。由於在迭代之前或之後 *可能* 會有該類型的值存在,如果您在不應該呼叫它的時候呼叫它,我們就讓迭代器的行為保持未定義。大多數 Iterator 的實作會拋出 StateError

摘要

#

這是一個非常詳盡的導覽,涵蓋了圍繞空值安全性的所有語言和程式庫變更。內容相當多,但這確實是一個非常重大的語言變更。更重要的是,我們希望達到一個 Dart 仍然感覺連貫且可用的程度。這不僅需要改變類型系統,還需要改變圍繞它的許多其他可用性功能。我們不希望讓人感覺空值安全性是硬加上去的。

核心要點如下:

  • 類型預設為不可為空,透過新增 ? 使其可為空。

  • 選用參數必須是可為空的,或具有預設值。您可以使用 required 使具名參數成為非選用的。不可為空的頂層變數和靜態欄位必須要有初始化器。不可為空的實例欄位必須在建構子主體開始之前初始化。

  • 在空值感知運算子之後的方法鏈,如果接收者為 null,則會短路。有新的空值感知級聯 (?..) 和索引 (?[]) 運算子。後綴空值斷言「bang」運算子 (!) 會將其可為空的運算元轉換為底層的不可為空類型。

  • 流程分析讓您可以安全地將可為空的區域變數和參數(以及私有 final 欄位,從 Dart 3.2 開始)轉換為可用的不可為空變數和參數。新的流程分析也針對類型提升、遺失的返回、無法觸及的程式碼和變數初始化,制定了更聰明的規則。

  • late 修飾詞讓您可以在原本可能無法使用的地方使用不可為空的類型和 final,但代價是需要執行階段檢查。它也為您提供了延遲初始化的欄位。

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

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