You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

307 lines
19 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 11代码块与作用域如何保证变量不会被遮蔽
你好我是Tony Bai。
在上一节课我们学习了变量的几种声明形式还掌握了不同类型的变量应该采用哪种声明形式。在这一节课里我们还是继续聊聊有关变量的事情。聊什么呢别急我们从一个Go变量遮蔽Variable Shadowing的问题说起。
什么是变量遮蔽呢?我们来看下面这段示例代码:
```plain
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函数的逻辑中所以就没有发生变化了。
变量遮蔽是Go开发人员在日常开发工作中最容易犯的编码错误之一它低级又不容易查找常常会让你陷入漫长的调试过程。上面的实例较为简单你可以通过肉眼很快找到问题所在但一旦遇到更为复杂的变量遮蔽的问题你就可能会被折腾很久甚至只能通过工具才能帮助捕捉问题所在。
变量遮蔽只是个引子,我真正想跟你说的是**代码块**Block也可译作词法块和**作用域**Scope这两个概念因为要想彻底保证不出现变量遮蔽问题我们需要深入了解这两个概念以及其背后的规则。
现在了,我们就来先学习一下代码块与作用域的概念。
## 代码块与作用域
我们先来解析一下Go里面的代码块。
Go语言中的代码块是包裹在一对大括号内部的声明和语句序列如果一对大括号内部没有任何声明或其他语句我们就把它叫做**空代码块**。Go代码块支持嵌套我们可以在一个代码块中嵌入多个层次的代码块如下面示例代码所示
```plain
func foo() { //代码块1
{ // 代码块2
{ // 代码块3
{ // 代码块4
}
}
}
}
```
在这个示例中函数foo的函数体是最外层的代码块这里我们将它编号为“代码块1”。而且在它的函数体内部又嵌套了三层代码块由外向内看分别为代码块2、代码块3以及代码块4。
像代码块1到代码块4这样的代码块它们都是由两个肉眼可见的且配对的大括号包裹起来的我们称这样的代码块为显式代码块Explicit Blocks。既然提到了显式代码块我们肯定也不能忽略另外一类代码块的存在也就是隐式代码块Implicit Block。顾名思义隐式代码块没有显式代码块那样的肉眼可见的配对大括号包裹我们无法通过大括号来识别隐式代码块。
虽然隐式代码块身着“隐身衣”但我们也不是没有方法来识别它因为Go语言规范对现存的几类隐式代码块做了明确的定义你可以先花一两分钟看看下面这张图
![图片](https://static001.geekbang.org/resource/image/3d/85/3d02138cf8f8a7a85fe0cfe5c29a6585.jpg?wh=1920x1047)
我们按代码块范围从大到小,逐一说明一下。
首先是位于最外层的**宇宙代码块Universe Block**它囊括的范围最大所有Go源码都在这个隐式代码块中你也可以将该隐式代码块想象为在所有Go代码的最外层加一对大括号就像图中最外层的那对大括号那样。
在**宇宙代码块**内部嵌套了**包代码块Package Block**每个Go包都对应一个隐式包代码块每个包代码块包含了该包中的所有Go源码不管这些代码分布在这个包里的多少个的源文件中。
我们再往里面看,在包代码块的内部嵌套着若干**文件代码块File Block**每个Go源文件都对应着一个文件代码块也就是说一个Go包如果有多个源文件那么就会有多个对应的文件代码块。
再下一个级别的隐式代码块就在控制语句层面了包括if、for与switch。我们可以把每个控制语句都视为在它自己的隐式代码块里。不过你要注意这里的控制语句隐式代码块与控制语句使用大括号包裹的显式代码块并不是一个代码块。你再看一下前面的图switch控制语句的隐式代码块的位置是在它显式代码块的外面的。
最后位于最内层的隐式代码块是switch或select语句的每个case/default子句中虽然没有大括号包裹但实质上每个子句都自成一个代码块。
有了这些代码块的概念后,你能更好理解作用域的概念了。作用域的概念是针对标识符的,不局限于变量。每个标识符都有自己的作用域,而**一个标识符的作用域就是指这个标识符在被声明后可以被有效使用的源码区域**。
显然,作用域是一个编译期的概念,也就是说,编译器在编译过程中会对每个标识符的作用域进行检查,对于在标识符作用域外使用该标识符的行为会给出编译错误的报错。
不过,我们可以使用代码块的概念来划定每个标识符的作用域。这个划定原则是什么呢?原则就是**声明于外层代码块中的标识符,其作用域包括所有内层代码块**。而且,这一原则同时适于显式代码块与隐式代码块。现在,对照上面的示意图,我们再举一些典型的例子,让你对作用域这个抽象的概念有更进一步的了解。
**首先,我们来看看位于最外层的宇宙隐式代码块的标识符。**
我们先来看第一个问题:我们要怎么声明这一区域的标识符呢?
这个问题的答案是我们并不能声明这一块的标识符因为这一区域是Go语言**预定义标识符**的自留地。这里我整理了Go语言当前版本定义里的所有预定义标识符你可以看看下面这张表
![图片](https://static001.geekbang.org/resource/image/01/95/01b8135e83c9fc39afcecdb26d4e1495.jpg?wh=1920x748)
由于这些预定义标识符位于包代码块的外层,所以它们的作用域是范围最大的,对于开发者而言,它们的作用域就是源代码中的任何位置。不过,这些预定义标识符不是关键字,我们同样可以在内层代码块中声明同名的标识符。
**那现在第二个问题就来了:既然宇宙代码块里存在预定义标识符,而且宇宙代码块的下一层是包代码块,那还有哪些标识符具有包代码块级作用域呢?**
答案是,包顶层声明中的常量、类型、变量或函数(不包括方法)对应的标识符的作用域是包代码块。
不过对于作用域为包代码块的标识符我需要你知道一个特殊情况。那就是当一个包A导入另外一个包B后包A仅可以使用被导入包包B中的导出标识符Exported Identifier
这是为什么呢?而且,什么是导出标识符呢?
按照Go语言定义一个标识符要成为导出标识符需同时具备两个条件一是这个标识符声明在包代码块中或者它是一个字段名或方法名二是它名字第一个字符是一个大写的Unicode字符。这两个条件缺一不可。
从我们前面的讲解中,你一定发现了大部分在包顶层声明的标识符都具有包代码块范围的作用域,那**还有标识符的作用域是文件代码块范围的吗?**
确实不多了。但还有一个我一说你肯定会有一种恍然大悟的感觉它就是导入的包名。也就是说如果一个包A有两个源文件要实现而且这两个源文件中的代码都依赖包B中的标识符那么这两个源文件都需要导入包B。
在源文件层面,去掉拥有包代码块作用域的标识符后,剩余的就都是一个个函数/方法的实现了。在这些函数/方法体中,标识符作用域划分原则更为简单,因为我们可以凭借肉眼可见的、配对的大括号来明确界定一个标识符的作用域范围,我们来看下面这个示例:
```plain
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条件分支语句为例来分析一下
```plain
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”条件分支语句结构根据我们前面讲过的隐式代码块规则我们将上面示例中隐式代码块转换为显式代码块后会得到下面这段等价的代码
```plain
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作用域终止于此
}
}
```
我们看到经过这么一个等价转换各个声明于if表达式中的变量的作用域就变得一目了然了。声明于不同层次的隐式代码块中的变量a、b和c的实际作用域都位于最内层的else显式代码块之外于是在println的那个显式代码块中变量a、b、c都是合法的而且还保持了初始值。
好了,到这里我们已经了解代码块与作用域的概念与规则了,那么我们要怎么利用这些知识避免在实际编码中的变量遮蔽问题呢?避免变量遮蔽的原则又是什么呢?
## 避免变量遮蔽的原则
变量是标识符的一种,所以我们前面说的标识符的作用域规则同样适用于变量。在前面的讲述中,我们已经知道了,一个变量的作用域起始于其声明所在的代码块,并且可以一直扩展到嵌入到该代码块中的所有内层代码块,而正是这样的作用域规则,成为了滋生“变量遮蔽问题”的土壤。
变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样,内层代码块中的同名变量就会替代那个外层变量,参与此层代码块内的相关计算,我们也就说内层变量遮蔽了外层同名变量。现在,我们先来看一下这个示例代码,它就存在着多种变量遮蔽的问题:
```plain
... ...
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")
}
```
这个变量遮蔽的例子还是有点复杂的,为了讲解方便,我给代码加上了行编号。我们首先运行一下这个例子:
```plain
$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函数下方放上下面代码
```plain
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的返回值赋值为正确的年份20212021被赋值给了遮蔽它的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版之后,变量遮蔽检查的插件就需要我们单独安装了,安装方法如下:
```plain
$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扫描代码并检查这里面有没有变量遮蔽的问题了。我们现在就来检查一下前面的示例代码看看效果怎么样。执行检查的命令如下
```plain
$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被遮蔽的情况并没有给出提示。可以看到工具确实可以辅助检测但也不是万能的不能穷尽找出代码中的所有问题所以你还是要深入理解代码块与作用域的概念尽可能在日常编码时就主动规避掉所有遮蔽问题。
## 小结
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
在这一讲中,我们学习了另外两个变量相关的概念:代码块与作用域。
代码块有显式与隐式之分显式代码块就是包裹在一对配对大括号内部的语句序列而隐式代码块则不容易肉眼分辨它是通过Go语言规范明确规定的。隐式代码块有五种分别是宇宙代码块、包代码块、文件代码块、分支控制语句隐式代码块以及switch/select的子句隐式代码块理解隐式代码块是理解代码块概念以及后续作用域概念的前提与基础。
作用域的概念是Go源码编译过程中标识符包括变量的一个属性。Go编译器会校验每个标识符的作用域如果它的使用范围超出其作用域编译器会报错。
不过呢我们可以使用代码块的概念来划定每个标识符的作用域。划定原则就是声明于外层代码块中的标识符其作用域包括所有内层代码块。但是Go的这种作用域划定也带来了变量遮蔽问题。简单的遮蔽问题我们通过分析代码可以很快找出复杂的遮蔽问题即便是通过go vet这样的静态代码分析工具也难于找全。
因此,我们只有了解变量遮蔽问题本质,在日常编写代码时注意同名变量的声明,注意短变量声明与控制语句的结合,才能从根源上尽量避免变量遮蔽问题的发生。
## 思考题
今天的思考题,你知道怎么来修正我们这节课最后那个复杂的变量遮蔽的例子吗?期待在留言区见到你的答案。
感谢你和我一起学习也欢迎你把这节课分享给更多对Go语言感兴趣的朋友。我是Tony Bai我们下节课见。