小心golang中的無型別常數

2023-03-20 15:01:10

對於無型別常數,可能大家是第一次聽說,但這篇我就不放進拾遺系列裡了。

因為雖然名字很陌生,但我們每天都在用,每天都有無數潛在的坑被埋下。包括我本人也犯過同樣的錯誤,當時程式碼已經合併並行布了,當我意識到出了什麼問題的時候為時已晚,最後不得不多了個合併請求留下了丟人的黑歷史。

為什麼我要提這種塵封往事呢,因為最近有朋友遇到了一樣的問題,於是勾起了上面的那些「美好」回憶。於是我決定記錄一下,一來備忘,二來幫大家避坑。

由於涉及各種隱私,朋友提問的程式碼沒法放出來,但我可以給一個簡單的復現程式碼,正如我所說,這個問題是很常見的:

package main

import "fmt"

type S string

const (
    A S = "a"
    B   = "b"
    C   = "c"
)

func output(s S) {
    fmt.Println(s)
}

func main() {
    output(A)
    output(B)
    output(C)
}

這段程式碼能正常編譯並執行,能有什麼問題?這裡我就要提示你一下了,BC的型別是什麼?

你會說他們都是S型別,那你就犯了第一個錯誤,我們用發射看看:

fmt.Println(reflect.TypeOf(any(A)))
fmt.Println(reflect.TypeOf(any(B)))
fmt.Println(reflect.TypeOf(any(C)))

輸出是:

main.S
string
string

驚不驚喜意不意外,常數的型別是由等號右邊的值推匯出來的(iota是例外,但只能處理整型相關的),除非你顯式指定了型別。

所以在這裡B和C都是string。

那真正的問題來了,正如我在這篇所說的,從原型別新定義的型別是獨立的型別,不能隱式轉換和賦值給原型別。

所以這樣的程式碼就是錯的:

func output(s S) {
    fmt.Println(s)
}

func main() {
    var a S = "a" 
    output(a)
}

編譯器會報錯。然而我們最開始的復現程式碼是沒有報錯的:

const (
    A S = "a"
    B   = "b"
    C   = "c"
)

func output(s S) {
    fmt.Println(s)
}

output函數只接受S型別的值,但我們的BC都是string型別的,為什麼這裡可以編譯通過還正常執行了呢?

這就要說到golang的坑點之一——無型別常數了

什麼是無型別常數

這個好理解,定義常數時沒指定型別,那就是無型別常數,比如:

const (
    A S = "a"
    B   = "b"
    C   = "c"
)

這裡A顯式指定了型別,所以不是無型別常數;而B和C沒有顯式指定型別,所以就是無型別常數(untyped constant)。

無型別常數的特性

無型別常數有一些特性和其他有型別的常數以及變數不一樣,得單獨講講。

預設的隱式型別

正如下面的程式碼裡我們看到的:

const (
    A = "a"
    B = 1
    C = 1.0
)

func main() {
    fmt.Println(reflect.TypeOf(any(A))) // string
    fmt.Println(reflect.TypeOf(any(B))) // int
    fmt.Println(reflect.TypeOf(any(C))) // float64
}

雖說我們沒給這些常數指定某個型別,但他們還是有自己的型別,和初始化他們的字面量的預設型別相應,比如整數位面量是int,字串字面量是string等等。

但只有一種情況下他們才會表現出自己的預設型別,也就是在上下文中沒法推斷出這個常數現在應該是什麼型別的時候,比如賦值給空介面。

型別自動匹配

這個名字不好,是我根據它的表現起的,官方的名字叫Representability,直譯過來是「代表性」。

看下這個例子:

const delta = 1 // untyped constant, default type is int
var num int64
num += delta

如果我們把const換成var,程式碼無法編譯,會爆出這種錯誤:invalid operation: num + delta (mismatched types int64 and int)

但為什麼常數可以呢?這就是Representability或者說型別自動匹配在搗鬼。

按照官方的解釋:如果一個無型別常數的值是一個型別T的有效值,那麼這個常數的型別就可以是型別T

舉個例子,int8型別的所有合法的值是[-128, 127),那麼只要值在這個範圍內的整數常數,都可以被轉換成int8。

字串型別同理,所有用字串初始化的無型別常數都可以轉換成字串以及那些基於字串建立的新型別

這就解釋了開頭那段程式碼為什麼沒問題:

type S string

const (
    A S = "a"
    B   = "b"
    C   = "c"
)

func output(s S) {
    fmt.Println(s)
}

func main() {
    output(A) // A 本來就是 S,自然沒問題
    output(B) // B 是無型別常數,預設型別string,可以表示成 S,沒問題
    output(C) // C 是無型別常數,預設型別string,可以表示成 S,沒問題
    // 下面的是有問題的,因為型別自動匹配不會發生在無型別常數和字面量以外的地方
    // s := "string"
    // output(s)
}

也就是說,在有明確給出型別的上下文裡,無型別常數會嘗試去匹配那個目標型別T,如果常數的值符合目標型別的要求,常數的型別就會變成目標型別T。例子裡的delta的型別就會自動變成int64型別。

我沒有去找為什麼golang會這麼設計,在c++、rust和Java裡常數的型別就是從初始化表示式推導或顯式指定的那個型別。

一個猜測是golang的設計初衷想讓常數的行為表現和字面量一樣。除了兩者都有的型別自動匹配,另一個有力證據是golang裡能作為常數的只有那些能做字面型別的型別(字串、整數、浮點數、複數)。

無型別常數的型別自動匹配會帶來很有限的好處,以及很噁心的坑。

無型別常數帶來的便利

便利只有一個,可以少些幾次型別轉換,考慮下面的例子:

const factor = 2

var result int64 = int64(num) * factor / ( (a + b + c) / factor )

這樣複雜的計算表示式在資料分析和影象處理的程式碼裡是很常見的,如果我們沒有自動型別匹配,那麼就需要顯式轉換factor的型別,光是想想就覺得煩人,所以我也就不寫顯式型別轉換的例子了。

有了無型別常數,這種表示式的書寫就沒那麼折磨了。

無型別常數的坑

說完聊勝於無的好處,下面來看看坑。

一種常見的在golang中模擬enum的方法如下:

type ConfigType string

const (
    CONFIG_XML ConfigType = "XML"
    CONFIG_JSON = "JSON"
)

發現上面的問題了嗎,沒錯,只有CONFIG_XMLConfigType型別的!

但因為無型別常數有自動型別匹配,所以你的程式碼目前為止執行起來一點問題也沒有,這也導致你沒發現這個缺陷,直到:

// 給enum加個方法,現在要能獲取常數的名字,以及他們在設定陣列裡的index
type ConfigType string

func (c ConfigType) Name() string {
    switch c {
    case CONFIG_XML:
        return "XML"
    case CONFIG_JSON:
        return "JSON"
    }
    return "invalid"
}

func (c ConfigType) Index() int {
    switch c {
    case CONFIG_XML:
        return 0
    case CONFIG_JSON:
        return 1
    }
    return -1
}

目前為止一切安好,然後程式碼炸了:

fmt.Println(CONFIG_XML.Name())
fmt.Println(CONFIG_JSON.Name()) // !!! error

編譯器不樂意,它說:CONFIG_JSON.Name undefined (type untyped string has no field or method Name)

為什麼呢,因為上下文裡沒明確指定型別,fmt.Println的引數要求都是any,所以這裡用了無型別常數的預設型別。當然在其他地方也一樣,CONFIG_JSON.Name()這個表示式是無法推斷出CONFIG_JSON要匹配成什麼型別的。

這一切只是因為你少寫了一個型別。

這還只是第一個坑,實際上因為只要是目標型別可以接受的值,就可以賦值給目標型別,那麼出現這種程式碼也不奇怪:

const NET_ERR_MESSAGE = "site is unreachable"

func doWithConfigType(t ConfigType)

doWithConfigType(CONFIG_JSON)
doWithConfigType(NET_ERR_MESSAGE) // WTF???

一不小心就能把錯得離譜的引數傳進去,如果你沒想到這點而做好防禦的話,生產事故就理你不遠了。

第一個坑還可以通過把常數定義寫全每個都加上型別來避免,第二個就只能靠防禦式程式設計湊活了。

看到這裡,你也應該猜到我當年闖的是什麼禍了。好在及時發現,最後補全宣告 + 防禦式程式設計在出事故前把問題解決了。

最後也許有人會問,golang實現enum這麼折磨?沒有別的辦法了嗎?

當然有,而且有不少,其中一個比較著名的是stringer: https://pkg.go.dev/golang.org/x/tools/cmd/stringer

這個工具也只能解決一部分問題,但以及比什麼都做不了要強太多了。

總結

無型別常數會自動轉換到匹配的型別,這會帶來意想不到的麻煩。

一點建議:

  1. 如果可以的話,儘量在定義常數時給出型別,尤其是你自定義的型別,int這種看情況可以不寫
  2. 嘗試用工具去生成enum,一定要自己寫過過癮的話記得處理必然存在的例外情況。

這就是golang的大道至簡,簡單它自己,坑都留給你。

參考

https://go.dev/ref/spec