Hero Image of content
谈谈Go隐式代码块及作用域的规则

Go 的代码块是由一对大括号声明的,而这种直观可见的大括号包裹的代码块叫做显式代码块。但在 Go 中的 ifforswitchselect 语句却含有隐式的代码块,了解这些隐式代码块有助于我们理解变量的作用域及一些古怪的问题。

上面的语句的隐式代码块有如下规则:

  1. 每个 if、for 和 switch 语句外面均有一个隐式代码块
  2. switch 或 select 语句的每个子句都被视为一个隐式代码块

switch 语句外面不仅有隐式代码块,其子句也有隐式代码块。

下面对各个语句进行分析。

if 语句

这里分析的都是在条件之前有执行简短语句的 if 语句形式。比如:

if v := math.Pow(x, n); v < lim {
		return v
	}

单 if 语句

完整示例代码为 Go 官网的 A Tour of Go 教程 If with a short statement

我们分析示例的核心代码:

func pow(x, n, lim float64) float64 {
	if v := math.Pow(x, n); v < lim {
		return v
	}
}

通过规则,等价变换为:

func pow(x, n, lim float64) float64 {

    { // 隐式代码块起点
        v := math.Pow(x, n);
        if v < lim {
            return v
        }
    } // 隐式代码块终点

}

if 语句在变化中,条件之前的短变量声明语句 v := math.Pow(x, n); 被提到 if 语句外面,但在隐式代码块里面。变量v的作用域范围在隐式代码块起点到隐式代码块终点这段中。

if-else 语句

完整示例代码为 Go 官网的 A Tour of Go 教程 If and else

示例:

func pow(x, n, lim float64) float64 {
	if v := math.Pow(x, n); v < lim {
		return v
	} else {
		fmt.Printf("%g >= %g\n", v, lim)
	}
	// can't use v here, though
	return lim
}

等价变换为:

func pow(x, n, lim float64) float64 {

    { // 隐式代码块起点
        v := math.Pow(x, n);
        if v < lim {
            return v
        } else {
            fmt.Printf("%g >= %g\n", v, lim)
        }
    } // 隐式代码块终点

	// can't use v here, though
	return lim
}

其实 if-else 语句的变换规则跟 if 一样,将v := math.Pow(x, n);从 if 语句中提出外面,及在套上一个隐式的花括号。而变量 v 的作用域也仅在隐式花括号范围内,这也是示例代码中有注释can’t use v here, though,因为在这里使用变量 v 已经超出 v 的作用域范围了。

if-else if-else 语句

我们知道 if-else if-else 语句可以进行如下等价变换:

if initStatement01; condition01 {
    ...
} else if initStatement02; condition02 {
    ...
} else {
    ...
}

// 等价变换:

if initStatement01; condition01 {
    ...
} else {
    if initStatement02; condition02 {
        ...
    } else {
        ...
    }
}

通过前面 if 语句和 if-else 语句的隐式变换,我们可以很容易对 if-else if-else 语句进行隐式代码块的变换。

{ // 隐式代码块①起点
    initStatement01;
    if condition01 {
        ...
    } else {
        { // 隐式代码块②起点
            initStatement02;
            if condition02 {
                ...
            } else {
                ...
            }
        } // 隐式代码块②终点
    }
} // 隐式代码块①终点

for 语句

for 语句有两种:for循环和for-range循环

for 循环

完整示例代码为官网 A Tour of Go 教程的 For

示例:

func main() {
	sum := 0
	for i := 0; i < 10; i++ {
		sum += i
	}
	fmt.Println(sum)
}

for 循环的隐式代码块变换方式跟 if 语句类似,都是将 init statement语句 提出到 for 语句外面,并套上大括号。

等价变换为:

func main() {
	sum := 0

    { // 隐式代码块起点
        i := 0;  // 提取到for外面
        for ; i < 10; i++ {
            sum += i
        }
    } // 隐式代码块终点

	fmt.Println(sum)
}

for-range

完整示例代码为 Go 官网 A Tour of Go 教程的 Range

示例:

var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}

for i, v := range pow {
    fmt.Printf("2**%d = %d\n", i, v)
}

等价变换为:

var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}

{ // 隐式代码块起点
    i, v := 0, 0 // 零值
    for i, v = range pow {
        fmt.Printf("2**%d = %d\n", i, v)
    }
} // 隐式代码块终点

在 for-range 使用过程中有一些坑,其中一个就是因为 for-range 隐式代码块及其作用域问题没理解导致的。

比如下面示例:


func demo() {
    var m = [...]int{1, 2, 3, 4, 5}

    for i, v := range m {
        go func() {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }()
    }

    time.Sleep(time.Second * 10)
}

根据隐式代码块的知识点,我们是知道 i 和 v 已在 for-range 语句外声明定义了,在 for-range 语句中的 i 和 v 只是对 for 语句外面的 i、v 变量的复用。

在迭代过程中,自始至终都只有一个变量 i 和一个变量 v,只是 i 和 v 不断地重新赋值。但 for 里面的 goroutine 启动时,for 早已经迭代完了,那么 i、v 变量的值就是最后一次迭代的值。所以执行的结果是:

4 5
4 5
4 5
4 5
4 5

我们再看一个更易错的例子

type student struct {
	Name string
	Age  int
}

func main() {
	s := make(map[string]*student)

	stus := []student{
		{Name: "zhansan", Age: 17},
		{Name: "lisi", Age: 18},
		{Name: "wangwu", Age: 19},
	}

    // ①
	for _, stu := range stus {
		s[stu.Name] = &stu
	}

	for k, v := range s {
		fmt.Println(k, " => ", v.Name)
	}
}

执行的结果是:

lisi  =>  wangwu
wangwu  =>  wangwu
zhansan  =>  wangwu

上面结果是不符合我们的预期的。

我们对 ① 处的 for-range 语句进行替换:

{
    stu := student{Name : "", Age : 0};  // ②
    for _, stu = range stus {
        s[stu.Name] = &stu  // ③
    }
}

在 for-range 迭代中,变量 stu 自始至终都是只有一个,该变量的内存地址是固定的,只是值在不断重新赋值。所以,

在 ③ 中,stu.Name 的 stu 变量值是变化的,都是迭代到的那个,但是 &stu 表示变量 stu 的地址,则一直都是 ② 定义时的地址,一直没有变化。在 for 迭代完后,变量 stu 的值就变成{wangwu 19}了,&stu 值为&{wangwu 19}, 而 s 这个 map 的 value 是一个指针,即 ② 定义的内存地址,其地址指向的内容已经是{wangwu 19}了。

应该修改为:


for i := 0; i < len(stus); i++ {
    s[stus[i].Name] = &stus[i]
}

switch-case 语句

switch-case 语句有两处地方可以进行隐式代码块变换。

  1. 一处类似前面的 if、for 语句,在 switch 上的短变量声明语句可以移除外面并套上一个大括号;

  2. 另一处是每个 case 语句都可套上隐式代码块。

示例为 Go 官网 A Tour of Go 教程的 Switch

代码示例:

switch os := runtime.GOOS; os {
case "darwin":
    fmt.Println("OS X.")
case "linux":
    fmt.Println("Linux.")
default:
    // freebsd, openbsd,
    // plan9, windows...
    fmt.Printf("%s.\n", os)
}

可以等价变换为:

{  // 隐式代码块①起点
    os := runtime.GOOS;
    switch os {
    case "darwin":
        { // 隐式代码块②起点
            fmt.Println("OS X.")
        } // 隐式代码块②终点
    case "linux":
        { // 隐式代码块③起点
            fmt.Println("Linux.")
        } // 隐式代码块③终点
    default:
        { // 隐式代码块④起点
            fmt.Printf("%s.\n", os)
        } // 隐式代码块④终点
    }
} // 隐式代码块①终点

select-case 语句

select 语句的隐式变换只有 case 子句,但 select 语句跟 switch 语句的 case 子句有点不同,select 语句的 case 语句可以有短变量声明语句。等价转换为隐式代码块时,case 子句的短变量声明的新变量,归为 case 子句的大括号里面的。

完整示例为 Go by Example: Select

代码示例:

select {
    case msg1 := <-c1:
        fmt.Println("received", msg1)
    case msg2 := <-c2:
        fmt.Println("received", msg2)
}

等价转换为:

select {
    case "如果被选中":
    {
        msg1 := <-c1;
        fmt.Println("received", msg1)
    }
    case "如果被选中":
    {
        msg2 := <-c2;
        fmt.Println("received", msg2)
    }
}

总结

了解 Go 的隐式代码块规则有助于我们理解 Go 变量的作用域,特别是当我们遇到“变量未定义”异常的时候。

在 if、for、switch 和 select 语句的分析中,其实 if、switch 和 select 的变换并不复杂,而且我们在使用这些语句中也不容易出错,最容易踩坑的其实是 for-range 语句。文章里也着重讲解了 for-range 语句的坑,但 for-range 的坑不止这些。所以 for-range 虽好用,但需谨慎。