作用域和生命期的概念可以借鉴 C 语言,但还有一些不太一样的地方,需要单独解释。

1. 作用域与生命期

任何一门高级计算机语言都有作用域的概念,go 也不例外。

说到作用域,必然也会想到生命期。有些同学可能会把作用域和生命期划上了等号,比如在 c 语言里,在函数中声明了局部变量 ​​int x​​​,这个 ​​x​​​ 的作用域就在函数体内,一旦执行完此函数,​​x​​ 也就销毁了。

实际上,作用域和生命期有着本质的区别:

  • 作用域是编译期概念
  • 生命期是运行时概念

换句话说,作用域限制了 object 的可见范围。比如在某个函数里声明了变量 ​​int x​​​,这个 ​​x​​ 就只能在这个函数里可见,在函数外面是无法使用它的,这一点是编译器做出的限制。

如果你真的想在函数外面使用 ​​x​​​,可不可以?在 c 语言里其实可以通过指针将 ​​x​​ 地址传出去,不过即使你有机会去修改它,可能会面临程序 core dump 的风险。

但是在 go 里我们知道,是允许将局部变量的地址传到外面进行访问的。这意味着 ​​x​​ 的生命期被延长。

2. 作用域

其实你可以跳过这一节,但是最好不要。在讲作用域前,先来明确语法块和词法块的概念。

2.1 语法块

在 go 里,使用花括号括起来的部分,称为语法块。比如函数体,循环体。

func f() {
var a int = 5
var b int = 6
var c int

语法块内部声明的变量对外部是不可见的。

2.2 词法块

包含了一组声明和语句的代码片段,称为词法块(不一定非得要花括号)。比如,语法块是词法块的一个特例。还有一个特殊的例子就是整个程序所构成的源码也是一个词法块,它比较特殊,有个单独的名字,叫全局词法块

还有很多例子,比如一个 package 构成包词法块,一个文件构成文件词法块,for、if、switch 语句所包含的词法块。

你需要注意的就是词法块的概念比语法块更大,语法块只是词法块的一种。

2.3 作用域

那么问题来了,作用域是什么?首先明确一下,作用域表示的是范围,接下来就是范围大小的问题。

  • 一条声明语句所在的词法块的范围决定了变量的作用域的范围。
  • goto, break, continue 后面的标签的作用域,在当前函数内部。
  • 作用域是可以嵌套的。
  • 整个程序源码所在的词法块决定了全局作用域范围。
  • 包级声明位于全局作用域,但是是否能在其它包中访问取决于名字是否以大写字母开头。

注意:go 的 goto 和 c 的一样,后面跟着标签。go 的 break 和 continue 还有另外一种用法,就是 break 和 continue 后面也可以加标号,用在 for 循环里。这是一种扩展的语法,以后遇到了再说。

当编译器查找一个名字的时候,首先从最内层的作用域开始查找,一层一层往外找,直到全局作用域。如果最后在全局作用域还找不到,编译器报错。

3. 示例

下面的例子都在目录 ​​gopl/programstructure/scope​​ 下面。下面的 4 个例子非常重要,请务必运行一遍。

  • 例1
// demo01.go
package main
import "fmt"
var g int = 100

func test() {
var x int = 5
fmt.Println(x) // ok
}

func main() {
var local int = 10
fmt.Println(g) // ok
fmt.Println(local) // ok
fmt.Println(x) // not ok
  • 例2
// demo02.go
package main
import "fmt"
func main() {
var x = "hello!"
for i := 0; i < len(x); i++ {
var x = x[i] // 这两个 x 位于不同词法块,作用域也不同
if x != '!' {
x := x + 'A' - 'a'
fmt.Printf("%c", x) // "HELLO"

上面的 for 语句创建了两个作用域:

  1. 花括号括起来的那部分
  2. 循环初始化部分 + 条件表达式 + 自增语句 + 花括号部分

也就是说,作用域 2 包含了作用域 1. (​​if else​​​ 条件语句也和 ​​for​​ 类似).

  • 例 3
package main

import "fmt"

func main() {
if x := 5; x == 0 {
fmt.Println(x)
var z = 8
} else if y := 6; y == 0 {
fmt.Println(x)
} else {
fmt.Println(x)
fmt.Println(y)
fmt.Println(z) // not ok

分析一下,第一个 if 创建了三个作用域。

  1. 比较容易看出来的是 if 后面第一个花括号的部分。
  2. 第二部分就是第二个 else 语句开始到最后一个 else 结束。
  3. 第三部分则是从 if 开始一直到最后一个 else 结束的部分。

这很难看出来,但是上面的程序实际就是下面的程序的简写,我相信看完下面的代码,你就明白了:

// demo04.go
package main

import "fmt"

func main() {
if x := 5; x == 0 {
fmt.Println(x)
var z = 8
} else {
if y := 6; y == 0 {
fmt.Println(x)
} else {
fmt.Println(x)
fmt.Println(y)
fmt.Println(z) // not ok,z 在这里不可见
  • 例 4
// demo05.go
package main

import "fmt"

func main() {
fmt.Println(g) // ok,尽管 g 声明在后面,但是这里仍然可以使用它
fmt.Println(local) // not ok.
var local int = 10
}

var g int = 100

这个例子想说明的是,包级声明的变量,顺序是无头紧要的,你可以在声明语句的上方就使用它。但是比包级作用域更小的作用域内声明变量就必须要按照顺序来(这种变量称为局部变量),一个变量必须要在使用前声明,否则会报错。

4. 变量的生命期

生命期是运行时的概念。

对于包级变量来说,它的生命期和整个程序的运行周期是一致的。程序运行结束,包级变量的生命期也随之结束。

但是局部变量的生命期是动态的:从变量被创建开始,直到该变量没有被引用为止。但是变量没有被引用,不意味着它的内存会被立即回收,可能会有一点延时,这取决于 go 的垃圾回收算法实现。

在 go 里,函数里的局部变量不一定是在栈上分配内存,使用 new 也不一定就非得在堆上分配内存。

举个例子:

var global *int

func f() {
var x int = 1 // 这个 x 必须在堆上来分配内存
global = &x
}

func g() {
y := new(int) // 这个 y 可以在堆上分配内存,也可以在栈上分配内存
*y = 1

上面的 ​​x​​​ 变量将自己的地址赋值到全局变量 ​​global 上​​​,这意味着在函数外面也能访问到 ​​x​​​. 没错, ​​x​​ 的生命期被延长为了整个程序的生命周期。

用 go 的专业术语是这样说的:​x escapes from ​f​,翻译成中文是:​​x​​​ 从函数 ​​f​​ 中逃逸。

而变量 ​​*y​​​并未发生逃逸,所以 ​​*y​​ 可能在栈上分配内存,也可能在堆上分配内存。

5. 总结

这一篇可能是你学习以来最难的一篇了,不要担心,多读几遍,理解下面几个关键的概念就算 ok.

  • 语法块
  • 词法块
  • 作用域
  • 生命期
  • 逃逸