我们从一个 Go 变量遮蔽(Variable Shadowing)的问题说起。
什么是变量遮蔽呢?我们来看下面这段示例代码:
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
}
你可以看到,在这段代码中,函数 foo 调用前后,包级变量 a 的值都没有发生变化。这是因为,虽然 foo 函数中也使用了变量 a,但是 foo 函数中的变量 a 遮蔽了外面的包级变量 a,这使得包级变量 a 没有参与到 foo 函数的逻辑中,所以就没有发生变化了。
变量遮蔽只是个引子,我真正想跟你说的是代码块(Block,也可译作词法块)和作用域(Scope)这两个概念,因为要想彻底保证不出现变量遮蔽问题,我们需要深入了解这两个概念以及其背后的规则。
现在,我们就来先学习一下代码块与作用域的概念。
代码块与作用域
Go 语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块。Go 代码块支持嵌套,我们可以在一个代码块中嵌入多个层次的代码块,如下面示例代码所示:
func foo() { //代码块1
{ // 代码块2
{ // 代码块3
{ // 代码块4
}
}
}
}
有了这些代码块的概念后,你能更好理解作用域的概念了。作用域的概念是针对标识符的,不局限于变量。每个标识符都有自己的作用域,而一个标识符的作用域就是指这个标识符在被声明后可以被有效使用的源码区域。
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的作用域终止于此
}
接下来,我们再看看位于控制语句隐式代码块中的标识符的作用域划分。我们以下面这个 if 条件分支语句为例来分析一下:
func bar() {
if a := 1; false {
} else if b := 2; false {
} else if c := 3; false {
} else {
println(a, b, c)
}
}
这是一个复杂的“if - else if - else”条件分支语句结构,根据我们前面讲过的隐式代码块规则,我们将上面示例中隐式代码块转换为显式代码块后,会得到下面这段等价的代码:
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作用域终止于此
}
}
避免变量遮蔽的原则
我们已经知道了,一个变量的作用域起始于其声明所在的代码块,并且可以一直扩展到嵌入到该代码块中的所有内层代码块,而正是这样的作用域规则,成为了滋生“变量遮蔽问题”的土壤。
变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样,内层代码块中的同名变量就会替代那个外层变量,参与此层代码块内的相关计算,我们也就说内层变量遮蔽了外层同名变量。现在,我们先来看一下这个示例代码,它就存在着多种变量遮蔽的问题:
... ...
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")
}
这个变量遮蔽的例子还是有点复杂的,为了讲解方便,我给代码加上了行编号。我们首先运行一下这个例子:
$go run complex.go
it is 2021
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 函数下方放上下面代码:
p := new(int)
*p = 11
你就会收到 Go 编译器的错误提示:“type int is not an expression”,如果没有意识到 new 被遮蔽掉,这个提示就会让你不知所措。不过,在上面示例代码中,遮蔽 new 并不是示例未按预期输出结果的真实原因,我们还得继续往下看。
这时我们发现了第二个问题:遮蔽包代码块中的变量。
你看,位于第 7 行的 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 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
一旦安装成功,我们就可以通过 go vet 扫描代码并检查这里面有没有变量遮蔽的问题了。我们现在就来检查一下前面的示例代码,看看效果怎么样。执行检查的命令如下:
$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 被遮蔽的情况并没有给出提示。可以看到,工具确实可以辅助检测,但也不是万能的,不能穷尽找出代码中的所有问题,所以你还是要深入理解代码块与作用域的概念,尽可能在日常编码时就主动规避掉所有遮蔽问题。
此文章为3月Day2学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》,对于小白推荐该课程。