0%

【文章翻譯】Why nullable types?

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

為什麼要使用可空類型?

幾週前,我們宣布了 Dart 空安全測試版,這是一項主要的生產力功能,旨在幫助您避免空錯誤。說到空值,在 /r/dart_lang subreddit 中,一位使用者最近問道

但是為什麼我們仍然擁有/想要空值?為什麼不完全擺脫它呢?我目前也在玩 Rust,它根本沒有空值。所以似乎沒有它也能活下去。

我喜歡這個問題。為什麼完全擺脫空值?本文是我在該討論串中回答內容的擴展版本。

簡短的答案是,是的,完全有可能在沒有空值的情況下生存,像 Rust 這樣的語言就是這樣做的。但是程式設計師確實會使用空值,所以在我們可以將其移除之前,我們需要了解為什麼要使用它。當我們在確實擁有它的語言中使用它時,空值通常什麼?

事實證明,空值通常用於表示值的缺失,這非常有用。有些人沒有中間名。有些郵寄地址沒有公寓號碼。有些怪物在你殺死它們時不會掉落任何寶藏。

在這種情況下,我們想要一種表達方式:「這個變數可以具有 X 類型的值,或者它可能根本沒有值。」那麼問題是如何建模呢?

一種選擇是說一個變數可以包含預期類型的值,或者它可以包含魔術值 null。如果我們在值為 null 時嘗試使用它,就會出現執行時錯誤。這就是 Dart 在空安全之前所做的,SQL 所做的,Java 對非基本類型所做的,以及 C# 對類類型所做的。

但是執行時失敗很糟糕。這意味著我們的使用者會遇到這個錯誤。我們程式設計師寧願在他們遇到之前就發現這些錯誤。事實上,如果我們能夠在我們執行程式之前就發現錯誤,我們會很高興。那麼我們如何以類型系統理解的方式對值的缺失進行建模呢?換句話說,我們如何給「可能缺失」的值和「肯定存在」的值不同的靜態類型?

主要有兩種解決方案:

  1. 使用選項或 maybe 類型
  2. 使用可空類型

解決方案 1:選項類型

這就是 ML 和大多數源自 ML 的函數式語言(包括 Rust、Scala 和 Swift)所做的。當我們知道我們肯定會有一個值時,我們只使用底層類型。如果我們寫 int,則表示「這裡肯定有一個整數」。

為了表示一個可能缺失的值,我們將底層類型包裝在一個 選項類型 中。所以 Option<int> 表示一個值,它可能是一個整數,也可能什麼都不是。它就像一個可以包含零個或一個項目的集合類型。

從類型系統的角度來看,intOption<int> 之間沒有直接關係。將它們視為不同的類型意味著我們不能意外地將可能缺失的 Option<int> 傳遞給預期接收真實 int 的東西。我們也不能意外地嘗試使用 Option<int> 好像它是一個整數一樣,因為它不支援任何這些操作。我們不能對 Option<int> 執行算術運算,就像我們不能對 List<int> 執行算術運算一樣。

要從底層類型的現有值(例如 3)建立選項類型的值,您可以像 Some(3) 一樣構造選項。要在值缺失時建立選項類型,您可以寫類似 None() 的內容。

為了使用儲存在 Option<int> 中的可能缺失的整數,我們必須首先檢查並查看值是否存在。如果存在,我們可以從選項中提取整數並使用它,就像從集合中讀取值一樣。具有選項類型的語言通常也具有良好的 模式匹配 語法,這為我們提供了一種優雅的方式來檢查值是否存在,如果存在則使用它。

解決方案 2:可空類型

另一種選擇 (heh) 是 Kotlin、TypeScript 和現在的 Dart 所做的。可空類型聯集類型 的一種特殊情況。

(題外話:這裡的命名非常令人困惑。選項類型——ML 和它的朋友們上面所做的——是 代數資料類型 的一種特殊情況。代數資料類型的另一個名稱是「區分聯集」。但是,儘管名稱中有「聯集」,但「區分聯集」與「聯集類型」卻大不相同。正如 Phil Karlton 所說,電腦科學中只有兩個難題:快取失效和命名。)

與選項類型方法類似,我們使用底層類型來表示一個肯定存在的值。所以 int 仍然表示我們絕對有一個整數。如果我們想要一個可能缺失的整數,我們可以使用 int? 可空類型。這個小小的問號是 int | Null 之類的聯集類型的語法糖。

就像選項類型一樣,可空類型不支援與底層類型相同的操作。類型系統不允許我們嘗試對可空 int 執行算術運算,因為這是不安全的。同樣,我們不能將可空整數傳遞給需要實際整數的東西。

然而,類型系統比選項類型更靈活一些。類型系統理解聯集類型是其分支的超類型。換句話說,intint? 的子類型。這意味著我們可以將肯定存在的整數傳遞給預期接收可能存在的整數的東西,因為這樣做是安全的。這是一個向上轉換,就像我們可以將 String 傳遞給接收 Object 的函數一樣。Dart 只禁止我們反過來——從可空到不可空——因為那將是一個向下轉換,而這些可能會失敗。

當我們有一個可空類型的值,並且我們想要查看是否存在實際值或 null 時,我們會像在 C 或 Java 中自然地那樣以命令式方式檢查該值:

1
2
3
4
5
foo(int? i) {
if (i != null) {
print(i + 1);
}
}

然後,語言使用 流程分析 來確定程式的哪些部分受到這些檢查的保護。分析確定只有在變數不為 null 時才能到達程式碼,因此在這些區域內,類型系統會將變數的類型收緊為不可空。因此,在這裡,它將 i 視為在 if 語句內具有 int 類型。

語言應該採取哪種解決方案?

因此,當我們 Dart 團隊決定讓語言以更安全的方式處理 null 時,我們應該如何選擇解決方案 1 或 2?我們可以從觀察我們的使用者開始。他們想要如何編寫檢查缺失值的程式碼?在函數式語言中,模式匹配是主要的控制流程結構之一,那裡的使用者對它非常熟悉。使用選項類型和模式匹配在這種風格中是很自然的。

在源自 C 的命令式語言中,像我之前的範例這樣的程式碼是檢查 null 的慣用方法。使用流程分析和可空類型使熟悉的程式碼能夠正確安全地工作。事實上,在 Dart 中,我們發現大多數現有程式碼在新類型系統下已經是靜態空安全的,因為新的流程分析可以正確地分析已經編寫的程式碼。

(這在某種程度上並不令人驚訝。大多數程式碼在處理 null 方面已經是動態正確的。如果沒有,它會一直崩潰。大部分工作只是使類型系統足夠聰明,以便看到該程式碼已經正確,從而使用者的注意力集中在少數不正確的部分。)

因此,如果我們的目標是最大限度地提高熟悉度和使用者舒適度(這語言設計中的重要標準),我們應該遵循我們語言的控制流程結構為我們設定的路徑。

表示缺失和存在

有一種更深層次的方法來處理這個問題,基於選項類型和可空類型的表示方式之間的差異。這種表示方式的差異迫使我們做出一些關鍵的取捨,而這些取捨可能會使我們傾向於某個方向。

在第一種方法中,選項類型的值具有與底層值不同的執行時表示。假設我們在 Dart 中選擇了選項類型,您建立了一個選項類型,然後將其向上轉換為 Object

1
2
3
var optionalInt = Some(3);
Object obj = optionalInt;
print(obj is int); // false

請注意最後一行。Option<int> 值,即使存在,也不像底層類型的值。Some(3)3 是不同的、可區分的