Go的奇特之处1--Go语言的作用域

作用域是指一个变量或函数在代码中能够有效地使用这个名称的范围。这个概念可能是老生常谈,在所有语言中都有这个概念,但是Go语言的作用域比较奇特。
为了行文方便,我把由一对花括号{ }框起来的区域叫做句法块(其实这是在Go语言圣经上看到的概念)。在一个句法块内定义的变量或函数在这个句法块外是不能被调用的,你可以在一个句法块外定义一个相同名称的变量或函数,但那不同于句法块内的变量和函数,相当于生成了另外一个同名的东西。举个例子,如果一个变量x被定义在了一个句法块中,并赋值为1,那么调用x的时候,只在其句法块范围内值为1,如果在其作用域外定义了另一个x,那这个外面的x就不是句法块内的x,其值也就不一定是1了。
这些概念看起来可能有点别扭,举一个简单的示例:

1
2
3
4
5
6
7
8
9
// ...以上省略若干代码
a := 1
flag := true
if flag {
a = 2
fmt.Println("inside: ", a)
}
fmt.Println("outside: ", a)
// ...以下省略若干代码

以上这段代码大家都知道,会输出:

1
2
inside:  2
outside: 2

但是,Go语言的神奇之处在于,他允许你在if的句法块里再定义一个同名变量a:

1
2
3
4
5
6
7
8
9
// ...以上省略若干代码
a := 1
flag := true
if flag {
a := 2
fmt.Println("inside: ", a)
}
fmt.Println("outside: ", a)
// ...以下省略若干代码

以上这段代码能够编译成功并运行,输出结果是这样:

1
2
inside:  2
outside: 1

惊不惊喜,意不意外?
Go语言的机制就是这样,允许在句法块内定义同名的变量或函数。再举几个更加奇特的例子:

1
2
3
4
5
6
7
8
// ...以上省略若干代码
a := []int{1, 2, 3, 4, 5}
for _, a := range a {
a++
fmt.Println("inside a: ", a)
}
fmt.Println("outside a: ", a)
// ...以下省略若干代码

以上代码是可以编译并运行的。在了解了Go语言作用域的概念后,以上这段代码的输出就可以理解了:

1
2
3
4
5
6
inside a:  2
inside a: 3
inside a: 4
inside a: 5
inside a: 6
outside a: [1 2 3 4 5]

还有可以定义和包级变量同名的变量:

1
2
3
4
5
6
7
8
9
10
11
// ...以上省略若干代码
var a = 1
func main() {
a := 2
fmt.Println("main a: ", a)
printA()
}
func printA() {
fmt.Println("global a: ", a)
}
// ...以下省略若干代码

输出为:

1
2
main a:  2
global a: 1

当然,以上的代码虽然说逻辑正确,但是代码风格不太好,建议大家不要学着这样做,否则可能会让你的同事review你的代码时一头雾水,影响日常搬砖效率。

看了这些例子后,除了感觉Go语言的作用域很奇特之外,能够对我们的日常搬砖工作造成什么影响呢?下面举两个工作中可能会遇到的相关问题。

Go语言奇特的作用域可能会造成Bug(从Go语言圣经上抄的一个例子):

1
2
3
4
5
6
7
8
var cwd string

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

以上这段代码的本意是获取当前路径并赋值给包级变量cwd,但是由于开发人员想偷懒写成这样。由于使用了快速赋值,在init()内又生成了一个跟包级变量同名的变量cwd,这样编译都通过不了,因为init()中的cwd未使用过。当然,现在的IDE比如GoLand都会在变量下标红色波浪线提示你这个变量没有被使用过,但多注意还是好的,万一哪一天你就到vim上写代码去了呢。
那么,如何改进这段代码,以达到最初始的目的呢?
很简单,不要用快速赋值,走传统语言先定义、后赋值的老路,事先定义一个变量err

1
2
3
4
5
6
7
8
9
var cwd string

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

这样的话,在init()内并没有新定义一个名为cwd的变量,而是对包级变量cwd进行了赋值。

那么,除了能够规避错误,理解了Go语言的作用域还能有啥用不?
比尔盖茨曾经说过:”I choose a lazy person to do a hard job. Because a lazy person will find an easy way to do it.” Go语言奇特的作用域还可以用来偷懒,特别是对于懒得想新变量名并且懒得写注释的懒癌晚期患者来说,是一个特别实用的feature。
比如Go语言饱受诟病的异常捕捉方式,返回的错误显式地写在了返回值里,那么根据Go语言奇特的作用域,这种写法也可以:

1
2
3
4
5
6
7
8
9
10
// ...以上省略若干代码
value, err := getValue()
if shouldRecord {
err := record()
if err != nil {
log.Println("record failed: ", err) // err is the returned error of record()
}
}
return value, err // err is the returned error of getValue()
// ...以下省略若干代码

以上代码的目的是获取值并返回,中间有个记录的步骤,如果记录失败则打印在日志里。那么在Go语言中,就不必再为record定义一个新错误名,直接用err即可,因为在if的句法块中,err相当于一个重新申请的一个变量,跟外面的那个err不是一个东西。通过类似的方法,巧用Go语言的作用域,可以节省想新变量名的时间和精力,也不用让同事被一个新出现的变量名抓耳挠腮,也可以不用专门写注释,做到了轻松、高效地搬砖,并改善了与同事之间的关系,何乐而不为呢。

以上就是Go语言作用域的奇特之处以及程序员可以如何利用这个奇特之处的小技巧。希望可以对看到这篇博客的人有所帮助。
最后打个小广告,由于我写Go语言也只有不到半年的时间,并且是完全由于工作需要。为了锻炼自己的搬砖能力,我开了一个GitHub的仓库用来刷leetcode,主要按题目类型分类来刷,烦请看到的各位star一波:
https://github.com/guanhonly/leetcode-solutions-in-Go

分享到