代码块和作用域

* 本页面主要介绍Go语言代码块和作用域的相关内容。

一、代码块

在 Go 语言中,代码块(也叫句法块)一般就是一个由花括号括起来的区域,里面可以包含表达式和语句。Go 语言本身以及我们编写的代码共同形成了一个非常大的代码块,也叫全域代码块。这主要体现在,只要是公开的全局变量,都可以被任何代码所使用。

相对小一些的代码块是代码包,一个代码包可以包含许多子代码包,所以这样的代码块也可以很大。

接下来,每个源码文件也都是一个代码块,每个函数也是一个代码块,每个if语句、for语句、switch语句和select语句都是一个代码块。甚至,switch或select语句中的case子句也都是独立的代码块。走个极端,我就在main函数中写一对紧挨着的花括号也算一个代码块:叫“空代码块”

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函数中的一个用花括号包起来的代码块。最后执行之后的结果如下:

              
  The block is inner.
  The block is function.
            
  • 首先,代码引用变量的时候总会最优先查找当前代码块中的那个变量
  • 其次,如果当前代码块中没有声明以此为名的变量,那么程序会沿着代码块的嵌套关系,从直接包含当前代码块的那个代码块开始,一层一层地查找。
  • 一般情况下,程序会一直查到当前代码包代表的代码块。如果仍然找不到,那么 Go 语言的编译器就会报错了。

所以,虽然通过var block = "package"声明的变量作用域是整个main代码包,但是在main函数中,它却被那两个同名的变量“屏蔽”了。相似的,虽然main函数首先声明的block的作用域,是整个main函数,但是在最内层的那个代码块中,它却是不可能被引用到的。反过来讲,最内层代码块中的block也不可能被该块之外的代码引用到。

三、捕获迭代变量的“坑”

这里特别强调一个Go词法作用域的陷阱。请务必仔细的阅读,弄清楚发生问题的原因。相信你一定在这里踩过雷

首先看下下面的程序示例:你被要求首先创建一些目录,再将目录删除。在下面的例子中我们用函数值来完成删除操作。下面的示例代码需要引入os包。为了使代码简单,我们忽略了所有的异常处理。

              
  var rmdirs []func()
  for _, d := range tempDirs() {
      dir := d // NOTE: necessary!
      os.MkdirAll(dir, 0755) // creates parent directories too
      rmdirs = append(rmdirs, func() {
          os.RemoveAll(dir)
      })
  }
  // ...do some work…
  for _, rmdir := range rmdirs {
      rmdir() // clean up
  }
            

你可能会感到困惑,为什么要在循环体中用循环变量d赋值一个新的局部变量,而不是像下面的代码一样直接使用循环变量dir。需要注意,下面的代码是错误的

              
  var rmdirs []func()
  for _, dir := range tempDirs() {
      os.MkdirAll(dir, 0755)
      rmdirs = append(rmdirs, func() {
          os.RemoveAll(dir) // NOTE: incorrect!
      })
  }
            

问题的原因在于循环变量的作用域。在上面的程序中,for循环语句引入了新的词法块,循环变量dir在这个词法块中被声明。在该循环中生成的所有函数值都共享相同的循环变量。需要注意,函数值中记录的是循环变量的内存地址,而不是循环变量某一时刻的值。以dir为例,后续的迭代会不断更新dir的值,当删除操作执行时,for循环已完成,dir中存储的值等于最后一次迭代的值。这意味着,每次对os.RemoveAll的调用删除的都是相同的目录

所以,看出来创建一个与循环变量同名的局部变量的重要性和合理性了吧!

不止在range中,在下面的例子中,依然存在这个问题。

              
  var rmdirs []func()
  dirs := tempDirs()
  for i := 0; i < len(dirs); i++ {
      os.MkdirAll(dirs[i], 0755) // OK
      rmdirs = append(rmdirs, func() {
          os.RemoveAll(dirs[i]) // NOTE: incorrect!
      })
  }
            

代码块和作用域的相关知识点就总结到这里!


* 本页内容参考以下数据源:

  • 《Go语言核心36讲》第4、5节
  • 《Go程序设计语言》

* 本页内容更新日志:

  • 2020年10月25日 20:59:15:第一次编辑
  • 2020年11月20日 19:43:07:第二次编辑,增加捕获迭代变量的“坑”

凯冰科技 · 代码改变世界,技术改变生活
Next Page→