You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

344 lines
19 KiB
Markdown

2 years ago
# 14常量Go在“常量”设计上的创新有哪些
你好我是Tony Bai。
在前面几节课中我们学习了变量以及Go原生支持的基本数据类型包括数值类型与字符串类型。这两类基本数据类型不仅仅可以被用来声明变量、明确变量绑定的内存块边界还可以被用来定义另外一大类语法元素**常量**。
你可能会问常量有什么好讲的呢常量不就是在程序生命周期内不会改变的值吗如果是其他主流语言的常量可讲的确实不多但Go在常量的设计上是有一些“创新”的。都有哪些创新呢我们不妨先来剧透一下。Go语言在常量方面的创新包括下面这几点
* 支持无类型常量;
* 支持隐式自动转型;
* 可用于实现枚举。
这些创新的具体内容是什么呢怎么来理解Go常量的这些创新呢你可以先思考一下接下来我们再来详细分析。
不过在讲解这些“创新”之前我们还是要从Go常量的一些基本概念说起这会有助于我们对Go常量有一个更为深入的理解。
## 常量以及Go原生支持常量的好处
Go语言的常量是一种在源码编译期间被创建的语法元素。这是在说这个元素的值可以像变量那样被初始化但它的初始化表达式必须是在编译期间可以求出值来的。
而且Go常量一旦声明并被初始化后它的值在整个程序的生命周期内便保持不变。这样我们在并发设计时就不用考虑常量访问的同步并且被创建并初始化后的常量还可以作为其他常量的初始表达式的一部分。
我们前面学过Go是使用var关键字声明变量的。在常量这里Go语言引入const关键字来声明常量。而且和var支持单行声明多个变量以及以代码块形式聚合变量声明一样const也支持单行声明多个常量以及以代码块形式聚合常量声明的形式具体你可以看下面这个示例代码
```plain
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定义记号来指代这些字面值这种定义方式被称为**宏定义常量**,比如下面这些宏:
```plain
#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。你可以看看下面这个代码它就存在着这样的问题
```plain
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你可以在下面代码中看出这一点
```plain
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后才能参与后续运算
```plain
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中的无类型常量来实现你可以看看这段代码
```plain
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编译器也会报错比如下面的代码就是这样
```plain
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类型
```plain
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语法提供了“隐式重复前一个非空表达式”的机制**,比如下面代码:
```plain
const (
Apple, Banana = 11, 22
Strawberry, Grape
Pear, Watermelon
)
```
这个代码里常量定义的后两行并没有被显式地赋予初始值所以Go编译器就为它们自动使用上一行的表达式也就获得了下面这个等价的代码
```plain
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的枚举常量的定义
```plain
// $GOROOT/src/sync/mutex.go
const (
mutexLocked = 1 << iota
mutexWoken
mutexStarving
mutexWaiterShift = iota
starvationThresholdNs = 1e6
)
```
这是一个很典型的诠释iota含义的例子我们一行一行来看一下。
首先这个const声明块的第一行是mutexLocked = 1 << iota iotaconstiota0mutexLocked1 << 01
接着第二行mutexWorken 。因为这个const声明块中并没有显式的常量初始化表达式所以我们根据const声明块里“隐式重复前一个非空表达式”的机制这一行就等价于mutexWorken = 1 << iotaconstiota1mutexWorken1 << 12
然后是mutexStarving。这个常量同mutexWorken一样这一行等价于mutexStarving = 1 << iotaiota2mutexStarving 1 << 24;
再然后我们看mutexWaiterShift = iota 这一行这一行为常量mutexWaiterShift做了显式初始化这样就不用再重复前一行了。由于这一行是第四行而且作为行偏移值的iota的值为3因此mutexWaiterShift的值就为3。
而最后一行代码中直接用了一个具体值1e6给常量starvationThresholdNs进行了赋值那么这个常量值就是1e6本身了。
看完这个例子的分析我相信你对于iota就会有更深的理解了。不过我还要提醒你的是位于同一行的iota即便出现多次多个iota的值也是一样的比如下面代码
```plain
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开始正式定义枚举常量我们可以效仿下面标准库中的代码
```plain
// $GOROOT/src/syscall/net_js.go
const (
_ = iota
IPV6_V6ONLY // 1
SOMAXCONN // 2
SO_ERROR // 3
)
```
在这个代码里我们使用了空白标识符作为第一个枚举常量它的值就是iota。虽然它本身没有实际意义但后面的常量值都会重复它的初值表达式这里是iota于是我们真正的枚举常量值就从1开始了。
那如果我们的枚举常量值并不连续,而是要略过某一个或几个值,又要怎么办呢?我们也可以借助空白标识符来实现,如下面这个代码:
```plain
const (
_ = iota // 0
Pin1
Pin2
Pin3
_
Pin5 // 5
)
```
你可以看到在上面这个枚举定义中枚举常量集合中没有Pin4。为了略过Pin4我们在它的位置上使用了空白标识符。
这样Pin5就会重复Pin3也就是向上数首个不为空的常量标识符的值这里就是iota而且由于它所在行的偏移值为5因此Pin5的值也为5这样我们成功略过了Pin4这个枚举常量以及4这个枚举值。
而且iota特性让我们维护枚举常量列表变得更加容易。比如我们使用传统的枚举常量声明方式来声明一组按首字母排序的“颜色”常量也就是这样
```plain
const (
Black = 1
Red = 2
Yellow = 3
)
```
假如这个时候我们要增加一个新颜色Blue。那根据字母序这个新常量应该放在Red的前面呀。但这样一来我们就需要像下面代码这样将Red到Yellow的常量值都手动加1十分费力。
```plain
const (
Blue = 1
Black = 2
Red = 3
Yellow = 4
)
```
那如果我们使用iota重新定义这组“颜色”枚举常量是不是可以更方便呢我们可以像下面代码这样试试看
```plain
const (
_ = iota
Blue
Red
Yellow
)
```
这样,无论后期我们需要增加多少种颜色,我们只需将常量名插入到对应位置就可以,其他就不需要再做任何手工调整了。
而且如果一个Go源文件中有多个const代码块定义的不同枚举每个const代码块中的iota也是独立变化的也就是说每个const代码块都拥有属于自己的iota如下面代码所示
```plain
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我们下节课见。