目錄

了解空值安全

由 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 可以流經您程式的位置,並確定它不會流到會導致當機的地方。

類型系統中的可空性

#

空值安全 (Null safety) 從靜態類型系統開始,因為其他一切都以此為基礎。您的 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 值。我們已將所有類型**預設設為不可為空 (non-nullable)**。如果您有一個 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 提供一個*可為空的類型 (nullable type)*。在底層,這本質上是在定義基礎類型和 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 的事物。允許這樣做會違反我們預設安全的目標。因此,在空值安全的情況下,我們將完全移除隱式向下轉型。

這使得對 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)位於頂部附近,而葉類(如您自己的類型)位於底部附近。

如果該有向圖在頂部匯聚到一個單一類型,該類型是超類型(直接或間接),則該類型稱為*頂部類型 (top type)*。同樣地,如果在底部存在一個奇怪的類型,它是每個類型的子類型,則您有一個*底部類型 (bottom type)*。(在這種情況下,您的有向圖是一個格 (lattice)。)

如果您的類型系統具有頂部和底部類型,則很方便,因為這表示諸如最小上界 (least upper bound) 之類的類型級別操作(類型推斷使用它來根據其兩個分支的類型計算條件表達式的類型)始終可以產生類型。在空值安全之前,Object 是 Dart 的頂部類型,而 Null 是它的底部類型。

由於 Object 現在不可為空,因此它不再是頂部類型。Null 不是它的子類型。Dart 沒有*命名*的頂部類型。如果您需要頂部類型,則需要 Object?。同樣地,Null 不再是底部類型。如果它是,那麼一切都會是可為空的。相反,我們添加了一個名為 Never 的新底部類型。

Top and Bottom

實際上,這表示:

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

  • 在您需要底部類型的罕見情況下,請使用 Never 而不是 Null。這對於表示函式永遠不會返回以協助可達性分析 (reachability analysis) 特別有用。如果您不知道是否需要底部類型,您可能就不需要。

確保正確性

#

我們將類型宇宙分為可為空和不可為空兩半。為了保持健全性以及我們絕不在運行時發生空指標錯誤的原則,除非您要求它,否則我們需要保證 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。這是一個方便的功能,但它相當有限。在 Null 安全性之前,以下功能相同的程式無法運作:

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

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

對於 Null 安全性,我們對此有限的分析進行了擴展,並在多個方面使其更加強大。

可達性分析

#

首先,我們修復了長期存在的抱怨,即型別提升對於早期回傳和其他無法到達的程式碼路徑並不聰明。在分析函式時,它現在會考慮 returnbreakthrow,以及執行可能在函式中提早終止的任何其他方式。在 Null 安全性下,此函式:

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

現在完全有效。由於當 object 不是 List 時,if 陳述式會退出函式,因此 Dart 會將 object 提升為第二個陳述式上的 List。這是一個非常好的改進,可以幫助許多 Dart 程式碼,甚至是不涉及可 Null 性程式碼。

針對無法連到的程式碼使用 Never

#

您也可以程式化此可達性分析。新的 bottom 型別 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 變數更具彈性。在 Null 安全性之前,如果您需要以任何有趣的方式初始化 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,但沒有初始化器。在 Null 安全性下使用更智慧的流程分析,此程式是可行的。分析可以判斷 result 在每個控制流程路徑上都僅明確初始化一次,因此標記變數 final 的限制已符合。

空值檢查的類型提升

#

更智慧的流程分析有助於許多 Dart 程式碼,甚至是不涉及可 Null 性的程式碼。但是我們現在進行這些變更並非巧合。我們已將型別劃分為可為空和不可為空的集合。如果您具有可為空型別的值,則實際上您無法對其執行任何有用的操作。在值 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>,並讓您在其上呼叫方法或將其傳遞給期望不可為空清單的函式。

這聽起來像是個相當微不足道的事情,但是這種基於流程的 Null 檢查提升才是使大多數現有 Dart 程式碼在 Null 安全性下正常運作的原因。大多數 Dart 程式碼在動態上是正確的,並且透過在呼叫方法之前檢查 null 來避免拋出 Null 參考錯誤。對 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 中,如果您已使用現在不可為空的 List 型別註釋該函式,則它知道 list 永遠不會是 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 的值跨過柵欄,進入非可為空的一側,在那裡我們可以使用它們。這是一大進步,但如果我們停在這裡,產生的系統仍然會令人痛苦地受限。流程分析僅對區域變數、參數和私有的最終欄位有幫助。

為了盡可能恢復 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 時,我們永遠不會存取它。但是,這需要了解 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 長期以來僅支援此表格中的三個角落,卻獨漏了具名 (named) + 必須 (mandatory) 的組合。透過空值安全 (null safety) 機制,我們補上了這一塊。您可以在參數前加上 required 來宣告一個必須的具名參數。

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

在此,所有參數都必須以具名方式傳遞。參數 ac 是可選的,可以省略。參數 bd 是必須的,必須傳遞。請注意,「必須」與「可為空值」是獨立的。您可以有可為空值型別的必須具名參數,以及具有預設值的不可為空值型別的可選具名參數。

我認為這是另一個讓 Dart 變得更好的功能,無論是否具備空值安全機制。它讓我覺得這個語言更加完整。

抽象欄位

#

Dart 的一個優點是它堅持所謂的一致存取原則 (uniform access principle)。簡單來說,這表示欄位與 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 的欄位能夠進行型別提升 (type promote)(除非有某些特定原因)。如果您因故無法使欄位為私有和 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 具有泛型類別和泛型方法。它們以一些似乎違反直覺的方式與空值性 (nullability) 互動,但一旦您仔細思考其含義,就會覺得合理。首先,「此型別是否可為空值?」不再是一個簡單的是或否問題。考慮一下:

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 並自然地移至不可為空值的型別,或接受 null 並以可為空值的型別順利接受它。

不過,有一些重要的角落:

Map 索引運算子可為空值

#

這並不是真正的變更,而更像是一件需要知道的事。如果金鑰不存在,則 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 上未命名的建構函式會建立一個具有指定大小的新列表,但不會初始化任何元素。如果您建立一個不可為空值型別的列表,然後存取一個元素,這會在健全性保證中造成一個非常大的漏洞。

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

在 Dart 中建立完全未初始化的列表的模式一直都感覺格格不入,而現在更是如此。如果您的程式碼因此而損壞,您始終可以使用其他許多方法來產生列表來修復它。

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

#

這很少人知道,但 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 安全性是硬加上去的。

要記住的核心重點是:

  • 預設情況下,類型是不可為 null 的,通過添加 ? 使其可為 null。

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

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

  • 流程分析可讓您安全地將可為 null 的局部變數和參數(以及 Dart 3.2 中的私有最終欄位)轉換為可用的不可為 null 的變數和參數。新的流程分析也具有更聰明的類型提升、遺失的返回、無法訪問的程式碼和變數初始化的規則。

  • late 修飾詞允許您在其他情況下可能無法使用不可為 null 的類型和 final 的地方使用它們,但代價是需要執行時檢查。它還為您提供了延遲初始化的欄位。

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

最後,一旦您吸收了所有這些內容,並讓您的程式碼進入 null 安全性的世界,您將獲得一個健全的程式,編譯器可以對其進行最佳化,並且執行時錯誤可能發生的每一個位置都會在您的程式碼中可見。我們希望您覺得值得為此付出努力。