Go代码块与作用域
# 7. Go代码块与作用域
# 7.1 变量遮蔽
var a = 11
func foo(n int) {
a := 1
a += n
}
func main() {
fmt.Println("a =", a) // 11
foo(5)
fmt.Println("after calling foo, a =", a) // 11
}
2
3
4
5
6
7
8
9
10
11
12
foo 函数中的变量 a 遮蔽了外面的包级变量 a,这使得包级变量 a 没有参与到 foo 函数的逻辑中,这就是代码遮蔽。一旦遇到更为复杂的变量遮蔽的问题,你就可能会被折腾很久,甚至只能通过工具才能帮助捕捉问题所在。
要想彻底保证不出现变量遮蔽问题,我们需要深入了解**代码块(Block,也可译作词法块)和作用域(Scope)**这两个概念以及其背后的规则。现在了,我们就来先学习一下代码块与作用域的概念。
# 7.2 代码块
Go 语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块。
Go 代码块支持嵌套,我们可以在一个代码块中嵌入多个层次的代码块,如下面示例代码所示:
func foo() { //代码块1
{ // 代码块2
{ // 代码块3
{ // 代码块4
}
}
}
}
2
3
4
5
6
7
8
9
10
# 7.2.1 显式代码块
**显式代码块(Explicit Blocks):**由两个肉眼可见的且配对的大括号包裹起来的代码块
# 7.2.2 隐式代码块
**隐式代码块(Implicit Block):**没有显式代码块那样的肉眼可见的配对大括号包裹,我们无法通过大括号来识别隐式代码块。
隐式代码块范围图:
- **宇宙代码块(Universe Block):**位于最外层,它囊括的范围最大,所有 Go 源码都在这个隐式代码块中,你也可以将该隐式代码块想象为在所有 Go 代码的最外层加一对大括号,就像图中最外层的那对大括号那样。
- **包代码块(Package Block):**每个 Go 包都对应一个隐式包代码块,每个包代码块包含了该包中的所有 Go 源码,不管这些代码分布在这个包里的多少个的源文件中。
- **文件代码块(File Block):**每个 Go 源文件都对应着一个文件代码块,也就是说一个 Go 包如果有多个源文件,那么就会有多个对应的文件代码块。
- **控制语句层面(if、for 与 switch):**可以把每个控制语句都视为在它自己的隐式代码块里。不过你要注意,这里的控制语句隐式代码块与控制语句使用大括号包裹的显式代码块并不是一个代码块。你再看一下前面的图,switch 控制语句的隐式代码块的位置是在它显式代码块的外面的。
- **switch 或 select 语句的每个 case/default 子句:**虽然没有大括号包裹,但实质上,每个子句都自成一个代码块。
# 7.3 作用域
针对标识符,不局限于变量。每个标识符都有自己的作用域,而一个标识符的作用域就是指这个标识符在被声明后可以被有效使用的源码区域。
作用域是一个编译期的概念,编译器在编译过程中会对每个标识符的作用域进行检查,对于在标识符作用域外使用该标识符的行为会给出编译错误的报错。
可以使用代码块的概念来划定每个标识符的作用域,划定原则:**声明于外层代码块中的标识符,其作用域包括所有内层代码块。**这一原则同时适于显式代码块与隐式代码块。
# 7.3.1 宇宙隐式代码块的标识符
我们并不能声明这一代码块块的标识符,因为这一区域是 Go 语言预定义标识符的自留地。
Go 语言当前版本(v16.5)定义里的所有预定义标识符:
不过,这些预定义标识符不是关键字,我们同样可以在内层代码块中声明同名的标识符。
# 7.3.2 包隐式代码块的标识符
包顶层声明中的常量、类型、变量或函数(不包括方法)对应的标识符的作用域是包代码块。
特殊情况:当一个包 A 导入另外一个包 B 后,包 A 仅可以使用被导入包包 B 中的导出标识符(Exported Identifier)。
**导出标识符:**按照 Go 语言定义,一个标识符要成为导出标识符需同时具备两个条件:一是这个标识符声明在包代码块中,或者它是一个字段名或方法名;二是它名字第一个字符是一个大写的 Unicode 字符。
# 7.3.3 文件隐式代码块的标识符
导入的包名,如果一个包 A 有两个源文件要实现,而且这两个源文件中的代码都依赖包 B 中的标识符,那么这两个源文件都需要导入包 B。
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的作用域终止于此
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
上面示例中定义了类型 T 的一个方法 M1,方法接收器 (receiver) 变量 t、函数参数 x,以及返回值变量 err 对应的标识符的作用域范围是 M1 函数体对应的显式代码块 1。虽然 t、x 和 err 并没有被函数体的大括号所显式包裹,但它们属于函数定义的一部分,所以作用域依旧是代码块 1。
函数体内部的语法元素:函数内部声明的常量或变量对应的标识符的作用域范围开始于常量或变量声明语句的末尾,并终止于其最内部的那个包含块的末尾。
# 7.3.4 控制语句隐式代码块的标识符
控制语句隐式代码块:
func bar() {
if a := 1; false {
} else if b := 2; false {
} else if c := 3; false {
} else {
println(a, b, c)
}
}
2
3
4
5
6
7
8
转换为显式代码块:
func bar() {
{ // 等价于第一个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作用域终止于此
}
}
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
声明于不同层次的隐式代码块中的变量 a、b 和 c 的实际作用域都位于最内层的 else 显式代码块之外,于是在 println 的那个显式代码块中,变量 a、b、c 都是合法的,而且还保持了初始值。
# 7.4 避免变量遮蔽的原则
变量是标识符的一种,所以标识符的作用域规则同样适用于变量。
一个变量的作用域起始于其声明所在的代码块,并且可以一直扩展到嵌入到该代码块中的所有内层代码块,而正是这样的作用域规则,成为了滋生“变量遮蔽问题”的土壤。
变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样,内层代码块中的同名变量就会替代那个外层变量,参与此层代码块内的相关计算,也就说内层变量遮蔽了外层同名变量。
... ...
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 = 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")
}
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
运行可得:
$go run complex.go
it is 2021
after check, it is 2020
call checkYear error: wrong year
2
3
4
存在问题:
第一个问题:遮蔽预定义标识符
type new int
new,这本是 Go 语言的一个预定义标识符,上面示例代码却用 new 这个名字定义了一个新类型,于是 new 这个标识符就被遮蔽了。不过遮蔽 new 并不是示例未按预期输出结果的真实原因。
第二个问题:遮蔽包代码块中的变量
switch a, err := getYear(); a
switch 语句在它自身的隐式代码块中,通过短变量声明形式重新声明了一个变量 a,这个变量 a 就遮蔽了外层包代码块中的包级变量 a,这就是打印“after check, it is 2020”的原因。包级变量 a 没有如预期那样被 getYear 的返回值赋值为正确的年份 2021,2021 被赋值给了遮蔽它的 switch 语句隐式代码块中的那个新声明的 a。
第三个问题:遮蔽外层显式代码块中的变量。
switch a, err := getYear(); a
switch 语句,除了声明一个新的变量 a 之外,它还声明了一个名为 err 的变量,这个变量就遮蔽了checkYear 函数在显式代码块中声明的 err 变量,这导致nil 赋值动作作用到了 switch 隐式代码块中的 err 变量上,而不是外层 checkYear 声明的本地变量 err 变量上,后者并非 nil,这样 checkYear 虽然从 getYear 得到了正确的年份值,但却返回了一个错误给 main 函数,这直接导致了 main 函数打印了错误:“call checkYear error: wrong year”。
# 7.4.1 利用工具检测变量遮蔽问题
Go 官方提供了 go vet 工具可以用于对 Go 源码做一系列静态检查。
在 Go 1.14 版以前默认支持变量遮蔽检查,Go 1.14 版之后,变量遮蔽检查的插件就需要单独安装,安装方法如下:
$go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
go: downloading golang.org/x/tools v0.1.5
go: downloading golang.org/x/mod v0.4.2
2
3
执行检查前面的示例代码:
$go vet -vettool=$(which shadow) -strict complex.go
./complex.go:13:12: declaration of "err" shadows declaration at line 11
2
go vet 只给出了 err 变量被遮蔽的提示,变量 a 以及预定义标识符 new 被遮蔽的情况并没有给出提示。
可以看到,工具确实可以辅助检测,但也不是万能的,不能穷尽找出代码中的所有问题,所以你还是要深入理解代码块与作用域的概念,尽可能在日常编码时就主动规避掉所有遮蔽问题。