代码块与作用域

关于Go的代码块和作用域,我们得从变量屏蔽(Variable Shadowing)这个话题说起。什么是变量屏蔽呢?简单来讲,就是在同名变量赋值或变化时,由于代码的作用域不同,导致最后得出的结果与我们所期盼的结果不同。

比如,我们写一个函数,这个函数为传入的数值每次递增1:

1
2
3
4
5
6
7
8
9
10
11
var x = 2

func add(x int) {
x += 1
}

func main() {
fmt.Println("x = ", x)
add(x)
fmt.Println("after calling add, x = ", x)
}

输出结果如下:

1
2
x =  2
after calling add, x = 2

这就是一个典型的变量屏蔽问题,我们原本想用add函数给定义的变量x加上1,但是由于add函数中的x变量屏蔽了外面的包级变量x,这就使得包级变量x没有参与到add函数的逻辑中,所以包级变量x就不会发生任何变化。

当然,还有另一个理解思路,由于Go传参是值传递(对于Go原始基本类型数据都是,除了切片等其他数据结构或某些特殊情况下是引用传递外),所以我们给add函数传递包级变量x时,函数会先将x变量的值复制一份,赋给函数里的x,函数里的x做完运算后就会被清除,不会干扰到函数外的变量。

变量遮蔽无论是在哪个编程语言都会发生,他们往往难以排查。要想确保程序不会出现变量遮蔽的问题,我们需要了解一下代码块(Block,也可译作词法块)和作用域(Scope)。

代码块与作用域

代码块

Go 语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块。Go 代码块支持嵌套,我们可以在一个代码块中嵌入多个层次的代码块,如下面示例代码所示:

1
2
3
4
5
6
7
8
9
func test() { //代码块1
{ // 代码块2
{ // 代码块3
{ // 代码块4

}
}
}
}

在这个示例中,函数 test的函数体是最外层的代码块,这里我们将它编号为“代码块 1”。而且,在它的函数体内部,又嵌套了三层代码块,由外向内看分别为代码块 2、代码块 3 以及代码块 4。

显示代码块

像代码块 1 ~代码块 4 这类的由两个肉眼可见的且配对的大括号包裹起来的代码块,我们称这样的代码块为显式代码块(Explicit Blocks)。

隐式代码块

隐式代码块(Implicit Block),顾名思义,隐式代码块没有显式代码块那样的肉眼可见的配对大括号包裹,我们无法通过大括号来识别隐式代码块。

虽然我们无法通过大括号来识别隐式代码块,但我们也不是没有方法来识别它,因为 Go 语言规范对现存的几类隐式代码块做了明确的定义,如下图:

我们按代码块范围从大到小,逐一解释。

宇宙代码块

宇宙代码块(Universe Block),顾名思义,如果我们用Go代码编写了可以表示整个世界的程序,那么宇宙代码块,就是这个世界外的宇宙,它囊括了所有的Go世界源代码,范围最大。我们也可以简单理解它是在所有Go源码之外加了一层大括号。

包代码块

在宇宙代码块内部嵌套了包代码块(Package Block),每个 Go 包都对应一个隐式包代码块,每个包代码块包含了该包中的所有 Go 源码,不管这些代码分布在这个包里的多少个的源文件中。

文件代码块

再剥开一层,在包代码块内部嵌套着若干的文件代码块(File Block),每个Go源文件都对应一个文件代码块,即一个Go包由多个源文件,那么它就会有相应数量的文件代码块。switch 控制语句的隐式代码块的位置是在它显式代码块的外面的。

再剥开一层,隐式代码块就在控制语句里了,包括if、for、switch。我们可以将每个控制语句都看成包括了一层薄膜的隐式代码块。这里要注意,switch隐式代码块与控制语句使用大括号包裹起来的显示代码块并不是一个代码块。switch 控制语句的隐式代码块的位置是在它显式代码块的外面的。

最后,位于最内层的隐式代码块是 switch 或 select 语句的每个 case/default 子句中,虽然没有大括号包裹,但实质上,每个子句都自成一个代码块。

下面是switch case子句的变量屏蔽例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var x = 2

func add(n int) {
x := 1
x += n
}

func judge(x int) bool {
x -= 1
return x == 1
}

func test(x int) {
switch{
case judge(x):
{
fmt.Println("we think that x == 1, but actually x = ", x)
}
}
}

func main() {
test(x)
}

>> we think that x == 1, but actually x = 2

作用域

作用域的概念针对标识符,不局限于变量。每个标识符都有自己的作用域,而一个标识符的作用域就是指这个标识符在被声明后可以被有效使用的源码区域。

作用域作为一个编译期的概念,在编译过程中编译器会对每个标识符的作用域进行检查,对于在标识符作用域外使用该标识符的行为会抛出编译错误的报错。

为了更加形象的理解作用域这个抽象的概念,我们可以使用代码块的概念来划定每个标识符的作用域,划定的原则就是声明于外层代码块中的标识符其作用域包括所有内层代码块,内层代码块的作用域仅限于在其内的代码块,无法影响外层代码块。这一原则适用于所有显示代码块和隐式代码块。

宇宙隐式代码块标识符

位于最外层的宇宙隐式代码块的标识符是我们无法声明的,因为这一区域是 Go 语言预定义标识符的自留地。即在这一块区域的标识符是Go语言预定义好的。

Go 语言当前版本定义里的所有预定义标识符如下表:

这些预定义标识符位于包代码块的外层,所以它们的作用域是范围最大的,对于开发者而言,它们的作用域就是源代码中的任何位置。不过,这些预定义标识符不是关键字,我们同样可以在内层代码块中声明同名的标识符。

1
2
3
4
func main() {
var int int = 2
fmt.Println(int) // 2
}

包代码块标识符

那现在第二个问题就来了:既然宇宙代码块里存在预定义标识符,而且宇宙代码块的下一层是包代码块,那还有哪些标识符具有包代码块级作用域呢?

答案是,包顶层声明中的常量、类型、变量或函数(不包括方法)对应的标识符的作用域是包代码块。

不过,对于作用域为包代码块的标识符,我需要你知道一个特殊情况。那就是当一个包 A 导入另外一个包 B 后,包 A 仅可以使用被导入包包 B 中的导出标识符(Exported Identifier)。

这是为什么呢?而且,什么是导出标识符呢?

按照 Go 语言定义,一个标识符要成为导出标识符需同时具备两个条件:一是这个标识符声明在包代码块中,或者它是一个字段名或方法名;二是它名字第一个字符是一个大写的 Unicode 字符。这两个条件缺一不可。

文件代码块标识符

在前面的介绍中,大部分在包顶层声明的标识符都具有包代码块范围的作用域,但还有一个特殊的标识符能作用到文件代码块,那就是导入的包名。

如果一个包 A 有两个源文件要实现,而且这两个源文件中的代码都依赖包 B 中的标识符,那么这两个源文件都需要导入包 B。

在源文件层面,去掉拥有包代码块作用域的标识符后,剩余的就都是一个个函数 / 方法的实现了。在这些函数 / 方法体中,标识符作用域划分原则更为简单,因为我们可以凭借肉眼可见的、配对的大括号来明确界定一个标识符的作用域范围,我们来看下面这个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (t T) M1(x int) (err error) {
// 代码块1
m := 13

// 代码块1是包含m、t、x和err三个标识符的最内部代码块
{ // 代码块2

// "代码块2"是包含类型bar标识符的最内部的那个包含代码块
type bar struct {} // 类型标识符bar的作用域始于此
{ // 代码块3

// "代码块3"是包含变量a标识符的最内部的那个包含代码块
a := 5 // a作用域开始于此
{ // 代码块4
//... ...
}
// a作用域终止于此
}
// 类型标识符bar的作用域终止于此
}
// m、t、x和err的作用域终止于此
}

我们可以看到,上面示例中定义了类型 T 的一个方法 M1,方法接收器 (receiver) 变量 t、函数参数 x,以及返回值变量 err 对应的标识符的作用域范围是 M1 函数体对应的显式代码块 1。虽然 t、x 和 err 并没有被函数体的大括号所显式包裹,但它们属于函数定义的一部分,所以作用域依旧是代码块 1。

说完了函数体外部的诸如函数参数、返回值等元素的作用域后,我们现在就来分析函数体内部的那些语法元素

函数内部声明的常量或变量对应的标识符的作用域范围开始于常量或变量声明语句的末尾,并终止于其最内部的那个包含块的末尾。在上述例子中,变量 m、自定义类型 bar 以及在代码块 3 中声明的变量 a 均符合这个划分规则。

接下来,我们再看看位于控制语句隐式代码块中的标识符的作用域划分。我们以下面这个 if 条件分支语句为例来分析一下:

1
2
3
4
5
6
7
8
func test() {
if a := 1; false {
} else if b := 2; false {
} else if c := 3; false {
} else {
println(a, b, c)
}
}

这是一个复杂的“if - else if - else”条件分支语句结构,根据我们前面讲过的隐式代码块规则,我们将上面示例中隐式代码块转换为显式代码块后,会得到下面这段等价的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func test() {
{ // 等价于第一个if的隐式代码块
a := 1 // 变量a作用域始于此
if false {

} else {
{ // 等价于第一个else if的隐式代码块
b := 2 // 变量b的作用域始于此
if false {

} else {
{ // 等价于第二个else if的隐式代码块
c := 3 // 变量c作用域始于此
if false {

} else {
println(a, b, c)
}
// 变量c的作用域终止于此
}
}
// 变量b的作用域终止于此
}
}
// 变量a作用域终止于此
}
}

我们看到,经过这么一个等价转换,各个声明于 if 表达式中的变量的作用域就变得一目了然了。声明于不同层次的隐式代码块中的变量 a、b 和 c 的实际作用域都位于最内层的 else 显式代码块之外,于是在 println 的那个显式代码块中,变量 a、b、c 都是合法的,而且还保持了初始值。

避免变量遮蔽的原则

变量是标识符的一种,所以我们前面说的标识符的作用域规则同样适用于变量。在前面的讲述中,我们已经知道了,一个变量的作用域起始于其声明所在的代码块,并且可以一直扩展到嵌入到该代码块中的所有内层代码块,而正是这样的作用域规则,成为了滋生“变量遮蔽问题”的土壤。

变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样,内层代码块中的同名变量就会替代那个外层变量,参与此层代码块内的相关计算,我们也就说内层变量遮蔽了外层同名变量。现在,我们先来看一下这个示例代码,它就存在着多种变量遮蔽的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"fmt"
"errors"
)

var a int = 2020

func checkYear() error {
err := errors.New("wrong year")

switch a, err := getYear(); a {
case 2020:
fmt.Println("it is", a, err)
case 2021:
fmt.Println("it is", a, err)
err = nil
}
fmt.Println("after check, it is", a)
return err
}

type new int

func getYear() (new, error) {
var b int16 = 2021
return new(b), nil
}

func main() {
err := checkYear()
if err != nil {
fmt.Println("call checkYear error:", err)
return
}
fmt.Println("call checkYear ok")
}

这个变量遮蔽的例子还是有点复杂的,为了讲解方便,我给代码加上了行编号。我们首先运行一下这个例子:

1
2
3
4
5
6
complex go build complex (注意,如果要这样构建的化,complex模块必须处于gopath中)
complex ./complex
or ➜ go run complex.go
it is 2021 <nil>
after check, it is 2020
call checkYear error: wrong year

我们可以看到,第 20 行定义的 getYear 函数返回了正确的年份 (2021),但是 checkYear 在结尾却输出“after check, it is 2020”,并且返回的 err 并非为 nil,这显然是变量遮蔽的“锅”!

根据我们前面给出的变量遮蔽的根本原因,我们来“找找茬”,看看上面这段代码究竟有几处变量遮蔽问题(包括标识符遮蔽问题)。

第一个问题:遮蔽预定义标识符

面对上面代码,我们一眼就看到了位于第 18 行的 new,这本是 Go 语言的一个预定义标识符,但上面示例代码呢,却用 new 这个名字定义了一个新类型,于是 new 这个标识符就被遮蔽了。如果这个时候你在 main 函数下方放上下面代码:

1
2
p := new(int)
*p = 11

你就会收到 Go 编译器的错误提示:“type int is not an expression”,如果没有意识到 new 被遮蔽掉,这个提示就会让你不知所措。不过,在上面示例代码中,遮蔽 new 并不是示例未按预期输出结果的真实原因,我们还得继续往下看。

第二个问题:遮蔽包代码块中的变量

你看,位于第 13 行的 switch 语句在它自身的隐式代码块中,通过短变量声明形式重新声明了一个变量 a,这个变量 a 就遮蔽了外层包代码块中的包级变量 a,这就是打印“after check, it is 2020”的原因。包级变量 a 没有如预期那样被 getYear 的返回值赋值为正确的年份 2021,2021 被赋值给了遮蔽它的 switch 语句隐式代码块中的那个新声明的 a。

不过,同一行里,其实还有第三个问题。

第三个问题:遮蔽外层显式代码块中的变量

同样还是第 7 行的 switch 语句,除了声明一个新的变量 a 之外,它还声明了一个名为 err 的变量,这个变量就遮蔽了第 4 行 checkYear 函数在显式代码块中声明的 err 变量,这导致第 12 行的 nil 赋值动作作用到了 switch 隐式代码块中的 err 变量上,而不是外层 checkYear 声明的本地变量 err 变量上,后者并非 nil,这样 checkYear 虽然从 getYear 得到了正确的年份值,但却返回了一个错误给 main 函数,这直接导致了 main 函数打印了错误:“call checkYear error: wrong year”。

通过这个示例,我们也可以看到,短变量声明与控制语句的结合十分容易导致变量遮蔽问题,并且很不容易识别,因此在日常 go 代码开发中你要尤其注意两者结合使用的地方。

不过,依靠肉眼识别变量遮蔽问题终归不是长久之计,有没有工具可以帮助我们识别这类问题呢?其实是有的,下面我们就来介绍一下可以检测变量遮蔽问题的工具。

利用工具检测变量遮蔽问题

Go 官方提供了 go vet 工具可以用于对 Go 源码做一系列静态检查,在 Go 1.14 版以前默认支持变量遮蔽检查,Go 1.14 版之后,变量遮蔽检查的插件就需要我们单独安装了,安装方法如下:

1
2
3
4
5
➜  ~ go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
go: downloading golang.org/x/tools v0.1.7
go: downloading golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e
go: downloading golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
go: downloading golang.org/x/mod v0.4.2

一旦安装成功,我们就可以通过 go vet 扫描代码并检查这里面有没有变量遮蔽的问题了。我们现在就来检查一下前面的示例代码,看看效果怎么样。执行检查的命令如下:

1
2
$go vet -vettool=$(which shadow) -strict complex.go 
./complex.go:13:12: declaration of "err" shadows declaration at line 11

我们看到,go vet 只给出了 err 变量被遮蔽的提示,变量 a 以及预定义标识符 new 被遮蔽的情况并没有给出提示。可以看到,工具确实可以辅助检测,但也不是万能的,不能穷尽找出代码中的所有问题,所以你还是要深入理解代码块与作用域的概念,尽可能在日常编码时就主动规避掉所有遮蔽问题。

要注意,变量的遮蔽是要在内层代码块也申请一个和外层代码块中相同的变量并给这个变量进行赋值修改值的操作才会产生,如果仅仅是对上层代码变量进行操作则仍然会影响最外层代码变量值。