對于無型別常量,可能大家是第一次聽說,但這篇我就不放進拾遺系列里了,
因為雖然名字很陌生,但我們每天都在用,每天都有無數潛在的坑被埋下,包括我本人也犯過同樣的錯誤,當時代碼已經合并并發布了,當我意識到出了什么問題的時候為時已晚,最后不得不多了個合并請求留下了丟人的黑歷史,
為什么我要提這種塵封往事呢,因為最近有朋友遇到了一樣的問題,于是勾起了上面的那些“美好”回憶,于是我決定記錄一下,一來備忘,二來幫大家避坑,
由于涉及各種隱私,朋友提問的代碼沒法放出來,但我可以給一個簡單的復現代碼,正如我所說,這個問題是很常見的:
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)
}
這段代碼能正常編譯并運行,能有什么問題?這里我就要提示你一下了,B和C的型別是什么?
你會說他們都是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型別的值,但我們的B和C都是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_XML是ConfigType型別的!
但因為無型別常量有自動型別匹配,所以你的代碼目前為止運行起來一點問題也沒有,這也導致你沒發現這個缺陷,直到:
// 給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
這個工具也只能解決一部分問題,但以及比什么都做不了要強太多了,
總結
無型別常量會自動轉換到匹配的型別,這會帶來意想不到的麻煩,
一點建議:
- 如果可以的話,盡量在定義常量時給出型別,尤其是你自定義的型別,int這種看情況可以不寫
- 嘗試用工具去生成enum,一定要自己寫過過癮的話記得處理必然存在的例外情況,
這就是golang的大道至簡,簡單它自己,坑都留給你,
參考
https://go.dev/ref/spec#Representability
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/547437.html
標籤:Go
