2.7. 作用域
一个声明语句将程序中的实体和一个名字关联, 比如一个函数或一个变量. 声明的作用域是指源代码中可以有效使用这个名字的范围.
不要将作用域和生命周期混为一谈. 声明的作用域对应的是一个源代码的文本区域; 它是一个编译时的属性. 一个变量的生命周期是程序运行时变量存在的有效时间段, 在此时间区域内存它可以被程序的其他部分引用. 是一个运行时的概念.
语法块是由花括弧所包含的一系列语句, 就像函数体或循环体那样. 语法块内部声明的名字是无法被外部语法块访问的. 语法决定了内部声明的名字的作用域范围. 我们可以这样理解, 语法块可以包含其他类似组批量声明等没有用花括弧包含的代码, 我们称之为词汇块. 有一个语法决为整个源代码, 称为全局块; 然后是每个包的语法决; 每个 for, if 和 switch 语句的语法决; 每个 switch 或 select 分支的 语法决; 当然也包含显示编写的语法块(花括弧包含).
声明的词法域决定了作用域范围是大还是小. 内置的类型, 函数和常量, 比如 int, len 和 true 等是在全局作用域的, 可以在整个程序中直接使用. 任何在在函数外部(也就是包级作用域)声明的名字可以在同一个包的任何Go文件访问. 导入的包, 例如 tempconv 导入的 fmt 包, 则是对应文件级的作用域, 因此只能在当前的文件中访问 fmt 包, 当前包的其它文件无法访问当前文件导入的包. 还有许多声明, 比如 tempconv.CToF 函数中的变量 c, 则是局部作用域的, 它只能在函数内部(甚至只能是某些部分)访问.
控制流标签, 例如 break, continue 或 goto 后面跟着的那种标签, 则是函数级的作用域.
一个程序可能包含多个同名的声明, 只有它们在不同的词法域就没有关系. 例如, 你可以声明一个局部变量, 和包级的变量同名. 或者是 2.3.3节的那样, 你可以将一个函数参数的名字声明为 new, 虽然内置的new是全局作用域的. 但是物极必反, 如果滥用重名的特性, 可能导致程序很难阅读.
当编译器遇到一个名字引用, 它看起来像一个声明, 它首先从最内层的词法域向全局的作用域查找. 如果查找失败, 则报告 "未声明的名字" 这样的错误. 如果名字在内部和外部的块分别声明, 则内部块的声明首先被找到. 在这种情况下, 内部声明屏蔽了外部同名的声明, 让外部的声明无法被访问:
func f() {}
var g = "g"
func main() {
f := "f"
fmt.Println(f) // "f"; local var f shadows package-level func f
fmt.Println(g) // "g"; package-level var
fmt.Println(h) // compile error: undefined: h
}
在函数中词法域可以深度嵌套, 因此内部的一个声明可能屏蔽外部的声明. 还有许多块是if或for等控制流语句构造的. 下面的代码有三个不同的变量x, 因为它们是定义在不同的词法域的原因. (这个例子只是为了演示作用域规则, 但不是好的编程风格.)
func main() {
x := "hello!"
for i := 0; i < len(x); i++ {
x := x[i]
if x != '!' {
x := x + 'A' - 'a'
fmt.Printf("%c", x) // "HELLO" (one letter per iteration)
}
}
}
在 x[i]
和 x + 'A' - 'a'
声明初始化的表达式中都引用了外部作用域声明的x变量, 稍后我们会解释这个. (注意, 后面的表达式和unicode.ToUpper并不等价.)
正如上面所示, 并不是所有的词法域都显示地对应到由花括弧包含的语句; 还有一些隐含的规则. 上面的for语句创建了两个词法域: 花括弧包含的是显式的部分是for的循环体, 另外一个隐式的部分则是循环的初始化部分, 比如用于迭代变量 i 的初始化. 隐式的部分的作用域还包含条件测试部分和循环后的迭代部分(i++), 当然也包含循环体.
下面的例子同样有三个不同的x变量, 每个声明在不同的块, 一个在函数体块, 一个在for语句块, 一个在循环体块; 只有两个块是显式创建的:
func main() {
x := "hello"
for _, x := range x {
x := x + 'A' - 'a'
fmt.Printf("%c", x) // "HELLO" (one letter per iteration)
}
}
和佛如循环类似, if和switch语句也会在条件部分创建隐式块, 还有它们对应的执行体块. 下面的 if-else 测试链演示的 x 和 y 的作用域范围:
if x := f(); x == 0 {
fmt.Println(x)
} else if y := g(x); x == y {
fmt.Println(x, y)
} else {
fmt.Println(x, y)
}
fmt.Println(x, y) // compile error: x and y are not visible here
第二个if语句嵌套在第一个内部, 因此一个if语句条件块声明的变量在第二个if中也可以访问. switch语句的每个分支也有类似的规则: 条件部分为一个隐式块, 然后每个是每个分支的主体块.
在包级别, 声明的顺序并不会影响作用域范围, 因此一个先声明的可以引用它自身或者是引用后面的一个声明, 这可以让我们定义一些相互嵌套或递归的类型或函数. 但是如果一个变量或常量递归引用了自身, 则会产生编译错误.
在这个程序中:
if f, err := os.Open(fname); err != nil { // compile error: unused: f
return err
}
f.ReadByte() // compile error: undefined f
f.Close() // compile error: undefined f
变量 f 的作用域只有if语句内, 因此后面的语句将无法引入它, 将导致编译错误. 你可能会收到一个局部变量f没有声明的错误提示, 具体错误信息依赖编译器的实现.
通常需要在if之前声明变量, 这样可以确保后面的语句依然可以访问变量:
f, err := os.Open(fname)
if err != nil {
return err
}
f.ReadByte()
f.Close()
你可能会考虑通过将ReadByte和Close移动到if的else块来解决这个问题:
if f, err := os.Open(fname); err != nil {
return err
} else {
// f and err are visible here too
f.ReadByte()
f.Close()
}
但这不是Go推荐的做法, Go的习惯是在if中处理错误然后直接返回, 这样可以确保正常成功执行的语句不需要代码缩进.
要特别注意短的变量声明的作用域范围, 考虑下面的程序, 它的目的是获取当前的工作目录然后保存到一个包级的变量中. 这可以通过直接调用 os.Getwd 完成, 但是将这个从主逻辑中分离出来可能会更好, 特别是在需要处理错误的时候. 函数 log.Fatalf 打印信息, 然后调用 os.Exit(1) 终止程序.
var cwd string
func init() {
cwd, err := os.Getwd() // compile error: unused: cwd
if err != nil {
log.Fatalf("os.Getwd failed: %v", err)
}
}
虽然cwd在外部已经声明过, 但是 :=
语句还是将 cwd 和 err 重新声明为局部变量. 内部声明的 cwd 将屏蔽外部的声明, 因此上面的代码并不会更新包级声明的 cwd 变量.
当前的编译器将检测到局部声明的cwd并没有本使用, 然后报告这可能是一个错误, 但是这种检测并不可靠. 一些小的代码变更, 例如增加一个局部cwd的打印语句, 就可能导致这种检测失效.
var cwd string
func init() {
cwd, err := os.Getwd() // NOTE: wrong!
if err != nil {
log.Fatalf("os.Getwd failed: %v", err)
}
log.Printf("Working directory = %s", cwd)
}
全局的cwd变量依然是没有被正确初始化的, 而且看似正常的日志输出更是这个BUG更加隐晦.
有许多方式可以避免出现类似潜在的问题. 最直接的是通过单独声明err变量, 来避免使用 :=
的简短声明方式:
var cwd string
func init() {
var err error
cwd, err = os.Getwd()
if err != nil {
log.Fatalf("os.Getwd failed: %v", err)
}
}
我们已经看到包, 文件, 声明和语句如何来表达一个程序结构. 在下面的两个章节, 我们将探讨数据的结构.
译注: 本章的词法域和作用域概念有些混淆, 需要重译一遍.