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)
    }
}

我们已经看到包, 文件, 声明和语句如何来表达一个程序结构. 在下面的两个章节, 我们将探讨数据的结构.

译注: 本章的词法域和作用域概念有些混淆, 需要重译一遍.