代码块的划分,间接地决定了程序实体的作用域。
what(代码块):
前面解释过,{}里面的部分
what(作用域):
一个程序实体被创造出来,是为了让别的代码引用的。那么哪里的代码可以引用?这就涉及了它的作用域。
程序实体的访问权限有三种:包级私有的、模块级私有的和公开的。这其实就是 Go 语言在语言层面,依据代码块对程序实体作用域进行的定义。
包级私有和模块级私有访问权限对应的都是代码包代码块,公开的访问权限对应的是全域代码块。然而,这个颗粒度是比较粗的,我们往往需要利用代码块再细化程序实体的作用域。
比如,我在一个函数中声明了一个变量,那么在通常情况下,这个变量是无法被这个函数以外的代码引用的。这里的函数就是一个代码块,而变量的作用域被限制在了该代码块中。当然了,还有例外的情况,这部分内容,我留到讲函数的时候再说。
总之,请记住,一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。 对“高内聚,低耦合”这种程序设计思想的实践,恰恰可以从这里开始。
问题:如果一个变量与其外层代码块中的变量重名会出现什么状况?
package main
import "fmt"
var block = "package"
func main() {
block := "function"
{
block := "inner"
fmt.Printf("The block is %s.\n", block)
}
fmt.Printf("The block is %s.\n", block)
}
这个命令源码文件中有四个代码块,它们是:全域代码块、
main包代表的代码块、main函数代表的代码块,以及在main函数中的一个用花括号包起来的代码块。我在后三个代码块中分别声明了一个名为
block的变量,并分别把字符串值"package"、"function"和"inner"赋给了它们。此外,我在后两个代码块的最后分别尝试用fmt.Printf函数打印出“The block is %s.”。这里的“%s”只是为了占位,程序会用block变量的实际值替换掉。
具体的问题是:该源码文件中的代码能通过编译吗?如果不能,原因是什么?如果能,运行它后会打印出什么内容?
结果
The block is inner.
The block is function.
问题解析
初看这道题,你可能会认为它无法通过编译,因为三处代码都声明了相同名称的变量。的确,声明重名的变量是无法通过编译的,用短变量声明对已有变量进行重声明除外,但这只是对于同一个代码块而言的。
对于不同的代码块来说,其中的变量重名没什么大不了,照样可以通过编译。即使这些代码块有直接的嵌套关系也是如此,就像👆.go 中的
main包代码块、main函数代码块和那个最内层的代码块那样。这样规定显然很方便也很合理,否则我们会每天为了选择变量名而烦恼。
但是这会导致另外一个问题,我引用变量时到底用的是哪一个?这也是这道题的第二个考点。 这其实有一个很有画面感的查找过程。这个查找过程不只针对于变量,还适用于任何程序实体。如下面所示。
- 首先,代码引用变量的时候总会最优先查找当前代码块中的那个变量。注意,这里的“当前代码块”仅仅是引用变量的代码所在的那个代码块,并不包含任何子代码块。
- 其次,如果当前代码块中没有声明以此为名的变量,那么程序会沿着代码块的嵌套关系,从直接包含当前代码块的那个代码块开始,一层一层地查找。
- 一般情况下,程序会一直查到当前代码包代表的代码块。如果仍然找不到,那么 Go 语言的编译器就会报错了。
如果我们在当前源码文件中导入了其他代码包,那么引用其中的程序实体时,是需要以限定符为前缀的。所以程序在找代表变量未加限定符的名字(即标识符)的时候,是不会去被导入的代码包中查找的.
特殊情况,如果我们把代码包导入语句写成
import . XXX的形式(注意中间的那个“.”),那么就会让这个“XXX”包中公开的程序实体,被当前源码文件中的代码,视为当前代码包中的程序实体。比如,如果有代码包导入语句
import . fmt,那么我们在当前源码文件中引用fmt.Printf函数的时候直接用Printf就可以了。在这个特殊情况下,程序在查找当前源码文件后会先去查用这种方式导入的那些代码包。
答案总结
从作用域的角度也可以说,虽然通过
var block = "package"声明的变量作用域是整个main代码包,但是在main函数中,它却被那两个同名的变量“屏蔽”了。相似的,虽然
main函数首先声明的block的作用域,是整个main函数,但是在最内层的那个代码块中,它却是不可能被引用到的。反过来讲,最内层代码块中的block也不可能被该块之外的代码引用到,这也是打印内容的第二行是“The block is function.”的另一半原因。
不同代码块中的重名变量与变量重声明中的变量区别到底在哪儿?
不论对变量重声明多少次,其类型必须始终一致,具体遵从它第一次被声明时给定的类型。而可重名变量之间不存在类似的限制,它们的类型可以是任意的。
当可重名变量的值被转换成某个接口类型值,或者它们的类型本身就是接口类型的时候,严格的类型检查就很有必要了。至于怎么检查,我们在下篇文章中再讨论
总结
我们先讨论了代码块,并且也谈到了它与程序实体的作用域,以及访问权限控制之间的巧妙关系。Go 语言本身对程序实体提供了相对粗粒度的访问控制。但我们自己可以利用代码块和作用域精细化控制它们。
如果在具有嵌套关系的不同代码块中存在重名的变量,那么我们应该特别小心,它们之间可能会发生“屏蔽”的现象。这样你在不同代码块中引用到变量很可能是不同的。具体的鉴别方式需要参考 Go 语言查找(代表了程序实体的)标识符的过程。
另外,请记住变量重声明与可重名变量之间的区别以及它们的重要特征。其中最容易产生隐晦问题的一点是,可重名变量可以各有各的类型。这时候我们往往应该在真正使用它们之前先对其类型进行检查。利用 Go 语言的语法、规范和命令做辅助的检查是很好的办法,但有些时候并不充分。
思考题
我们在讨论 Go 语言查找标识符时的范围的时候,提到过import . XXX这种导入代码包的方式。这里有个思考题:
如果通过这种方式导入的代码包中的变量与当前代码包中的变量重名了,那么 Go 语言是会把它们当做“可重名变量”看待还是会报错呢?
这个点操作的含义就是这个包导入之后在你调用这个包的函数时,你可以省略前缀的包名。 这两个变量会成为“可重名变量”。虽然这两个变量在这种情况下的作用域都是当前代码包的当前文件,但是它们所处的代码块是不同的。
前文件中的变量处在该文件所代表的代码块中,而被导入的代码包中的变量却处在声明它的那个文件所代表的代码块中。当然,我们也可以说被导入的代码包所代表的代码块包含了这个变量。
在当前文件中,本地的变量会“屏蔽”掉被导入的变量。