14 常量:Go在“常量”设计上的创新有哪些?

你好,我是Tony Bai。

在前面几节课中,我们学习了变量以及Go原生支持的基本数据类型,包括数值类型与字符串类型。这两类基本数据类型不仅仅可以被用来声明变量、明确变量绑定的内存块边界,还可以被用来定义另外一大类语法元素:常量

你可能会问:常量有什么好讲的呢?常量不就是在程序生命周期内不会改变的值吗?如果是其他主流语言的常量,可讲的确实不多,但Go在常量的设计上是有一些“创新”的。都有哪些创新呢?我们不妨先来剧透一下。Go语言在常量方面的创新包括下面这几点:

这些创新的具体内容是什么呢?怎么来理解Go常量的这些创新呢?你可以先思考一下,接下来我们再来详细分析。

不过在讲解这些“创新”之前,我们还是要从Go常量的一些基本概念说起,这会有助于我们对Go常量有一个更为深入的理解。

常量以及Go原生支持常量的好处

Go语言的常量是一种在源码编译期间被创建的语法元素。这是在说这个元素的值可以像变量那样被初始化,但它的初始化表达式必须是在编译期间可以求出值来的。

而且,Go常量一旦声明并被初始化后,它的值在整个程序的生命周期内便保持不变。这样,我们在并发设计时就不用考虑常量访问的同步,并且被创建并初始化后的常量还可以作为其他常量的初始表达式的一部分。

我们前面学过,Go是使用var关键字声明变量的。在常量这里,Go语言引入const关键字来声明常量。而且,和var支持单行声明多个变量,以及以代码块形式聚合变量声明一样,const也支持单行声明多个常量,以及以代码块形式聚合常量声明的形式,具体你可以看下面这个示例代码:

const Pi float64 = 3.14159265358979323846 // 单行常量声明

// 以const代码块形式声明常量
const (
    size int64 = 4096
    i, j, s = 13, 14, "bar" // 单行声明多个常量
)

不过,Go语言规范规定,Go常量的类型只局限于前面我们学过的Go基本数据类型,包括数值类型、字符串类型,以及只有两个取值(true和false)的布尔类型。

那常量的引入究竟给Go语言带来什么好处呢?没有对比便没有伤害。让我们先来回顾一下原生不支持常量的C语言的境况。

在C语言中,字面值担负着常量的角色,我们可以使用数值型、字符串型字面值来应对不同场合对常量的需求。

为了不让这些字面值以“魔数(Magic Number)”的形式分布于源码各处,早期C语言的常用实践是使用宏(macro)定义记号来指代这些字面值,这种定义方式被称为宏定义常量,比如下面这些宏:

#define FILE_MAX_LEN 0x22334455
#define PI 3.1415926
#define GO_GREETING "Hello, Gopher"
#define A_CHAR 'a'

使用宏定义常量的习惯一直是C编码中的主流风格,即便后续的C标准中提供了const关键字后也是这样,但宏定义的常量会有很多问题。比如,它是一种仅在预编译阶段进行替换的字面值,继承了宏替换的复杂性和易错性,而且还有类型不安全、无法在调试时通过宏名字输出常量的值,等等问题。

即使我们改用后续C标准中提供的const关键字修饰的标识符,也依然不是一种圆满方案。因为const关键字修饰的标识符本质上依旧是变量,它甚至无法用作数组变量声明中的初始长度(除非用GNU扩展C)。你可以看看下面这个代码,它就存在着这样的问题:

const int size = 5;
int a[size] = {1,2,3,4,5}; // size本质不是常量,这将导致编译器错误

正是因为如此,作为站在C语言等编程语言的肩膀之上诞生的Go语言,它吸取了C语言的教训。Go原生提供的用const关键字定义的常量,整合了C语言中宏定义常量、const修饰的“只读变量”,以及枚举常量这三种形式,并消除了每种形式的不足,使得Go常量是类型安全的,而且对编译器优化友好。

Go在消除了C语言无原生支持的常量的弊端的同时,还针对常量做了一些额外的创新。下面我们就来看第一个创新点:无类型常量

无类型常量

通过前面的学习,我们知道Go语言对类型安全是有严格要求的:即便两个类型拥有着相同的底层类型,但它们仍然是不同的数据类型,不可以被相互比较或混在一个表达式中进行运算。这一要求不仅仅适用于变量,也同样适用于有类型常量(Typed Constant)中,你可以在下面代码中看出这一点:

type myInt int
const n myInt = 13
const m int = n + 5 // 编译器报错:cannot use n + 5 (type myInt) as type int in const initializer

func main() {
    var a int = 5
    fmt.Println(a + n) // 编译器报错:invalid operation: a + n (mismatched types int and myInt)
}

而且,有类型常量与变量混合在一起进行运算求值的时候,也必须遵守类型相同这一要求,否则我们只能通过显式转型才能让上面代码正常工作,比如下面代码中,我们就必须通过将常量n显式转型为int后才能参与后续运算:

type myInt int
const n myInt = 13
const m int = int(n) + 5  // OK

func main() {
    var a int = 5
    fmt.Println(a + int(n))  // 输出:18
}

那么在Go语言中,只有这一种方法能让上面代码编译通过、正常运行吗 ?当然不是,我们也可以使用Go中的无类型常量来实现,你可以看看这段代码:

type myInt int
const n = 13

func main() {
    var a myInt = 5
    fmt.Println(a + n)  // 输出:18
}

你可以看到,在这个代码中,常量n在声明时并没有显式地被赋予类型,在Go中,这样的常量就被称为无类型常量(Untyped Constant)

不过,无类型常量也不是说就真的没有类型,它也有自己的默认类型,不过它的默认类型是根据它的初值形式来决定的。像上面代码中的常量n的初值为整数形式,所以它的默认类型为int。

不过,到这里,你可能已经发现问题了:常量n的默认类型int与myInt并不是同一个类型啊,为什么可以放在一个表达式中计算而没有报编译错误呢?

别急,我们继续用Go常量的第二个创新点,隐式转型来回答这个问题。

隐式转型

隐式转型说的就是,对于无类型常量参与的表达式求值,Go编译器会根据上下文中的类型信息,把无类型常量自动转换为相应的类型后,再参与求值计算,这一转型动作是隐式进行的。但由于转型的对象是一个常量,所以这并不会引发类型安全问题,Go编译器会保证这一转型的安全性。

我们继续以上面代码为例来分析一下,Go编译器会自动将a+n这个表达式中的常量n转型为myInt类型,再与变量a相加。由于变量a的类型myInt的底层类型也是int,所以这个隐式转型不会有任何问题。

不过,如果Go编译器在做隐式转型时,发现无法将常量转换为目标类型,Go编译器也会报错,比如下面的代码就是这样:

const m = 1333333333

var k int8 = 1
j := k + m // 编译器报错:constant 1333333333 overflows int8

这个代码中常量m的值1333333333已经超出了int8类型可以表示的范围,所以我们将它转换为int8类型时,就会导致编译器报溢出错误。

从前面这些分析中,我们可以看到,无类型常量与常量隐式转型的“珠联璧合”使得在Go这样的具有强类型系统的语言,在处理表达式混合数据类型运算的时候具有比较大的灵活性,代码编写也得到了一定程度的简化。也就是说,我们不需要在求值表达式中做任何显式转型了。所以说,在Go中,使用无类型常量是一种惯用法,你可以多多熟悉这种形式。

接下来,我们再来看看Go常量的最后一个重要创新,同样也是常量被应用较为广泛的一个领域:实现枚举

实现枚举

不知道你有没有注意到,在前面讲解Go基本数据类型时,我们并没有提到过枚举类型,这是因为Go语言其实并没有原生提供枚举类型

但是Go开发者对枚举这种类型的需求是现实存在的呀。那这要怎么办呢?其实,在Go语言中,我们可以使用const代码块定义的常量集合,来实现枚举。这是因为,枚举类型本质上就是一个由有限数量常量所构成的集合,所以这样做并没有什么问题。

不过,用Go常量实现枚举可不是我们的临时起意,而是Go设计者们的原创,他们在语言设计之初就希望将枚举类型与常量合二为一,这样就不需要再单独提供枚举类型了,于是他们将Go的前辈C语言中的枚举类型特性移植到常量的特性中并进行了“改良”。

那么接下来,我们就先来回顾一下C语言枚举类型,看看究竟它有哪些特性被移植到Go常量中了。在C语言中,枚举是一个命名的整型常数的集合,下面是我们使用枚举定义的Weekday类型:

enum Weekday {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
};

int main() {
    enum Weekday d = SATURDAY;
    printf("%d\n", d); // 6
}

你运行上面的C语言代码就会发现,其实C语言针对枚举类型提供了很多语法上的便利特性。比如说,如果你没有显式给枚举常量赋初始值,那么枚举类型的第一个常量的值就为0,后续常量的值再依次加1。

你看,上面这个代码中的Weekday枚举类型的所有枚举常量都没有显式赋值,那么第一个枚举常量SUNDAY的值就会被赋值为0,它后面的枚举常量值依次加1,这也是为什么输出的SATURDAY的值为6的原因。

但Go并没有直接继承这一特性,而是将C语言枚举类型的这种基于前一个枚举值加1的特性,分解成了Go中的两个特性:自动重复上一行,以及引入const块中的行偏移量指示器iota,这样它们就可以分别独立使用了。

接下来我们逐一看看这两个特性。首先,Go的const语法提供了“隐式重复前一个非空表达式”的机制,比如下面代码:

const (
    Apple, Banana = 11, 22
    Strawberry, Grape 
    Pear, Watermelon 
)

这个代码里,常量定义的后两行并没有被显式地赋予初始值,所以Go编译器就为它们自动使用上一行的表达式,也就获得了下面这个等价的代码:

const (
    Apple, Banana = 11, 22
    Strawberry, Grape  = 11, 22 // 使用上一行的初始化表达式
    Pear, Watermelon  = 11, 22 // 使用上一行的初始化表达式
)

不过,仅仅是重复上一行显然无法满足“枚举”的要求,因为枚举类型中的每个枚举常量的值都是唯一的。所以,Go在这个特性的基础上又提供了“神器”:iota,有了iota,我们就可以定义满足各种场景的枚举常量了。

iota是Go语言的一个预定义标识符,它表示的是const声明块(包括单行声明)中,每个常量所处位置在块中的偏移值(从零开始)。同时,每一行中的iota自身也是一个无类型常量,可以像前面我们提到的无类型常量那样,自动参与到不同类型的求值过程中来,不需要我们再对它进行显式转型操作。

你可以看看下面这个Go标准库中sync/mutex.go中的一段基于iota的枚举常量的定义:

// $GOROOT/src/sync/mutex.go 
const ( 
    mutexLocked = 1 << iota
    mutexWoken
    mutexStarving
    mutexWaiterShift = iota
    starvationThresholdNs = 1e6
)

这是一个很典型的诠释iota含义的例子,我们一行一行来看一下。

首先,这个const声明块的第一行是mutexLocked = 1 << iota ,iota的值是这行在const块中的偏移,因此iota的值为0,我们得到mutexLocked这个常量的值为1 << 0,也就是1。

接着,第二行:mutexWorken 。因为这个const声明块中并没有显式的常量初始化表达式,所以我们根据const声明块里“隐式重复前一个非空表达式”的机制,这一行就等价于mutexWorken = 1 << iota。而且,又因为这一行是const块中的第二行,所以它的偏移量iota的值为1,我们得到mutexWorken这个常量的值为1 << 1,也就是2。

然后是mutexStarving。这个常量同mutexWorken一样,这一行等价于mutexStarving = 1 << iota。而且,也因为这行的iota的值为2,我们可以得到mutexStarving这个常量的值为 1 << 2,也就是4;

再然后我们看mutexWaiterShift = iota 这一行,这一行为常量mutexWaiterShift做了显式初始化,这样就不用再重复前一行了。由于这一行是第四行,而且作为行偏移值的iota的值为3,因此mutexWaiterShift的值就为3。

而最后一行,代码中直接用了一个具体值1e6给常量starvationThresholdNs进行了赋值,那么这个常量值就是1e6本身了。

看完这个例子的分析,我相信你对于iota就会有更深的理解了。不过我还要提醒你的是,位于同一行的iota即便出现多次,多个iota的值也是一样的,比如下面代码:

const (
    Apple, Banana = iota, iota + 10 // 0, 10 (iota = 0)
    Strawberry, Grape // 1, 11 (iota = 1)
    Pear, Watermelon  // 2, 12 (iota = 2)
)

我们以第一组常量Apple与Banana为例分析一下,它们分为被赋值为iota与iota+10,而且由于这是const常量声明块的第一行,因此两个iota的值都为0,于是就有了“Apple=0, Banana=10”的结果。下面两组变量分析过程也是类似的,你可以自己试一下。

如果我们要略过iota = 0,从iota = 1开始正式定义枚举常量,我们可以效仿下面标准库中的代码:

// $GOROOT/src/syscall/net_js.go
const (
    _ = iota
    IPV6_V6ONLY  // 1
    SOMAXCONN    // 2
    SO_ERROR     // 3
)

在这个代码里,我们使用了空白标识符作为第一个枚举常量,它的值就是iota。虽然它本身没有实际意义,但后面的常量值都会重复它的初值表达式(这里是iota),于是我们真正的枚举常量值就从1开始了。

那如果我们的枚举常量值并不连续,而是要略过某一个或几个值,又要怎么办呢?我们也可以借助空白标识符来实现,如下面这个代码:

const (
    _ = iota // 0
    Pin1
    Pin2
    Pin3
    _
    Pin5    // 5   
)

你可以看到,在上面这个枚举定义中,枚举常量集合中没有Pin4。为了略过Pin4,我们在它的位置上使用了空白标识符。

这样,Pin5就会重复Pin3,也就是向上数首个不为空的常量标识符的值,这里就是iota,而且由于它所在行的偏移值为5,因此Pin5的值也为5,这样我们成功略过了Pin4这个枚举常量以及4这个枚举值。

而且,iota特性让我们维护枚举常量列表变得更加容易。比如我们使用传统的枚举常量声明方式,来声明一组按首字母排序的“颜色”常量,也就是这样:

const ( 
    Black  = 1 
    Red    = 2
    Yellow = 3
)

假如这个时候,我们要增加一个新颜色Blue。那根据字母序,这个新常量应该放在Red的前面呀。但这样一来,我们就需要像下面代码这样将Red到Yellow的常量值都手动加1,十分费力。

const (
    Blue   = 1
    Black  = 2
    Red    = 3
    Yellow = 4
)

那如果我们使用iota重新定义这组“颜色”枚举常量是不是可以更方便呢?我们可以像下面代码这样试试看:

const (
    _ = iota     
    Blue
    Red 
    Yellow     
) 

这样,无论后期我们需要增加多少种颜色,我们只需将常量名插入到对应位置就可以,其他就不需要再做任何手工调整了。

而且,如果一个Go源文件中有多个const代码块定义的不同枚举,每个const代码块中的iota也是独立变化的,也就是说,每个const代码块都拥有属于自己的iota,如下面代码所示:

const (
    a = iota + 1 // 1, iota = 0
    b            // 2, iota = 1
    c            // 3, iota = 2
)

const (
    i = iota << 1 // 0, iota = 0
    j             // 2, iota = 1
    k             // 4, iota = 2
)

你可以看到,每个iota的生命周期都始于一个const代码块的开始,在该const代码块结束时结束。

小结

好了,今天的课讲到这里就结束了。今天我们学习了Go中最常用的一类语法元素:常量

常量是一种在源码编译期间被创建的语法元素,它的值在程序的生命周期内保持不变。所有常量的求值计算都是在编译期完成的,而不是在运行期,这样可以减少运行时的工作,也方便编译器进行编译优化。另外,当操作数是常量表达式时,一些运行时的错误也可以在编译时被发现,例如整数除零、字符串索引越界等。

Go语言原生提供了对常量的支持,所以我们可以避免像C语言那样,使用宏定义常量,这比较复杂,也容易发生错误。而且,Go编译器还为我们提供的类型安全的保证。

接着,我们也学习了无类型常量,这是Go在常量方面的创新。无类型常量拥有和字面值一样的灵活性,它可以直接参与到表达式求值中,而不需要使用显式地类型转换。这得益于Go对常量的另一个创新:隐式转型,也就是将无类型常量的默认类型自动隐式转换为求值上下文中所需要的类型,并且这一过程由Go编译器保证安全性,这大大简化了代码编写。

此外,Go常量还“移植”并改良了前辈C语言的枚举类型的特性,在const代码块中支持自动重复上一行和iota行偏移量指示器。这样我们就可以使用Go常量语法来实现枚举常量的定义。并且,基于Go常量特性的枚举定义十分灵活,维护起来也更为简便。比如,我们可以选择以任意数值作为枚举值列表的起始值,也可以定义不连续枚举常量,添加和删除有序枚举常量时也不需要手工调整枚举的值。

思考题

今天我也给你留了思考题:虽然iota带来了灵活性与便利,但是否存在一些场合,在定义枚举常量时使用显式字面值更为适合呢?你可以思考一下,欢迎在留言区留下你的答案。

感谢你和我一起学习,也欢迎你把这门课分享给更多对Go语言学习感兴趣的朋友。我是Tony Bai,我们下节课见。