一篇學會Go 語言類型可比性
前段時間,一位讀者私信了我一個 Go 代碼例子,并問我這是不是一個 bug。我覺得蠻有意思的,故整理出了本文的分享內容。
在討論代碼之前,讀者需要有一些前置知識。
Go 可比較類型
在 Go 中,數據類型可以被分為兩類,可比較與不可比較。兩者區分非常簡單:類型是否可以使用運算符 == 和 != 進行比較。
那哪些類型是可比較的呢?
- Boolean(布爾值)、Integer(整型)、Floating-point(浮點數)、Complex(復數)、String(字符)這些類型是毫無疑問可以比較的。
- Poniter (指針) 可以比較:如果兩個指針指向同一個變量,或者兩個指針類型相同且值都為 nil,則它們相等。注意,指向不同的零大小變量的指針可能相等,也可能不相等。
- Channel (通道)具有可比性:如果兩個通道值是由同一個 make 調用創建的,則它們相等。
- c1 := make(chan int, 2)
- c2 := make(chan int, 2)
- c3 := c1
- fmt.Println(c3 == c1) // true
- fmt.Println(c2 == c1) // false
- Interface (接口值)具有可比性:如果兩個接口值具有相同的動態類型和相等的動態值,則它們相等。
- 當類型 X 的值具有可比性且 X 實現 T 時,非接口類型 X 的值 x 和接口類型 T 的值 t 具有可比性。如果 t 的動態類型與 X 相同且 t 的動態值等于 x,則它們相等。
- 如果所有字段都具有可比性,則 struct (結構體值)具有可比性:如果它們對應的非空字段相等,則兩個結構體值相等。
- 如果 array(數組)元素類型的值是可比較的,則數組值是可比較的:如果它們對應的元素相等,則兩個數組值相等。
哪些類型是不可比較的?
- slice、map、function 這些是不可以比較的,但是也有特殊情況,那就是當他們值是 nil 時,可以與 nil 進行比較。
動態類型
在上文接口可比性中,我們提到了動態類型與動態值,這里需要介紹一下。
常規變量(非接口)的類型是由聲明所定義,這是靜態類型,例如 var x int。
接口類型的變量有一個獨特的動態類型,它是運行時存儲在變量中的值的實際類型。動態類型在執行過程中可能會有所不同,但始終可以分配給接口變量一個靜態類型。
例如
- var someVariable interface{} = 101
someVariable 變量的靜態類型是 interface{},但是它的動態類型是 int,并且很可能在之后發生變化。
- var someVariable interface{} = 101
- someVariable = 'Gopher'
如上, someVariable 變量的動態類型從 int 變為了 string。
代碼場景示例
我們為當前業務所需的數據模型定義一個結構體 Data,它包含兩個字段:一個 string 類型的 UUID 和 interface{} 類型的 Content。
- type Data struct {
- UUID string
- Content interface{}
- }
根據上文介紹, string 類型和 interface 是可比較類型,那么兩個 Data 類型的數據,我們可以通過 == 操作符進行比較。
- var x, y Data
- x = Data{
- UUID: "856f5555806443e98b7ed04c5a9d6a9a",
- Content: 1,
- }
- y = Data{
- UUID: "745dee7719304991862e6985ea9c02a9",
- Content: 2,
- }
- fmt.Println(x == y)
但是,如果在 Content 中的動態類型是 map 會怎樣?
- var m, n Data
- m = Data{
- UUID: "9584dba3fe26418d86252d71a5d78049",
- Content: map[int]string{1: "GO", 2: "Python"},
- }
- n = Data{
- UUID: "9584dba3fe26418d86252d71a5d78049",
- Content: map[int]string{1: "GO", 2: "Python"},
- }
- fmt.Println(m == n)
此時,我們程序編譯通過,但會發生運行時錯誤。
- panic: runtime error: comparing uncomparable type map[int]string
那針對這種需求:即對于不可比較類型,因為不能使用比較操作符 ==,但我們想要比較它們包含的值是否相等時,應該怎么辦。
此時我們可以采用 reflect.DeepEqual 函數進行比較,即將上述的 m==n 替換
- fmt.Println(reflect.DeepEqual(m,n)) // true
我們得出結論:如果我們的變量中包含不可比較類型,或者 interface 類型(它的動態類型可能存在不可比較的情況),那么我們直接運用比較運算符 == ,會引發程序錯誤。此時應該選用 reflect.DeepEqual 函數(當然也有特殊情況,例如 []byte,可以通過 bytes. Equal 函數進行比較)。
Bug 代碼?
好,鋪墊了這么久,終于可以展示讀者給我的代碼了。
- var x, y Data
- x = Data{
- UUID: "856f5555806443e98b7ed04c5a9d6a9a",
- Content: 1,
- }
- bytes, _ := json.Marshal(x)
- _ = json.Unmarshal(bytes, &y)
- fmt.Println(x) // {856f5555806443e98b7ed04c5a9d6a9a 1}
- fmt.Println(y) // {856f5555806443e98b7ed04c5a9d6a9a 1}
- fmt.Println(reflect.DeepEqual(x, y)) // false
對于同一個原始數據,經過 json 的 Marshal 和 Unmarshal 過程后,竟然不相等了?難道有 bug?
不慌,這種時候,我們直接上調試看看。
debug
原來此 1 非彼 1,Content 字段的數據類型由 int 轉換為了 float64 。而在接口中,其動態類型不一致時,它的比較是不相等的。
經過排查,發現問題就出在 Unmarshal 函數上:如果要將 Json 對象 Unmarshal 為接口值,那么它的類型轉換規則如下
Unmarshal
可以看到,數值型的 json 解析操作統一為了 float64。
因此,如果我們將 Content: 1 改為 Content: 1.0 ,那么它 reflect.DeepEqual(x, y) 的值將是 true。
增強型 DeepEqual 函數
針對 json 解析的這種類型改變特性,我們可以基于 reflect.DeepEqual 函數進行改造適配。
- func DeepEqual(v1, v2 interface{}) bool {
- if reflect.DeepEqual(v1, v2) {
- return true
- }
- bytesA, _ := json.Marshal(v1)
- bytesB, _ := json.Marshal(v2)
- return bytes.Equal(bytesA, bytesB)
- }
當我們使用增強后的函數來運行上述的 “bug” 例子
- var x, y Data
- x = Data{
- UUID: "856f5555806443e98b7ed04c5a9d6a9a",
- Content: 1,
- }
- b, _ := json.Marshal(x)
- _ = json.Unmarshal(b, &y)
- fmt.Println(DeepEqual(x, y)) // true
此時,結果符合我們的預期。
結論
本文討論了 Go 的可比較與不可比較類型,并對靜態、動態類型進行了闡述。
不可比較類型包括 slice、map、function,它們不能使用 == 進行比較。雖然我們可以通過 == 操作符對 interface 進行比較,由于動態類型的存在,如果實現 interface 的 T 有不可比較類型,這將引起運行時錯誤。
在不能確定 interface 的實現類型的情況下,對 interface 的比較,可以使用 reflect.DeepEqual 函數。
最后,我們通過 json 庫的解析與反解析過程中,發現了 json 解析存在數據類型轉換操作。這一個細節,讀者在使用過程中需要注意,以免產生想法“這代碼有 bug” 。
參考
https://golang.org/ref/spec#Comparison_operators
https://golang.org/ref/spec#Types
https://pkg.go.dev/encoding/json#Unmarshal