內容

了解 Null 安全性

作者:Bob Nystrom
2020 年 7 月

Null 安全性是我們對 Dart 進行的最大變更,因為我們在 Dart 2.0 中用 健全的靜態類型系統 取代了原始的非健全可選類型系統。當 Dart 首次推出時,編譯時期的 Null 安全性是一個罕見的功能,需要長時間的介紹。如今,Kotlin、Swift、Rust 和其他語言都對這個已經變得非常 常見的問題 有了自己的解答。以下是一個範例

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

main() {
  isEmpty(null);
}

如果您在沒有 Null 安全性的情況下執行這個 Dart 程式,它會在呼叫 .length 時擲出 NoSuchMethodError 例外。null 值是 Null 類別的實例,而 Null 沒有「長度」取得器。執行時期失敗很糟糕。這在像 Dart 這種設計用於在終端使用者裝置上執行的語言中尤其如此。如果伺服器應用程式失敗,您通常可以在任何人注意到之前重新啟動它。但當 Flutter 應用程式在使用者的手機上崩潰時,他們會不開心。當您的使用者不開心時,您也不會開心。

開發人員喜歡 Dart 等靜態類型語言,因為它們讓類型檢查器可以在編譯時期找出程式碼中的錯誤,通常就在 IDE 中。您越快找到錯誤,就能越快修復錯誤。當語言設計人員談論「修復 Null 參考錯誤」時,他們的意思是豐富靜態類型檢查器,讓語言可以偵測到像上述嘗試對可能為 null 的值呼叫 .length 的錯誤。

對於這個問題,沒有單一的真正解答。Rust 和 Kotlin 都各自有自己的方法,在這些語言的脈絡下有其道理。這份文件將詳細說明我們對 Dart 的解答。它包括對靜態類型系統的變更,以及一系列其他修改和新的語言功能,讓您不僅可以撰寫 Null 安全的程式碼,而且希望您在撰寫時能享受這個過程。

這份文件很長。如果你想要較短的內容,僅涵蓋你開始執行和運作時需要知道的資訊,請從 概觀 開始。當你準備好深入了解並有時間時,再回來這裡,這樣你就能了解語言如何處理 null,我們為何這樣設計,以及如何撰寫慣用語法、現代、null-safe 的 Dart。(劇透警示:最後會發現它與你現在撰寫 Dart 的方式驚人地相似。)

語言處理 null 參考錯誤的各種方式各有優缺點。這些原則指導我們做出選擇

  • 預設情況下,程式碼應該是安全的。如果你撰寫新的 Dart 程式碼,而且沒有使用任何明確不安全的特性,它在執行階段永遠不會擲出 null 參考錯誤。所有可能的 null 參考錯誤都會在靜態中被捕捉。如果你想要將部分檢查延後到執行階段以獲得更大的彈性,你可以這麼做,但你必須選擇使用在程式碼中以文字可見的方式呈現的某些特性。

    換句話說,我們不會給你救生衣,然後讓你自行決定每次出海時是否要穿上它。相反地,我們給你一艘不會沉沒的船。除非你跳船,否則你會保持乾燥。

  • Null safe 程式碼應該很容易撰寫。現有的 Dart 程式碼大多是動態正確的,而且不會擲出 null 參考錯誤。你喜歡 Dart 程式現在的外觀,我們希望你能夠繼續這樣撰寫程式碼。安全性不應該犧牲可用性、向類型檢查器懺悔,或大幅改變你的思考方式。

  • 產生的 null safe 程式碼應該是完全健全的。在靜態檢查的脈絡中,「健全性」對不同的人來說有不同的意義。對我們來說,在 null 安全性的脈絡中,這表示如果表達式具有不允許 null 的靜態類型,則該表達式的任何可能的執行都永遠無法評估為 null。語言主要透過靜態檢查提供此保證,但也可以涉及一些執行階段檢查。(不過,請注意第一個原則:這些執行階段檢查發生的地方將由你選擇。)

    健全性對於使用者的信心很重要。一艘大多數時間都能保持漂浮的船,並不是你熱衷於駕駛它冒險航行大海的船。但這對於我們無畏的編譯器駭客也很重要。當語言對程式的語義屬性做出嚴格的保證時,這表示編譯器可以執行最佳化,假設這些屬性為真。當涉及到 null 時,這表示我們可以產生較小的程式碼,消除不必要的 null 檢查,以及較快的程式碼,在呼叫方法之前不需要驗證接收器是否為非 null

    一個警告:我們僅保證在完全 null safe 的 Dart 程式中健全性。Dart 支援包含較新的 null safe 程式碼和較舊的舊版程式碼的程式。在這些混合版本的程式中,null 參考錯誤仍可能發生。在混合版本的程式中,你會在 null safe 的部分獲得所有靜態安全性優點,但直到整個應用程式都是 null safe,你才會獲得完整的執行階段健全性。

請注意,消除 null 並非目標。null 沒有問題。相反地,能夠表示值不存在非常有用。直接在語言中建置對特殊「不存在」值的支援,讓處理不存在的狀況變得靈活且可用。它支撐了選用參數、便利的 ?. null-aware 運算子,以及預設初始化。造成問題的並非 null,而是 null 出現在您意想不到的地方

因此,在 null 安全中,我們的目標是讓您控制了解 null 在程式中傳遞的位置,並確保它不會傳遞到會導致崩潰的地方。

類型系統中的可空性

#

Null 安全從靜態類型系統開始,因為其他所有內容都建立在它之上。您的 Dart 程式中有整個類型的世界:基本類型,例如 intString,集合類型,例如 List,以及您和您使用的套件定義的所有類別和類型。在 null 安全之前,靜態類型系統允許值 null 傳遞到任何這些類型的表達式中。

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

Null Safety Hierarchy Before

某些表達式允許的運算集(取得器、設定器、方法和運算子)是由其類型定義的。如果類型是 List,您可以在其上呼叫 .add()[]。如果是 int,您可以呼叫 +。但 null 值未定義任何這些方法。允許 null 傳遞到其他類型的表達式中,表示任何這些運算都可能失敗。這真的是 null 參考錯誤的關鍵所在 - 每個失敗都來自於嘗試在 null 上尋找它沒有的方法或屬性。

不可空和可空類型

#

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

Null Safety Hierarchy After

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

如果我們不認為 null 有用,我們可以就此打住。但 null 是有用的,所以我們仍然需要一種方法來處理它。選用參數是一個很好的說明案例。考慮這個 null 安全的 Dart 程式碼

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

在這裡,我們希望允許 dairy 參數接受任何字串或值 null,但不能接受其他任何內容。為了表達這一點,我們在基礎基本類型 String 的結尾加上 ? 來給予 dairy 一個可為 null 的類型。在底層,這基本上是定義基礎類型和 Null 類型的聯合。因此,如果 Dart 有完整特色的聯合類型,String? 將是 String|Null 的簡寫。

使用可空類型

#

如果您有一個具有可為 null 類型的表達式,您可以對結果做什麼?由於我們的原則預設為安全,因此答案不多。我們不能讓您在它上面呼叫基礎類型的函式,因為如果值為 null,這些函式可能會失敗

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

main() {
  bad(null);
}

如果你執行這段程式碼,它會當機。我們只能安全地讓你存取由底層類型和 Null 類別定義的方法和屬性。這僅包括 toString()==hashCode。因此,你可以將可為 Null 的類型用於映射鍵、將它們儲存在集合中、將它們與其他值進行比較,並將它們用於字串內插,但這大概就是全部了。

它們如何與不可為 Null 的類型互動?將可為 Null 的類型傳遞給需要可為 Null 類型的物件總是安全的。如果函式接受 String?,則傳遞 String 是允許的,因為這不會造成任何問題。我們透過讓每個可為 Null 的類型成為其底層類型的超類別來建模這一點。你也可以安全地將 null 傳遞給需要可為 Null 類型的物件,因此 Null 也是每個可為 Null 類型的子類別

Nullable

但反過來將可為 Null 的類型傳遞給需要底層不可為 Null 類型的物件是不安全的。需要 String 的程式碼可能會對值呼叫 String 方法。如果你傳遞 String? 給它,null 可能會流入其中,這可能會失敗

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

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

這個程式不安全,我們不應該允許它。不過,Dart 一直都有所謂的隱式向下轉型。例如,如果你傳遞 Object 類型的值給需要 String 的函式,類型檢查器會允許這樣做

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

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

為了維持健全性,編譯器會在傳遞給 requireStringNotObject() 的引數上靜默插入 as String 轉型。該轉型可能會失敗並在執行階段擲回例外,但在編譯階段,Dart 會說這沒問題。由於不可為 Null 的類型被建模為可為 Null 類型的子類別,因此隱式向下轉型會讓你傳遞 String? 給需要 String 的物件。允許這樣做會違反我們預設安全的目標。因此,透過 Null 安全性,我們完全移除了隱式向下轉型。

這會讓呼叫 requireStringNotNull() 產生編譯錯誤,這正是你想要的。但這也表示所有隱式向下轉型都會變成編譯錯誤,包括呼叫 requireStringNotObject()。你必須自己新增明確的向下轉型

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

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 運算式為 true 時執行,則在該主體內,變數的類型會「提升」為測試的類型。

在此範例中,if 陳述式的 then 分支僅在 object 實際包含清單時執行。因此,Dart 會將 object 提升為類型 List,而不是其宣告的類型 Object。這是一個方便的功能,但它相當有限。在空安全之前,下列功能相同的程式無法執行

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 沒有任何值。(什麼樣的值同時是 Stringboolint?)那麼,表達式具有 Never 類型是什麼意思?表示該表達式永遠無法成功完成評估。它必須擲回例外、中止,或以其他方式確保期待表達式結果的周圍程式碼永遠不會執行。

事實上,根據語言,throw 表達式的靜態類型是 Never。類型 Never 在核心函式庫中宣告,您可以將其用作類型註解。也許您有一個輔助函式,可以讓擲回特定類型的例外變得更容易

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。它已提升為 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 的約束條件已滿足。

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 可以如何流經您的程式,有助於確保您新增程式碼來處理 null。但我們也可以使用相同的分析來偵測您不需要的程式碼。在 Null 安全之前,如果您撰寫類似

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

Dart 無法得知那個 Null 感知 ?. 運算子是否有用。就它所知,您可以將 null 傳遞給函式。但在 Null 安全 Dart 中,如果您已使用現在不可為 Null 的 List 類型註解該函式,則它知道 list 永遠不會是 null。這表示 ?. 永遠不會執行任何有用的操作,您可以而且應該只使用 .

為了協助您簡化程式碼,我們已針對此類不必要的程式碼新增警告,因為靜態分析現在足夠精確可以偵測到它。在不可為 Null 的類型上使用 Null 感知運算子,甚至檢查 == null!= 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 圍捕到可為空類型的集合中。透過流程分析,我們可以安全地讓一些非 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:
showGizmo(Thing? thing) {
  print(thing?.doohickey.gizmo);
}

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

dart
// Using null safety:
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()}';
}

當底層類型冗長時,這個單字元「驚嘆號運算子」特別好用。為了從某個類型中轉型掉一個 `?`,必須撰寫 `as Map<TransactionProviderFactory, List<Set<ResponseFilter>>>`,這會非常惱人。

當然,與任何轉型一樣,使用 `!` 會伴隨靜態安全性的損失。必須在執行時期檢查轉型以維護健全性,而且它可能會失敗並擲回例外。但你可以控制這些轉型插入的位置,而且你隨時都可以透過查看程式碼來看到它們。

延遲變數

#

類型檢查器無法證明程式碼安全性的最常見位置在頂層變數和欄位附近。以下是一個範例

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

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

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

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

在此,`heat()` 方法會在 `serve()` 之前呼叫。這表示 `_temperature` 會在使用前初始化為非 `null` 值。但靜態分析無法判斷這一點。(對於像這個一樣微不足道的範例,這可能是可行的,但嘗試追蹤類別每個執行個體狀態的一般情況是難以處理的。)

由於類型檢查器無法分析欄位和頂層變數的用途,因此它有一個保守的規則,即非可為空欄位必須在其宣告時初始化(或在執行個體欄位的建構函式初始化清單中)。因此,Dart 會針對此類別報告編譯錯誤。

你可以透過將欄位設為可為空,然後對用途使用 null 斷言運算子來修正錯誤

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` 欄位具有非可為空類型,但未初始化。此外,在使用它時沒有明確的 null 斷言。你可以將幾個模型套用至 `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、呼叫方法或存取執行個體上的欄位。

延遲 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 型別的 null 參數,型別檢查器要求所有選用參數都具有 Null 型別或預設值。如果您想要具有非 Null 型別且沒有預設值的命名參數怎麼辦?這表示您希望要求呼叫者永遠傳遞它。換句話說,您想要一個命名但非選用的參數。

我用這個表格視覺化各種 Dart 參數

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

出於不明原因,Dart 長期支援這個表格的三個角落,但讓命名+強制組合保持空白。有了 Null 安全,我們填入了它。您在參數前放置 required 來宣告一個必要的命名參數

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

在此,所有參數都必須按名稱傳遞。參數 ac 是選用的,可以省略。參數 bd 是必要的,必須傳遞。請注意,必要性與 Null 性無關。您可以有 Null 型別的必要命名參數,以及非 Null 型別的選用命名參數(如果它們有預設值)。

這是另一個我認為讓 Dart 變得更好的功能,與 Null 安全無關。它只是讓這門語言對我來說感覺更完整。

抽象欄位

#

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 宣告,如同第二個範例。但這有點冗長,因此在 Null 安全中,我們也新增了對明確抽象欄位宣告的支援

dart
abstract class Cup {
  abstract Beverage contents;
}

這與第二個範例的行為完全相同。它只是宣告一個具有指定名稱和類型的抽象 getter 和 setter。

使用可為 Null 的欄位

#

這些新功能涵蓋許多常見模式,並讓處理 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。)

因此,由於我們重視健全性,所以 public 和/或 non-final 欄位不會提升,而上述方法無法編譯。這很惱人。在像這裡這樣簡單的情況下,你最好的方法是在使用欄位時加上 !。這似乎多餘,但這或多或少就是 Dart 目前的行為。

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

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

由於類型提升確實適用於區域變數,所以現在運作良好。如果你需要變更數值,請記得儲存回欄位,而不要只儲存到區域變數。

如需進一步了解如何處理這些和其他類型提升問題,請參閱 修復類型提升失敗

可為 Null 性和泛型

#

與大多數現代靜態類型語言一樣,Dart 具有泛型類別和泛型方法。它們以幾種看似反直覺的方式與可空性互動,但一旦你思考過其含義,就會覺得有道理。首先是「這個類型是否可空?」不再是一個簡單的 yes 或 no 問題。考慮

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

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

Box 的定義中,T 是可空類型還是非可空類型?正如你所見,它可以用任一種來實例化。答案是 T潛在可空類型。在泛型類別或方法的主體內部,潛在可空類型具有可空類型非可空類型的所有限制。

前者表示您無法呼叫其上任何方法,除了在 Object 上定義的少數方法。後者表示您必須在使用任何該類型的欄位或變數前初始化它們。這可能會讓類型參數難以使用。

實際上,會出現一些模式。在類型參數可以使用任何類型來建立實例的類別(例如集合類別)中,您必須處理限制。在多數情況下,例如此範例,表示您必須確保在需要使用類型引數類型值時可以存取該值。幸運的是,類別(例如集合)很少會呼叫其元素的方法。

在無法存取值的場合,您可以讓類型參數使用可為 Null

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

請注意 object 宣告中的 ?。現在欄位有明確的可為 Null 類型,因此可以不用初始化。

當您讓類型參數類型在此處可為 Null(例如 T?),您可能需要將可為 Null 性轉型為其他類型。正確的方式是使用明確的 as T 轉型,而非 ! 營運子

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

  T unbox() => object as T;
}

! 營運子總是在值為 null 時擲回例外。但如果類型參數已使用可為 Null 類型建立實例,則 nullT 的完全有效值

dart
// Using null safety:
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;
}

如果約束不可為 Null,則類型參數也不可為 Null。這表示您有不可為 Null 類型的限制,您無法讓欄位和變數未初始化。此範例類別必須有初始化欄位的建構函式。

作為此限制的回報,您可以呼叫類型參數類型值中,在其約束上宣告的任何方法。不過,有不可為 Null 的約束會阻止使用者使用可為 Null 的類型引數建立您的泛型類別的實例。這對於多數類別來說可能是一個合理的限制。

您也可以使用可為 Null 的約束

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,但您也受到可為 null 的限制。在處理可為 null 的情況之前,您無法呼叫該類型變數的任何內容。在此範例中,我們將欄位複製到區域變數,並檢查這些區域變數是否為 null,以便在我們使用 <= 之前,流程分析將它們提升為不可為 null 的類型。

請注意,可為 null 的繫結不會阻止使用者使用不可為 null 的類型來建立類別實體。可為 null 的繫結表示類型引數可以為 null,而不是必須為 null。(事實上,如果您沒有撰寫 extends 子句,類型參數的預設繫結是可為 null 的繫結 Object?。)沒有辦法要求可為 null 的類型引數。如果您希望類型參數的使用可靠地為 null,並隱式初始化為 null,您可以在類別主體內使用 T?

核心函式庫變更

#

語言中還有其他一些調整,但這些調整很小。例如,沒有 on 子句的 catch 的預設類型現在是 Object,而不是 dynamic。switch 陳述式中的穿透分析使用新的流程分析。

對您來說真正重要的其餘變更在於核心函式庫。在我們踏上 Null Safety 大冒險之前,我們擔心會發現沒有辦法讓我們的核心函式庫在不大幅破壞世界的狀況下實現 Null Safety。結果沒有那麼可怕。確實有一些重大變更,但大部分的移轉都很順利。大多數核心函式庫不是不接受 null 並自然移至不可為 null 的類型,就是接受 null 並優雅地以可為 null 的類型接受它。

不過,有幾個重要的角落

Map 索引運算子可為 Null

#

這並不是真正的變更,而是更需要知道的事情。Map 類別上的索引 [] 營運子在找不到金鑰時會傳回 null。這表示該營運子的傳回類型必須可為 null:V? 而不是 V

我們可以變更該方法,讓它在找不到金鑰時擲回例外狀況,然後給它一個較容易使用的不可為 null 的傳回類型。但是,根據我們的分析,使用索引營運子並檢查 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 上的未命名建構函式會建立一個具有指定大小的新清單,但不會初始化任何元素。如果您建立一個不可為空類型清單,然後存取一個元素,這會在健全保證中戳一個很大的洞。

為了避免這種情況,我們已完全移除建構函式。在 Null 安全程式碼中呼叫 List() 是錯誤的,即使使用可為空類型也是如此。這聽起來很可怕,但實際上大多數程式碼都是使用清單文字、List.filled()List.generate() 或轉換其他集合的結果來建立清單。對於您想要建立某種類型的空清單的臨界狀況,我們新增了一個 List.empty() 建構函式。

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

無法設定不可為 Null 的清單中較大的長度

#

這一點鮮為人知,但 List 上的 length getter 也有一個對應的 setter。你可以將長度設定為較短的值來截斷清單。你也可以將它設定為較 的長度,以未初始化的元素來填充清單。

如果你要對非可為空類型的清單執行此操作,稍後存取那些未寫入的元素時,你會違反健全性。為了防止發生這種情況,如果清單具有非可為空元素類型你將其設定為較 的長度,則 length setter 會擲出執行時期例外。截斷所有類型的清單仍然沒問題,而且你可以擴充可為空類型的清單。

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

無法在反覆運算之前或之後存取 Iterator.current

#

Iterator 類別是可變的「指標」類別,用於遍歷實作 Iterable 的類型的元素。預期你會在存取任何元素之前呼叫 moveNext() 以進展到第一個元素。當該方法傳回 false 時,表示你已到達結尾,而且沒有更多元素。

過去,如果你在第一次呼叫 moveNext() 之前或在反覆運算結束後呼叫 current,它會傳回 null。在 null 安全性中,這會要求 current 的傳回類型為 E?,而不是 E。這反過來表示每個元素存取都需要執行時期 null 檢查。

考慮到幾乎沒有人會以這種錯誤的方式存取目前的元素,這些檢查將毫無用處。相反地,我們已將 current 的類型設為 E。由於在反覆運算之前或之後可能有該類型的值可用,因此如果你在不應該的時候呼叫它,我們已將反覆運算器的行為保留為未定義。大多數 Iterator 的實作會擲出 StateError

摘要

#

這是一個非常詳細的說明,說明 null 安全性周圍的所有語言和函式庫變更。這有很多東西,但這是一個相當大的語言變更。更重要的是,我們希望到達一個點,讓 Dart 仍然感覺有凝聚力且可用。這不僅需要變更類型系統,還需要變更周圍的許多其他可用性功能。我們不希望它感覺像是 null 安全性被硬加進去的。

要帶走的重點是

  • 類型預設為非可為空,並透過加入 ? 來使其可為空。

  • 選用參數必須可為空或具有預設值。你可以使用 required 來讓命名參數成為非選用。非可為空的頂層變數和靜態欄位必須具有初始化項。非可為空的執行個體欄位必須在建構函式主體開始前初始化。

  • 如果接收器為 null,空值感知運算子之後的方法鏈會短路。有新的空值感知串接 (?..) 和索引 (?[]) 運算子。後綴空值斷言「驚嘆號」運算子 (!) 會將其可為空值的操作數轉換為底層不可為空值類型。

  • 流程分析讓您可以安全地將可為空值的地方變數和參數 (以及 Dart 3.2 的私有最終欄位) 轉換為可使用的不可為空值類型。新的流程分析也有更聰明的規則,用於類型提升、遺漏回傳、無法到達的程式碼和變數初始化。

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

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

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