Go 語言中 enum 實現(xiàn)方式有哪些?一定要絕對類型安全嗎?
嗨!大家好,本文 Go 語言小技巧系列的第十二篇,往期文章查看:Go 語言小技巧。
你是否了解過 Go 中的枚舉呢?
枚舉,即 enum,可用于表示一組范圍固定的值,它能助我們寫出清晰、安全的代碼。
以編寫游戲程序為一個簡單案例:游戲中的角色有如戰(zhàn)士、法師或者弓箭手,這種范圍固定的值,就可以用枚舉來表示。
但 Go 中,枚舉的表現(xiàn)方式不像在某些其他語言中那樣直接。我們要想在 Go 中用好枚舉,就要了解 Go 中枚舉的不同表示形式和使用注意點。
使用 iota 和常量
在 Go 中,使用 iota 和常量是最常見的表示枚舉的方式。
什么是 iota?
iota 是 Go 中是一個非常特別的 Keyword,它可以幫助我們按一定規(guī)則創(chuàng)建一系列相關的常量,而無需手動為每個變量單獨賦值。這一點與枚舉的用途天然契合。
不了解上面文字的含義?
讓我們來看一個例子,基于 iota 快速創(chuàng)建特定規(guī)則的常量。
示例代碼,如下所示:
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
例子中,Weekday 類型有 7 個值,分別代表一周的七天。內部值從 0 開始,iota 自動增加賦值給每個常量,從 Sunday 到 Saturday 分別賦值 0-6。
圖片
現(xiàn)在,我們就不用手動給每個常量賦值。
iota 還有很多騷操作,本文目標不在此,就不展開了。
這種方法的優(yōu)點是簡單,提供了一定程度上類型安全,但它也有局限性。
我覺得主要是兩點不足。
首先,對比其他語言的枚舉,它不能直接從字符串轉換到枚舉類型,以上面代碼為例,它不能從 "Sunday" 字符串轉為 Sunday 枚舉值。
其次,它的類型安全不是絕對安全。
如上的 Weekday 類型,我們雖不能將一個明確類型的變量賦值給 Weekday 類型變量:
day := 0 // int
// compiler: cannot use day (variable of type int)
// as Weekday value in variable declaration [IncompatibleAssign]
var sunday Weekday = day
但卻可以將一個非 Weekday 類的字面量賦值給它。
// 字面量 10 賦值給類型為 Weekday 的 day 變量
var day Weekday = 10
很明顯,10 這個數(shù)字并不在 Weekday 的有效范圍,但卻可以有效賦值而并沒有報錯。
圖片
如果是其他枚舉機制完善的 enum 類型的語言,肯定是無法編譯通過的。
除了最基礎的實現(xiàn)方式,我們繼續(xù)看看還有哪些其他表示形式吧。
支持字符串轉化的枚舉值
我們在開發(fā) Web 應用時,常會遇到要將枚舉值以字符串形式表示的需求,特別是在與前端對接時。那么,就讓我們先嘗試實現(xiàn)這一個需求,string 變量與枚舉變量相互轉化。
圖片
這個問題說來簡單,Go 語言中,我們可采用字符串常量作為枚舉值。
示例代碼,如下所示:
type HttpMethod string
const (
Get HttpMethod = "GET"
Post HttpMethod = "POST"
Put HttpMethod = "PUT"
Delete HttpMethod = "DELETE"
)
這種方法簡單直觀,而且也易于與 JSON 等數(shù)據格式轉換。
type Request struct {
Method HttpMethod
URL string
}
func main() {
r := Request{Method: Get, URL: "https://zhihu.com"}
result, _ := json.Marshal(r)
fmt.Printf("%s\n", result)
}
輸出:
{"Method":"GET","URL":"https://zhihu.com"}
當如果我們還想保留原始的 iota 的整型枚舉值,畢竟它更輕量,占用內存空少。這是否可以實現(xiàn)呢?我們嘗試一下吧。
定義如下:
type HttpMethod int
const (
Get HttpMethod = iota
Post
Put
Delete
)
只要在枚舉類型上增加整型值與字符串兩者間相互轉化的方法即可。
代碼如下所示:
// 從 string 轉為 HttpMethod
func NewFromString(method string) HttpMethod {
switch h {
case "Get":
// 省略 ...
case "Delete":
default:
return Get // default is Get or error, panic
}
}
// 從 HttpMethod 轉為 string
func (h HttpMethod) String() string {
switch h {
case Get:
return "Get"
// 省略 ...
default:
return "Unknown" // or error, panic
}
}
我們實現(xiàn)從 string 構造 enum 方法,和從 enum 類型轉為 string 的 String 方法。
這里存在的一個問題,如果希望支持友好的 JSON 序列化反序列化的話,即枚舉用字符串表示,則需要為 HttpMethod 新增方法,實現(xiàn) json.Marshaler和json.Unmarshaler接口,自定義這個轉化過程。
圖片
代碼如下:
// MarshalJSON 實現(xiàn) json.Marshaler 接口
func (h HttpMethod) MarshalJSON() ([]byte, error) {
return json.Marshal(h.String())
}
// UnmarshalJSON 實現(xiàn) json.Unmarshaler 接口
func (h *HttpMethod) UnmarshalJSON(data []byte) error {
var method string
if err := json.Unmarshal(data, &method); err != nil {
return err
}
*h = NewFromString(method)
return nil
}
如果去找一些開源項目,可能會發(fā)現(xiàn)一些實現(xiàn)了這種 enum 的包,你只要通過 iota 定義枚舉類型,從字符串和枚舉間轉化的代碼可通過命令直接生成。
robpike 開發(fā)過一個工具名為 stringer[1],可直接基于類似如上 HttpMethod 定義生成 String() 方法,不過它不是完整的 enum 支持。
//go:generate stringer -type=HttpMethod
type HttpMethod int
const (
Get HttpMethod = iota
Post
Put
Delete
)
我們執(zhí)行 go generate 即可為 HttpMethod 類型生成 String 方法。
go generate
這里有個提前,要單獨安裝下 stringer 命令。
不過,即使到現(xiàn)在,依然存在類型安全的問題,類似 var Hello HttpMethod = 10 這樣的代碼依然有效。
繼續(xù)吧!
結構體枚舉值
GO 中可基于結構體類型,實現(xiàn)枚舉效果。
舉例說明,我們創(chuàng)建一個顏色的枚舉,要求不僅有顏色的名字,還有顏色的 RGB 值。同時,為了方便記錄,我們可以給它加上一個枚舉整型值。
type Color struct {
Index int
Name string
RGB string
}
這樣我們就有了一個顏色的枚舉,每個顏色都有一個索引、名字和 RGB 值。
如何使用呢?
類似于前面的方式,我們直接定義,如下所示:
var (
Red = Color{0, "Red", "#FF0000"}
Green = Color{1, "Green", "#00FF00"}
Blue = Color{2, "Blue", "#0000FF"}
)
這種方法比較靈活,但也相對復雜。
好處也比較明顯,如現(xiàn)在能存儲的信息也更加豐富,前面類似于整型與字符串的各種轉化都變的輕而易舉了。我們直接整型數(shù)值 Color.Index、字符串 Color.Name。
不過,如果要最大化與其他庫結合,如自定義 JSON 轉化規(guī)則,要實現(xiàn) JSON 序列和反序列接口,字符串格式化要實現(xiàn) Stringer 接口等。
還有,這種結構其實不是常量類型的,就存在數(shù)據可更改的問題。不過,有這個安全需求的話,可考慮將成員字段私有化,通過方法變更即可。
結構體類似命名空間效果
在網上看到個有點傻的設計,順便也提一下吧。
假設,我們有很多枚舉類型,擔心可能會出現(xiàn)命名沖突,可以用結構體來創(chuàng)建一個“命名空間”,把相關的枚舉值組織在一起:
示例代碼如下所示:
var Colors = struct {
Red, Green, Blue Color
}{
Red = Color{0, "Red", "#FF0000"}
Green = Color{1, "Green", "#00FF00"}
Blue = Color{2, "Blue", "#0000FF"}
}
上面的例子中定義了 Colors 變量,它是匿名結構體類型,字段名表示顏色,我們可通過 Colors.xxx 形式調用顏色。
我初期看到這個寫法,還以為限定了類型可定義的枚舉值范圍。發(fā)現(xiàn)其實不是,我依然可使用 Color 類型定義新值。
這很不優(yōu)雅,也很雞肋,其實我完全可以新建 package 實現(xiàn)。不過既然發(fā)現(xiàn)了這個方案,就寫到這里吧。
類型安全?
到這里,其實所有實現(xiàn)方式都沒有解決一個問題,那就是定義完枚舉后,依然繼續(xù)添加新的枚舉值。
我真的想實現(xiàn)這樣的能力呢?該如何做呢?
以前面 HttpMethod 為例,我要做的就是禁止通過 HttpMethod(1) 創(chuàng)建新枚舉值。
這不是很簡單嗎?
圖片
我們只要將枚舉實現(xiàn)封裝成一個 package,將類型小寫,如 httpMethod,暴露它的類似 FromString 和其它函數(shù),實現(xiàn)強制通過轉化函數(shù)它。
package httpmethod
type httpmethod string
const (
Get = "Get"
Post = "Post"
)
func FromString(method string) httpmethod {
switch method {
case "Get":
return Get
case "Post":
return Post
}
}
現(xiàn)在,枚舉創(chuàng)建必須通過方法,我們就可以在其中實現(xiàn)限定創(chuàng)建規(guī)則。
方法可能挺好,但好像沒見到這么玩的?
為什么呢?
我的猜想是,開發(fā)時我們不會隨意創(chuàng)建新的枚舉值,對于邊界數(shù)據的傳遞,確保通過轉化函數(shù)處理不就行了嗎?
真實場景
對真實場景下枚舉的使用,有價值之處主要在與其他系統(tǒng)的對接。
圖片
舉例而言,如來自前端 API 或數(shù)據庫,有時可能出現(xiàn)一些異常值。對這類場景,通過前面介紹,可提供轉化函數(shù),在其中設置檢查規(guī)則。如果發(fā)現(xiàn)異常選擇丟棄,執(zhí)行如 error 或 panic。
而對于內部系統(tǒng),如果使用類似于 protobuffer 協(xié)議,可在協(xié)議上限定好枚舉范圍,標記異常數(shù)據等。
當然,可能出現(xiàn)因為發(fā)布時間次序或者兄弟團隊忘記通知等,導致系統(tǒng)間枚舉值對不齊的情況,也會按上面的邏輯丟棄、error 等,便于即使發(fā)現(xiàn)。
對于團隊合作這類場景,最好的解決方式,還是要在設計系統(tǒng)時,考慮上下游的兼容性,而不是每當有變動,全員亂糟糟,這最容易導致生產事故了。
其實無論哪一種情況,重點在于保證進入系統(tǒng)的數(shù)據是否可通過轉化檢測,而不是多此一舉,限制類似于 HttpMethod("Get") 的類型轉化,因為沒有人會這么寫代碼。
總結
Go 語言中,枚舉的表達方式多種多樣。從簡單的 iota 到復雜的結構體方式,每種方法都有其適用場景。作為開發(fā)者,最好是根據自己的具體需求,選擇合適的實現(xiàn)方式。
最后,希望這篇文章能幫助你在使用 Go 語言時,更加靈活且游刃有余地使用枚舉 enum。
博客地址:Go語言中 enum 實現(xiàn)方式有哪些?一定要類型安全嗎?[2]
引用鏈接
[1] stringer: https://pkg.go.dev/golang.org/x/tools/cmd/stringer
[2] Go語言中 enum 實現(xiàn)方式有哪些?一定要類型安全嗎?: https://www.poloxue.com/posts/2024-02-02-how-to-use-enums-type-in-golang/