天天看點

Go 中的泛型:激動人心的突破

作者 | Marko Milojevic

譯者 | 王強

策劃 | 劉燕

一個特性改變一切。

在我們選擇的程式設計語言中,我們多長時間會經曆一次根本性的變化?有些語言會變化得更頻繁一些,但還有些語言會比溫布爾登更保守。

Go 語言就屬于後者。有時對我來說它實在太古闆了。“Go 不是這麼寫的!”是我夢到最多的一句話。Go 的多數新版本都隻是對已有方向循序漸進的改善。

一開始,我并不覺得自己喜歡這樣的路徑。沒什麼新鮮事物刺激的話,總是用一種工具遲早會令人厭煩的。有時我甯願看無聊的《與卡戴珊姐妹同行》也不想碰 Go 了。

(開個玩笑。我沒裝電視的一個原因就是想逃離那些可能污染我美麗眼球的電視節目。)

然後……新鮮血液終于來了。去年底,Go 團隊宣布 1.18 版開始支援泛型,這可不是以前那種小打小鬧的改進,也不是什麼對開發人員行為絮絮叨叨的建議和限制。

打起精神來吧,革命來臨了。

那麼,什麼是泛型?

泛型讓我們能在定義接口、函數、結構時參數化類型。泛型不是什麼新概念。我們從古老的 Ada 語言的第一個版本就開始使用它了,後來 C++ 中的模闆也有泛型,直到 Java 和 C# 中的現代實作都是很常見的例子。

不談什麼複雜的定義,我們來看看真實的例子——下面的代碼中,泛型讓我們得以避開許多 Max 或 Min 函數,而是寫成:

隻聲明一個方法,如下所示:

等等,剛剛發生了什麼?其實我們沒有在 Go 中為每種類型都定義一個方法,而是使用了泛型——我們使用泛型類型,參數 T 作為這個方法的參數。通過這個小小的調整,我們就能支援所有 orderable 的類型。參數 T 代表滿足 Ordered 限制的任何類型(稍後我們将讨論限制主題)。是以,一開始我們需要定義 T 是什麼類型。

接下來,我們定義要在何處使用這個參數化類型。這裡,我們确定輸入和輸出參數都是 T 類型。如果我們将 T 定義為整數來執行方法,那麼這裡的所有内容都是整數:

能做的不僅是這些。我們可以提供盡可能多的參數化類型。我們可以将它們配置設定給不同的輸入和輸出參數,随我們的喜好:

這裡我們有三個參數,R、S 和 T。正如我們從限制 any 中看到的那樣(其行為類似于 interface{}),這些類型可以是任何東西。是以現在我們應該清楚了什麼是泛型,以及我們如何在 Go 中使用它們了。下面我們來談談它帶來的激動人心的影響。

如何在本地環境中啟用泛型?

目前 Go 1.18 的穩定版本尚未釋出。是以我們需要做一些調整來在本地對其進行測試。

為了啟用泛型,我使用了 Jetbrains 的 Goland。我在他們的網站上找到了一篇有用的文章,用于設定在 Goland 中運作代碼的環境。

與那篇文章的唯一差別是我使用了帶有 master 分支的 Go 源代碼(https://go.googlesource.com/go),而不是文章中的那個分支。

在 master 分支上,我們可以享用來自标準 Go 庫的新包,Constraints。

速度,我要的是速度

Go 中的泛型與反射是不一樣的。在講一些複雜的例子之前,我們有必要先檢查一下泛型的基準測試分數。從邏輯上講,我們并不指望它的性能接近反射,因為在這種情況下我們不需要泛型。

當然,泛型并不像反射,它也沒打算做成那樣。不過至少在某些用例中,泛型是生成代碼的一種替代方法。

是以,這意味着我們想看到的是基于泛型的代碼與“經典”執行的代碼具有相同的基準測試結果。我們來檢查一個基本案例:

這裡是将一種 Number 類型轉換為另一種的小方法。Number 是我們基于 Go 标準庫中的 Integer 和 Float 限制建構的限制(我們稍後将讨論這個主題)。Number 可以是 Go 中的任何數值類型:從 int 的任何衍生到 uint、float 等等。方法 Trasforms 會以第一個參數化數值類型 S 作為切片基數的切片,并将其轉換為以第二個參數化數字類型 T 作為切片基數的切片。

簡而言之,如果我們想将一個整數切片轉換成一個浮點切片,我們會像在 main 函數中所做的那樣調用這個方法。

我們函數的非泛型替代方法需要一個整數切片并傳回一個浮點切片。是以,這就是我們将在基準測試中測試的内容:

并沒有驚喜。兩種方法的執行時間幾乎一樣,也就是說使用泛型不會影響我們應用程式的性能。但是它對結構(struct)有影響嗎?我們嘗試一下。現在,我們将使用結構并将方法附加到它們上。測試任務沒變——将一個切片轉換為另一個切片:

依舊沒有驚喜。不管使用泛型還是經典實作都不會對 Go 代碼的性能帶來任何影響。是的,我們的确沒有測試太複雜的用例,但如果有顯著差異我們肯定已經看到了才對。是以,我們可以安心了。

約 束

如果我們想測試更複雜的示例,添加任意參數化類型并運作應用程式是不夠的。如果我們決定在沒有任何複雜計算的情況下對一些變量做一個簡單的示例,那麼我們不需要添加什麼特殊的東西:

除了我們的方法 Max 不計算其輸入的最大值而是将它們都傳回之外,上面的示例并沒有什麼奇怪的地方。為此,我們使用一個定義為 interface{}的參數化類型 T。在這個示例中,我們不應将 interface{}視為一種類型,而應将其視為一種限制。我們使用限制來為我們的參數化類型定義規則,并為 Go 編譯器提供一些關于期望的背景知識。

重複一遍:我們在這裡不使用 interface{}作為類型,而是作為限制。我們為參數化類型定義各種規則,在這個例子中該類型必須支援 interface{}所做的任何事情。是以實際上,我們也可以在這裡使用 any 限制。

(老實說,在所有示例中,我更喜歡 interface{}而不是 any,因為我的 Goland IDE 不支援新的保留字(any、comparable),然後我的 IDE 中出現了大量錯誤消息,自動完成也不能用了。)

在編譯時,編譯器可以接受一個限制,并使用它來檢查參數化類型是否支援我們想要在以下代碼中執行的運算符和方法。

由于編譯器在運作時進行大部分優化工作(是以我們就不會影響運作時了,正如我們在基準測試中看到的那樣),它隻允許為特定限制定義的運算符和函數。

是以,要了解限制的重要性,我們來完成 Max 方法的實作并嘗試比較 a 和 b 變量:

當我們嘗試觸發這個應用程式時得到一個錯誤——operator>not defined on T。因為我們将 T 類型定義為 any,是以最終類型可以是任何東西。從這裡開始,編譯器就不知道如何處理這個運算符了。為了解決這個問題,我們需要将參數化類型 T 定義為允許這種運算符的某種限制。感謝 Go 團隊的出色表現,我們已經有了 Constraints 包,它就有這樣的限制。

我們要使用的限制名為 Ordered,調整後的代碼如此優雅:

通過使用 Ordered 限制,我們得到了結果。這個例子的好處是我們可以看到編譯器如何解釋最終類型 T,這取決于我們傳遞給方法的值。我們無需在方括号中定義實際類型,就像前兩種情況一樣,編譯器可以識别用于參數的類型——在 Go 中應該是 int 和 float64。

另一方面,如果我們想使用某些不是預設的類型,比如 int64 或 float32,就應該嚴格在方括号中傳遞這些類型。然後我們确切地編譯器具體該做什麼。

如果需要,我們可以擴充函數 Max 中的功能以支援在數組中搜尋最大值:

在這個例子中我們可以看到兩個有趣的點:

在方括号中定義類型 T 之後,我們可以在函數簽名中以多種不同的方式使用它:簡單類型、切片類型,甚至是映射的一部分。

當我們想要傳回特定類型的零值時,我們可以使用 T(0)。Go 編譯器足夠聰明,可以将零值轉換為所需的類型,例如第一種情況下的空字元串。我們可以看到比較某種類型的值是一種什麼樣的限制。通過 Ordered 限制,我們可以使用定義在整數、浮點數和字元串上的任何運算符。

如果我們想使用運算符 ==,可以使用一個新的保留字 comparable,這是一個僅支援此類運算符的唯一限制:

在上面的示例中,我們可以看到 comparable 限制的用法應該是什麼樣的。同樣,即使沒有在方括号中嚴格定義它們,編譯器也可以識别實際類型。示例中要提到的一點是,我們在兩種不同的方法 Equal 和 Dummy 中為兩種參數化類型使用了相同的字母 T。

每個 T 類型僅在這個方法的作用域(或結構及其方法)中定義,我們不會在其作用域之外談論相同的 T 類型。我們可以用不同的方法重複同一個字母,類型仍然是互相獨立的。

自定義限制

我們可以自定義限制,這很容易。限制可以是我們想要的任何類型,但最好的選擇可能是使用接口:

我們定義了一個 Greeter 接口,以便将它用作 Greetings 方法中的限制。不是為了示範的話,這裡我們可以直接使用 Greeter 類型的變量而不是泛型。

類型集

每個類型都有一個關聯的類型集。普通的非接口類型 T 的類型集隻是包含 T 本身的集合。接口類型的類型集(本節隻讨論普通接口類型,沒有類型清單)是聲明接口所有方法的所有類型的集合。上面的定義來自類型集的提案。它已經加入了 Go 的源代碼,是以我們可以在想要的任何地方使用它。

這一重大變更為我們帶來了很多新的可能性:我們的接口類型也可以嵌入原始類型,如 int、float64、byte 而不僅僅是其他接口。這個特性使我們能夠定義更靈活的限制。

檢查以下示例:

我們定義了 Comparable 限制,而且那種類型看起來有點奇怪,對吧?Go 中使用類型集的新方法允許我們定義一個應該是類型聯合的接口。為了描述兩種類型之間的聯合,我們應該将它們放在接口中,并在它們之間放置一個運算符:|。

是以在我們的示例中,Comparable 接口是以下類型的聯合:rune、float64 和……我猜是 int?是的,它确實是 int,但這裡定義為一個近似元素。

正如你在類型集的提案中看到的那樣,一個近似元素 T 的類型集是類型 T 和所有基礎類型為 T 的類型的類型集。

是以,僅僅因為我們使用了~int 近似元素,我們就可以将 customInt 類型的變量提供給 Compare 方法。如你所見,我們将 customInt 定義為自定義類型,其中 int 是底層類型。

如果我們沒有添加操作符~,編譯器就會抱怨,不會執行應用程式。

我們能走多遠?

我們可以自由翺翔。說真的,這個特性徹底改變了 Go 語言。我的意思是,有許多新代碼在不斷出現。可能這會對依賴代碼生成的那些包産生重大影響,比如 Ent。

從标準庫開始,我已經可以看到許多代碼會在未來的版本中被重構,轉而使用泛型。泛型甚至可能推動一些 ORM 的發展,例如我們在 Doctrine 中看到的一樣。

例如,考慮一個來自 Gorm 包的模型:

想象一下,我們想在 Go 中為兩個模型(ProductGorm 和 UserGorm)實作存儲庫模式。在目前的穩定版本的 Go 中,我們隻能選擇以下某種解決方案:

編寫兩個單獨的存儲庫結構

編寫一個應該使用模闆來建立這兩個存儲庫結構的代碼生成器

決定不使用存儲庫現在有了泛型,我們就能轉向更靈活的方法,可以這樣做:

是以,我們有了 Repository 結構,它有一個參數化類型 T,可以是任何東西。請注意,我們僅在 Repository 類型定義中定義了 T,并且隻是将其配置設定的函數傳遞給它。這裡我們隻能看到 Create 和 Get 兩個方法,隻是為了示範而已。為了讓示範更簡單一些,我們建立兩個單獨的方法來初始化不同的 Repositories:

這兩種方法傳回具有預定義類型的存儲庫執行個體。下面對這個小應用程式進行最終測試:

是的,它能行。一種 Repository 的實作,支援兩種模型。零反射,零代碼生成。我以為我永遠不會在 Go 中看到這樣的東西。我太高興了,眼淚都快流出來了

總 結

毫無疑問,Go 中的泛型是一個巨大的變化,可以迅速改變 Go 的使用方式,而且很快會在 Go 社群中引發許多重構。

雖然我幾乎每天都在玩泛型,想看看我們還能用它做什麼好東西,但我也迫不及待地想在穩定的 Go 版本中看到它們。革命萬歲!

https://levelup.gitconnected.com/generics-in-go-viva-la-revolution-e27898bf5495

繼續閱讀