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