0%

【文章翻譯】Dart declaration-site variance

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

Dart 宣告點變異數

A code snippet showing the contravariant variance modifier (`in`) in use.

宣告點變異數是我的 Dart 團隊實習專案,我在 Dart 實習生的生活 這篇文章中記錄了我在團隊中的個人經驗。作為 宣告點變異數功能 的主要實作者,我想分享可靠變異數的用法和好處。

我們將討論如何使用變異數、為什麼我們想要使用修飾符、該功能如何建構在不使用修飾符的類別之上,以及此功能為我們提供了哪些好處。

注意: 變異數的實作尚未完成。雖然您可以透過啟用實驗(說明如下)來試用它,但在最終確定之前,該功能可能會發生變化。

在深入探討 Dart 的宣告點變異數功能之前,我們將快速繞道討論變異數的含義以及它的使用方法。

什麼是變異數?

為了簡要介紹變異數,我們可以看看這個例子:

1
2
Iterable<int> integers = [1, 2, 3];
Iterable<Object> objects = integers; // 正確!

由此可見,整數的 Iterable 可以替換為物件的 Iterable,因為整數一個物件,並且可以在 Iterable 中的任何物件使用的地方使用。該語言允許這樣做,方法是說如果兩個相同泛型類型(例如這裡的 Iterable<int>Iterable<Object>)的類型引數(intObject)是子類型,則它們被視為子類型。這種子類型關係被視為協變

這很方便且合乎邏輯。這很有道理,也就是說,直到您查看方法參數的變異數。假設您想在 Dart 中建立一個 objectWriter

1
2
3
4
5
6
class Writer<T> {
void write(T object) {}
}

Writer<int> integerWriter = Writer<int>();
Writer<Object> objectWriter = integerWriter;

然後,您急切地讓您的 objectWriter 寫入一個字串,卻發現它產生了執行時錯誤。編譯器允許這段程式碼,但是當您執行它時,它會拋出一個異常。

1
type 'Writer<int>' is not a subtype of type 'Writer<Object>' in type cast

這是為什麼呢?對於逆變,子類型關係與協變相反。我們需要能夠向 objectWriter 寫入任何物件,但是從前面我們知道 objectWriter 實際上是一個偽裝的整數寫入器。

您需要知道的最後一個變異數類型是不變。不變的子類型關係意味著兩個不變的類型之間沒有子類型關係,除非它們是完全相同的類型。

1
2
3
4
5
6
7
class Invariant<T> {
T value;
Invariant(this.value);
}

Invariant<int> i = Invariant<int>(0);
Invariant<num> n = i; // 錯誤

Dart 中的變異數功能是什麼?

由於 Dart 團隊提議在語言中加入明確的變異數修飾符,我們將預覽一些預期的變化。

Dart 將具有可以應用於類別和混入中的類型參數的變異數修飾符。語法類似於 C# 中的變異數修飾符。

您可以分別使用關鍵字 outininout 來宣告協變、逆變和不變的類型參數。這與泛型類型一起使用,如下所示:

1
2
3
class Covariant<out T> {}
class Contravariant<in T> {}
class Invariant<inout T> {}

為什麼要為泛型類型定義明確的變異數?為什麼我們想要這個功能?

Dart 的靜態類型系統目前將所有類型參數視為協變。這對於泛型來說是正確且方便的,其中類型在安全協變的位置(例如返回類型)中使用。但是,當類型引數應該是逆變或不變時,這是錯誤的:

1
2
3
4
5
6
7
8
class Writer<T> {
void write(T object) {}
}

Writer<int> integerWriter = Writer<int>();
Writer<Object> objectWriter = integerWriter; // 靜態類型正確,但動態類型不正確

objectWriter.write("我是一個字串!"); // 執行時錯誤

當您使用 objectWriter 時,您期望能夠寫入任何物件。不幸的是,objectWriter 只寫入整數。編譯器不知道任何更好的方法,當您執行程式碼時,您會收到可怕的執行時錯誤。為了避免不健全,如果您以不安全的方式使用類型引數,Dart 會在執行時拋出錯誤。

幸運的是,加入變異數修飾符會將這種不正確的使用從執行時錯誤轉變為編譯時錯誤。

1
2
3
4
5
6
class Writer<in T> { // 加入 'in' 修飾符
void write(T object) {}
}

Writer<int> integerWriter = Writer<int>();
Writer<Object> objectWriter = integerWriter; // 編譯時錯誤

這樣好多了。早在您寫入 objectWriter.write("我是一個字串!"); 之前,編譯器就會通知您有問題。

現在,讓我們來看看使用變異數修飾符加入安全類型的參數會為您提供什麼。

成員中的類型參數

如果您使用 out 標記泛型類型參數,則如果您在方法或欄位中在不安全協變的位置(例如返回類型)中使用該類型,編譯器會發出靜態錯誤。同樣地,標記為 in 的類型參數只能在安全逆變的位置(例如方法參數類型)中使用。標記為 inout 的類型參數可以在任何地方使用。

以下是一些您可能會覺得有用的方法變異數位置錯誤和正確用法。相同的錯誤檢查也發生在混入中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Covariant<out T> {
T method1() => null; // 正確
void method2(T value) {} // 錯誤
T field1 = 42; // 錯誤
final T field2 = ''; // 錯誤
}

class Contravariant<in T> {
T method1() => null; // 錯誤
void method2(T value) {} // 正確
T field1; // 正確
T field2 = 42; // 正確
}

class Invariant<inout T> {
T method1() => null; // 正確
void method2(T value) {} // 正確
T field1; // 正確
T field2 = 42; // 正確
}

欄位中也可能會發出錯誤。

1
2
3
4
5
6
7
8
class Covariant<out T> {
T field1 = 42; // 錯誤
}

class Contravariant<in T> {
T field1; // 正確
T field2 = ''; // 正確
}

分配和子類型

編譯器報告的關於類型參數誤用的錯誤可以幫助泛型類別作者編寫正確的程式碼。另一半是幫助其他人正確使用類別的一組錯誤。可靠變異數修飾符帶來的變化之一是我們可以透過分配看到的子類型變化。

如果泛型類型參數是協變的,那麼當其類型引數是預期類型的類型引數的子類型時,您可以分配它。例如,您可以將 Reader<int> 分配給預期 Reader<Object> 的內容。

1
2
3
4
class Reader<out T> {}

Reader<int> integerReader = Reader<int>();
Reader<Object> objectReader = integerReader; // 正確

同樣地,如果泛型類型參數是逆變的,則當其類型引數是預期類型的類型引數的超類型時,允許分配。您可以將 Writer<Object> 分配給預期 Writer<int> 的內容,如下所示:

1
2
3
4
class Writer<in T> {}

Writer<Object> objectWriter = Writer<Object>();
Writer<int> integerWriter = objectWriter; // 正確

對於不變的參數,類型引數必須是相同的類型。

1
2
3
4
class Invariant<inout T> {}

Invariant<int> i = Invariant<int>();
Invariant<num> n = i; // 錯誤

介面繼承

所以您可能會問:「即使繼承舊的類別,我們也可以選擇使用變異數進行更強的編譯時檢查嗎?」好消息是您可以;但是,有一些限制。

out 參數只能繼承協變或具有預設 Dart 類型變異數的參數位置。

請記住,從傳統類別繼承的任何方法仍然可能是變異數不健全的,因此仍然可能導致執行時錯誤。否則,如果類型在不健全的位置,具有變異數修飾符的類型參數的子類別中的所有新方法都將發出錯誤。

1
2
3
4
5
6
7
8
// 傳統類別
class Legacy {
void method(Object o) {} // 預設協變
}
class Covariant<out T> extends Legacy {
T method1() => null; // 正確
void method2(T value) {} // 錯誤
}

in 參數只能繼承逆變的參數位置。

1
2
3
4
5
6
7
8
class Contravariant<in T> {
void method(T value) {} // 正確
}

class ContraSub<in T> extends Contravariant<T> {
void method2(T value) {} // 正確
T method1() => null; // 錯誤
}

inout 參數可以繼承所有參數位置。但是,定義為 inout 的參數只能由其他不變位置繼承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Invariant<inout T> {
void method(T value) {}
T method1() => null;
}

class InvariantSub<inout T> extends Invariant<T> {
void method2(T value) {} // 正確
T method1() => null; // 正確
}

class CovariantSub<out T> extends Invariant<T> { // 錯誤:不變只能被不變繼承
void method(T value) {}
}

如何提供關於變異數功能的回饋?

我們建議使用 最新的開發頻道 試用變異數功能。試用這個例子,以掌握變異數的工作原理以及它可以為您做些什麼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void main() {
final integerWriter = Writer<int>();
integerWriter.write(42);

final objectWriter = Writer<Object>(); // 錯誤,因為 Writer 現在是不變的
objectWriter.write("I'm a string!");

final covariantReader = Reader<int>();
final objectReader = covariantReader as Reader<Object>;
print(objectReader);

final contravariantWriter = Writer<Object>();
final intWriter = contravariantWriter as Writer<int>;
print(intWriter);
}

因為變異數功能仍在實作中,您需要設定一個實驗性標誌來啟用它:

1
dart --enable-experiment=variance variance_example.dart

我們感謝任何和所有回饋!您可以在 GitHub 問題 中告訴我們您的想法。

總結

現有的 Dart 泛型預設為協變,這使得開始編寫新類別和入門變得容易。然而,這意味著更多錯誤出現在執行時而不是編譯時。使用者還要付出額外的執行時檢查的成本。變異數背後的主要思想是在編譯時為使用者提供更多資訊的錯誤檢查。

變異數僅針對泛型類別和混入的參數定義。使用者可以透過在類型參數前面加入 inoutinout 關鍵字之一來使用變異數功能。

此外,具有這些修飾符的新泛型介面可以繼承沒有變異數修飾符的傳統介面。

宣告點變異數允許您獲得許多新的好處,包括:

  • 介面成員內的編譯時變異數位置檢查
  • 移除由向下和向上轉換引起的煩人的執行時錯誤
  • 根據宣告的變異數進行額外的子類型更改
  • 更具資訊性且易於存取的錯誤檢查

現在,您不必擔心 objectWriter 是否真的是任何物件的寫入器。您知道它是。


Dart 宣告點變異數 最初發佈在 Dart 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。