代码块和标识符作用域

本文将解释代码块和标识符的作用域。

(注意:本文中描述的代码块的层级关系和Go白皮书中有所不同。)

代码块

Go代码中有四种代码块。

  • 万物代码块(the universe code block)。一个程序只有一个万物代码块,它包含着一个程序中所有的代码;
  • 包代码块(package code block)。一个包代码块含着一个代码包中的所有代码,但不包括此代码包中的源代码文件中的所有引入声明。
  • 文件代码块(file code block)。一个文件代码块包含着一个源文件中的所有代码,包括此文件中的包引入声明。
  • 局部代码块(local code block)。一般说来,一对大括号{}中的代码形成了一个局部代码块。 但是也有一些局部代码块并不包含在一对大括号中,这样的代码块称为隐式代码块,而包含在一对大括号中的局部代码块称为显式代码块。 组合字面量中的大括号和代码块无关。

各种控制流程中的一些关键字跟随着一些隐式局部代码块:

  • 一个ifswitch或者for关键字跟随着两个内嵌在一起的局部代码块。 其中一个代码块是隐式的,另一个是显式的,此显式的代码块内嵌在此隐式代码块之中。 如果这样的一个关键字跟随着一个变量短声明形式,则被声明的变量声明在此隐式代码块中。
  • 一个else关键字可以跟随着一个显式或者隐式代码块。此显式或者隐式代码块内嵌在跟随在对应if关键字后的隐式代码块中。 如果此else关键字立即跟随着另一个if关键字,则跟随在此else关键字后的代码块可以为隐式的,否则,此代码块必须为显式的。
  • 一个select关键字跟随着一个显式局部代码块。
  • 每个casedefault关键字后跟随着一个隐式代码块,此隐式代码块内嵌在对应的switch或者select关键字后跟随的显式代码块中。

不内嵌在任何其它局部代码块中的局部代码块称为顶层(或者包级)局部代码块。顶层局部代码块肯定都是函数体。

注意,一个函数声明中的输入参数和输出结果变量都被看作是声明在此函数体代码块内,虽然看上去它们好像声明在函数体代码块之外。

各种代码块的层级关系:

  • 所有的包代码块均直接内嵌在万物代码块中;
  • 所有的文件代码块也均直接内嵌在万物代码块中;(注意:go/*标准库认为文件代码块内嵌在包代码块中。)
  • 每个顶层局部代码块同时直接内嵌在一个包代码块和一个文件代码块中;(注意:go/*标准库认为顶层局部代码块内嵌在文件代码中。)
  • 一个非顶层局部代码块肯定直接内嵌在另一个局部代码块中。

(本文和`go/`标准库的解释有所不同的原因是为了让下面对标识符遮挡的解释更加简单和清楚。)*

下面是一张展示上述代码块层级关系的图片:

代码块层级关系

代码块主要用来解释各种代码元素声明中的标识符的可声明位置和作用域。

各种代码元素的可声明位置

我们可以声明六种代码元素:

  • 包引入;
  • 定义类型和类型别名;
  • 有名常量;
  • 变量;
  • 函数;
  • 跳转标签。

在一个代码元素的声明中,一个标识符和一个代码元素绑定在了一起。 或者说,在此声明中,被声明的代码元素将被赋予此标识符做为它的名称。 此后,我们就可以用此标识符来代表此代码元素。

下标展示了各种代码元素可以被直接声明在何种代码块中:

万物代码块包代码块文件代码块局部代码块
预声明的(即内置的)代码元素(1)可以
包引入可以
定义类型和类型别名(不含内置的)可以可以可以
有名常量(不含内置常量)可以可以可以
变量(不含内置变量)(2)可以可以可以
函数(不含内置函数)可以可以
跳转标签可以

(1) 预声明代码元素展示在builtin标准库中。
(2) 不包括结构体字段变量声明。

所以,

  • 包引入不能声明在包代码块和局部代码块中;
  • 函数不能被声明在局部代码块中;(匿名函数可以定义在局部代码块中,但它们不属于元素声明。)
  • 跳转标签只能被声明在局部代码块中。

请注意:

  • 如果包含两个代码元素声明的最内层代码块为同一个,则这两个代码元素不能同名。
  • 声明在一个包中的一个包级代码元素的名称不能和此包中任何源文件中的包引入名同名。 或者反过来说更容易理解:一个包中的任何包引入名不能和此包中的任何包级代码元素的名称重名。 此规则可能会在以后被放宽
  • 如果包含两个跳转标签的最内层函数体为同一个,则这两个标签不能同名;
  • 一个跳转标签的所有引用必须处于包含此跳转标签声明的最内层函数体代码块内;
  • 各种控制流程中的隐式代码块对元素声明有特殊的要求。 一般说来,声明语句不允许出现在这样的隐式代码块中,除了一些变量短声明:
    • 每个ifswitch或者for关键字后可以紧跟着一条变量短声明语句;
    • 一个select控制流程中的每个case关键字后可以紧跟着一条变量短声明语句。

(顺便说一下,`go/`标准库代码包认为文件代码块中只能包含包引入声明。)*

声明在包代码块中并且在所有局部代码块之外的代码元素称为包级(package-level)元素。 包级元素可以是有名常量、变量、函数、定义类型或类型别名。

代码元素标识符的作用域

一个代码元素标识符的作用域是指此标识符可被识别的代码范围(或可见范围)。

不考虑本文最后一节将要解释的标识符遮挡,Go白皮书这样描述各种代码元素的标识符的作用域:

  • 内置代码元素标识符的作用域为整个万物代码块;
  • 一个包引入声明标识符的作用域为包含它的声明的文件代码块;
  • 直接声明在一个包代码块中的一个常量、类型、变量或者函数(不包括方法)的标识符的作用域为此包代码块;
  • 在函数体中声明的一个常量或者变量的标识符的作用域起始于此常量或者变量的描述(specification)的结尾(对于变量短声明,为此变量声明的结尾),并终止于包含此常量或者变量的声明的最内层代码块的结尾;
  • 一个函数参数(包括方法属主参数)和结果标识符的作用域为其对应函数体局部代码块;
  • 在函数体中声明的一个类型的标识符的作用域起始于此类型的描述中它的标识符的结尾,并终止于包含此类型的声明的最内层代码块的结尾;
  • 一个跳转标签的标识符的作用域为包含此标签的声明的最内层函数体代码块,但要排除掉此内嵌在此最内层函数体代码块中的各个匿名函数体代码块。

空标识符没有作用域。

(注意,预声明的iota标识符只能使用在常量声明中。)

你可能已经注意到了局部定义类型的作用域和其它局部元素(变量、常量和类型别名)的作用域的定义有微小的差别。 此差别体现在一个定义类型的声明中可以立即使用此定义类型的标识符。 下面这个例子展示了这一差异:

  1. package main
  2. func main() {
  3. // var v int = v // error: v未定义
  4. // const C int = C // error: C未定义
  5. /*
  6. type T = struct {
  7. *T // error: 不可循环引用
  8. x []T // error: 不可循环引用
  9. }
  10. */
  11. // 下面所有的类型定义声明都是合法的。
  12. type T struct {
  13. *T
  14. x []T
  15. }
  16. type A [5]*A
  17. type S []S
  18. type M map[int]M
  19. type F func(F) F
  20. type Ch chan Ch
  21. type P *P
  22. // ...
  23. var p P
  24. p = &p
  25. p = ***********************p
  26. ***********************p = p
  27. var s = make(S, 3)
  28. s[0] = s
  29. s = s[0][0][0][0][0][0][0][0]
  30. var m = M{}
  31. m[1] = m
  32. m = m[1][1][1][1][1][1][1][1]
  33. }

注意:fmt.Print(s)fmt.Print(m)调用都将导致恐慌(因为堆栈溢出)。

下面是一个展示了包级声明和局部声明的标识符的作用域差异的例子:

  1. package main
  2. // 下面这两行中各自等号左边和右边的标识符表示同一个代码元素。
  3. // 右边的标识符不是预声明的标识符。
  4. /*
  5. const iota = iota // error: 循环引用
  6. var true = true // error: 循环引用
  7. */
  8. var a = b // 可以使用其后声明的变量的标识符
  9. var b = 123
  10. func main() {
  11. // 下面两行中右边的标识符为预声明的标识符。
  12. const iota = iota // ok
  13. var true = true // ok
  14. _ = true
  15. // 下面几行编译不通过。
  16. /*
  17. var c = d // 不能使用其后声明变量标识符
  18. var d = 123
  19. _ = c
  20. */
  21. }

标识符遮挡

不考虑跳转标签,一个在外层代码块直接声明的标识符将被在内层代码块直接声明的相同标识符所遮挡。

跳转标签标识符不会被遮挡。

如果一个标识符被遮挡了,它的作用域将不包括遮挡它的标识符的作用域。

下面是一个有趣的例子。在此例子中,有6个变量均被声明为x。 一个在更深层代码块中声明的x遮挡了所有在外层声明的x

  1. package main
  2. import "fmt"
  3. var p0, p1, p2, p3, p4, p5 *int
  4. var x = 9999 // x#0
  5. func main() {
  6. p0 = &x
  7. var x = 888 // x#1
  8. p1 = &x
  9. for x := 70; x < 77; x++ { // x#2
  10. p2 = &x
  11. x := x - 70 // // x#3
  12. p3 = &x
  13. if x := x - 3; x > 0 { // x#4
  14. p4 = &x
  15. x := -x // x#5
  16. p5 = &x
  17. }
  18. }
  19. // 9999 888 77 6 3 -3
  20. fmt.Println(*p0, *p1, *p2, *p3, *p4, *p5)
  21. }

下面是另一个关于标识符遮挡和作用域的例子。此例子程序运行将输出Sheep Goat而不是Sheep Sheep。 请阅读其中的注释获取原因。

  1. package main
  2. import "fmt"
  3. var f = func(b bool) {
  4. fmt.Print("Goat")
  5. }
  6. func main() {
  7. var f = func(b bool) {
  8. fmt.Print("Sheep")
  9. if b {
  10. fmt.Print(" ")
  11. f(!b) // 此f乃包级变量f也。
  12. }
  13. }
  14. f(true) // 此f为刚声明的局部变量f。
  15. }

如果我们将上例中局部变量声明中的var关键字删除(从而将其变为一个纯赋值语句),或者将上例更改为如下所示,则此程序将运行输出Sheep Sheep

  1. func main() {
  2. var f func(b bool)
  3. f = func(b bool) {
  4. fmt.Print("Sheep")
  5. if b {
  6. fmt.Print(" ")
  7. f(!b) // 现在,此f变为局部变量f了。
  8. }
  9. }
  10. f(true)
  11. }

在某些情况下,当一些标识符被内层的一个变量短声明中声明的变量所遮挡时,一些新手Go程序员会搞不清楚此变量短声明中声明的哪些变量是新声明的变量。 下面这个例子(含有bug)展示了Go编程中一个比较有名的陷阱。 几乎每个Go程序员在刚开始使用Go的时候都曾经掉入过此陷阱。

  1. package main
  2. import "fmt"
  3. import "strconv"
  4. func parseInt(s string) (int, error) {
  5. n, err := strconv.Atoi(s)
  6. if err != nil {
  7. // 一些新手Go程序员会认为下一行中声明
  8. // 的err变量已经在外层声明过了。然而其
  9. // 实下一行中的b和err都是新声明的变量。
  10. // 此新声明的err遮挡了外层声明的err。
  11. b, err := strconv.ParseBool(s)
  12. if err != nil {
  13. return 0, err
  14. }
  15. // 如果代码运行到这里,一些新手Go程序员
  16. // 期望着内层的nil err将被返回。但是其实
  17. // 返回是外层的非nil err。因为内层的err
  18. // 的作用域到外层if代码块结尾就结束了。
  19. if b {
  20. n = 1
  21. }
  22. }
  23. return n, err
  24. }
  25. func main() {
  26. fmt.Println(parseInt("TRUE"))
  27. }

程序输出:

  1. 1 strconv.Atoi: parsing "TRUE": invalid syntax

Go语言目前只有25个关键字。 关键字不能被用做标识符。Go中很多常见的名称,比如intboolstringlencapnil等,并不是关键字,它们是预声明标识符。 这些预声明的标识符声明在万物代码块中,所以它们可以被声明在内层的相同标识符所遮挡。 下面是一个展示了预声明标识符被遮挡的古怪的例子。它编译和运行都没有问题。

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. const len = 3 // 遮挡了内置函数len
  6. var true = 0 // 遮挡了内置常量true
  7. type nil struct {} // 遮挡了内置变量nil
  8. func int(){} // 遮挡了内置类型int
  9. func main() {
  10. fmt.Println("a weird program")
  11. var output = fmt.Println
  12. var fmt = [len]nil{{}, {}, {}} // 遮挡了包引入fmt
  13. // var n = len(fmt) // error: len是一个常量
  14. var n = cap(fmt) // 我们只好使用内置cap函数
  15. // for关键字跟随着一个隐式代码块和一个显式代码块。
  16. // 变量短声明中的true遮挡了全局变量true。
  17. for true := 0; true < n; true++ {
  18. // 下面声明的false遮挡了内置常量false。
  19. var false = fmt[true]
  20. // 下面声明的true遮挡了循环变量true。
  21. var true = true+1
  22. // 下一行编译不通过,因为fmt是一个数组。
  23. // fmt.Println(true, false)
  24. output(true, false)
  25. }
  26. }

输出结果:

  1. a weird program
  2. 1 {}
  3. 2 {}
  4. 3 {}

是的,此例子是一个极端的例子。标识符遮挡是一个有用的特性,但是千万不要滥用之。