從Go 1.18正式引入泛型,再到Go 1.21大量泛型函數/類型進入標準庫開始已經過去了三年。儘管有着不支持類型特化、不支持泛型方法、實現方式有少量運行時開銷、使用指針類型時不夠直觀等限制,泛型編程還是在golang社區和各種項目中遍地開花甚至碩果累累了。
不過也因為泛型功能上的種種限制,大多數代碼中對其的應用仍然只停留在最基本的層面——僅僅減少重複代碼上。但golang泛型的威力遠不止如此,即使不能進行復雜的類型編程,泛型也可以讓你的代碼變得更安全、更健壯。
這篇文章要説的是泛型在強化代碼安全性和健壯性方面的應用。
強化代碼類型安全
第一個應用是強化類型安全,讓類型錯誤儘可能在編譯階段就全部暴露出來。
我手上正好有這樣一個系統,系統裏有A、B、C三種不同類型的消息,我們的系統只接收C類型的消息,也只發送A或者B類型的消息。每種消息都實現了自己的序列化方法,當然為了例子足夠簡潔,這裏我做了很大的簡化:
type A struct {
ID uint64
Name string
}
func (a *A) Encode() string {
return fmt.Sprintf("A: %#v", a)
}
type B struct {
Name string
Age uint32
CompanyID uint32
}
func (b *B) Encode() string {
return fmt.Sprintf("B: %#v", b)
}
type C struct {
RequestID string
Name string
}
func (c *C) Encode() string {
return fmt.Sprintf("C: %#v", c)
}
如果意外發送了C類型的消息,其他的服務會出現錯誤。
A和B類型的消息只是字段不太一樣,發送的邏輯是完全相同的,所以很自然我們為了DRY原則會寫出下面這樣的代碼:
type Encoder interface {
Encode() string
}
func SendMessage(msg Encoder) {
fmt.Println(msg.Encode())
// 其他一些發送數據和校驗的邏輯
}
這是最自然不過的,既然邏輯都一樣,而且A和B的操作確實有一定關聯性,那麼我們就沒必要把發送代碼寫兩遍,定義一個能同時容納A和B的接口,再把接口作為SendMessage的參數類型即可。
這樣的代碼其實是很不安全的,因為C也實現了Encoder接口,所以函數可以錯誤地發送C導致整個系統崩潰。
作為泛型時代之前的解決辦法,我們只能在函數中加上類型斷言或者type switch,但這會帶來不小的運行時開銷,同時也不能避免代碼被誤用,根本原因在於我們不能控制接口被哪些類型實現,因此無法避免一個我們不期望的類型被作為參數傳入。
有了泛型情況就不一樣了,我們現在可以在編譯階段就檢查出所有誤用並且幾乎不需要支付運行時開銷。
然而想實現這個效果會很難,你可能會寫出這樣的代碼:
func SendMessage[T A | B](msg *T) {
fmt.Println(msg.Encode())
}
遺憾的是這樣的代碼會收穫編譯錯誤:msg.Encode undefined (type *T is pointer to type parameter, not type parameter)。這是個常見錯誤了,直接取泛型變量的指針大多數時候都會報這種錯,我以前的博客裏有解釋過原因,這裏不再贅述。
你也許會靈機一動,直接讓T本身是指針類型不就行了嗎:
- func SendMessage[T A | B](msg *T) {
+ func SendMessage[T *A | *B](msg T) {
fmt.Println(msg.Encode())
}
這回確實有變化,只不過是報錯信息變了:msg.Encode undefined (type T has no field or method Encode)。
這是因為golang規定如果泛型的類型約束是具體的類型,那麼允許在泛型對象上執行的只有內置的那些加減乘除以及==、a[123]這樣的操作,並且多個類型之間允許的操作會取交集。很遺憾,方法調用並不在允許的範圍內。對於寫慣了其他語言中泛型代碼的開發者來説,go的這類限制多少有點自廢武功的意味。
好消息是稍微繞一條路,我們也可以達成相同的效果:
type SendAble[T A | B] interface {
*T
Encoder
}
func SendMessage[T A | B, PT SendAble[T]](msg *T) {
ptrMsg := PT(msg)
fmt.Println(ptrMsg.Encode())
}
通過引入新的類型約束SendAble,我們可以限制參數的類型了。SendAble中的*T表示被約束的類型只能是T的指針,而我們限制了T只能是A或者B;第二行則包含了Encoder的方法,這要求這個指針類型也必須實現了這些方法。新代碼中的ptrMsg := PT(msg)則把指針類型轉換成了另一個類型參數PT,PT擁有Encode方法因此可以正常調用,而且編譯器在類型推導中不會把*T當成類型參數的指針,而是實際的類型T的指針,這也避免了最初一版代碼的報錯。
這個模式雖然有些繞,但形式相當固定,因此很容易掌握,你可以當成一些golang的慣用法來看待。現在如果我們傳遞了C類型的變量到函數中,編譯器會報錯:C does not satisfy A | B (C missing in main.A | main.B)。錯誤描述還是多少有點不盡人意,但總比運行時出問題要好得多。
除了代碼稍微複雜了一些,這段代碼本質上是調用了泛型的接口,雖然編譯器做了很多優化,但難免還是會因為golang選擇的泛型實現方式導致一點點的性能下降。不過比起類型斷言來説,這點下降影響往往沒有前者那麼大。
這只是使用泛型保護類型安全的一個比較常見也比較簡單的例子,充分利用泛型特性可以在保證代碼簡潔的同時讓代碼更安全。
保證常量安全
golang中的常量很簡單,類型只能是整數、浮點、字符串或者以這些為底層類型的自定義類型。
對於1、2、3、4、5這樣的數字常量,golang默認都是int類型。大多數時候這都是我們希望的,然而有時候也會帶來煩惱:
func handleOdd(n int)
func handleEven(n int)
假設我們有兩個分別處理奇偶數的函數handleOdd和handleEven,函數參數類型自然只能是int,但int的取值實在是太寬泛了,對於我們的函數來説裏面有接近二分之一的值是不可接受的。
然而除了運行時檢查參數之外,我們並沒有其他的手段避免錯誤的值被傳入函數,儘管這些值很可能是常量,人工檢查一眼就能發現錯誤的那種。
這和上一節提到的interface意外接受錯誤的類型一樣,屬於如何從某個大集合中獲取滿足特定條件的元素的子集,只不過討論的對象從變量變成了常量。
在前泛型時代我們只能靠運行時檢查解決問題,當然在泛型時代因為golang的限制我們也沒法解決上面的奇偶數檢查問題,但對於更具體的實際場景來説,泛型剛好能派上用場。
例子同樣選自生產環境中的系統,這個系統裏有一個請求發送組件,它接受特定格式的數據對象和一個url,通過http請求把數據發送至url,然後再把返回結果存進特定格式的對象裏,偽代碼如下:
type ARequest struct{}
type AResponse struct{}
func (a *ARequest) RequestData() string { return "A Request" }
func (a *AResponse) ResponseData() string { return "A Response" }
type BRequest struct{}
type BResponse struct{}
func (a *BRequest) RequestData() string { return "B Request" }
func (a *BResponse) ResponseData() string { return "B Response" }
type Requester interface {
RequestData() string
}
type Responser interface {
ResponseData() string
}
type Endpoint string
const (
AURL Endpoint = "https://a/api"
BURL Endpoint = "https://b/api"
)
func SendRequest(url Endpoint, req Requester) Resopnser {
//...
}
是的,發送邏輯是單一且固定的,所以我們又使用接口來刪除冗餘代碼,只保留一個泛用的SendRequest函數。但問題在於,url、request和reponse是嚴格配對的,而我們的函數可以接受他們的任意組合,比如我們只允許SendRequest(AURL, &ARequest{}),但即使寫了SendRequest(AURL, &BRequest{})代碼也能正常通過編譯,這會導致系統在運行時崩潰或者更遭的遇到一些難以排查的髒數據問題。這就是常量安全問題最常見的一種體現。
當然你還是可以在運行時通過字符串比較和類型斷言來做校驗,但代碼會很複雜而且有不低的性能開銷。但所有參數我們其實在編譯時就知道了,url都是常量,參數類型和返回值類型也是已知的,只不過編譯器不知道他們之間的配對關係。換句話説,只要我們把常量和類型之間的配對關係以某種方式告訴編譯器,那麼就有機會把這些參數校驗放在編譯時完成,根本不需要付出運行時代價,也不會讓代碼變得過於複雜。
正好泛型編程中的Phantom Type可以解決這種區分常量以及類型配對的問題。
所謂Phantom Type,其實就是把一些簡單的類型泛型化加上類型參數,但這些類型參數只是簡單佔位和該泛型類型的值無關也不參與實際的計算和處理:
type PhantomString[T any] string
type PhantomInt[T, U any] int
PhantomString和PhantomInt仍然可以當做字符串和整形來使用,但因為加上了類型參數,所以即使他們底層的值相同,也會因為類型不同而被視為不同的常量:
const (
AP PhantomString[int] = "hello"
BP PhantomString[int] = "hello"
)
// AP和BP因為有完全不同的類型,所有即使值相同,他們也是不同的
const (
A PhantomInt[int, uint] = 1
B PhantomInt[int, float64] = 1
C PhantomInt[[]rune, string] = 1
)
// 同理ABC也是完全不同的
可以看到類型參數本身和類型的值沒有任何關聯,就像幻影一樣,所以得名Phantom Type。
熟悉Haskell或者c++模板元編程的開發者應該知道這種技巧,通過賦予常量不同的類型,我們可以靠類型系統來區分這些常量。而且泛型允許的類型參數可以有多個,所以我們還能把類型之間的組合關係綁定到這些類型化的常量上。
因此上面的例子可以使用Phantom Type改寫:
// 將URL常量和請求/應答類型進行綁定
type Endpoint[Req Requester, Resp Responser] string
const (
AURL Endpoint[*ARequest, *AResponse] = "https://a/api"
BURL Endpoint[*BRequest, *BResponse] = "https://b/api"
)
func SendMessage[Req Requester, Resp Responser](url Endpoint[Req, Resp], req Req) Resp {
// 可以直接把url轉回string
fmt.Printf("send request to: %s\n", string(url))
var ret Resp
return ret
}
func main() {
ret := SendMessage(AURL, &ARequest{})
fmt.Println(ret.ResponseData())
// 編譯報錯
// SendMessage(AURL, &BRequest{})
}
現在我們為常量綁定了請求和應答的類型,常量傳入函數後編譯器會自動推導出請求參數和返回值必須與常量綁定的類型一致,任何不匹配都會報錯。比如註釋中的表達式:in call to SendMessage, type *BRequest of &BRequest{} does not match inferred type *ARequest for Req。這次的報錯信息也相當清晰。
利用Phantom Type的代碼整體上也遠比運行時檢查清晰簡潔,而且這次我們不會付出任何運行時的性能代價,所有檢查都在編譯代碼時就完成了。
不過有一點需要注意,golang不會自動推導函數返回值的類型,這裏我們通過Endpoint綁定請求/應答類型,能夠讓編譯器推導出所有類型參數,但其他場景下得注意這個限制,有時候需要明確給出所有類型參數才行,這時候代碼可能就沒那麼簡潔了。
總結
本文只是簡單介紹了兩種最常見的泛型增強代碼安全性的用法,實際上還有很多實用技巧等待大家去發現。
核心思想很簡單:利用泛型的類型參數來綁定類型之間的關係,並通過不同的類型來區分不同種類的值。上一節裏把這兩種思想綜合運用之後可以得到既安全又簡潔的代碼。
在即將發佈的 Go 1.26 版本中,泛型的實用性將進一步增強。儘管限制仍然很多,但利用好泛型不僅可以少寫代碼,還可以讓你的代碼安全性更上一層樓。