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 语言通过设定文件级作用域,让每个文件都可独立编译,防止循环依赖。