Go 语言作用域

基本概念

作用域(Scope)是指代码中定义的变量、常量、函数或类型在程序中可被访问的区域。在 Go 语言中作用域分为 3 种:包级作用域块级作用域文件级作用域

预定义标识符(如内置函数和类型)的作用域覆盖整个项目,而自定义标识符的作用域取决于声明位置。

包级作用域

包级作用域也叫全局作用域,指在包顶层(代码块外)声明的标识符,可以在包内任何文件、任何位置访问:

package main

import "fmt"

var global = "全局可见"

func printGlobal() {
	fmt.Println(global) // 访问全局变量
}

func main() {
	printGlobal()
	fmt.Println(global) // 在 main 中也可以访问

	// 访问另一个 main 包文件中的全局变量
	// 由于是 main 包,所以在编译运行时需要指定所有源文件
	// 否则会提示找不到另一个文件中定义的全局变量
	fmt.Println(packageScope)
}

使用规范

一般要避免在包级别声明变量,需要时使用常量来储存不可变的配置值,原因如下:

  • 安全性:全局变量在整个包范围内可见,可能会导致意外副作用,特别在并发程序中,无法很好地处理。
  • 维护性:变量的全局状态让代码难以测试和调试。
  • 操控性:不易控制全局变量的生命周期,容易导致资源泄漏。
  • 全局常量:全局常量不可变,没有上述问题,使用常量可以确保代码的稳定性。

全局标识符允许定义而不使用,这点和局部标识符不同,原因如下:

  • 代码质量:声明局部变量而不用的原因,可能是代码未完成、垃圾代码,或拼写错误。编译器通过报错来提醒开发者修复潜在问题。
  • 资源管理:局部变量会占用栈空间,栈空间容量有限。
  • 无副作用:全局标识符生命周期和程序绑定,在程序启动时初始化,在结束时销毁,不存在副作用。
  • 跨包依赖:全局标识符一般在不同包之间传递使用,而定义包本身可能并不需要使用,属于正常模块化设计。

导出

包级作用域的标识符根据能否在包外访问,又分为可导出与未导出:

  • 可导出:标识符以大写字母开头,可以在包外访问。
  • 未导出:标识符以小写字母开头,只能在包内访问。

通过导入包,可以将包级别标识符的作用域扩展到使用这些包的文件中。假设有个 config 包,包含可导出函数和变量:

// Package config 存放程序配置
package config

import "fmt"

// MaxConnections 是一个可导出全局变量,表示最大连接数
var MaxConnections int = 100

// version 是不可导出常量,只能在 config 包中访问
const version = 0.1

// ShowVersion 函数访问 version 常量
func ShowVersion() {
	fmt.Println("版本号:", version)
}

在主函数导入 config 包后,只能看到包内可导出标识符:

package main

import (
	"fmt"
	"new/internal/config"
)

func main() {
	// 访问可导出变量
	fmt.Println("最大连接数:", config.MaxConnections)

	// 修改可导出变量
	config.MaxConnections = 200
	fmt.Println("更新最大连接数到:", config.MaxConnections)

	// 报错没找到,引用包中未导出常量 version
	//fmt.Println(config.version)

	// 访问可导出函数,间接访问常量 version
	config.ShowVersion()
}

块级作用域

块级作用域也叫局部作用域,指在代码块内声明的标识符,只能在代码块内使用。包括在函数内部、if 语句、for 循环、switch 语句以及任何 {} 代码块内声明的标识符:

package main

import "fmt"

func f() int {
	a := 1         // 在函数代码块中定义局部变量
	fmt.Println(a) // 函数内部可见
	return a + 1
}

func main() {
	b := f()
	if b != 0 {
		fmt.Println(b) // 可调用代码块外部变量
		c := 3         // 在 if 代码块中定义局部变量
		fmt.Println(c) // if 代码块内可见
	}

	//fmt.Println(a) // 无法调用其他函数内变量
	//fmt.Println(c) // 无法调用 if 代码块内变量
}

简单来说,在内部代码块中可以访问外部代码块中标识符,反之则不行。

遮蔽

如果在内部作用域中声明与外部作用域同名的标识符,则外部标识符在内部会被暂时遮蔽:

package main

import "fmt"

var a = 1

func printValue() {
	fmt.Println(a) // 显示全局变量值,输出:1
}

func main() {
	a := "hello"   // 局部变量屏蔽全局变量
	fmt.Println(a) // 显示局部变量值,输出:hello
	printValue()
}

原因是编译器从内层作用域向外寻找标识符,在内层先找到则直接使用。

函数作用域

特指函数签名中定义的参数和返回值,属于函数作用域,仅在函数体内可见:

package main

import "fmt"

func f(s string) (i int) {
	fmt.Println(s) // 输出:go
	fmt.Println(i) // 输出:0

	// 无需定义,直接赋值
	s += " lang"
	i = len(s)
	return i
}

func main() {
	f("go")

	//fmt.Println(s, i) // 错误:均超出作用域
}

实际上调用函数 f 时,会先在函数内部对参数 s 和返回值 i 初始化赋值,这一过程对开发者隐藏,开发只需直接使用即可。

作用域延续

if 语句中,初始化语句定义的变量作用域边界比较特殊,会延续到语句后面代码块中:

package main

import "fmt"

func main() {
	if a := 1; false {
		fmt.Println(a)
		// 不能引用下面语句定义的块级变量
		// 此时 b 还未初始化
		//fmt.Println(b)
	} else if b := 2; a > b {
		// 可以引用上面语句定义的块级变量
		// a 已经初始化
		fmt.Println(a - b)
	} else {
		// 在最后 a 和 b 都可以引用
		fmt.Println(a + b)
	}
}

可以理解为,流程控制语句外层有个隐藏代码块,而初始化语句和判断条件属于平级关系。编译器展开后类似下面代码:

package main

import "fmt"

func main() {
	{ // 隐藏语句块
		// if 判断
		a := 1
		if false {
			fmt.Println(a)
			//fmt.Println(b) // 还无法解析
			return
		}

		// else if 判断
		b := 2
		if a > b {
			fmt.Println(a - b)
			return
		}

		// else 判断
		fmt.Println(a + b)
	}
	
	//fmt.Println(a, b) // 代码块外无法解析
}

文件级作用域

主要用于导入声明场景。包中一个源文件导入的外部包仅对该文件有效,同个包中其他文件如果需要相同包,也必须显式地导入。例如有一个 config 包,导入并使用第三方日志库:

package config

import "github.com/rs/zerolog/log"

func ShowVersion() {
	log.Print("版本号:", 1.1)
}

在主函数导入 config 包后,并不会自动导入第三方日志库,必须手动导入才能使用:

package main

import (
	"github.com/rs/zerolog/log" // 再次导入
	"new/internal/config"
)

func main() {
	log.Print("调用其他包函数")
	config.ShowVersion()
}

Go 语言通过设定文件级作用域,让每个文件都可独立编译,防止循环依赖。