第 4 章:变量作用域

阅读本课程能够帮助你:

  • 知悉变量作用域的好处

  • 学会用更简洁的方式声明变量

  • 了解到 forifswitch 是如何与变量作用域相互协作的

  • 学会如何控制作用域的范围

在程序运行的过程中,很多变量都会在短暂使用之后被丢弃,这是由编程语言的作用域规则决定的。

{请考虑这一点}

你可以在脑海里面一次记住多少东西?

据说人类的短期记忆最多只能记住大概 7 样东西, 比如七位数的电话号码就是一个很好的例子。

虽然计算机的短期记忆存储器或者说随机访问存储器(RAM)可以记住大量值, 但是别忘了, 程序代码除了需要被计算机读取之外, 还需要被人类阅读, 所以它还是应该尽可能地保持简洁。

与此类似, 如果程序中的变量可以随时修改又或者在任何位置随意访问, 那么光是跟踪大型程序中的变量就足以让人手忙脚乱。 变量作用域的好处是可以让程序员聚焦于特定函数或者部分代码的相关变量, 而不需要考虑除此之外的其他变量。

4.1 审视作用域

变量从声明之时开始就处于作用域当中,换句话来说变量就是从那时开始变为可见的(visible)。 只要变量仍然存在于作用域当中,程序就可以随时访问它,然而变量一旦离开作用域,尝试继续访问它将引发错误。

变量作用域的其中一个好处是让我们可以为不同的变量重用相同的名字。 因为除了极少数小型程序之外,程序的变量几乎不可能不出现重名。

除此之外,变量作用域还能够帮助我们更好地阅读代码,让我们无需在脑海里记住所有变量。 毕竟一旦某个变量离开了作用域,我们就不必再关心它了。

../_images/watch.jpg

Go 的作用域通常会随着大括号 {} 的出现而开启和结束。 在接下来展示的代码清单 4-1 中,main 函数开启了一个作用域,而 for 循环则开启了一个嵌套作用域。


代码清单 4-1 作用域规则: scope.go

package main

import (
    "fmt"
    "math/rand"
)

func main() {
    var count = 0

    for count < 10 {    // 开启新的作用域
        var num = rand.Intn(10) + 1
        fmt.Println(num)

        count++
    }   // 作用域结束
}

因为 count 变量的声明位于 main 函数的函数作用域之内,所以它在 main 函数结束之前将一直可见。 反观 num 变量,因为它的声明位于 for 循环的作用域之内,所以它在循环结束之后便不再可见。

尝试在循环结束之后访问 num 变量将引发 Go 编译器报错。 与之相对的是,因为 count 变量的声明位于 for 循环之外,所以即使在循环结束之后,程序也可以在有需要的时候继续访问 count 变量。 另一方面,如果我们想要把 count 变量也限制在循环的作用域之内,那么就需要用到在 Go 中声明变量的另一种方式。

{速查 4-1}

  1. 变量作用域对我们有什么好处?

  2. 变量在脱离作用域之后会发生什么事情? 请修改代码清单 4-1 , 尝试在循环结束之后访问 num 变量, 看看会什么发生什么事情?

{速查 4-1 答案}

  1. 作用域可以让我们在多个不同的地方使用相同的变量名而不会引发任何冲突, 并且在编程的时候只需要考虑位于当前作用域之内的变量即可。

  2. 脱离作用域的变量将变得不可见并且无法访问。 尝试在 num 变量的作用域之外访问它将导致 Go 编译器报告以下错误:undefined: num

4.2 简短声明

简短声明为 var 关键字提供了另一种备选语法。 以下两行代码是完全等效的:

var count = 10
count := 10

初看上去,少键入两三个字符似乎不算什么,但小数怕长计,所以简短声明还是要比 var 关键字流行得多。 更重要的是,简短声明还可以用在一些 var 关键字无法使用的地方。

代码清单 4-2 展示了 for 循环的另一种形式,它包含了初始化语句、比较条件语句以及对 count 变量执行减法运算的后置语句。 在使用这种形式的 for 循环时,我们需要依次向循环提供初始化语句、比较条件语句和后置语句。


代码清单 4-2 更简洁的倒数程序: loop.go

var count = 0

for count = 10; count > 0; count-- {
    fmt.Println(count)
}

fmt.Println(count)  // count 变量仍然处于作用域之内

在不使用简短声明的情况下,count 变量的声明必须放置在循环之外,这意味着 count 变量将在循环结束之后继续存在于作用域。

但是正如代码清单 4-3 所示,在使用简短声明的情况下,count 变量的声明和初始化将成为 for 循环的一部分,并且该变量将在循环结束之后脱离作用域,而尝试在循环之外访问 count 变量将导致 Go 编译器报告 undefined: count 错误。


代码清单 4-3 在 for 循环中使用简短声明: short-loop.go

for count := 10; count > 0; count-- {
    fmt.Println(count)
}    // 随着循环结束,count 变量将不再处于作用域之内。

{提示}

为了代码的可读性考虑, 声明变量的位置和使用变量的位置应该尽可能地贴近。

除了 for 循环之外,简短声明还可以在 if 语句里面声明新的变量。 比如代码清单 4-4 中的 num 变量就可以用在 if 语句的所有分支当中。


代码清单 4-4 在 if 语句中使用简短声明: short-if.go

if num := rand.Intn(3); num == 0 {
    fmt.Println("Space Adventures")
} else if num == 1 {
    fmt.Println("SpaceX")
} else {
    fmt.Println("Virgin Galactic")
}    // 随着 if 语句结束,num 变量将不再处于作用域之内。

正如代码清单 4-5 所示,跟 if 语言一样,简短声明也可以用在 switch 语句里面。


代码清单 4-5 在 switch 语句中使用简短声明: short-switch.go

switch num := rand.Intn(10); num {
case 0:
    fmt.Println("Space Adventures")
case 1:
    fmt.Println("SpaceX")
case 2:
    fmt.Println("Virgin Galactic")
default:
    fmt.Println("Random spaceline #", num)
}

{速查 4-5}

如果代码清单 4-4 和 4-5 不使用简短声明, 那么 num 变量的作用域将产生何种变化?

{速查 4-2 答案}

因为 if 语句、 switch 语句和 for 语句只能使用业已声明的变量, 所以在不使用简短声明的情况下, 程序只能在 if 等语句的前面声明 num 变量, 从而导致该变量在 if 等语句结束之后仍然存在于作用域。

4.3 作用域的范围

代码清单 4-6 展示的程序能够生成并显示一个随机的日期(这个日期也许就是我们启程去火星的日期)。 除此之外,这个程序还演示了 Go 中的几种不同的作用域,并阐明了在声明变量时考虑作用域的重要性。


代码清单 4-6 变量作用域规则: scope-rules.go

package main

import (
    "fmt"
    "math/rand"
)

var era = "AD"      // era 变量在整个包都是可用的。

func main() {
    year := 2018    // era 变量和 year 变量都处于作用域之内。

    switch month := rand.Intn(12) + 1; month {  // 变量 era 、year 和 month 都处于作用域之内。
    case 2:
        day := rand.Intn(28) + 1                // 变量 era 、year 、month 和 day 都处于作用域之内。
        fmt.Println(era, year, month, day)
    case 4, 6, 9, 11:
        day := rand.Intn(30) + 1                // 这两个 day 变量是全新声明的变量,跟上面声明的同名变量并不相同。
        fmt.Println(era, year, month, day)
    default:
        day := rand.Intn(31) + 1                // 这两个 day 变量是全新声明的变量,跟上面声明的同名变量并不相同。
        fmt.Println(era, year, month, day)
    }   // month 变量和 day 变量不再处于作用域之内。
}       // year 变量不再处于作用域之内。

因为 era 变量的声明位于 main 函数之外的包作用域中,所以它对于 main 包中的所有函数都是可见的。

{注意}

因为包作用域不允许使用简短声明, 所以我们无法在这个作用域中使用 era := "AD" 来进行声明。

year 变量只在 main 函数中可见。 如果包中还存在着其他函数,那么它们将会看见 era 变量,但是却无法看到 year 变量。 函数作用域比包作用域要狭窄,它起始于 func 关键字,并终结于函数声明的右大括号。

month 变量在整个 switch 语句的任何位置都可见,不过一旦 switch 语句结束,month 就不再处于作用域之内了。 switch 语句的作用域始于 switch 关键字,并终结于 switch 语句的右大括号。

因为 switch 的每个 case 分支都拥有自己独立的作用域,所以三个分支分别拥有三个独立的 day 变量。 在每个分支结束之后,该分支声明的 day 变量将不再处于作用域之内。 switch 分支的作用域是唯一一种无需使用大括号标识的作用域。

代码清单 4-6 中的代码距离完美还有相当远的一段距离。 变量 monthday 狭窄的作用域导致 Println 重复出现了三次,这种代码重复可能会引发修改,并因此导致错误。 比如说,我们可能会决定不再在每个分支中都打印 era 变量,但是却忘记了修改某个分支。 在某些情况下,出现代码重复是正常的,但这种情况通常被认为是代码的坏味道,需要谨慎地处理。

为了消除重复并简化代码,我们需要将代码清单 4-6 中的某些变量声明移动到范围更宽广的函数作用域中,使得这些变量可以在 switch 语句结束之后继续为程序所用。 为此,我们需要对代码实施重构,也即是在不改变代码行为的基础上对代码进行修改。 重构得出的代码清单 4-7 跟之前的代码行为完全相同,它们都可以选取并打印出随机的日期。


代码清单 4-7 重构后的随机日期选取程序: random-date.go

package main

import (
    "fmt"
    "math/rand"
)

var era = "AD"

func main() {
    year := 2018
    month := rand.Intn(12) + 1
    daysInMonth := 31

    switch month {
    case 2:
        daysInMonth = 28
    case 4, 6, 9, 11:
        daysInMonth = 30
    }

    day := rand.Intn(daysInMonth) + 1
    fmt.Println(era, year, month, day)
}

尽管狭窄的作用域有助于减少脑力负担,但代码清单 4-6 的例子也表明了过分约束变量将损害代码的可读性。 在遇到这种问题的时候,我们应该根据具体情况逐步实施重构,直到代码的可读性能够满足我们的要求为止。

{速查 4-3}

请说出一种能够鉴别变量是否被约束得太紧的方法。

{速查 4-3 答案}

如果代码重复是由变量声明引起的, 那么变量可能就是被约束得太紧了。

4.4 课后小结

  • 左大括号 { 开启一个新的作用域而右大括号 } 则结束该作用域。

  • 虽然没有用到大括号,但关键字 casedefault 也都引入了新的作用域。

  • 声明变量的位置决定了变量所处的作用域。

  • 简短声明不仅仅是 var 声明的快捷方式,它还可以用在 var 声明无法使用的地方。

  • for 语句、 if 语句和 switch 语句所在行声明的变量,其作用域将持续至语句结束为止。

  • 宽广的作用域有时候会比狭窄的作用域更好,反之亦然。

为了检验你是否已经掌握了上述知识,请尝试完成以下实验。

实验: random-dates.go

请修改代码清单 4-7 ,让它可以处理闰年:

  • 生成一个随机年份而不是一直使用 2018 年。

  • 如果生成的年份为闰年,那么将一月份的 daysInMonth 变量的值设置为 29 ,反之则将其设置为 28 。 提示:你可以在 case 代码块的内部放置 if 语句。

  • 使用 for 循环生成并显示 10 个随机日期。