Go 部落格

名稱(別名)有什麼意義?

Robert Griesemer
2024 年 9 月 17 日

這篇文章是關於泛型別名型別的:它們是什麼,以及為什麼我們需要它們。

背景

Go 設計之初就是為了應對規模化程式設計。規模化程式設計意味著處理大量資料,也意味著處理大型程式碼庫,以及許多工程師在長時間內維護這些程式碼庫。

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

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

將一個函式 F 從一個包移動到另一個包,同時保留在原包中,這很容易:只需要一個包裝函式。要將 Fpkg1 移動到 pkg2pkg2 宣告一個新函式 F(包裝函式),其簽名與 pkg1.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 宣告並匯出一個泛型型別 G,帶有一個適當約束的型別引數 P

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 的宣告中檢索(pkg1.G 本身可能是一個別名)。這樣會影響可讀性,而可讀性程式碼是 Go 專案的主要目標之一。

寫下明確的型別引數列表起初可能看起來是不必要的負擔,但它也提供了額外的靈活性。首先,別名型別宣告的型別引數數量不必與被別名化型別的型別引數數量匹配。考慮一個泛型 map 型別

type Map[K comparable, V any] mapImplementation

如果將 Map 用作集合很常見,別名

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]

這個 map 可以用任何滿足 integers 約束的鍵型別進行例項化 (playground)。因為 integers 滿足 comparable 約束,型別引數 K 可以作為 Set 的引數 K 的型別實參,遵循常規的例項化規則。

最後,因為別名也可以表示型別字面量,引數化別名使得建立泛型型別字面量成為可能 (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 的預宣告型別(int, string 等)只能透過它們的名稱訪問,並且像定義型別和型別引數一樣,如果它們的名稱不同,則它們也不同(暫時忽略 byterune 別名型別)。預宣告型別確實是命名型別。

因此,隨著 Go 1.18 的釋出,規範兜了一圈,正式重新引入了命名型別的概念,現在它包括“預宣告型別、定義型別和型別引數”。為了修正表示型別字面量的別名型別,規範規定:“如果別名宣告中給定的型別是命名型別,則該別名表示一個命名型別。”

暫時跳出 Go 命名法的框架,Go 中命名型別的正確技術術語可能是標稱型別 (nominal type)。標稱型別的標識明確地與其名稱繫結,這正是 Go 命名型別(現在使用 1.18 術語)的含義。標稱型別的行為與結構型別 (structural type) 形成對比,後者的行為僅取決於其結構,而不取決於其名稱(如果它本來有名稱的話)。總而言之,Go 的預宣告型別、定義型別和型別引數型別都是標稱型別,而 Go 的型別字面量和表示型別字面量的別名是結構型別。標稱型別和結構型別都可以有名稱,但有名稱並不意味著該型別是標稱型別,它只意味著它被命名了。

對於 Go 的日常使用而言,這一切都不重要,實踐中可以安全地忽略這些細節。但在規範中,精確的術語很重要,因為它使得描述語言規則變得更容易。那麼規範應該再次更改其術語嗎?可能不值得折騰:不僅規範需要更新,許多配套文件也需要更新。不少關於 Go 的書籍可能會變得不準確。此外,“命名”雖然不夠精確,但對於大多數人來說,可能比“標稱”更直觀清晰。它也與規範中使用的原始術語相符,即使現在對於表示型別字面量的別名型別需要一個例外。

可用性

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

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

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

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

一如既往,如果您遇到任何問題,請透過提交一個問題告知我們;我們對新功能的測試越充分,正式釋出就會越順利。

感謝閱讀,祝您重構愉快!

下一篇文章:Go 誕生 15 週年
上一篇文章:在 Go 中構建基於 LLM 的應用
部落格索引