变量的作用域是指程序代码中可以有效使用这个变量的范围。不要将作用域和生命期混在一起。作用域是代码中的一块区域,是一个编译期的属性;生命期是程序运行期间变量存活的时间段,在此时间段内,变量可以被程序的其它部分所引用,是运行期的概念。
语法块是包含在花括号内的一系列语句,例如函数体或者循环体。语法块内部声明的变量是无法被语法块外部代码访问的。我们可以扩展局部语法块的概念,在某些场景下,是不需要花括号的,这种形式称之为词法块。词法块分为几种:全局词法块,包含所有源代码;包 词法块,包含整个package;文件词法块,包含整个文件;for、if、switch语句的词法块;switch或select中的case分支的词法块;当然也包含之前提到的语法块。
声明语句的词法块决定了变量的作用域。Go语言的内置类型、内置函数、内置常量都是全局词法块,因此它们都是全局作用域的,例如int、len、true等,可以在整个程序直接使用;对于导入的包,例如temconv导入的fmt包,是文件词法块,因此只能在当前文件中访问fmt包,这里fmt是全文件范围的作用域;tempconv.CToF函数中的变量c,则是局部词法块(语法块)的,因此它的作用域是函数的内部。
控制语句后面的标签(label),例如break、continue或goto后的标签,它们的作用域是在控制语句所在的函数内部。
一个程序可能会有多个相同的变量名,只要它们的声明在不同的词法块就好。例如,你可以在函数内声明一个局部变量x,同时再声明一个包级的变量x,这是在函数内部,局部变量x就会替代后者,这里称之为shadow,意味着在函数作用域内局部变量将包变量隐藏了。
当编译器遇到一个变量名的引用时,会去搜索该变量的声明语句,首先从最内部的词法块开始,然后直到全局词法块。如果编译期找不到变量名的声明语句,那么就会报错:undeclared name。如果变量名在内部的词法块和外部的词法块同时声明,那么根据编译期的搜索规则,内部的词法块会先找到。在这种情况下,内部的声明会隐藏外部的声明(shadow),此时,外部声明的变量是无法访问的:
func f() {} var g = "g" func main() { f := "f" fmt.Println(f) // "f"; 本地变量f隐藏了包级函数f fmt.Println(g) // "g"; 包级变量g fmt.Println(h) // compile error: undefined: h }
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变量,后面会解释。
就像之前提到的那样,不是所有的词法块都有显式的花括号。上面的for循环创建了两个词法块:带花括号的循环主体,显式词法块;还有不带花括号的隐式词法块,例如for循环的条件语句中声明一个变量i。这里i的作用域包含for的条件语句和for的主体。
下面的例子也创建了三个变量x,每个都在不同的词法块中声明,一个在函数主体中,一个在for的隐式词法块中,还有一个在显式词法块-循环主体中,这其中只有1和3是显式词法块:
func main() { x := "hello" for _, x := range x { x := x + 'A' - 'a' fmt.Printf("%c", x) // "HELLO" (每次循环一个字符) } }就像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语句里声明的变量对第二个if语句是可见的。在switch中也有类似的规则:除了条件词法块外,每个case也有自己的词法块。
对于包级变量来说,声明的顺序和作用域是无关的,所有一个包级变量声明时可以引用它自身也可以引用在它之后声明的包级变量,然而,如果一个变量或者常量在声明时引用了它自己,编译器会报错。
看下面的程序:
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 ff的作用域仅仅是if语句,因此在if之外的词法块是不可访问的,报编译错误。
这里可以更改代码,提前声明f变量:
f, err := os.Open(fname) if err != nil { return err } f.ReadByte() f.Close()如果不想在外部词法块声明变量,可以这么写:
if f, err := os.Open(fname); err != nil { return err } else { // f and err are visible here too f.ReadByte() f.Close() }但是第三种不是Go推荐的写法,第二种比较合适,将正常逻辑和错误处理分离。
短声明变量的作用域是要特别注意的,考虑下面的程序,开始时会获取当前的工作目录,保存在一个包级变量中。这个本来可以通过在main函数中调用os.Getwd来完成,但是用init函数将这块儿逻辑从主逻辑中分离是一个更好的选择,特别是因为获取目录的操作可能会是失败的,这个时候需要处理返回的错误。函数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和err在init的词法块中都没有声明过,因此 := 语句会将它们两声明为本地变量。init内部的cwd声明会隐藏外部的,因此这个程序没有达到更新包级变量cwd的目的。
当前版本,Go编译器会检测到本地变量cwd从未使用,因此会报错,但是这种检查并不是很严格,例如,如果在log.Fatalf中打印cwd的值(这时本地变量cwd会被使用),那么这种错误就会被隐藏!!!
var cwd string func init() { cwd, err := os.Getwd() // 注意这里的包级cwd被本地隐藏了,但是编译器没有报错! if err != nil { log.Fatalf("os.Getwd failed: %v", err) } log.Printf("Working directory = %s", cwd) }这里全局变量cwd没有得到正确的初始化,同时,log函数使用了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) } }
在这一章里,我们简单学习了包,文件,声明,语句等。在接下来的两章,我们会学习数据结构。
PS. 这一章真心很难写,足足用了3个小时。作为质量对比大家可以参见这篇文章Scope。
文章所有权:Golang隐修会 联系人:孙飞,CTO@188.com!
有疑问加站长微信联系(非本文作者)