Go 部落格
(別名)名稱的內容?
此文章探討一般化別名類型、其內容以及我們需要它們的原因。
背景知識
Go 的設計旨在於大規模程式設計。大規模程式設計表示處理大量資料,但也包括規模龐大的程式碼庫,以及許多工程師長期間處理這些程式碼庫。
Go 的程式碼庫組織成套件,這可將大規模程式碼庫分割成更小、更易於管理的區段來達成大規模程式設計的目的,這些區段通常由不同的人撰寫,並透過公開 API 相互連接。在 Go 中,這些 API 包含套件所輸出的識別碼:輸出的常數、類型、變數和函數。其中包括結構和類型方法的輸出欄位。
隨著軟體專案隨著時間推移或需求改變,原本將原始碼組織為套件的方式可能變得不理想,並需要進行重構。重構可能涉及將外部識別碼及其各自的宣告從舊套件移至新套件。這也需要更新任何對已移轉宣告的參考,以使其參考新位置。在大型程式碼庫中,以原子化方式進行這種變更可能不切實際或不可行;或者換句話說,必須在單一變更中進行移轉並更新所有用戶端。相反地,變更必須循序漸進地進行:例如,要「移轉」函式 F
,我們可以在新套件中加入其宣告,而不刪除舊套件中原本的宣告。如此一來,用戶端可以隨著時間推移逐漸更新。一旦所有呼叫方都參考新套件中的 F
,就可以安全地刪除 F
的原本宣告(除非必須為了向下相容性而永久保留)。Russ Cox 在 2016 年關於 程式碼庫重構(透過 Go 協助) 的文章中詳細描述了重構。
將函式 F
從一個套件移轉到另一個套件,同時也保留在原本的套件中非常容易,需要的只是一個封裝函式。要將 F
從 pkg1
移轉到 pkg2
,pkg2
宣告一個具有與 pkg1.F
相同簽章的新函式 F
(封裝函式),而 pkg2.F
會呼叫 pkg1.F
。新的呼叫方可以呼叫 pkg2.F
,舊的呼叫方可以呼叫 pkg1.F
,但兩種情況最終呼叫的函式是相同的。
移轉常數也同樣簡單。變數需要更多工作:可能必須在新套件中引入指向原本變數的指標,或使用存取器函式。這不是理想的,但至少可行。這裡的重點在於,對於常數、變數和函式,現有的語言功能允許像上面描述的循序漸進重構。
可是要如何移轉類型呢?
在 Go 中,(限定)識別碼 或簡稱名稱會決定類型的身分:由套件 pkg1
定義並外傳的類型 T
與套件 pkg2
外傳的類型 T
(類型定義否則相同)不同。此特性讓將 T
從一個套件移轉到另一個套件,同時保留在原本套件中的複本變得複雜。例如,類型為 pkg2.T
的值無法指定給類型為 pkg1.T
的變數,原因在於其類型名稱及類型身分不同。在循序漸進的更新階段,用戶端可能具有兩種類型的值和變數,即使程式設計師的用意是讓它們有相同的類型。
為了解決這個問題,Go 1.9 引入了 類型別名 的概念。類型別名提供了一個現有類型的名稱,而不會引入一個具有不同身分的類型。
相較於常規的 類型定義
type T T0
它宣告了一個新的類型,它永遠不會與宣告右邊的類型相同,別名宣告
type A = T // the "=" indicates an alias declaration
僅宣告了右邊類型的新名稱 A
:在此,A
和 T
表示相同且身分相同的類型 T
。
別名宣告可以提供一個既有類型(在新的套件中!)的新名稱,同時保持類型身分
package pkg2
import "path/to/pkg1"
type T = pkg1.T
類型名稱已從 pkg1.T
變更為 pkg2.T
,但 pkg2.T
類型的值與 pkg1.T
類型的變數具有相同的類型。
一般別名類型
Go 1.18 引入了泛型。自那次版本發布以來,類型定義和函式宣告可以透過類型參數自訂。由於技術原因,別名類型當時並未獲得相同的處理能力。顯然,當時也沒有大型程式碼庫會匯出泛型類型且需要重構。
如今,泛型已經存在好幾年了,而大型程式碼庫正在使用泛型功能。最後,重構這些程式碼庫的需求將會出現,並因此需要將泛型類型從一個套件遷移到另一個套件。
為了支援涉及泛型類型的遞增重構,預計在 2025 年 2 月初發布的未來 Go 1.24 版本將完全支援別名類型中的類型參數,依據提議 #46477 的規定。新的語法遵循與類型定義和函式宣告相同的模式,在左邊的識別碼(別名名稱)後接可選的類型參數清單。在此變更之前,只能撰寫
type Alias = someType
但現在我們也可以在別名宣告中宣告類型參數
type Alias[P1 C1, P2 C2] = someType
考慮之前的範例,現在是使用泛型類型。原始套件 pkg1
宣告並匯出了泛型類型 G
,類型參數為適當受限的 P
package pkg1
type Constraint someConstraint
type G[P Constraint] someType
如果需要提供從新的套件 pkg2
存取相同類型 G
的方式,泛型別名類型就是完美的選擇 (遊樂場)
package pkg2
import "path/to/pkg1"
type Constraint = pkg1.Constraint // pkg1.Constraint could also be used directly in G
type G[P Constraint] = pkg1.G[P]
請注意,不能 僅撰寫
type G = pkg1.G
原因有幾個
-
根據 現有的規範規則,泛型類型必須在使用時執行初始化。別名宣告的右邊使用了類型
pkg1.G
,因此必須提供類型引數。不這樣做的話,這個狀況將需要一個例外,讓規範變得更複雜。並不顯而易見的是,這樣的微小便利是否值得讓事情變得這麼複雜。 -
如果別名宣告不需要宣告自己的類型參數,而是簡單地「繼承」別名類型
pkg1.G
的類型參數,那麼對G
的宣告並不會顯示它是個泛型類型。它的類型參數與限制必須從pkg1.G
的宣告中擷取 (而pkg1.G
本身可能也是別名)。可讀性將會受損,但可讀的程式碼卻是 Go 專案的主要目標之一。
一開始,將明確的類型參數清單寫下來可能看似是不必要的負擔,但它也提供了額外的彈性。其一,別名類型所宣告的類型參數數量不必與別名類型的類型參數數量相符。考量一個泛型映射類型
type Map[K comparable, V any] mapImplementation
如果將 Map
的使用方法設定為常見的情況,別名
type Set[K comparable] = Map[K, bool]
可能實用 (遊樂場)。由於它是一個別名,因此 Set[int]
和 Map[int, bool]
這樣的類型是相同的。如果 Set
是一個 已定義 (非別名)類型,情況就不會是如此。
此外,泛型別名類型的類型限制不必與別名類型的限制相符,它們只需要 滿足 它們。例如,重複使用上述集合範例,可以如下定義一個 IntSet
type integers interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type IntSet[K integers] = Set[K]
這個映射可以實例化為任何滿足 integers
限制的關鍵字類型 (遊樂場)。由於 integers
滿足 comparable
,因此類型參數 K
可以用作 Set
中 K
參數的類型參數,遵循一般的實例化規則。
最後,由於別名也可以表示類型字面值,因此參數化別名可以建立泛型類型字面值 (遊樂場)
type Point3D[E any] = struct{ x, y, z E }
說得清楚點,這些範例都不是「特殊狀況」,也不需要規範中的額外規則。它們直接遵循既有泛型規則的應用。規範中唯一改變的事項是別名宣告中宣告類型參數的能力。
關於類型名稱的插曲
在導入別名類型之前,Go 只有一種形式的類型宣告
type TypeName existingType
此宣告從現有的類型建立一個新的且不同的類型,並給予該新類型一個名稱。稱呼這些類型為已命名類型很自然,因為它們與類型名稱不同,例如 struct{x、y int}
等未命名的 類型字面值。
在 Go 1.9 中導入別名類型之後,也可以為類型字面值提供一個名稱(別名)。例如,請考慮
type Point2D = struct{ x, y int }
忽然之間,已命名類型的觀念無法很好地描述與類型字面值不同的東西,因為別名顯然就是類型的名稱,因此所指的類型(可能是類型字面值、不是類型名稱!)可以說成是「已命名類型」。
因為(適當的)命名的類型有特別的特性(可以繫結方法給它們,它們遵循著不同的指派規則等),因此為了避免混淆,使用新用語似乎很明智。因此,自 Go 1.9 起,規格將過去稱為已命名的類型稱為已定義類型:只有已定義類型具有繫結於其名稱的屬性(方法、可指派性限制等)。使用類型定義來定義已定義類型,而使用別名宣告來定義別名類型。在這兩種情況下,都為類型提供名稱。
Go 1.18 中導入泛型讓情況變得更為複雜。類型參數也是類型,它們有一個名稱,並且與已定義類型有相同的規則。例如,與已定義類型一樣,兩個名稱不同的類型參數表示不同的類型。換句話說,類型參數是已命名的類型,而且在某些方面它們的行為與 Go 的原始已命名類型類似。
最重要的是,Go 的預先宣告類型(int
、string
等)只能透過它們的名稱存取,而且與已定義類型和類型參數一樣,如果它們的名稱不同(暫且忽略 byte
和 rune
別名類型),則它們是不同的。預先宣告類型真的是已命名的類型。
因此,使用 Go 1.18,規格完成了一個迴圈,並正式重新引入了已命名類型的概念,該概念現在包含「預先宣告類型、已定義類型和類型參數」。為了修正表示類型文字的別名類型,規格說明:「如果別名宣告中給定的類型是已命名類型,則別名表示已命名類型。」
暫時跳脫 Go 命名法,Go 中已命名類型的正確技術術語可能是名義類型。名義類型的身分明確繫結於其名稱,這正是 Go 的已命名類型(現在使用 1.18 術語)的全部意義。名義類型的行為與結構類型相反,後者的行為僅取決於其結構,而不取決於其名稱(如果一開始就有名稱的話)。綜合來說,Go 的預先宣告類型、已定義類型和類型參數類型都是名義類型,而 Go 表示類型文字的類型文字和別名則是結構類型。名義類型和結構類型都可以有名稱,但有名稱並不表示該類型就是名義類型,這僅表示該類型已命名。
對於日常使用 Go,上述都不重要,在實務中可以安全地忽略這些細節。但精確的術語在規範中很重要,因為它有助於描述規範語言的規則。因此,規範是否應該再更換一次術語呢?這可能不值得大費周章:需要的更新不僅是規範,還有許多支援文件。相當多使用 Go 編寫的書籍可能變得不準確。此外,「命名」,雖然不太精確,但對於大多數人來說,可能比「名義上」更清楚易懂。它也符合規範中使用的原始術語,即使它現在需要對表示文字類型的別名類型設定例外情況。
可用性
實作通用類型別名花費的時間比預期更長:這些必要的變更需要新增一個新的匯出的Alias
類型至 go/types
,然後新增將類型參數記錄至該類型 的能力。在編譯器方面,類似的變更也需要修改匯出資料格式,此檔案格式描述套件的匯出,現在需要能夠描述別名的類型參數。這些變更的影響不僅限於編譯器,還會影響 go/types
的用戶端,並進而影響許多第三方套件。這會大幅變更大型程式碼庫;為了避免危及使用,必須在多個版本中逐步推出。
在完成所有這些工作之後,通用別名類型將最終在 Go 1.24 中預設可用。
為了讓第三方用戶端準備好他們的程式碼,從 Go 1.23 開始,可在呼叫 go
工具時設定 GOEXPERIMENT=aliastypeparams
來啟用對通用類型別名的支援。但請注意,該版本尚未支援匯出的通用別名。
完整支援(包括匯出)已在 tip 中實作,並且GOEXPERIMENT
的預設設定即將切換,以便預設啟用通用類型別名。因此,另一個選擇是在 tip 中測試 Go 的最新版本。
一如往常,如果您遇到任何問題,請務必透過提出 問題通報 讓我們知道;我們對新功能進行的測試越多,整體推出的過程就會越順利。
祝您重組愉快!
下一篇文章: Go 滿 15 歲
前一篇文章: 在 Go 中建置 LLM 支援應用程式
技術部落格索引