小心golang中的無類型常量

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

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

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

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

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#Representability

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章