023-作用域(Scope)与生命期(Lifetime)

作用域和生命期的概念可以借鉴 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
    c = a + b
}

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

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.

  • 语法块
  • 词法块
  • 作用域
  • 生命期
  • 逃逸
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值