变量遮蔽是 Go 开发人员在日常开发工作中最容易犯的编码错误之一,它低级又不容易查找,常常会让你陷入漫长的调试过程。
一个问题
1 | package main |
两次的打印都是 10,包级变量 x 的值,并没有发生变化,这是因为虽然 foo 函数中也使用了变量 x,但是 foo 函数中的变量 x 遮蔽了外面的包级变量 x,这使得包级变量 a 没有参与到 foo 函数的逻辑中,所以就没有发生变化了。
变量遮蔽只是个引子,想要保证不出现变量遮蔽的问题,需要深入了解代码块和作用域的概念及其背后的规则。
代码块
代码块是什么?
Go 语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块。
例如:
1 | func foo() { // 代码块1 |
在这个示例中,函数 foo 的函数体是最外层的代码块,这里将它编号为代码块 1。而且,在它的函数体内部,又嵌套了两层代码块,由外向内看分别为代码块 2、代码块 3。
形如代码块 1 到代码块 3 这样的代码块,它们都是由两个肉眼可见的且配对的大括号包裹起来的,我们称这样的代码块为显式代码块(Explicit Blocks)。
既然有显式代码块的存在,没错,与之对应的就是隐式代码块。
隐式代码块没有显式代码块那样的肉眼可见的配对大括号包裹,我们无法通过大括号来识别隐式代码块。
怎么理解隐式代码块呢?
来看下面这张图:
它没有明确的实体,只能通过抽象的方式去理解。
最外面的宇宙代码块、靠里面的包代码块、文件代码块以及if、for、switch 的控制语句,这些都有隐式代码块。
作用域
按照变量的作用域,可以把变量划分为局部变量、包级变量、全局变量。
局部变量 | 包级变量 | 全局变量 | |
---|---|---|---|
定义 | 定义在函数内部的变量、方法接收器变量以及函数的形参都算局部变量 | 定义在函数外面的变量称为包级变量 | 同样是定义在函数外面的变量,但是首字母大小,那么这个包级变量就会被视为全局变量 |
作用域范围 | 从定义那一行开始直到与其所在的代码块结束 | 当前包文件都可见 | 整个 Go 程序都可见 |
生命周期 | 从程序运行到定义那一行开始分配存储空间直至程序离开该变量所在的作用域 | 程序启动时初始化,直至程序结束 | 程序启动时初始化,直至程序结束 |
避免变量遮蔽的原则
在了解了 Go 语言的代码块和作用域之后,再来看看前面的那个变量遮蔽问题。
这一次同时把变量的地址打印出来:
1 | package main |
从上面的输出可以进一步确认,因为作用域的不同,在内存中实际对应两个不同的地址。
内存分配发生在运行期,编译后的机器码从不使用变量名,而是直接通过内存地址来访问目标数据。
尽管变量的名称是相同的,但是内存地址并不相同,所以本质上就不是同一个变量。
再来看一个例子,加深一下印象:
1 | package main |
变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样就会导致内层代码块中的同名变量就会替代外层变量,参与此层代码块内的相关计算,从而被形象地称之为内层变量遮蔽了外层同名变量。
解决方案
可以利用官方提供的 go vet
工具检测变量遮蔽问题,该工具用于对 Go 源码做一系列静态检查。
在 Go 1.14 版以前默认支持变量遮蔽检查,Go 1.14 版之后,变量遮蔽检查的插件就需要我们单独安装了,安装方法如下:
1 | $go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest |
Go 默认不做覆盖检查,添加 shadow 选项来启用:
1 | $ go vet -vettool=$(which shadow) -strict complex.go |
工具确实可以辅助检测,但也不是万能的,所以编码时,需要注意同名变量的声明以及短变量声明的作用域。