Golang 第二章 7.作用域与生命周期

  • By v2ray节点

  • 2024-08-20 20:12:10

  • 评论

声明将名称与程序实体(如函数或变量)关联起来。声明的作用域是源代码中使用该声明名称的部分。

不要将作用域与生命周期混淆。声明的作用域是程序文本的一个区域,它是编译时属性。变量的生命周期是在程序执行过程中,其他部分可以引用该变量的时间范围,这是运行时属性。语法块是指由大括号包围的一系列语句,如函数或循环的主体。语法块中声明的名称在块外部是不可见的。块包围了其声明并确定其作用域。我们可以将块的概念推广到其他没有在源代码中明确被大括号包围的声明组,我们称它们为词法块。整个源代码都有一个词法块,称为全局块;每个包有一个词法块;每个文件有一个词法块;每个 forifswitch 语句有一个词法块;switchselect 语句中的每个 case 有一个词法块;当然,每个显式语法块也有一个词法块。

声明的词法块决定了其作用域,作用域可能大也可能小。内置类型、函数和常量(如 intlentrue)的声明位于全局块中,可以在整个程序中引用。在任何函数之外的声明,即包级别的声明,可以在同一包中的任何文件中引用。导入的包(如 tempconv 示例中的 fmt)在文件级别声明,因此可以在同一文件中引用,但不能在同一包的另一个文件中引用,除非再次导入。许多声明,如 tempconv.CToF 函数中的变量 c 的声明,是局部的,因此只能在同一函数内或其部分内引用。

控制流标签(如在 breakcontinuegoto 语句中使用的标签)的作用域是整个封闭函数。

一个程序中可以包含多个相同名称的声明,只要每个声明在不同的词法块中。例如,你可以声明一个与包级别变量同名的局部变量。或者,如2.3.3节所示,你可以声明一个名为 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 循环创建了两个词法块:一个是循环主体的显式块,另一个是额外包含初始化子句中声明的变量(如 i)的隐式块。隐式块中声明的变量的作用域包括条件、后置语句(i++)和 for 语句的主体。

下面的例子中也有三个名为 x 的变量,每个变量都在不同的块中声明——一个在函数体中,一个在 for 语句的块中,另一个在循环体中——但只有两个块是显式的:

func main() {
	x := "hello"
	for _, x := range x {
		x := x + 'A' - 'a'
		fmt.Printf("%c", x) // "HELLO" (one letter per iteration)
	}
}

和 for 循环类似,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 语句中,因此在第一个语句的初始化器中声明的变量在第二个语句中也是可见的。类似的规则也适用于 switch 语句的每个 case:其中包含条件的块以及每个 case 体的块。

在包级别,声明出现的顺序对其作用域没有影响,因此一个声明可以引用它自己或之后的另一个声明,这使得我们可以声明递归或相互递归的类型和函数。然而,如果一个常量或变量声明引用了自身,编译器会报告错误。

在这个程序中:

	if f, err := os.Open(fname); err != nil { // compile error: unused: f
		return err
	}
	f.Stat()  // compile error: undefined f
	f.Close() // compile error: undefined f

f 的作用域仅限于 if 语句,因此在后续的语句中无法访问 f,这会导致编译错误。根据编译器的不同,你可能还会收到一个额外的错误,提示本地变量 f 从未被使用。

因此,通常需要在条件之前声明 f,以便它在后续的代码中可以被访问,如下所示:

	f, err := os.Open(fname)
	if err != nil {
		return err
	}
	f.Stat()
	f.Close()

你可能会想通过将对 ReadByte 和 Close 的调用移到 else 块中,避免在外部块中声明 f 和 err。例如:

	if f, err := os.Open(fname); err != nil {
		return err
	} else {
		// f and err are visible here too
		f.Stat()
		f.Close()
	}


但在 Go 中的常见做法是,在 if 块中处理错误并立即返回,这样成功执行的路径就不会缩进。

短变量声明要求对作用域有一定的了解。考虑下面的程序,它首先获取当前工作目录并将其保存在一个包级变量中。这可以通过在 main 函数中调用 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)
	}
}

由于 cwderrinit 函数的块中都没有被声明,因此 := 语句将它们都声明为局部变量。cwd 的内部声明使得外部的 cwd 变量不可访问,因此该语句没有如预期那样更新包级的 cwd 变量。

当前的 Go 编译器能够检测到局部 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。

有多种方法来处理这个潜在的问题。最直接的方法是通过在单独的 var 声明中声明 err,从而避免使用 :=


var cwd string

func init() {
	var err error
	cwd, err = os.Getwd()
	if err != nil {
		log.Fatalf("os.Getwd failed: %v", err)
	}
}

我们现在已经了解了包、文件、声明和语句如何表达程序的结构。在接下来的两章中,我们将研究数据的结构。

v2ray节点购买