Generics for Go

Ben Hoyt

Go 的泛型

原文由 Ben Hoyt 發布,訂閱此部落格

Go 程式語言於 2009 年首次發布,並在 2012 年 3 月推出 1.0 版本。即使在 1.0 版之前,就有部分開發者批評該語言過於簡化,部分原因在於它缺乏使用者自訂的泛型型別以及以型別為參數的函式。儘管有這項缺漏,Go 仍被廣泛使用,全球開發者數量估計約有 100 至 200 萬人。多年來陸續出現了數個為語言加入某種形式泛型的提案,不過由核心開發者 Ian Lance Taylor 與 Robert Griesemer 所撰寫的最新提案,看起來最有可能被納入未來的 Go 版本中。

背景

Go 是一種靜態型別語言,因此型別會在原始碼中指定(或由原始碼推斷),並由編譯器進行檢查。編譯器會產生最佳化的機器碼,因此在執行 CPU 密集型程式時,效率遠高於 Python 或 Ruby 這類使用位元組碼編譯器並透過虛擬機器執行的語言。

泛型,又稱為「參數化型別」或「參數多型」,是一種撰寫程式碼或建構資料結構的方式,讓同一份程式碼或結構能適用於任何資料型別;可以針對各種不同的資料型別進行實例化,而無需重複撰寫程式碼。在撰寫排序、搜尋等通用演算法,以及樹狀結構、執行緒安全的 map 等與型別無關的資料結構時,泛型特別有用。舉例來說,開發者可以撰寫一個適用於所有整數與浮點數型別的泛型 min() 函式,或是建立一個能將鍵型別對應到值型別的二元樹(可處理字串、整數或使用者自訂型別)。有了泛型,就能在不重複程式碼的情況下撰寫這類程式,同時讓編譯器依然能進行靜態型別檢查。

如同最早版本的 Java,Go 並未提供使用者自訂的泛型。正如 Go FAQ 所述,泛型「未來很有可能會加入」;同時也說明了當初不加入是經過權衡後的刻意選擇:

泛型雖然方便,但會在型別系統與執行時期帶來複雜度的代價。我們至今尚未找到一個能帶來與其複雜度相稱價值的設計,雖然我們仍在持續思考。同時,Go 內建的 map 與 slice,加上可以使用空介面來建構容器(需明確進行 unboxing)的能力,意味著在許多情況下,仍有可能寫出泛型所能實現的功能,只是沒那麼順暢。

實際使用者之所以沒有強烈抱怨缺乏泛型,部分原因在於 Go 已針對內建的容器型別提供了泛型支援,具體來說就是 slice(Go 中可動態增長的陣列型別)、map(雜湊表)以及 channel(執行緒安全的通訊佇列)。例如,撰寫部落格軟體的開發者可能會寫一個取得最新文章清單或將作者 ID 對應到作者資訊的函式:

    // takes ID, returns "slice of Article" (compiler checks types)
    func GetLatestArticles(num int) []Article {
        ...
    }

    // takes "slice of int" of IDs, returns "map of int IDs to Author"
    func GetAuthors(authorIDs []int) map[int]Author {
        ...
    }

內建函式如 len()append() 可用於這些容器型別,不過開發者無法自行定義類似的泛型內建函式。正如許多 Go 開發者所證實,即使沒有使用者自訂的泛型型別,擁有按型別參數化的可增長陣列與 map 內建版本,已經能滿足很大一部分需求。

此外,Go 還支援兩項常被用來替代泛型或繞過其缺漏的功能:介面與閉包。例如,Go 中的排序是透過 sort.Interface 型別來完成的,這是一個要求實作三個方法的介面:

    type Interface interface {
        Len() int           // length of this collection
        Less(i, j int) bool // true if i'th element < j'th element
        Swap(i, j int)      // swap i'th and j'th elements
    }

如果使用者自訂的集合實作了這個介面,就能使用標準函式庫的 sort.Sort() 函式進行排序。自 Go 1.8 加入 sort.Slice() 後,開發者也可以使用該函式並傳入一個「比較用閉包」,而不必實作完整的排序介面;例如:

    // declare a struct for names and ages and a slice of those structs with four entries
    people := []struct {
        Name string
        Age  int
    }{
        {"Gopher", 7},
        {"Alice", 55},
        {"Vera", 24},
        {"Bob", 75},
    }

    // sort people using the "less-than closure" specified in the call
    sort.Slice(
        people,
        func(i, j int) bool { // i and j are the two slice indices
            return people[i].Name < people[j].Name
        },
    )

還有其他繞過 Go 缺乏泛型的做法,例如建立使用 interface{}(「空介面」)的容器型別。這實際上會將插入集合的每個值都進行 boxing,並需要在執行時期進行型別斷言,因此既沒有效率,也不具型別安全。不過它確實可行,甚至連標準函式庫中的某些型別如 sync.Map 也採用了這種做法。

有些開發者甚至認為根本不該為 Go 加入泛型,因為那會帶來過多的複雜度。例如,Greg Hall 期望Go 永遠不要有泛型,或者如果真的要加入,設計者能找到某種方式,避免我在 Java 泛型與 C++ 樣板中看到的那些複雜度與困難」。

Go 團隊對複雜度問題也相當重視。正如核心開發者 Russ Cox 在其 2009 年的文章「The Generic Dilemma」中所言:

泛型似乎有三種基本的做法:

  1. (C 語言的做法。)不提供泛型。這會讓程式設計師變慢,但不會為語言增加任何複雜度。
  2. (C++ 的做法。)編譯期特化或巨集展開。這會拖慢編譯速度,會產生大量程式碼,其中許多是重複的,需要一個優秀的連結器來消除重複的副本。[...]
  3. (Java 的做法。)將所有東西隱式 boxing。這會拖慢執行速度。[...]

泛型的兩難在於:你想要慢的程式設計師、慢的編譯器與臃腫的二進位檔,還是慢的執行速度?

儘管如此,仍有許多 Go 開發者期待泛型,多年來也針對如何以符合 Go 風格的方式加入泛型,進行了大量的討論。不少開發者已在「經驗報告」中,根據自己使用 Go 的實際經驗提出了深思熟慮的理由。Taylor 在官方 Go 部落格上的文章「Why Generics?」詳細說明了為 Go 加入泛型將帶來的好處,並列出了 Go 團隊在加入泛型時所遵循的準則:

最重要的是,現在的 Go 是一個簡單的語言。Go 程式通常清晰且易於理解。我們長期探索這個領域的一個重要部分,就是試圖理解如何在保持這種清晰與簡潔的同時加入泛型。我們需要找到能很好地融入現有語言、而不會讓它變成截然不同語言的機制。

這些準則應該適用於 Go 中任何泛型的實作。這是我今天最想傳達的重點:泛型能為語言帶來顯著的好處,但只有在 Go 依然保有 Go 的感覺時,才值得去做。

近期提案

Taylor 尤其在為 Go 加入泛型的議題上著墨甚多,至今已撰寫了至少六份提案。前四份寫於 2010 年至 2013 年間,列於他的文件「Go should have generics」底部。關於這些提案,他提到:「全都有各種缺陷」。2019 年 7 月,他發表了上述提到的「Why Generics?」部落格文章,其中連結到由 Taylor 與 Griesemer 撰寫的篇幅很長的 2019 年提案,該提案以「contracts」為基礎來實現泛型。將近一年後,在 2020 年 6 月,Taylor 與 Griesemer 發表了目前的提案,其中不再加入 contracts。用 Taylor 的話來說

泛型的早期草案設計透過一種名為 contracts 的新語言結構來實作約束。型別列表僅出現在 contracts 中,而不是介面型別上。然而,許多人很難理解 contracts 與介面型別之間的差異。後來也發現,contracts 可以表示為一組對應的介面;因此在不使用 contracts 的情況下,並不會損失任何表達能力。我們決定簡化做法,僅使用介面型別。

移除 contracts 的決定,部分是基於 Philip Wadler 及其合作者在 2020 年 5 月發表的論文「Featherweight Go [PDF]」(影片簡報)中的研究成果。Wadler 是一位型別理論學者,曾參與 Haskell 的設計,也曾在 2004 年參與為 Java 加入泛型。Go 的創建者之一 Rob Pike 曾詢問 Wadler 是否願意「幫我們把多型搞對(以及/或釐清什麼才是『對的』)以用於未來某個 Go 版本」;這篇論文就是對 Pike 邀請的回應。

2020 年的提案建議為函式與型別加入可選的型別參數,分別用以支援泛型演算法與泛型容器型別。以下是根據此提案,一個泛型函式的寫法範例:

    // Stringify calls the String method on each element of s,
    // and returns the results.
    func Stringify(type T Stringer)(s []T) []string {
        var ret []string
        for _, v := range s {
            ret = append(ret, v.String())
        }
        return ret
    }

    // Stringer is a type constraint that requires the type argument to have
    // a String method and permits the generic function to call String.
    // The String method should return a string representation of the value.
    type Stringer interface {
        String() string
    }

型別參數是 T(任意名稱),在函式名稱後方額外的一對括號中指定,並加上 Stringer 約束:type T Stringer。函式的實際參數則在第二對括號中,即 s []T。目前在 Go 中還無法這樣撰寫函式;它不允許將具體型別的 slice 傳遞給接受介面型別(例如 Stringer)slice 的函式。

除了泛型函式之外,新提案也支援型別的參數化,以支援如二元樹、圖形資料結構等型別安全的集合。以下是泛型 Vector 型別可能的寫法:

    // Vector is a name for a slice of any element type.
    type Vector(type T) []T

    // Push adds a value to the end of a vector.
    func (v *Vector(T)) Push(x T) {
        *v = append(*v, x)
    }

    // v is a Vector of Authors
    var v Vector(Author)
    v.Push(Author{Name: "Ben Hoyt"})

由於 Go 不支援運算子多載,也未將運算子定義為方法,因此無法透過介面約束來指定某個型別必須支援 < 運算子(舉例來說)。在提案中,這是透過一項名為「type lists」的新功能來實現的,範例如下所示:

    // Ordered is a type constraint that matches any ordered type.
    // An ordered type is one that supports the <, <=, >, and >= operators.
    type Ordered interface {
        type int, int8, int16, int32, int64,
            uint, uint8, uint16, uint32, uint64, uintptr,
            float32, float64,
            string
    }

實務上,標準函式庫中很可能會新增一個 constraints 套件,預先定義像 Ordered 這類常見的約束。Type lists 讓開發者能夠撰寫使用內建運算子的泛型函式:

    // Smallest returns the smallest element in a slice of "Ordered" values.
    func Smallest(type T Ordered)(s []T) T {
        r := s[0]
        for _, v := range s[1:] {
            if v < r { // works due to the "Ordered" constraint
                r = v
            }
        }
        return r
    }

唯一無法以 type list 形式撰寫的約束,是針對 ==!= 運算子的約束,因為 Go 允許對結構、陣列與介面型別進行相等性比較。為了解決這個問題,提案建議加入一個內建的 comparable 約束來支援相等運算子。例如,這在尋找某個值在 slice 或陣列中索引的函式中會很有用:

    // Index returns the index of x in s, or -1 if not found.
    func Index(type T comparable)(s []T, x T) int {
        for i, v := range s {
            // v and x are type T, which has the comparable
            // constraint, so we can use == here.
            if v == x {
                return i
            }
        }
        return -1
    }

Taylor 與 Griesemer 已開發出一個供實驗用的工具(位於 go2go 分支),可將依照此提案撰寫的 Go 程式碼轉換為一般的 Go 程式碼,讓開發者今天就能編譯並執行泛型程式碼。甚至還有一個 Go playground 的版本,讓人們可以在線上分享並執行依此提案撰寫的程式碼——例如,這裡就有一個上述 Stringify() 函式的可執行範例

Go 團隊正邀請開發者嘗試使用泛型實驗工具來解決自身遇到的問題,並針對以下問題提供詳細的回饋:

第一,泛型程式碼是否合理?它感覺像 Go 嗎?人們會遇到哪些令人意外的狀況?錯誤訊息是否有幫助?

第二,我們知道許多人說 Go 需要泛型,但我們不一定清楚那究竟是什麼意思。這個草案設計是否以有用的方式解決了問題?如果有一個讓你覺得「如果 Go 有泛型就能解決」的問題,使用這個工具後是否真的能解決?

討論

自最新提案發布以來,主要的 golang-nuts 郵件論壇上已出現大量關於泛型的公開討論,在 Hacker Newsreddit.com/r/golang 的討論串中也是如此。

正如 Pike 在去年所說 [YouTube],「語法不是問題,至少現在還不是」,然而,郵件論壇上的許多討論串卻立刻對語法大加批評。無可否認,這個語法確實不太尋常,而且它為 Go 又增加了一組(小)括號——Go 本來就以括號多而聞名(例如,Go 的方法定義會為方法的接收者型別使用一組括號,再為方法的參數使用另一組)。提案試圖透過說明為何選擇小括號而非角括號,來預先緩解關於語法的爭論:

在解析函式內的程式碼時,例如 v := F<T>,在看到 < 的當下,很難判斷看到的是型別實例化還是使用了 < 運算子的運算式。要釐清這一點,需要實際上無界的前瞻(lookahead)。一般來說,我們致力於保持 Go 解析器的高效率。

郵件論壇上的大多數回應者都提議使用像 C++、Java 與 C# 那樣的角括號,例如使用 List<T> 而非 List(T)。Taylor 更關心的是新提案的語意是否合理,但他仍耐心地在每一個這類語法討論串中回覆類似以下的內容:

在我們擔心替代方案之前,先看看用建議的語法寫出來的實際程式碼會是什麼樣子。謝謝。

這種情況發生得太頻繁,以至於郵件論壇的參與者 Tyler Compton 整理了一份實用的清單,列出了所有與語法相關的討論串。

泛型將有助於消除為多種型別重複定義的型別與函式,例如 sort 套件中的 sort.Intssort.Float64ssort.Strings。在 Hacker News 上的一則留言中,Kyle Conroy 展示了「一個四行的替代方案,可取代標準函式庫中各種 sql.Null* 型別」:

    type Null(type T) struct {
        Val   T
        Valid bool // Valid is true if Val is not NULL
    }

郵件論壇的參與者 Pee Jai 詢問是否有辦法將型別約束為僅允許結構,但 Taylor 表示這並不可行;他指出泛型並不能解決所有問題」。Robert Engels 表示,在這種情況下無論如何仍需要 reflect 套件

在另一個討論串中,「i3dmaster」詢問了一些關於自訂 map 型別的問題,而 Taylor 澄清說「自訂的容器型別將無法支援 len()range」。集合型別的建立者將無法使用這種特殊語法,而是需要自行定義 Len() 方法,以及自行提供遍歷集合的方式。

Go 核心貢獻者 Bryan Mills 在不少討論串中發表了富有洞見的回覆。他也建立了自己的儲存庫,其中包含他實驗泛型過程中的各種筆記與程式碼範例,包括一篇關於他為何認為 type lists 不甚理想的說明。該儲存庫還包含了各種嘗試使用提案中的泛型來重新實作內建 append() 的範例。

時程

在他們最近的部落格文章中,Taylor 與 Griesemer 明確表示,為語言加入泛型不會是一個快速的過程——他們希望做到正確,並納入社群的回饋:

我們將利用從 Go 社群收集到的回饋來決定如何往下走。如果草案設計獲得良好迴響且不需要重大修改,下一步將會是正式的語言變更提案。為了讓大家有個預期,如果大家對設計草案完全滿意且不需要進一步調整,泛型最早有可能在排定於 2021 年 8 月發布的 Go 1.17 版本中加入。當然,實際上可能會出現未預見的問題,所以這是一個樂觀的時程;我們無法做出任何確定的預測。

我個人猜測,2021 年 8 月(僅一年多後)對於如此規模的功能而言是過於樂觀的。要徵求回饋、反覆調整設計,並以可投入正式環境的方式實作泛型,而非使用目前的 Go-to-Go 轉譯器,將需要相當長的時間。但鑑於至今已有的提案數量與回饋數量,無論泛型最終何時到來,可以確定它將會是一個被廣泛使用(並希望被謹慎使用)的功能。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言