Go 部落格

常量

Rob Pike
2014 年 8 月 25 日

引言

Go 是一種靜態型別語言,不允許混合數值型別的操作。您不能將 float64 新增到 int 中,甚至不能將 int32 新增到 int 中。然而,編寫 1e6*time.Secondmath.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 時,我們決定透過強制規定數值型別之間不允許混合來避免這個雷區。如果您想新增 iu,則必須明確說明您想要的結果是什麼。假設有

var u uint
var i int

您可以編寫 uint(i)+ui+int(u),這兩種寫法都清楚地表達了加法的含義和型別,但與 C 不同,您不能編寫 i+u。您甚至不能混合使用 intint32,即使在 int 是 32 位型別的情況下也不行。

這種嚴格性消除了 bug 和其他故障的常見原因。它是 Go 的一個重要特性。但它也有代價:有時需要程式設計師用笨拙的數值轉換來裝飾他們的程式碼,以清晰地表達其含義。

那麼常量呢?考慮到上面的宣告,是什麼使得編寫 i = 0u = 0 合法呢? 0型別是什麼?要求常量在諸如 i = int(0) 這樣的簡單上下文中進行型別轉換是不合理的。

我們很快意識到,答案在於讓數值常量的行為不同於它們在其他類似 C 的語言中的行為。經過大量的思考和實驗,我們提出了一種我們認為幾乎總是正確的方案,它使得程式設計師無需一直轉換常量,同時又能夠編寫諸如 math.Sqrt(2) 這樣的程式碼而不會被編譯器責備。

簡而言之,Go 中的常量幾乎總是能夠正常工作。讓我們看看這是如何實現的。

術語

首先,一個簡短的定義。在 Go 中,const 是一個關鍵字,用於引入標量值的名稱,例如 23.14159"scrumptious"。這些值,無論是否命名,在 Go 中都稱為常量。常量也可以透過由常量構建的表示式建立,例如 2+32+3imath.Pi/2("go"+"pher")

有些語言沒有常量,有些語言對常量或單詞 const 的應用有更一般的定義。例如,在 C 和 C++ 中,const 是一個型別限定符,可以編碼更復雜值的更復雜的屬性。

但在 Go 中,常量只是一個簡單、不變的值,從這裡開始,我們只討論 Go。

字串常量

有多種數值常量——整數、浮點數、rune、有符號、無符號、虛數、複數——所以讓我們從一種更簡單的常量形式開始:字串。字串常量易於理解,並提供了一個更小的範圍來探討 Go 中常量的型別問題。

字串常量用雙引號將一些文字括起來。(Go 也有原始字串字面量,用反引號 `` 括起來,但出於本討論的目的,它們具有所有相同的屬性。)這是一個字串常量

"Hello, 世界"

(有關字串表示和解釋的更多詳細資訊,請參閱這篇部落格文章。)

這個字串常量的型別是什麼?顯而易見的答案是 string,但這不對

這是一個無型別字串常量,也就是說,它是一個尚未具有固定型別的常量文字值。是的,它是一個字串,但它不是型別為 string 的 Go 值。即使給它一個名稱,它仍然是一個無型別字串常量

const hello = "Hello, 世界"

在此宣告之後,hello 也是一個無型別字串常量。無型別常量只是一個值,尚未賦予一個定義的型別,從而強制它遵守防止組合不同型別值的嚴格規則。

正是這種無型別常量的概念,使得我們在 Go 中能夠非常自由地使用常量。

那麼,什麼是有型別字串常量呢?它是被賦予了型別的常量,像這樣

const typedHello string = "Hello, 世界"

注意,typedHello 的宣告在等號前面有一個明確的 string 型別。這意味著 typedHello 具有 Go 型別 string,並且不能賦值給不同型別的 Go 變數。也就是說,這段程式碼可以工作


package main

import "fmt"

const typedHello string = "Hello, 世界"

func main() {

    var s string
    s = typedHello
    fmt.Println(s)
}

但這段程式碼不行


package main

import "fmt"

const typedHello string = "Hello, 世界"

func main() {

    type MyString string
    var m MyString
    m = typedHello // Type error
    fmt.Println(m)
}

變數 m 的型別是 MyString,不能被賦給不同型別的值。它只能被賦給型別為 MyString 的值,像這樣


package main

import "fmt"

const typedHello string = "Hello, 世界"

func main() {
    type MyString string
    var m MyString

    const myStringHello MyString = "Hello, 世界"
    m = myStringHello // OK
    fmt.Println(m)
}

或者透過強制進行型別轉換,像這樣


package main

import "fmt"

const typedHello string = "Hello, 世界"

func main() {
    type MyString string
    var m MyString

    m = MyString(typedHello)
    fmt.Println(m)
}

回到我們的無型別字串常量,它有一個很有用的特性:由於它沒有型別,將其賦值給有型別變數不會導致型別錯誤。也就是說,我們可以寫

m = "Hello, 世界"

或者

m = hello

因為,與有型別的常量 typedHellomyStringHello 不同,無型別的常量 "Hello, 世界"hello 沒有型別。將它們賦值給任何與字串相容的型別的變數都可以正常工作,不會出錯。

這些無型別字串常量當然是字串,所以它們只能在允許使用字串的地方使用,但它們不具有 string 型別

預設型別

作為一名 Go 程式設計師,您肯定見過許多像這樣的宣告

str := "Hello, 世界"

現在您可能會問:“如果常量是無型別的,那麼在這個變數宣告中,str 如何獲得型別?”答案是,無型別常量有一個預設型別,如果需要型別但未提供,它會將這個隱式型別傳遞給值。對於無型別字串常量,這個預設型別顯然是 string,所以

str := "Hello, 世界"

或者

var str = "Hello, 世界"

意思是完全相同的

var str string = "Hello, 世界"

一種思考無型別常量的方式是,它們存在於一種理想的值空間中,這種空間比 Go 的完整型別系統限制更少。但要用它們做任何事情,我們需要將它們賦值給變數,當發生這種情況時,變數(而不是常量本身)需要一個型別,而常量可以告訴變數它應該具有什麼型別。在這個例子中,str 成為一個型別為 string 的值,因為無型別字串常量將其預設型別 string 賦予了宣告。

在這樣的宣告中,變數被宣告時指定了型別和初始值。然而,有時當我們使用常量時,值的目的地並不那麼明確。例如,考慮這個語句


package main

import "fmt"

func main() {

    fmt.Printf("%s", "Hello, 世界")
}

fmt.Printf 的函式簽名是

func Printf(format string, a ...interface{}) (n int, err error)

也就是說,它的引數(格式字串之後)是介面值。當用一個無型別常量呼叫 fmt.Printf 時,會建立一個介面值作為引數傳遞,並且為該引數儲存的具體型別是常量的預設型別。這個過程類似於我們前面在使用無型別字串常量宣告初始值時所看到的。

您可以在這個例子中看到結果,它使用格式 %v 來列印值,使用 %T 來列印傳遞給 fmt.Printf 的值的型別


package main

import "fmt"

const hello = "Hello, 世界"

func main() {

    fmt.Printf("%T: %v\n", "Hello, 世界", "Hello, 世界")
    fmt.Printf("%T: %v\n", hello, hello)
}

如果常量有型別,那麼該型別會進入介面,如下例所示


package main

import "fmt"

type MyString string

const myStringHello MyString = "Hello, 世界"

func main() {

    fmt.Printf("%T: %v\n", myStringHello, myStringHello)
}

(有關介面值如何工作的更多資訊,請參閱這篇部落格文章的前幾節。)

總而言之,有型別常量遵守 Go 中有型別值的所有規則。另一方面,無型別常量不以相同的方式攜帶 Go 型別,可以更自由地混合和匹配。然而,它確實有一個預設型別,該型別僅在沒有其他型別資訊可用時才暴露出來。

由語法決定的預設型別

無型別常量的預設型別由其語法決定。對於字串常量,唯一可能的隱式型別是 string。對於數值常量,隱式型別有更多種類。整數常量預設型別為 int,浮點常量預設為 float64,rune 常量預設為 runeint32 的別名),虛數常量預設為 complex128。這是我們反覆使用的標準列印語句,以展示預設型別的實際效果


package main

import "fmt"

func main() {

    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' 的結果。)

布林值

我們關於無型別字串常量所說的一切也適用於無型別布林常量。值 truefalse 是無型別布林常量,可以賦值給任何布林變數,但一旦給定型別,布林變數就不能混合使用


package main

import "fmt"

func main() {

    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”那一行再執行一次。這裡的模式與字串常量完全一致。

浮點數

浮點常量在大多數方面都像布林常量一樣。我們的標準例子在翻譯中按預期工作


package main

import "fmt"

func main() {

    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 中有兩種浮點型別:float32float64。浮點常量的預設型別是 float64,儘管無型別浮點常量也可以很好地賦值給 float32


package main

import "fmt"

func main() {
    const Zero = 0.0
    const TypedZero float64 = 0.0

    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

——畢竟它只是一個數字——但我們不能賦值它,甚至不能列印它。這條語句甚至無法編譯


package main

import "fmt"

func main() {
    const Huge = 1e1000

    fmt.Println(Huge)
}

錯誤是“常量 1.00000e+1000 溢位 float64”,這是事實。但是 Huge 可能有用:我們可以在與其他常量的表示式中使用它,並且如果結果可以在 float64 的範圍內表示,則可以使用這些表示式的值。語句


package main

import "fmt"

func main() {
    const Huge = 1e1000

    fmt.Println(Huge / 1e999)
}

打印出 10,正如預期的那樣。

與此相關的是,浮點常量可能具有非常高的精度,從而使涉及它們的算術運算更加準確。math 包中定義的常量的位數比 float64 可用的位數多得多。以下是 math.Pi 的定義

Pi  = 3.14159265358979323846264338327950288419716939937510582097494459

當該值賦值給變數時,會丟失一些精度;該賦值將建立最接近高精度值的 float64(或 float32)值。這段程式碼


package main

import (
    "fmt"
    "math"
)

func main() {

    pi := math.Pi
    fmt.Println(pi)
}

打印出 3.141592653589793

可用位數如此之多意味著像 Pi/2 或其他更復雜的計算可以保留更多精度,直到結果被賦值,這使得涉及常量的計算更容易編寫而不會丟失精度。這也意味著在常量表達式中不會出現諸如無窮大、軟下溢和 NaN 等浮點數邊界情況。(除以常量零是編譯時錯誤,而且當一切都是數字時,就不存在“不是數字”這樣的東西。)

複數

複數常量的行為很像浮點常量。以下是我們將熟悉的例子翻譯成複數版本


package main

import "fmt"

func main() {

    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.0i1.0i 甚至 1i

我們來玩個小把戲。我們知道在 Go 中,數值常量只是一個數字。如果那個數字是一個沒有虛部的複數,也就是說是一個實數呢?這裡有一個

    const Two = 2.0 + 0i

這是一個無型別複數常量。即使它沒有虛部,表示式的語法也定義了它的預設型別是 complex128。因此,如果我們用它來宣告一個變數,預設型別將是 complex128。這段程式碼


package main

import "fmt"

func main() {
    const Two = 2.0 + 0i

    s := Two
    fmt.Printf("%T: %v\n", s, s)
}

打印出 complex128: (2+0i)。但在數值上,Two 可以儲存在標量浮點數 float64float32 中,而不會丟失資訊。因此,我們可以將 Two 賦值給 float64,無論是在初始化還是賦值時,都不會有問題


package main

import "fmt"

func main() {
    const Two = 2.0 + 0i

    var f float64
    var g float64 = Two
    f = Two
    fmt.Println(f, "and", g)
}

輸出是 2 and 2。儘管 Two 是一個複數常量,但它可以賦值給標量浮點變數。常量這種“跨越”型別的能力將被證明很有用。

整數

最後,我們來看整數。它們有更多的變化部分——多種大小、有符號或無符號,等等——但它們遵循相同的規則。這是我們熟悉的例子,最後一次,這次只使用 int


package main

import "fmt"

func main() {

    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

(加上 byteuint8 的別名,runeint32 的別名)。種類很多,但常量的工作模式應該足夠熟悉了,您可以看出它們將如何表現。

如上所述,整數有幾種形式,每種形式都有自己的預設型別:對於像 1230xFF-14 這樣的簡單常量是 int,對於像 ‘a’、‘世’ 或 ‘\r’ 這樣的帶引號字元是 rune

沒有常量的預設型別是無符號整數型別。然而,無型別常量的靈活性意味著只要我們明確指定型別,就可以使用簡單常量初始化無符號整數變數。這類似於我們如何使用虛部為零的複數初始化 float64。這裡有幾種不同的初始化 uint 的方法;它們都是等價的,但所有方法都必須顯式提及型別,結果才能是無符號的。

var u uint = 17
var u = uint(17)
u := uint(17)

與浮點值部分中提到的範圍問題類似,並非所有整數值都能適應所有整數型別。可能會出現兩個問題:值可能太大,或者值是負數並被賦給無符號整數型別。例如,int8 的範圍是 -128 到 127,因此超出該範圍的常量永遠不能賦值給型別為 int8 的變數


package main

func main() {

    var i8 int8 = 128 // Error: too large.
   _ = i8
}

類似地,uint8,也稱為 byte,範圍是 0 到 255,因此較大的或負的常量不能賦值給 uint8


package main

func main() {

    var u8 uint8 = -1 // Error: negative value.
   _ = u8
}

這種型別檢查可以捕獲像這樣的錯誤


package main

func main() {

    type Char byte
    var c Char = '世' // Error: '世' has value 0x4e16, too large.
   _ = c
}

如果編譯器對您使用常量的方式發出警告,那很可能是像這樣的真實 bug。

一個練習:最大的無符號整數

這是一個很有啟發性的小練習。我們如何表示一個常量,它代表能放入 uint 中的最大值?如果我們討論的是 uint32 而不是 uint,我們可以寫

const MaxUint32 = 1<<32 - 1

但我們想要的是 uint,而不是 uint32intuint 型別擁有相等但未指定位數的位元,可以是 32 位或 64 位。由於可用位元數取決於架構,我們不能直接寫下一個具體的值。

補碼算術的擁躉們(Go 的整數被定義為使用補碼)都知道,-1 的表示形式是所有位都設定為 1,所以 -1 的位模式在內部與最大的無符號整數相同。因此,我們可能認為可以寫


package main

func main() {

    const MaxUint uint = -1 // Error: negative value
}

但這是非法的,因為 -1 不能用無符號變量表示;-1 不在無符號值的範圍內。出於同樣的原因,型別轉換也無濟於事


package main

func main() {

    const MaxUint uint = uint(-1) // Error: negative value
}

儘管在執行時,-1 的值可以轉換為無符號整數,但常量轉換的規則禁止這種編譯時強制轉換。也就是說,這段程式碼可以工作


package main

func main() {

    var u uint
    var v = -1
    u = uint(v)
   _ = u
}

但僅因為 v 是一個變數;如果我們將 v 變為常量,即使是無型別常量,我們也將回到禁止的領域


package main

func main() {

    var u uint
    const v = -1
    u = uint(v) // Error: negative value
   _ = u
}

我們回到之前的方法,但這次不是 -1,而是嘗試 ^0,這是任意數量零位的按位取反。但這也會失敗,原因類似:在數值空間中,^0 代表無限多個 1,所以如果我們將其賦值給任何固定大小的整數,就會丟失資訊


package main

func main() {

    const MaxUint uint = ^0 // Error: overflow
}

那麼我們如何將最大的無符號整數表示為常量呢?

關鍵是將操作限制在 uint 的位數範圍內,並避免使用 uint 中不可表示的值,例如負數。最簡單的 uint 值是帶型別的常量 uint(0)。如果 uint 有 32 位或 64 位,則 uint(0) 分別有 32 或 64 個零位。如果我們對這些位逐個取反,我們將得到正確數量的一位,這就是最大的 uint 值。

因此,我們不對無型別常量 0 的位進行翻轉,而是對有型別常量 uint(0) 的位進行翻轉。那麼,這就是我們的常量


package main

import "fmt"

func main() {

    const MaxUint = ^uint(0)
    fmt.Printf("%x\n", MaxUint)
}

無論當前執行環境中(在playground上是 32 位)表示 uint 需要多少位,這個常量都能正確表示型別為 uint 的變數可以容納的最大值。

如果您理解了得到這個結果的分析過程,那麼您就理解了 Go 中所有關於常量的要點。

數字

Go 中無型別常量的概念意味著所有數值常量,無論是整數、浮點數、複數,甚至字元值,都存在於一種統一的空間中。只有當我們把它們帶入變數、賦值和操作的計算世界時,實際的型別才變得重要。但是隻要我們停留在數值常量的世界裡,我們就可以隨意混合和匹配值。所有這些常量的數值都是 1

1
1.000
1e3-99.0*10-9
'\x01'
'\u0001'
'b' - 'a'
1.0+3i-3.0i

因此,儘管它們具有不同的隱式預設型別,但作為無型別常量,它們可以賦值給任何數值型別的變數


package main

import "fmt"

func main() {

    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

您甚至可以做一些奇怪的事情,比如


package main

import "fmt"

func main() {

    var f = 'a' * 1.5
    fmt.Println(f)
}

這會得到 145.5,這除了證明一個觀點之外毫無意義。

但這些規則真正的意義在於靈活性。這種靈活性意味著,儘管在 Go 中,在同一個表示式中混合浮點變數和整數變數,甚至混合 intint32 變數是違法的,但編寫以下程式碼是沒問題的

sqrt2 := math.Sqrt(2)

或者

const millisecond = time.Second/1e3

或者

bigBufferWithHeader := make([]byte, 512+1e6)

並且結果符合您的預期。

因為在 Go 中,數值常量的行為符合您的預期:就像數字一樣。

下一篇文章:使用 Docker 部署 Go 伺服器
上一篇文章:Go 在 OSCON
部落格索引