目录

变量遮蔽和作用域

💡 变量作用域等相关内容,后面变量遮蔽是学习过程中摘录了tonybai在极客时间的博客(不是这个网址),加深理解,下面有些例子还是很绕的,要稍微注意,看看理解一下,别浪费时间深究,实际编码注意就是了。

1. 作用域分类

  • 全局
  • 函数内部
  • 语句块
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var a = 100 // 这个是全局变量
func f() {
	// 先在函数内部查找,函数中优先使用局部变量
	// 找不到就在函数的外面查找,一直找到全局
	name := "小明"
	fmt.Println(name)

}
func main() {
	f()
	// 语句块变量,这个只能在语句块内使用,外部是无法使用的
	if i := 10; i < 18 {
		fmt.Println("不能去网吧哦")
	}

2. 变量遮蔽

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var a = 11
// 对n进行+1
func foo(n int) {
	a := 1
	a += n
}

func main() {
	fmt.Println("a=", a)
	foo(5)
	fmt.Println("after calling foo,a=", a)
}

out

1
2
a= 11
after calling foo,a= 11

可以看到,在这段代码中,函数 foo 调用前后,包级变量 a 的值都没有发生变化。这是因为,虽然 foo 函数中也使用了变量 a,但是 foo 函数中的变量 a 遮蔽了外面的包级变量 a,这使得包级变量 a 没有参与到 foo 函数的逻辑中,所以就没有发生变化了。

变量遮蔽是 Go 开发人员在日常开发工作中最容易犯的编码错误之一,它低级又不容易查找,常常会让你陷入漫长的调试过程。上面的实例较为简单,你可以通过肉眼很快找到问题所在,但一旦遇到更为复杂的变量遮蔽的问题,你就可能会被折腾很久,甚至只能通过工具才能帮助捕捉问题所在。

3. 代码块

Go 语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块。Go 代码块支持嵌套,我们可以在一个代码块中嵌入多个层次的代码块,如下面示例代码所示:

1
2
3
4
5
6
7
8
9
func foo() { //代码块1
    { // 代码块2
        { // 代码块3
            { // 代码块4

            }
        }
    }
}

在这个示例中,函数 foo 的函数体是最外层的代码块,这里我们将它编号为“代码块 1”。而且,在它的函数体内部,又嵌套了三层代码块,由外向内看分别为代码块 2、代码块 3 以及代码块 4。

像代码块 1 到代码块 4 这样的代码块,它们都是由两个肉眼可见的且配对的大括号包裹起来的,我们称这样的代码块为显式代码块(Explicit Blocks)。既然提到了显式代码块,我们肯定也不能忽略另外一类代码块的存在,也就是隐式代码块(Implicit Block)。顾名思义,隐式代码块没有显式代码块那样的肉眼可见的配对大括号包裹,我们无法通过大括号来识别隐式代码块。

虽然隐式代码块身着“隐身衣”,但我们也不是没有方法来识别它,因为 Go 语言规范对现存的几类隐式代码块做了明确的定义,你可以先花一两分钟看看下面这张图

/go基础/20230423114118.png

我们按代码块范围从大到小,逐一说明一下。首先是位于最外层的宇宙代码块(Universe Block),它囊括的范围最大,所有 Go 源码都在这个隐式代码块中,你也可以将该隐式代码块想象为在所有 Go 代码的最外层加一对大括号,就像图中最外层的那对大括号那样。

在宇宙代码块内部嵌套了包代码块(Package Block),每个 Go 包都对应一个隐式包代码块,每个包代码块包含了该包中的所有 Go 源码,不管这些代码分布在这个包里的多少个的源文件中。

我们再往里面看,在包代码块的内部嵌套着若干文件代码块(File Block),每个 Go 源文件都对应着一个文件代码块,也就是说一个 Go 包如果有多个源文件,那么就会有多个对应的文件代码块。

再下一个级别的隐式代码块就在控制语句层面了,包括 if、for 与 switch。我们可以把每个控制语句都视为在它自己的隐式代码块里。不过你要注意,这里的控制语句隐式代码块与控制语句使用大括号包裹的显式代码块并不是一个代码块。你再看一下前面的图,switch 控制语句的隐式代码块的位置是在它显式代码块的外面的。最后,位于最内层的隐式代码块是 switch 或 select 语句的每个 case/default 子句中,虽然没有大括号包裹,但实质上,每个子句都自成一个代码块。

最后,位于最内层的隐式代码块是 switch 或 select 语句的每个 case/default 子句中,虽然没有大括号包裹,但实质上,每个子句都自成一个代码块。

4. 作用域

按照 Go 语言定义,一个标识符要成为导出标识符需同时具备两个条件:一是这个标识符声明在包代码块中,或者它是一个字段名或方法名;二是它名字第一个字符是一个大写的 Unicode 字符。这两个条件缺一不可。

在源文件层面,去掉拥有包代码块作用域的标识符后,剩余的就都是一个个函数 / 方法的实现了。在这些函数 / 方法体中,标识符作用域划分原则更为简单,因为我们可以凭借肉眼可见的、配对的大括号来明确界定一个标识符的作用域范围,我们来看下面这个示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 条件分支语句为例来分析一下:

1
2
3
4
5
6
7
8
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”条件分支语句结构,根据我们前面讲过的隐式代码块规则,我们将上面示例中隐式代码块转换为显式代码块后,会得到下面这段等价的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 都是合法的,而且还保持了初始值。

5. 避免变量遮蔽

变量是标识符的一种,所以我们前面说的标识符的作用域规则同样适用于变量。在前面的讲述中,我们已经知道了,一个变量的作用域起始于其声明所在的代码块,并且可以一直扩展到嵌入到该代码块中的所有内层代码块,而正是这样的作用域规则,成为了滋生“变量遮蔽问题”的土壤。

变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样,内层代码块中的同名变量就会替代那个外层变量,参与此层代码块内的相关计算,我们也就说内层变量遮蔽了外层同名变量。现在,我们先来看一下这个示例代码,它就存在着多种变量遮蔽的问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
... ...
 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")
 }

这个变量遮蔽的例子还是有点复杂的,为了讲解方便,我给代码加上了行编号。我们首先运行一下这个例子:

1
2
3
4
$go run complex.go
it is 2021
after check, it is 2020
call checkYear error: wrong year

上面代碼有下面几个遮蔽问题

  • 遮蔽预定义标识符

面对上面代码,我们一眼就看到了位于第 18 行的 new,这本是 Go 语言的一个预定义标识符,但上面示例代码呢,却用 new 这个名字定义了一个新类型,于是 new 这个标识符就被遮蔽了。如果这个时候你在 main 函数下方放上下面代码

1
2
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 代码开发中你要尤其注意两者结合使用的地方。

因此,我们只有了解变量遮蔽问题本质,在日常编写代码时注意同名变量的声明,注意短变量声明与控制语句的结合,才能从根源上尽量避免变量遮蔽问题的发生。

goland中集成了go vet工具,可以识别出部分遮蔽问题,Goland本身也可以给我们一定的提示,可以最大限度避免出现变量遮蔽的问题。