小艾的自留地

Stay foolish, Stay hungry

变量遮蔽是 Go 开发人员在日常开发工作中最容易犯的编码错误之一,它低级又不容易查找,常常会让你陷入漫长的调试过程。

一个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

var x = 10

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

func main() {
fmt.Println("x = ", x) // 10

foo(99)
fmt.Println("调用函数之后,x 现在的值是:", x) // 10
}

两次的打印都是 10,包级变量 x 的值,并没有发生变化,这是因为虽然 foo 函数中也使用了变量 x,但是 foo 函数中的变量 x 遮蔽了外面的包级变量 x,这使得包级变量 a 没有参与到 foo 函数的逻辑中,所以就没有发生变化了。

变量遮蔽只是个引子,想要保证不出现变量遮蔽的问题,需要深入了解代码块和作用域的概念及其背后的规则。

代码块

代码块是什么?
Go 语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块。

例如:

1
2
3
4
5
6
7
func foo() { // 代码块1
{ // 代码块2
{ // 代码块3

}
}
}

在这个示例中,函数 foo 的函数体是最外层的代码块,这里将它编号为代码块 1。而且,在它的函数体内部,又嵌套了两层代码块,由外向内看分别为代码块 2、代码块 3。

形如代码块 1 到代码块 3 这样的代码块,它们都是由两个肉眼可见的且配对的大括号包裹起来的,我们称这样的代码块为显式代码块(Explicit Blocks)。

既然有显式代码块的存在,没错,与之对应的就是隐式代码块。
隐式代码块没有显式代码块那样的肉眼可见的配对大括号包裹,我们无法通过大括号来识别隐式代码块。

怎么理解隐式代码块呢?

来看下面这张图:

它没有明确的实体,只能通过抽象的方式去理解。

最外面的宇宙代码块、靠里面的包代码块、文件代码块以及if、for、switch 的控制语句,这些都有隐式代码块。

作用域

按照变量的作用域,可以把变量划分为局部变量、包级变量、全局变量。

局部变量 包级变量 全局变量
定义 定义在函数内部的变量、方法接收器变量以及函数的形参都算局部变量 定义在函数外面的变量称为包级变量 同样是定义在函数外面的变量,但是首字母大小,那么这个包级变量就会被视为全局变量
作用域范围 从定义那一行开始直到与其所在的代码块结束 当前包文件都可见 整个 Go 程序都可见
生命周期 从程序运行到定义那一行开始分配存储空间直至程序离开该变量所在的作用域 程序启动时初始化,直至程序结束 程序启动时初始化,直至程序结束

避免变量遮蔽的原则

在了解了 Go 语言的代码块和作用域之后,再来看看前面的那个变量遮蔽问题。

这一次同时把变量的地址打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

var x = 10

func foo(n int) {
x := 1
fmt.Println(&x, x) // 0xc00001c0a8 1

x += n
fmt.Println(&x, x) // 0xc00001c0a8 100
}

func main() {
fmt.Println("x = ", x)
fmt.Println(&x, x) // 0x111c368 10

foo(99)
fmt.Println(&x, x) // 0x111c368 10
}

从上面的输出可以进一步确认,因为作用域的不同,在内存中实际对应两个不同的地址。

内存分配发生在运行期,编译后的机器码从不使用变量名,而是直接通过内存地址来访问目标数据。
尽管变量的名称是相同的,但是内存地址并不相同,所以本质上就不是同一个变量。

再来看一个例子,加深一下印象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

// 包级变量
var y = 12

func main() {
fmt.Println(&y, y) // 0x111c370 12

// 局部变量
y := 24
fmt.Println(&y, y) // 0xc00001c0d8 24
}

变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样就会导致内层代码块中的同名变量就会替代外层变量,参与此层代码块内的相关计算,从而被形象地称之为内层变量遮蔽了外层同名变量。

解决方案

可以利用官方提供的 go vet 工具检测变量遮蔽问题,该工具用于对 Go 源码做一系列静态检查。

在 Go 1.14 版以前默认支持变量遮蔽检查,Go 1.14 版之后,变量遮蔽检查的插件就需要我们单独安装了,安装方法如下:

1
2
3
$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

Go 默认不做覆盖检查,添加 shadow 选项来启用:

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

工具确实可以辅助检测,但也不是万能的,所以编码时,需要注意同名变量的声明以及短变量声明的作用域。

参考链接

评论