Go 部落格
常量
引言
Go 是一種靜態型別語言,不允許混合數值型別進行操作。你不能將 float64
與 int
相加,甚至不能將 int32
與 int
相加。然而,寫出 1e6*time.Second
、math.Exp(1)
甚至 1<<(' '+2.0)
卻是合法的。在 Go 中,常量與變數不同,它們的行為幾乎就像普通的數字。本文將解釋這是為什麼以及它的含義。
背景:C 語言
在 Go 最初的設計構思階段,我們討論了 C 及其衍生語言允許隨意混合使用數值型別所導致的一系列問題。許多神秘的 bug、崩潰和可移植性問題都源於結合了不同大小和“有符號/無符號”的整數的表示式。儘管對於經驗豐富的 C 程式設計師來說,計算結果如
unsigned int u = 1e9;
long signed int i = -1;
... i + u ...
可能會很熟悉,但它並非 *先驗* 就很明顯。結果有多大?它的值是多少?它是有符號還是無符號的?
這裡潛藏著惱人的 bug。
C 語言有一套稱為“通常算術轉換”的規則,而這些規則隨著時間的推移而改變(引入了更多 bug,甚至是追溯性的),這足以說明它們的微妙之處。
在設計 Go 時,我們決定透過強制要求 *不能* 混合使用數值型別來避免這個雷區。如果你想將 i
和 u
相加,你必須明確說明你想要結果是什麼型別。給定
var u uint
var i int
你可以寫成 uint(i)+u
或 i+int(u)
,並且加法的含義和型別都得到了清晰的表達,但與 C 不同的是,你不能寫 i+u
。你甚至不能混合 int
和 int32
,即使 int
是 32 位型別。
這種嚴格性消除了 bugs 和其他故障的一個常見原因。這是 Go 的一個重要特性。但它是有代價的:它有時會要求程式設計師在程式碼中新增笨拙的數字轉換來清晰地表達他們的意圖。
那麼常量呢?鑑於上述宣告,什麼能讓 i
=
0
或 u
=
0
成為合法寫法?0
的 *型別* 是什麼?在 i
=
int(0)
這樣的簡單上下文中要求常量進行型別轉換是不合理的。
我們很快意識到答案在於使數值常量比其他類 C 語言中的行為方式不同。經過大量的思考和實驗,我們提出了一種我們認為幾乎總是感覺恰當的設計,它使程式設計師能夠非常自由地使用常量,而無需總是進行轉換,同時又能寫出 math.Sqrt(2)
這樣的程式碼而不會被編譯器報錯。
簡而言之,Go 中的常量幾乎都能正常工作。讓我們來看看這是如何發生的。
術語
首先,快速定義一下。在 Go 中,const
是一個關鍵字,用於引入標量值的名稱,例如 2
、3.14159
或 "scrumptious"
。這些值,無論是否有名稱,在 Go 中都稱為*常量*。常量也可以透過由常量構建的表示式來建立,例如 2+3
、2+3i
、math.Pi/2
或 ("go"+"pher")
。
有些語言沒有常量,有些語言對常量或 const
關鍵字的應用有更廣泛的定義。例如,在 C 和 C++ 中,const
是一個型別限定符,可以對更復雜的值進行更精細的屬性編碼。
但在 Go 中,常量只是一個簡單、不變的值,從現在開始我們只討論 Go。
字串常量
數值常量有很多種——整數、浮點數、符文、有符號、無符號、虛數、複數——所以讓我們從一種更簡單的常量形式開始:字串。字串常量易於理解,並且提供了一個更小的空間來探索 Go 中常量的型別問題。
字串常量用雙引號括起一些文字。(Go 也有原始字串字面量,用反引號 ``
括起,但就此討論而言,它們都具有相同的屬性。)這是一個字串常量
"Hello, 世界"
(有關字串的表示和解釋的更多細節,請參閱這篇博文。)
這個字串常量有什麼型別?顯而易見的答案是 string
,但這是*錯誤*的。
這是一個*未型別化字串常量*,也就是說,它是一個文字值,還沒有固定的型別。是的,它是字串,但它不是 Go 的 string
型別的值。即使給它命名,它仍然是一個未型別化的字串常量
const hello = "Hello, 世界"
在此宣告之後,hello
也是一個未型別化的字串常量。未型別化常量只是一個值,還沒有被賦予一個確定的型別,該型別會強制它遵守阻止組合不同型別值的嚴格規則。
正是這種*未型別化*常量的概念,使得我們能夠在 Go 中非常自由地使用常量。
那麼,*已型別化*字串常量又是什麼呢?就是已經賦予了型別的常量,如下所示
const typedHello string = "Hello, 世界"
請注意,typedHello
的宣告在等號之前有一個明確的 string
型別。這意味著 typedHello
的 Go 型別是 string
,不能分配給不同型別的 Go 變數。也就是說,這段程式碼是有效的
var s string s = typedHello fmt.Println(s)
但這無效
type MyString string var m MyString m = typedHello // Type error fmt.Println(m)
變數 m
的型別是 MyString
,不能賦給不同型別的值。它只能賦給 MyString
型別的值,如下所示
const myStringHello MyString = "Hello, 世界" m = myStringHello // OK fmt.Println(m)
或者透過強制轉換來解決,如下所示
m = MyString(typedHello) fmt.Println(m)
回到我們的*未型別化*字串常量,它有一個有用的屬性,即因為它沒有型別,所以將其分配給一個已型別變數不會導致型別錯誤。也就是說,我們可以寫
m = "Hello, 世界"
或
m = hello
因為,與已型別常量 typedHello
和 myStringHello
不同,未型別常量 "Hello, 世界"
和 hello
*沒有型別*。將它們分配給任何與字串相容的型別的變數都不會出錯。
這些未型別字串常量當然是字串,所以它們只能在允許字串的地方使用,但它們*不是* string
型別。
預設型別
作為 Go 程式設計師,你肯定見過很多宣告,比如
str := "Hello, 世界"
現在你可能會問:“如果常量是未型別化的,str
在這個變數宣告中是如何獲得型別的?”答案是,未型別化常量有一個預設型別,一個它在需要型別但未提供型別時會隱式傳遞給值的型別。對於未型別字串常量,預設型別顯然是 string
,所以
str := "Hello, 世界"
或
var str = "Hello, 世界"
與...的含義完全相同
var str string = "Hello, 世界"
可以這樣理解未型別常量:它們存在於一種理想的值空間中,一個比 Go 的完整型別系統限制更少 Thus, we can assign Two
to a float64
, either in an initialization or an assignment, without problems的空間。但要對它們做任何事情,我們需要將它們分配給變數,當發生這種情況時,*變數*(而不是常量本身)需要一個型別,而常量可以告訴變數它應該具有什麼型別。在這個例子中,str
成為 string
型別的值,因為未型別字串常量為其宣告提供了預設型別 string
。
在這種宣告中,變數被聲明瞭型別和初始值。但有時我們使用常量時,值的目標不那麼明確。例如,考慮這個語句
fmt.Printf("%s", "Hello, 世界")
fmt.Printf
的簽名是
func Printf(format string, a ...interface{}) (n int, err error)
也就是說,它的引數(格式字串之後)是介面值。當 fmt.Printf
使用未型別常量呼叫時,會建立一個介面值作為引數傳遞,並且為該引數儲存的具體型別是常量的預設型別。這個過程與我們之前在使用未型別字串常量宣告初始化值時所見的過程類似。
你可以在這個示例中看到結果,它使用 %v
格式化列印值,並使用 %T
列印傳遞給 fmt.Printf
的值的型別
fmt.Printf("%T: %v\n", "Hello, 世界", "Hello, 世界") fmt.Printf("%T: %v\n", hello, hello)
如果常量有型別,它就會進入介面,正如這個例子所示
fmt.Printf("%T: %v\n", myStringHello, myStringHello)
(有關介面值如何工作的更多資訊,請參閱這篇博文的前幾節。)
總而言之,已型別常量遵循 Go 中已型別值的所有規則。另一方面,未型別常量不以相同的方式攜帶 Go 型別,可以更自由地混合和匹配。但是,它有一個預設型別,當且僅當沒有其他型別資訊可用時才會暴露。
預設型別由語法確定
未型別常量的預設型別由其語法確定。對於字串常量,唯一可能的隱式型別是 string
。對於數值常量,隱式型別有更多種類。整數常量預設為 int
,浮點數常量預設為 float64
,符文常量預設為 rune
(int32
的別名),虛數常量預設為 complex128
。這裡是我們使用的標準列印語句,反覆用於展示預設型別的實際應用
fmt.Printf("%T %v\n", 0, 0) fmt.Printf("%T %v\n", 0.0, 0.0) fmt.Printf("%T %v\n", 'x', 'x') fmt.Printf("%T %v\n", 0i, 0i)
(練習:解釋 'x'
的結果。)
布林值
關於未型別字串常量所說的一切,都可以適用於未型別布林常量。值 true
和 false
是未型別布林常量,可以分配給任何布林變數,但一旦賦予了型別,布林變數就不能混合使用
type MyBool bool const True = true const TypedTrue bool = true var mb MyBool mb = true // OK mb = True // OK mb = TypedTrue // Bad fmt.Println(mb)
執行示例並檢視結果,然後註釋掉“Bad”行再次執行。這裡的模式與字串常量完全一致。
浮點數
大多數情況下,浮點數常量與布林常量相似。我們標準的例子在翻譯後效果符合預期
type MyFloat64 float64 const Zero = 0.0 const TypedZero float64 = 0.0 var mf MyFloat64 mf = 0.0 // OK mf = Zero // OK mf = TypedZero // Bad fmt.Println(mf)
一個細微之處是,Go 中有*兩種*浮點型別:float32
和 float64
。浮點常量的預設型別是 float64
,儘管未型別浮點常量可以很好地分配給 float32
值
var f32 float32 f32 = 0.0 f32 = Zero // OK: Zero is untyped f32 = TypedZero // Bad: TypedZero is float64 not float32. fmt.Println(f32)
浮點值是引入溢位概念或值範圍的好地方。
數值常量存在於任意精度數值空間中;它們就是普通的數字。但當它們被分配給變數時,值必須能夠適應目標。我們可以用一個非常大的值宣告一個常量
const Huge = 1e1000
——畢竟,那只是一個數字——但我們無法分配或甚至列印它。這個語句甚至無法編譯
fmt.Println(Huge)
錯誤是,“constant 1.00000e+1000 overflows float64”,這是事實。但是 Huge
可能有用:我們可以將它與其它常量一起用在表示式中,並且如果結果可以在 float64
的範圍內表示,就可以使用這些表示式的值。語句
fmt.Println(Huge / 1e999)
列印 10
,正如預期的那樣。
同樣,浮點數常量可以具有非常高的精度,因此涉及它們的算術運算會更準確。math
包中定義的常量,其精度遠高於 float64
可用的精度。這是 math.Pi
的定義
Pi = 3.14159265358979323846264338327950288419716939937510582097494459
當該值被分配給變數時,一些精度會丟失;賦值會建立最接近高精度值的 float64
(或 float32
)值。這段程式碼片段
pi := math.Pi fmt.Println(pi)
列印 3.141592653589793
。
擁有如此多的可用數字意味著像 Pi/2
這樣的計算或其他更復雜的求值可以在結果分配之前保持更高的精度,從而使涉及常量的計算更容易編寫而不損失精度。這也意味著浮點數的一些特殊情況,如無窮大、軟下溢和 NaN
,在常量表達式中不會出現。(除以常量零是編譯時錯誤,並且當一切都是數字時,就沒有“非數字”這回事了。)
複數
複數常量在很多方面與浮點數常量非常相似。這是我們現在熟悉的敘述在複數上的翻譯版本
type MyComplex128 complex128 const I = (0.0 + 1.0i) const TypedI complex128 = (0.0 + 1.0i) var mc MyComplex128 mc = (0.0 + 1.0i) // OK mc = I // OK mc = TypedI // Bad fmt.Println(mc)
複數的預設型別是 complex128
,這是由兩個 float64
值組成的高精度版本。
為了在我們舉例的清晰度,我們寫出了完整的表示式 (0.0+1.0i)
,但這個值可以縮寫為 0.0+1.0i
、1.0i
甚至 1i
。
我們來玩個把戲。我們知道在 Go 中,數值常量只是一個數字。如果那個數字是一個虛部為零的複數,也就是說,一個實數呢?這裡有一個
const Two = 2.0 + 0i
這是一個未型別的複數常量。即使它沒有虛部,表示式的*語法*將其定義為預設型別 complex128
。因此,如果我們用它來宣告一個變數,預設型別將是 complex128
。程式碼片段
s := Two fmt.Printf("%T: %v\n", s, s)
列印 complex128:
(2+0i)
。但從數值上看,Two
可以儲存在標量浮點數 float64
或 float32
中,而不會丟失資訊。因此,我們可以毫無問題地將 Two
分配給 float64
,無論是初始化還是賦值
var f float64 var g float64 = Two f = Two fmt.Println(f, "and", g)
輸出是 2
and
2
。即使 Two
是一個複數常量,它也可以分配給標量浮點變數。這種常量能夠“跨越”型別的能力很有用。
整數
最後我們來看整數。它們有更多的組成部分——許多大小、有符號或無符號,以及更多——但它們遵循相同的規則。最後一次,這是我們熟悉的例子,這次只使用 int
type MyInt int const Three = 3 const TypedThree int = 3 var mi MyInt mi = 3 // OK mi = Three // OK mi = TypedThree // Bad fmt.Println(mi)
同樣的例子可以為任何整數型別構建,它們是
int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64
uintptr
(加上 byte
作為 uint8
的別名,以及 rune
作為 int32
的別名)。這有很多,但常量工作方式的模式應該足夠熟悉了,你可以看到事情會如何發展。
如上所述,整數有兩種形式,每種形式都有自己的預設型別:int
用於簡單的常量,如 123
、0xFF
或 -14
;rune
用於引用的字元,如 ‘a’、‘世’ 或 ‘\r’。
沒有哪種常量形式的預設型別是無符號整數型別。但是,未型別常量的靈活性意味著我們可以使用簡單的常量來初始化無符號整數變數,只要我們清楚型別。這類似於我們如何用一個虛部為零的複數來初始化 float64
。以下是初始化 uint
的幾種不同方式;它們都等效,但為了得到無符號結果,都必須明確提及型別。
var u uint = 17
var u = uint(17)
u := uint(17)
與浮點數值部分提到的範圍問題類似,並非所有整數值都能放入所有整數型別。可能出現兩個問題:值可能太大,或者負值被賦給無符號整數型別。例如,int8
的範圍是 -128 到 127,因此超出該範圍的常量永遠不能賦給 int8
型別的變數
var i8 int8 = 128 // Error: too large.
同樣,uint8
(也稱為 byte
)的範圍是 0 到 255,因此不能將大常量或負常量賦給 uint8
var u8 uint8 = -1 // Error: negative value.
這種型別檢查可以捕獲類似的錯誤
type Char byte var c Char = '世' // Error: '世' has value 0x4e16, too large.
如果編譯器抱怨你使用了常量,那很可能是一個像這樣的真實 bug。
一個練習:最大的無符號整數
這是一個資訊豐富的小練習。如何表示一個代表 uint
最大值的常量?如果我們討論的是 uint32
而不是 uint
,我們可以寫
const MaxUint32 = 1<<32 - 1
但我們想要 uint
,而不是 uint32
。int
和 uint
型別具有相同的未指定位數,可能是 32 或 64。由於可用位數取決於架構,我們無法只寫一個值。
熟悉二進位制補碼算術(Go 的整數就是用它定義的)的人知道,-1
的表示形式是將所有位設定為 1,因此 -1
的位模式在內部與最大的無符號整數的位模式相同。因此,我們可能會認為可以寫
const MaxUint uint = -1 // Error: negative value
但這是非法的,因為 -1 不能被無符號變量表示;-1
不在無符號值的範圍內。轉換也無濟於事,原因相同
const MaxUint uint = uint(-1) // Error: negative value
儘管在執行時可以將 -1 的值轉換為無符號整數,但常量轉換的規則禁止這種編譯時強制轉換。也就是說,以下是有效的
var u uint var v = -1 u = uint(v)
但那是因為 v
是一個變數;如果我們把 v
變成一個常量,即使是未型別化的常量,我們也會回到禁區
var u uint const v = -1 u = uint(v) // Error: negative value
我們回到之前的方法,但使用 ^0
代替 -1
,這是對任意數量零位的按位取反。但這同樣失敗了:在數值空間中,^0
表示無限數量的 1,如果我們將其分配給任何固定大小的整數,就會丟失資訊
const MaxUint uint = ^0 // Error: overflow
那麼,如何表示最大的無符號整數作為常量呢?
關鍵在於將操作限制在 uint
的位數範圍內,並避免那些不可在 uint
中表示的值,例如負數。最簡單的 uint
值是已型別常量 uint(0)
。如果 uint
有 32 位或 64 位,uint(0)
相應地就有 32 位或 64 個零位。如果我們反轉這些位中的每一個,我們將得到正確數量的 1 位,這就是最大的 uint
值。
因此,我們不翻轉未型別常量 0
的位,而是翻轉已型別常量 uint(0)
的位。因此,這裡是我們的常量
const MaxUint = ^uint(0) fmt.Printf("%x\n", MaxUint)
在當前執行環境中表示 uint
所需的位數是多少(在playground 上是 32 位),該常量就能正確表示 uint
型別變數可以容納的最大值。
如果你理解了得出此結果的分析,你就理解了 Go 中關於常量所有重要要點。
數字
Go 中未型別常量的概念意味著所有數值常量,無論是整數、浮點數、複數,甚至是字元值,都存在於一種統一的空間中。只有當我們把它們帶入變數、賦值和運算的計算世界時,實際型別才變得重要。但只要我們停留在數值常量的世界裡,我們就可以隨意混合和匹配值。所有這些常量的值都是 1
1
1.000
1e3-99.0*10-9
'\x01'
'\u0001'
'b' - 'a'
1.0+3i-3.0i
因此,儘管它們具有不同的隱式預設型別,但以未型別常量形式編寫時,它們可以被賦給任何數值型別的變數
var f float32 = 1 var i int = 1.000 var u uint32 = 1e3 - 99.0*10.0 - 9 var c float64 = '\x01' var p uintptr = '\u0001' var r complex64 = 'b' - 'a' var b byte = 1.0 + 3i - 3.0i fmt.Println(f, i, u, c, p, r, b)
此程式碼片段的輸出是:1 1 1 1 1 (1+0i) 1
。
你甚至可以做一些瘋狂的事情,比如
var f = 'a' * 1.5 fmt.Println(f)
得到 145.5,這除了證明觀點外毫無意義。
但這些規則的真正要點是靈活性。這種靈活性意味著,儘管在 Go 中,在一個表示式中混合浮點數和整數變數,或者甚至 int
和 int32
變數是非法的,但寫出
sqrt2 := math.Sqrt(2)
或
const millisecond = time.Second/1e3
或
bigBufferWithHeader := make([]byte, 512+1e6)
並且讓結果如你所願是有意義的。
因為在 Go 中,數值常量就像你期望的那樣工作:像數字一樣。
下一篇文章: 使用 Docker 部署 Go 伺服器
上一篇文章: Go 在 OSCON 的情況
部落格索引