0%

【文章翻譯】Dart extension method fundamentals

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

Dart 擴展方法基礎

在未來的版本中,Dart 語言將添加一個新功能,擴展方法,它允許您(假裝)向現有類型添加新成員。即使它實際上只是一個靜態函數,擴展方法也可以像普通方法一樣被調用,例如 o.extensionMethod(42)

為什麼我們要添加擴展方法?它們有什麼用?您如何使用它們?為什麼我稱它們為「擴展方法」,而您也可以添加其他成員?(最後一個很簡單:我個人認為它們是擴展成員,但「擴展方法」是工作標題,而且它與其他語言中的類似功能的名稱相同,因此秉承 Dart 的良好傳統,我們選擇了熟悉且不出所料的名稱。在這裡我不會用到擴展 getter、setter 或運算子,但如果您願意,您可以完全向 String 添加一個 % 運算子,無論我們怎麼稱呼這個功能。)

由於我是設計此功能的人員之一,因此我將抓住機會在其他人有機會之前回答所有這些問題。(而且,因為我在完成此功能之前發佈了這篇文章,所以我甚至可以編輯掉那些不再正確的東西!)

但首先,我們要繞個彎路!

在擴展方法之前我會做什麼

假設,純粹假設,我認為 Future 上的 catchError 函數很糟糕,應該用更新、更閃亮、更好的東西來代替。例如,因為它將 Function 作為參數而不是適當的函數類型,這是出於完全合理的歷史原因,這意味著您不會獲得任何靜態類型檢查。這很糟糕,而且這個方法應該讓人感覺很糟糕。

顯然我不能移除這個函數,這會破壞幾乎所有曾經出現過的 Dart 程式。

那麼我至少想向 Future<T> 添加一個新方法,以便使用者可以使用它來代替,例如:

1
2
3
4
extension MyFuture<T> on Future<T> {
Future<T> onError(void Function(Object, StackTrace) onError) =>
catchError(onError);
}

您可以這樣調用它:

1
eventualInteger.onError((e, s) { ... });

遺憾的是,我不能直接將其添加到 Future 類中。如果我這樣做,我也會將其添加到 Future 介面中,而任何其他實作該介面的類將不完整,並且將無法再編譯。在某個時候,我們計算了 76 個實作 Future 的類別。那是一段時間以前的事了,我們已經停止計算了。我們仍然不能讓所有人失望,因此這個選項也不在考慮範圍內。

那麼,我會使用一個靜態輔助函數:

1
2
3
Future<T> onError<T>(Future<T> future, 
void Function(Object, StackTrace) onError) =>
future.catchError(onError);

您可以這樣調用它:

1
onError(eventualInteger, (e, s) { ... });

同樣遺憾的是,這讀起來不太好。我們喜歡使用基於 . 的方法鏈,因為它允許我們從左到右閱讀:「執行此操作,然後執行該操作,然後執行更多操作」。使用靜態輔助函數會迫使我們將其讀作:「對以下內容執行該操作:執行此操作。之後,執行更多操作」… 什麼?它沒有相同的流程,相同的schwung。在實踐中,它幾乎是不可讀的。

好吧,我沒有被嚇倒,所以我沒有改進 Future 類別,而是引入了一個新的改進的介面,並為使用者提供了一種包裝舊介面的方法:

1
2
3
4
5
6
7
8
class MyFuture<T> {
final Future<T> _wrappee;
MyFuture(this._wrappee);
// ...
MyFuture<T> onError(void Function(Object, StackTrace) onError) =>
MyFuture(_wrappee.catchError(onError));
// ... other forwarding methods
}

您可以這樣使用它:

1
MyFuture(eventualInteger).onError((e, s) { ... });

我甚至可能會讓 MyFuture 實作 Future 並將所有 Future 成員轉發到 _wrappee future,然後讓所有方法再次返回 MyFuture 包裝器,這樣我就可以繼續下去了。

如果我這麼說的話,那就太好了!

在擴展方法出現之前,這幾乎是最好的方法…這意味著手動添加包裝器,並承擔額外的包裝器物件和中間轉發函數帶來的效能損失。

我將如何使用擴展方法

一旦我們擺脫了沒有擴展的黑暗時代,我就可以使用擴展方法來獲得我真正想要的東西。我會這樣寫:

1
2
3
4
extension MyFuture<T> on Future<T> {
Future<T> onError(void Function(Object, StackTrace) onError) =>
this.catchError(onError);
}

然後您可以這樣調用它:

1
eventualInteger.onError((e, s) { ... });

就這樣。任務在五行內完成!

「但它是如何運作的?」,您可能會問。它運行得非常好,謝謝。

事實上,它的行為與包裝器類別幾乎完全相同,即使它實際上只是一個靜態輔助函數。您甚至可以顯式地寫 MyFuture(eventualInteger).onError(...),就好像擴展是一個包裝器類別一樣。它不是,但它看起來和行為幾乎就像它是。而且您可以省略顯式包裝,並在類型正確時隱式地應用它。

它(不是)一個包裝器類別

擴展聲明的設計故意使其看起來像一個類別或混入聲明,並且它的行為就像一個帶有隱藏 _wrappee 的包裝器類別一樣。您甚至可以在聲明中使用靜態成員,它們就像類別或混入聲明中的靜態成員一樣工作。

與包裝器類別相比,有一個改進:您可以在實例成員內部編寫 this 來引用 _wrappee 而不是包裝器物件。

更改 this 的含義不僅僅是一個改進。這些是靜態擴展方法,正如我之前所說的,它們只是一種更方便的調用靜態函數的方式。這意味著沒有包裝器物件。它從未存在過,我們只是假裝它存在,但這意味著我們不能讓 this 引用不存在的物件。

我們也不允許您將 MyFuture(eventualInteger) 作為一個值使用,因此如果您嘗試執行 var myFuture = MyFuture(eventualInteger),我們將不允許。使用 MyFuture(eventualInteger) 的唯一方法是作為擴展成員調用的目標。

1
2
MyFuture(eventualInteger).onError(...); // 良好:用於調用方法。
var x = MyFuture(eventualInteger); // 錯誤:用作獨立值。

這與您可以使用 super 來調用方法,但不能用於其值的方式相同。或者就像一個函式庫前綴。您所能做的就是存取一個成員;您不能將其視為一個值,因為它沒有值,也沒有值可以供它使用。

因為沒有物件,所以您不能在擴展聲明中聲明實例欄位。但是,您可以聲明 getter 和 setter,甚至可以透過 Expando 支援它們。擴展也不能聲明任何構造函數,因為沒有任何東西被構造;它只是假裝有一個構造函數接受 wrappee 物件。

它(不)擴展類型

如果您每次使用擴展成員時都必須編寫 MyFuture(...) 包裝器,那麼這將不是一個很大的改進。我們可以直接編寫包裝器類別,並花費一些編譯器工程師的時間來確保我們優化掉中間物件。

我在上面說過,您可以編寫 eventualInteger.onError(...)。這是可行的,因為我們會根據表達式的靜態類型和它們所調用成員的名稱隱式地包裝表達式。當以下所有條件都為真時,我們會自動將 expr.method() 包裝為 Ext(expr).method()

  • expr 的靜態類型沒有名稱為 method 的成員(介面始終獲勝)。
  • 擴展 Ext 已導入或在當前函式庫範圍內聲明(擴展是可存取的)。
  • 擴展聲明了一個名稱為 method 的成員,並且 expr 的靜態類型是 Ext 聲明的 on 類型的子類型(擴展是適用的)。

如果成員調用有多個可存取且適用的擴展,則有一些規則可以決定哪個擴展將在衝突中獲勝。在某些情況下,沒有辦法選擇獲勝者,那麼它只是一個編譯時錯誤。這些規則僅取決於擴展聲明的 on 類型,而不是成員聲明。(Dart 沒有「重載」——多個具有相同名稱和不同簽名的方法,您可以根據參數結構或類型在它們之間進行選擇——而擴展方法不提供後門來獲得重載。)

這一切都是靜態的

我在上面說了「靜態擴展方法」,我這樣做是有原因的!

Dart 是靜態類型的。編譯器在編譯時知道每個表達式的類型,因此如果您編寫 target.member(42),並且 member 是一個擴展成員,則編譯器需要弄清楚要隱式地將 target 包裝成哪個擴展,以便找到整個成員調用的類型。

如果隱式擴展包裝必須在找到目標表達式的類型找到成員調用的類型之間發生,那麼很明顯,「擴展推斷」必須在越來越不準確地命名為「類型推斷」的階段中發生。這個階段主要以填補缺少的泛型而聞名。

我的確寫了 eventualInteger.onError((FormatException e, s) {...}),即使 MyFuture 擴展和 onError 方法都是泛型的。在進行類型推斷時,Dart 編譯器既選擇擴展,也推斷缺少的類型參數。在這裡,它首先決定使用 MyFuture 擴展,然後插入隱式包裝器,最後對擴展應用 MyFuture(eventualInteger).onError((FormatException e, s) {...}) 執行類型推斷,其方式與對應的包裝器類別完全相同

1
2
3
4
5
6
7
8
class MyFuture<T> {
final Future<T> _wrappee;
MyFuture(this._wrappee);
Future<T> onError(void Function(Object, StackTrace) onError) =>
_wrappee.catchError(onError);
}

MyFuture(eventualInteger).onError((FormatException e, s) { ... });

在這種情況下,類型推斷將推斷以下擴展應用,並完成調用的完整類型:

1
2
MyFuture<int>(eventualInteger).onError<int>((FormatException e, s) {...});

這意味著擴展的類型參數基於被包裝表達式的靜態類型。如果您有 Future<num> fut = Future<int>.value(42);,則 fut.onError(...) 將在編譯時將 MyFutureT 類型參數綁定到 num,而不是綁定到 int。這一切都是靜態的,就像任何其他推斷的類型參數一樣。

這也意味著您將永遠無法在類型為 dynamic 的目標上調用擴展成員。

衝突解決

如上所述,當範圍內有多個適用的擴展時,有一些規則可以決定哪個擴展將獲勝。基本上,獲勝者是 on 類型最接近您正在調用成員的表達式的實際類型的擴展,有一些注意事項和決勝局。對於一起編寫的擴展,它通常「可以正常工作」。我不會詳細介紹這些細節,而是告訴您當它不能正常工作時該怎麼辦。

當兩個不同的作者為相同的類型和成員名稱編寫了衝突的擴展時,您可能會遇到問題。假設擴展 Ext1Ext2 都定義了一個適用於您的 List 物件的 bubbleSort 方法,並且衝突沒有明確的獲勝者,或者獲勝的不是您實際想要調用的那個(例如 Ext2 獲勝,而您想要調用 Ext1.bubbleSort)。那麼您必須做點什麼

最簡單的解決方案是使用顯式擴展應用:Ext1(list).bubbleSort()。這樣可以避免自動解析,只選擇您想要的那個。如果您只有少數幾個衝突,那麼這既簡單又可讀。

但是,如果您在同一個檔案中有三百個衝突,那麼您可能希望避免額外的輸入。很難更改擴展是否適用於調用,但您可以更改它是否可存取

您可以透過在導入衝突擴展(或擴展,如果您真的運氣不好)時隱藏它來做到這一點:import "ext2lib.dart" hide Ext2;。這樣做將阻止 Ext2 擴展被導入到當前的函式庫範圍內,從而使其無法存取。顯然,完全不導入 ext2lib.dart 也會這樣做,但除非擴展是您從該函式庫中使用的唯一東西,否則這是不切實際的。

(12 月 11 日編輯)在這裡我曾經說過,您可以使用前綴導入其中一個衝突的擴展,然後它將無法隱式使用。事實證明,有些人會在與他們擴展的類別相同的函式庫中聲明擴展方法,如果在使用前綴導入該函式庫時它無法工作,那就真的很煩人。所以我們修復了這個問題。使用前綴導入的擴展可以隱式地工作。如果您真的需要在同一個函式庫中使用兩個衝突的擴展,則必須在每個存在衝突的地方使用顯式擴展應用。我們可能會考慮在未來添加一種不同的方法來禁用隱式擴展,至少如果衝突的擴展成為一個反復出現的問題的話。

總結

Dart 將在即將發佈的版本中獲得擴展方法——一種調用靜態函數的漂亮方法。

您可以為實例方法運算子settergetter 定義擴展成員,但不能定義欄位

您可以顯式地調用擴展方法,或者在沒有與介面成員或其他擴展衝突時隱式地調用:

1
2
Ext1(list).bubbleSort() // 顯式,就像它是一個包裝器類別一樣。
list.bubbleSort() // 隱式,就像它擴展了類型一樣。

隱式調用與顯式調用的工作方式相同,但它們首先推斷正在應用哪個擴展。如果由於衝突的擴展而導致擴展推斷失敗,則您可以執行以下任一操作:

  • 顯式地應用擴展。
  • 完全不導入衝突的擴展(移除導入或隱藏擴展)。
  • (12 月 11 日編輯):就這樣(目前)。

擴展是靜態的。關於它們的一切都是基於靜態類型決定的。

負責任地享受!


Dart 擴展方法基礎 最初發佈在 Dart 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。