【文章翻譯】Bringing Primary Constructors to Dart
【文章內容使用 Gemini 2.5 Flash 自動翻譯產生】
原文:https://dart.dev/blog/bringing-primary-constructors-to-dart
(AI 披露:這篇文章的每個句子都是我親自撰寫的 — 包括破折號。)
我最喜歡的 Dart 3.13 功能是主要建構子。在語言團隊設計出我們認為穩固的設計之前,這花費了大量時間和迭代。由於你們中的許多人一直耐心等待這個功能,我想值得寫一篇關於我們為將這個大的語法變更引入 Dart 所面臨的一些挑戰。
關於語法糖
使用者多年來一直要求類似主要建構子的功能。這是一個備受期待的功能,這有點奇怪,如果你仔細思考的話。主要建構子並不能讓你做任何你已經無法在 Dart 中做的事情。它們只是一種不同——希望更好!——的語法,用於表達你已經可以表達的內容。
在 1960 年代,Peter Landin 創造了「語法糖衣」(syntactic sugaring)這個術語,指的是在更基礎但不愉快的語言之上添加一些文本上的美觀。今天,我們傾向於將這個術語更多地用作名詞,將這些功能稱為「語法糖」。
每個程式語言的宿敵都是複雜性。即使是最小的功能也必須經過設計、規範、實作、測試和文件。成本是巨大的。我將語言中的複雜性視為飛機的重量。一定量的複雜性對於事物運作是必要的,但你必須小心不要不必要地增加重量,否則整個裝置就有可能無法起飛。
從這個角度來看,語法糖似乎是個壞主意。它是額外的複雜性,沒有額外的功用。更糟糕的是,一旦我們添加它,我們也將複雜性轉嫁給我們的使用者。現在他們每次嘗試表達某種內容時,都必須選擇使用哪種語法。
這種功能什麼時候是個好主意呢?(我承認我感覺有必要證明這一點,因為我過去幾年的大部分工作都在為 Dart 添加這類功能。)我認為語法糖可以透過幾種方式發揮作用:
新方法就是簡單好用
儘管我們的語氣有些機械化,並且喜歡 EBNF,但我們語言設計師是人,也會犯錯。此外,我們一直在學習,我們服務的生態系統不斷發現製作軟體的新方法,並且使用者期望會隨著時間推移而改變。
Dart 最初設計時,必須使用明確的 new 關鍵字來呼叫建構子。這是為了讓來自 C++、Java、JavaScript 和其他語言的使用者感到熟悉。其意圖是在程式碼中更清楚地標示何時呼叫會分配一個新物件。隨著垃圾收集器變得更好,使用者也更習慣於自動記憶體管理,大多數使用者發現 new 造成的噪音多於信號。
(老實說,Dart 一直透過支援工廠建構子來破壞這個信號。即使你用 new 調用工廠建構子,它也可以返回之前創建的物件。)
在 Dart 2.0 中,我們發布了一項語言變更,允許您在呼叫建構子時省略 new 關鍵字(以及許多地方的 const)。我們仍然支援舊的語法,所以這個語言變更本質上是語法糖,但我們真正保留舊語法只是為了向後相容性。
我們總是希望您使用新的簡短語法。我們提供了工具來自動移除不必要的 new 關鍵字,並且有 一個 lint 提醒您何時忘記。舊的語法實際上已經被棄用,隨著時間的推移,您會越來越少看到它。如果您是 Dart 的新手,您甚至可能沒有意識到我們支援在建構子呼叫中使用 new。
這意味著支援有 new 和沒有 new 的建構子呼叫的複雜性很低。現有使用者學習新語法需要一個過渡成本。但新使用者大多只會學習新方法,而不會遇到舊方法。在兩種語法之間選擇時幾乎沒有認知負擔,因為您只需始終使用新語法(如果沒有,我們的工具會輕輕地提醒您)。
除非有時光機可以回到過去並第一次就做對,否則這是我們為修復語言錯誤所能做的次優選擇。
對於常見的使用場景,語法可以好很多
在其公開存在的最初幾年,Dart 不支援 enum 宣告。該語言不允許你這樣寫:
1 | enum Color { red, blue, yellow } |
相反,您必須這樣寫:
1 | class Color { |
老 Java 程式設計師會記得這就是 Josh Bloch 的「型別安全列舉模式」。在底層,這個更冗長的類別宣告幾乎與 Dart 中今天的列舉宣告做完全相同的事情。Dart 列舉宣告幾乎完全是糖。(我說「幾乎」是因為列舉宣告在 switch 中提供 窮舉檢查)。
然而,從這兩個例子你可以看出,列舉宣告是非常棒的語法糖。一個簡單的列舉宣告會展開成許多 Dart 程式碼。現在,如果幾乎沒有人撰寫列舉型別,那麼為此使用場景添加語法來優化可能仍然不值得。但在一個優先考慮型別安全和資料驗證的語言中,列舉非常常見。僅 Flutter 框架就定義了數十個。
相對少量的語法糖有時可以使大量使用者程式碼更短更簡單。
語法可以使意圖更清晰
上一節聽起來像是簡潔就是重點。我想在一個我們越來越多地按每個 token 的成本付費給 AI 代理來讀寫程式碼的世界中,這有直接的財務誘因。但這不僅僅是字元計數。考慮一下:
1 | class Color { |
這是一個列舉型別嗎?我的意思是「列舉型別」:使用這個類別的人是否應該假設他們唯一需要擔心的 Color 實例是 red、blue 或 yellow?
請注意,建構子是公開的,因此其他函式庫可以自由調用建構子並創建其他顏色。這個類別的意圖是一個封閉的顏色列表,還是一個帶有一些預定義值的開放工廠?
閱讀程式碼,我們不知道。程式碼是大量定義型別和一些常量的機制。它看起來像你想要列舉時會寫的程式碼,但機制並沒有揭示意圖。程式碼告訴編譯器程式碼的含義,但它沒有告訴讀者如何使用它。
如果我們將其更改為列舉宣告,那麼它是封閉值集合的策略就會變得顯而易見。(而且,現在 Dart 有真正的列舉,選擇不將此程式碼更改為列舉宣告可能表示它不是封閉集。)
對我來說,這是添加語法糖的一個令人信服的理由。程式碼是作為語法編寫和執行的,但每個處理程式碼的使用者關心的是它意味著什麼—它的語義。要正確維護程式碼,我們需要理解其意圖和策略。在 AI 常常比我們有時間仔細審查程式碼更快地生成程式碼的世界中,這一點越來越重要。
即使可以透過將多個現有語言功能的機制拼湊起來讓編譯器執行您想要的操作,擁有能產生相同行為的語法糖也可能值得,因為更好的語法將該行為提升到更高的抽象層次,其中預期的語義更為明顯。這可以減少理解程式碼所需的認知工作,儘管整個語言變得更複雜。
為什麼選擇主要建構子
好的,我應該討論主要建構子,而不是列舉和 new 關鍵字。(儘管 — 預示!— 我也會討論 new。)多年來,Dart 語言儲存庫上 #1 的開放議題 一直是資料類別的功能請求。如果您不知道,資料類別是 Kotlin 的一個功能,它允許您定義一個帶有一些欄位的類別,編譯器會免費為您提供相等性、雜湊碼和其他一些東西。
如果你仔細閱讀該議題上的數百條評論,你會發現大多數使用者對值語義部分 — 等式和雜湊碼 — 不那麼感興趣。他們主要感興趣的是以更簡單的方式定義具有建構子並儲存一些狀態的類別。
該功能實際上來自 Kotlin 的一個不同的、更基礎的功能:主要建構子。我相信 Kotlin 從 Scala 獲得了這個想法。從那以後,C# 和 Java 也引入了他們自己對這個概念的理解。
(資料類別的值語義部分也很有用。我們正在單獨探索這個問題。)
主要建構子有兩個關鍵部分:
- 您可以透過將參數列表直接寫入類別標頭來定義建構子。這樣可以避免重複寫關鍵字或重複類別名稱來宣告建構子。在只包含一些狀態的非常簡單的類別中,它還避免了類別主體和建構子參數列表的兩級嵌套和縮進。
- 在該參數列表內部,您可以指出某些參數應宣告相應的實例欄位,這些欄位會從該參數自動初始化。
如果沒有主要建構子或任何其他語法糖,我們必須在 Dart 中執行類似以下操作:
1 | class Point { |
在這個例子中,我們必須寫兩次類別名稱。對於每一點狀態,我們寫了它的型別兩次,它的名稱四次。在這個例子中,它並不太糟糕,因為只有兩個欄位,而且名稱都很短。一旦你開始處理具有長名稱和大量狀態的複雜領域特定內容,它就會變得醜陋。
這不是一個新問題,Dart 長期以來都有一點語法糖,稱為「初始化形式參數」,以提供幫助:
1 | class Point { |
在建構子參數上使用 this. 意味著您只需寫入每個欄位的類型一次,名稱兩次。更好!但您仍然必須寫入類別名稱兩次,每個欄位的名稱兩次。初始化形參很好,但使用者仍然告訴我們它們不夠用。
關於借鑒其他語言的功能
那麼,一些從其他語言轉向 Dart 的使用者告訴我們他們缺少某個功能。我們該如何處理這類回饋呢?
我個人喜歡從其他語言借鑒功能。那些語言的創造者已經投入了大量工作來設計和驗證該功能。我們可以從他們那裡學到很多東西,而那些其他語言的存在證明了該功能在概念上是連貫且易於實作的。
從其他語言汲取靈感也可以讓我們的語言更容易學習。除非使用者是完全的程式設計新手,否則他們不會從頭開始學習 Dart。他們帶著從其他語言學到的一切來到我們這裡。他們需要學習的,是他們所知與 Dart 所包含內容之間的差異。當我們從其他語言借用語法和語義時,我們就縮小了這種差異的規模,並降低了學習 Dart 的努力。
這種哲學一直是 Dart 成功的關鍵。從微小的分號到類別,Dart 的設計都旨在讓來自 JavaScript、Java 和 C# 等其他主流語言的使用者感到熟悉且易於學習。
同時,好的語言設計是情境化且全面的。「一雙好鞋是什麼?」在北極苔原和夏威夷海灘上的答案會非常不同。一個在 Rust 中運作良好的語言功能,可能無法優雅地融入 Dart,因為 Dart 有其獨特的語法、語義、歷史、使用者基礎和生態系統。
我不希望 Dart 感覺像是一個由其他語言中撕裂下來的身體部位拼湊而成的科學怪人。因此,當 Dart 語言團隊研究其他語言的功能時,我們同時會研究該功能如何在該語言的上下文中解決問題,以及該上下文與 Dart 自己的匹配程度。
將主要建構子引入 Dart
我們知道使用者想要一種更好的語法來定義一個從建構子參數初始化某些欄位的類別。透過主要建構子,您編寫建構子,編譯器則綜合欄位。語言也可以反其道而行之。您編寫欄位宣告,編譯器則免費為您提供建構子。Swift 使用成員初始化器來實現。
任何時候你的語言從一個語法片段中衍生出兩個宣告時,都會面臨一個挑戰:這個語法需要處理所有你可能配置這兩個宣告的各種方式。在我們這裡的案例中,實例欄位可能是 final 或不是。它可能帶有 override 等元資料或文件註釋。建構子可以是具名或匿名,const 或不是。建構子參數可以是位置參數或具名參數,可選或必選。如果它是可選的,它可能需要指定一個預設值。
我們花了一些時間研究 從欄位宣告推斷建構子,但最終決定參數是使用者手動編寫更有用的宣告。由於建構子通常是公共 API,因此完全控制簽名很重要:建構子的名稱和 const 屬性、哪些參數是具名或位置參數、位置參數的順序以及它們的預設值。
為了從建構子參數推斷實例欄位,使用者需要提供的唯一缺少的部分是該欄位是否應該是 final。允許在參數前加上 final 或 var 來控制這一點是相當自然的。兩者皆無修飾符則表示該參數根本不宣告實例欄位。這類似於 Scala 和 Kotlin 對 val 和 var 的處理方式。Dart 的結果如下:
1 | class Point( |
(由於主要建構子使空類別主體更常見,我們現在也允許您使用 ; 而不是 {} 來表示空類別主體。)
關於語法懸崖
這看起來很不錯,但是如果主要建構子也需要一個主體或初始化列表怎麼辦?一種選擇是簡單地說:「好吧,在這種情況下,不要使用主要建構子。」語法糖通常會採用用例的一個子集,並為它們提供更簡潔的語法。如果您不屬於該子集,則要求使用者退回到更舊、更複雜的語法是合理的。
在某些情況下,這是正確的選擇。但語言團隊非常注意程式碼會隨著時間演進。假設您正在編寫一個類別。它一開始很簡單,只有幾個從建構子參數初始化的欄位:
1 | class FormatterOptions({ |
主要建構子的完美用例。後來你添加了一些欄位和參數。太棒了。沒多久,你就有了一個包含許多宣告欄位的參數的建構子:
1 | class FormatterOptions( |
然後有一天你決定要在建構子主體中做一些記錄。如果主要建構子不支援主體,那麼你就必須將整個主要建構子轉換成一個內建建構子:
1 | class FormatterOptions { |
這是可行的。Dart SDK 包含了許多快速修復工具,您只需點擊按鈕即可為您完成這些確切的更改,因此更改程式碼在機制上並不困難。但它仍然是一個很大的文字更改。您只是想添加一行日誌記錄,現在您有 20 行更改需要查看。
在語言團隊中,我們稱之為「語法懸崖」。您想進行一個小的語義變更(這裡,添加一行日誌記錄),但您想表達的內容恰好略超出優化語法所支援的範圍。您從該語法的平穩高原上跌落,降落在下方更冗長的區域。
當這種情況發生時感覺不好。你正在努力自由探索程式的語義空間,但在語法方面,一些微小的意義上的步驟似乎觸手可及。在設計語言功能時,我們花費大量時間討論這些懸崖,並在可能的情況下努力避免它們。
我們希望語言感覺像平坦的地形,小的語義變更只需要同樣小的文本變更。當您進行程式碼審查時,我們希望變更的行反映程式的行為變更,而不是語言語法中沒有意義的橫向移動。
主要建構子主體
Kotlin 的解決方案是允許在類別主體內使用初始化器區塊。在 Dart 中,我們支援類似的功能,但使用 this 關鍵字:
1 | class FormatterOptions( |
初始化區塊為您提供了一個空間來填寫主要建構子的主體或初始化列表。它也是為建構子添加文件註釋的自然場所。(如果您將文件註釋放在類別標頭上方,它將應用於整個類別,而不僅僅是主要建構子。)
這些初始化區塊有點像語法糖之上的語法糖。我們不需要它們,但它們有助於防止使用者跌落語法懸崖。您可以從一個主要建構子開始,當您的類別簡單時,語言永遠不會在您的類別演變時迫使您放棄該選擇。
關於語義綁定
現在,即使語言不會強迫您將主要建構子變成內部建構子,您可能仍然希望在類別主體內定義一個建構子。類別標頭中可能有很多內容:型別參數、extends 子句、with 子句中的混入,可能還有 implements。建構子參數可能會有文件註解。在那裡可能會變得雜亂無章。
或者,您可能有一個具有多個建構子的類別,其中沒有一個建構子明顯比其他建構子更「主要」。(當您有一個主要建構子時,類別的所有其他非工廠建構子都必須重定向到它。) 或者,也許最基本的建構子是私有的,您認為將私有建構子放在高度可見的類別標頭中看起來很混亂。
由於這些原因以及更多,Dart 仍然支援在類別主體內宣告的建構子。我們不認為主要建構子本質上優於內部建構子,只是不同且更適合某些使用情況。
然而,只有主要建構子才能存取參數上的 var 和 final 語法糖,該參數隱式宣告一個實例欄位並從該參數初始化它。這兩個功能——在類別標頭中宣告建構子和引導欄位的建構子參數——是捆綁在一起的。沒有前者就不能使用後者。
一般來說,我們在語言設計上盡力不讓使用者處於這樣一種境地:他們只想要兩種行為中的一種,但語言卻將它們綁定在一起,迫使他們接受兩種。例如,當我們添加類別修飾符時,我們特意添加了 final 和 sealed。它們非常相似,但 final 允許您防止子類化,而不必選擇啟用窮舉檢查。
決定捆綁什麼是一種平衡的行為。我相信,賦予每種程式語言其特性和適用於某些領域的很大一部分原因是它如何選擇將語義映射到語法。其中一部分是它如何將多種行為掛載到單個文本上。例如,在大多數物件導向語言中,使一個類別成為另一個類別的子類別,也使其成為靜態型別系統中的子型別。這並非絕對必要,正如 C++ 中的私有繼承所示。但對於大多數物件導向語言來說,這種耦合似乎是有意義的。
擁有行為與文字之間嚴格的一對一映射似乎是理想的,但捆綁行為可以讓表達常見模式更容易。想想走進一家漢堡店說「我要一份 2 號餐」比說「一個雙層起司漢堡,加芥末不加番茄醬,中薯,和一杯中杯汽水」容易多少。
當談到將宣告參數與主要建構子結合時,這感覺是一個相對安全的捆綁。宣告參數本身就是語法糖,並且不能讓您表達任何您已經無法在普通類別宣告中表達的內容。如果您出於任何原因不想讓建構子成為主要建構子,您必須放棄宣告參數的語法便利性。但您放棄的只是簡潔性。您仍然可以按照您想要的方式定義您的類別,並具有完全符合您需求的 API 和語義。
能夠在內部建構子中使用宣告參數會很棒,我們也為此努力提出了一個建議。最終,我們認為負面影響太多了。如果在類別主體中的任何隨機建構子都可以隱式宣告其實例欄位在其參數列表中,那麼要找到一個類別儲存的所有狀態並推斷它就變得更加困難。這在主要建構子中不是一個問題,因為主要建構子總是位於類別的頂部。
更短的內部建構子
Dart 現有建構子宣告語法冗長度的另一個來源是必須重複類別名稱。在 Point 這樣的範例中,使用短名稱看起來還不錯,但當您有一個像 AnimatedFractionallySizedBox 這樣的類別名稱時,重複整個 28 個字元的識別碼會佔用大量空間,而這些空間原本可以用於建構子的參數列表。
這個問題我們可以解決。Dart 的建構子語法繼承自 Java 和 C#,而 Java 和 C# 又繼承自 C++。Bjarne Stroustrup 選擇使用類別名稱是為了最大限度地減少新關鍵字的數量,並模仿建構子呼叫的樣子。這是一個可愛的語法,但連他自己也承認「這可能過於聰明了」。
語言語法的一部分與另一部分互相映照,這是一種有用的工具,可以幫助使用者理解程式碼的含義。當兩段程式碼看起來相同時,它會發出一個訊號,表明它們之間可能存在某種關聯。如果宣告看起來像呼叫點,那麼一旦你閱讀了宣告,你就知道如何撰寫程式碼來呼叫它。
至少,這是個想法。但 Dart 並沒有完全貫徹這個原則。如果忽略型別註釋,它對函式和方法宣告還算有效。Getters 使用 get 關鍵字宣告,但調用時卻不使用。運算子使用特殊的 operator 關鍵字宣告,並且右側參數在括號中,儘管你不需要括號來呼叫運算子。即使在函式中,Dart 也使用 {...} 來宣告具名參數,這與你在呼叫點傳遞它們的方式完全不同。
大多數其他物件導向語言不使用類別名稱來定義建構子。Swift、Ruby 和 Objective-C 使用 init()。Python 灑上一些底線並使用 __init__()。JavaScript、TypeScript 和 Kotlin 使用 constructor。PHP 使用 __construct。
這使我們得出結論,重複建構子的類別名稱在熟悉度或一致性方面並沒有為我們帶來太多好處。而且使用者必須付出代價。名稱通常很冗長,每個希望將建構子注入類別的人類、模板處理器、程式碼生成器、生成程式碼的 AI 代理或未來的元程式設計功能都需要知道要使用類別名稱。這幾乎就像每個類別都有自己特殊的小上下文關鍵字。
這個問題在我們目前正在開發的另一個功能中尤其突出:靜態擴展成員。Dart 中的擴展允許您將實例成員附加到現有型別,但它們目前不允許您添加靜態成員或建構子。我們也想支援這些,但這會引發一個棘手的邊緣情況。擴展不僅可以定義在類別上,還可以定義在任何靜態型別上,包括 typedef。考慮以下情況:
1 | class SomeClass {} |
如果你想在這個擴展中添加一個建構子,你該用哪個名稱:OtherName 還是 SomeClass?請記住,從型別系統的角度來看,它們是完全相同的型別。程式中沒有任何地方有名為 OtherName 的類別。typedef 並沒有創建一個新的具名型別,它只是定義了一個可以用來指代其他型別的臨時別名。
我們可以要求你使用 OtherName,因為那是你在擴展宣告頂部寫下的名稱。但這違反了型別可以替換為指代相同型別的 typedef 而不破壞任何東西的原則。通常,用 typedef 替換對某種型別的引用是一個透明的更改。在這裡,而且只有在這裡,typedef 名稱才會變得有意義。
或者我們可以採取另一種方式,說你必須使用 typedef 解析到的底層類別的名稱。但這會破壞 typedef 的封裝。typedef 可能在另一個函式庫中,並引用一個你在你的函式庫中甚至無法存取的私有類別!
這裡的所有複雜性都是我們自己透過使用類別名稱作為「建構子」的魔術識別碼所造成的問題。如果我們只是選擇一個通用的關鍵字,這個問題就會消失。
所以這就是我們所做的。在 Dart 3.13 中,您可以使用 new 代替類別名稱來宣告非工廠建構子,並使用 factory 來宣告工廠建構子。如果您想要一個具名建構子,請將名稱放在 new 或 factory 關鍵字之後。
1 | // Before Dart 3.13: |
(重定向建構子的語法類似。)
這屬於我們認為絕對更好的語法糖類別。除非您的類別名稱短於三個字母,否則新語法總是更短。它更規則。除了目前不熟悉(這種感覺會過去),我們認為它總體上更好。
然而,使用 new 來定義建構子確實會導致一個奇怪的組合。如果您也希望該建構子是常量,則需要一個 const 修飾符:
1 | class SomeClass { |
我承認 const new 看起來自相矛盾。對於那些經歷過每個建構子調用都以 new 或 const 開頭的 Dart 使用者來說,更是如此。在那個世界裡,兩者是直接對立的。但現在你不再寫 new 來調用建構子,所以在我看來,new 關鍵字主要只是意味著「宣告一個建構子」,而 const 總是一個修飾符,意味著「使事物成為常量」。
總結
感謝您與我一起回顧這漫長的設計過程。我們花了這麼長時間仔細思考建構子的語義和語法的每一個細節,為 Dart 3.13 做準備,我還可以再寫五千字來討論它。我們曾考慮將主要建構子參數列表放在類別標頭中的 extends、implements 和 with 子句之後。我們就類別主體的每個角落進行了辯論,以決定主要建構子參數應該在哪個範圍內。我們是否應該允許在類別標頭中調用超類?這對 with 子句意味著什麼?如果您有一個名為 factory 的工廠建構子會發生什麼?
建構子是物件導向程式設計的基礎,Dart 已經在它們上面花費了大量的語言複雜性。將主要建構子織入 Dart 需要非常仔細地修補語言結構中的數十處皺褶。我希望結果感覺無縫,但我確信它並不完美。
如果您在新功能中遇到任何感覺奇怪或任意的部分,我希望這篇長文能幫助您更好地理解。如果沒有,請告訴我們。語言總是在發展,我們也總是在努力使其變得更好。同時,我們希望您會發現 Dart 3.13 中的程式碼感覺更清晰、更簡單、閱讀和編寫起來更愉快。
更多來自 Dart
Dart 中的 JS 互操作歷史
由於 Dart 3.3 達到了令人興奮的 JavaScript 互操作里程碑,Wasm 的支援剛剛在當前的 Flutter Beta 版中推出。要了解...
Dart DevTools:使用 CPU Profiler 分析應用程式效能
無論您是使用 Dart 編寫命令列工具的後端開發人員,還是使用 Flutter 構建應用程式的 UX 工程師,程式效能對於專案的成功都至關重要。命令列工具應最大限度地減少延遲,應用程式應響應迅速且流暢,沒有丟失的幀。作為開發人員,我們盡力編寫高效能程式碼,但有時不清楚為什麼我們的程式碼效能不如預期。


