Go 部落格

別名(Alias)的含義?

Robert Griesemer
2024 年 9 月 17 日

這篇博文將介紹泛型別名型別,它們是什麼,以及我們為什麼需要它們。

背景

Go 的設計初衷是為了大規模程式設計。大規模程式設計意味著需要處理大量資料,同時也要處理大型程式碼庫,並且有許多工程師在長時間內維護這些程式碼庫。

Go 將程式碼組織成包(package)的方式,透過將大型程式碼庫拆分成更小、更易於管理的部分,通常由不同的人編寫,並透過公共 API 連線起來,從而實現了大規模程式設計。在 Go 中,這些 API 由包匯出的識別符號組成:匯出的常量、型別、變數和函式。這包括匯出的結構體欄位和型別的方法。

隨著軟體專案隨著時間的推移而演進或需求發生變化,程式碼最初的包組織方式可能變得不足,需要進行重構。重構可能涉及將匯出的識別符號及其宣告從一箇舊包移動到一個新包。這還要求必須更新對被移動宣告的所有引用,以便它們指向新的位置。在大型程式碼庫中,可能無法或不切實際地原子地進行此類更改;換句話說,一次性完成移動並更新所有客戶端。相反,更改必須增量進行:例如,要“移動”一個函式 F,我們在新包中新增其宣告,而不刪除舊包中的原始宣告。這樣,客戶端就可以隨著時間的推移增量更新。一旦所有呼叫者都引用新包中的 F,就可以安全地刪除 F 的原始宣告(除非出於向後相容性必須無限期保留)。Russ Cox 在他 2016 年的博文 《程式碼庫重構(在 Go 的幫助下)》 中詳細描述了重構。

將一個函式 F 從一個包移動到另一個包,同時在原始包中保留它,這很容易:只需要一個包裝函式。要將 Fpkg1 移動到 pkg2pkg2 宣告一個與 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:在這裡,AT 表示同一個、因此是相同的型別 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 宣告並匯出了一個帶有型別引數 P 的泛型型別 G,該型別已得到適當約束。

package pkg1

type Constraint      someConstraint
type G[P Constraint] someType

如果需要從新包 pkg2 提供對同一型別 G 的訪問,泛型別名型別正是合適的選擇 (playground)

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

有幾個原因:

  1. 根據現有規範規則,泛型型別在使用時必須被例項化。別名宣告的右側使用了型別 pkg1.G,因此必須提供型別實參。不這樣做將需要對此情況進行例外處理,從而使規範更加複雜。不清楚微小的便利性是否值得這種複雜性。

  2. 如果別名宣告不需要宣告自己的型別引數,而是簡單地“繼承”自被別名化的型別 pkg1.G,那麼 G 的宣告就不會指示它是一個泛型型別。其型別引數和約束必須從 pkg1.G 的宣告中檢索(它本身可能是一個別名)。可讀性會受到影響,而可讀的程式碼是 Go 專案的主要目標之一。

顯式地寫出型別引數列表起初可能看起來是額外的負擔,但它也提供了額外的靈活性。例如,別名型別宣告的型別引數數量不必與被別名化型別的型別引數數量匹配。考慮一個泛型對映型別

type Map[K comparable, V any] mapImplementation

如果將 Map 用作集合(set)的場景很常見,那麼別名

type Set[K comparable] = Map[K, bool]

可能很有用 (playground)。因為它是別名,所以 Set[int]Map[int, bool] 是相同的型別。如果 Set 是一個(非別名)已定義的型別,情況就不是這樣了。

此外,泛型別名型別的型別約束不必與被別名化型別的約束匹配,它們只需要滿足它們。例如,重用上面的集合示例,可以按如下方式定義 IntSet

type integers interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type IntSet[K integers] = Set[K]

此對映可以與滿足 integers 約束的任何鍵型別進行例項化 (playground)。因為 integers 滿足 comparable,所以型別引數 K 可以用作 SetK 引數的型別實參,遵循常規的例項化規則。

最後,因為別名也可以表示型別字面量,引數化別名使得建立泛型型別字面量成為可能 (playground)

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 的預宣告型別(intstring 等)只能透過其名稱訪問,並且與已定義型別和型別引數一樣,如果它們的名稱不同,則表示不同的型別(暫時忽略 byterune 別名型別)。預宣告型別確實是命名型別。

因此,隨著 Go 1.18,規範完成了閉環,並正式重新引入了命名型別的概念,該概念現在包括“預宣告型別、已定義型別和型別引數”。為了糾正別名型別表示型別字面量的情況,規範指出:“當別名所代表的型別是命名型別時,該別名就表示一個命名型別。”

暫時退一步,暫時不考慮 Go 的術語,對 Go 中的命名型別最準確的技術術語可能是名義型別。名義型別的身份明確地與其名稱相關聯,這正是 Go 的命名型別(現在使用 1.18 的術語)的全部內容。名義型別的行為與結構型別相反,結構型別的行為僅取決於其結構,而與名稱無關(如果它有名稱的話)。總而言之,Go 的預宣告型別、已定義型別和型別引數型別都是名義型別,而 Go 的型別字面量和表示型別字面量的別名是結構型別。名義型別和結構型別都可以有名稱,但有名稱並不意味著型別是名義的,它只是意味著它是命名的。

這一切對日常使用 Go 而言都無關緊要,在實踐中可以安全地忽略這些細節。但精確的術語在規範中很重要,因為它使得描述語言規則更加容易。那麼規範是否應該再次更改術語?這可能不值得引起混亂:不僅需要更新規範,還需要更新大量支援文件。許多關於 Go 的書籍可能會變得不準確。此外,“命名”雖然不太精確,但對大多數人來說可能比“名義”更直觀易懂。它也與規範中使用的原始術語相匹配,即使現在需要對錶示型別字面量的別名型別進行例外處理。

可用性

實現泛型類型別名花費的時間比預期要長:必要的更改要求向 go/types 新增一個新的匯出型別 Alias,然後新增記錄該型別的型別引數的功能。在編譯器方面,類似的更改也需要修改匯出資料格式,該檔案格式描述了包的匯出內容,現在需要能夠描述別名的型別引數。這些更改的影響不僅限於編譯器,還影響了 go/types 的客戶端,從而影響了許多第三方包。這是一項影響大型程式碼庫的重大更改;為了避免破壞現有功能,有必要分階段釋出。

經過所有這些工作,泛型類型別名最終將在 Go 1.24 中預設可用。

為了讓第三方客戶端能夠準備好程式碼,從 Go 1.23 開始,可以透過在呼叫 go 工具時設定 GOEXPERIMENT=aliastypeparams 來啟用對泛型類型別名的支援。但是,請注意,該版本仍然缺少對匯出泛型別名的支援。

完整的支援(包括匯出)已在最新版本中實現,並且 GOEXPERIMENT 的預設設定很快將進行更改,以便預設啟用泛型類型別名。因此,另一種選擇是嘗試 Go 的最新版本。

一如既往,如果您遇到任何問題,請透過提交問題告知我們;我們對新功能進行測試的次數越多,整體釋出就越順利。

感謝,祝您重構愉快!

下一篇文章:Go 迎來 15 週年
上一篇文章:在 Go 中構建 LLM 驅動的應用程式
部落格索引