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 的情况
博客索引