尽管Go语法一切都很便捷,但我对Go 的 range循环总是有点疑惑。不单是我个人对以下代码有疑惑:

现在我努力记住这些实例,但我可能很快忘记。为了更好地记住这一特性,我需要弄清楚什么原因导致range循环有这种特性。所以,让我们开始探索吧。

第一步:阅读说明文档

第一步,先去阅读range 循环的文档,Go 语言有关range循环说明文档在the for statement section 。我不会将整个文档内容复制到这里,只总结了一些有趣的点

第一,让我们提醒自己关注点

1
2
3
for i := range a {
    fmt.Println(i)
}

range的变量

大部分人都知道range左边部分的表达

  • 赋值(=)
  • 简单变量声明(:=)

你也可以一起忽略掉左边的两个变量,即

1
2
for range a {
}

如果你使用简单变量声明风格(:=),Go会循环域中重复使用该变量

range的表达式

在range的右边,你可以看到是什么调用了range的表达式。以下可以调用range的类型:

  • array
  • pointer to an array
  • slice
  • string
  • map
  • 所有可读channel

range的表达式在执行循环前,会求一次值。注意有一个例外规则:你若遍历数组/指针,使用的是index,每次调用len(a),len(a)会在编译时期才会求新的值。len 函数的特性解释:

1
如果s是数组或指向数组的指针,并且s不包含channel,len(s)和cap(s)就是常数,否则不是。

“evaluate"到底是什么意思呢?运气不好,我没有在goalng说明文档中中找不到这个具体信息。Of course I can guess that it means to execute the expression completely until it can not be reduced further. 不管怎么样,len(a)在开始进入循环之前(在for之前)执行了一次。它怎么做到只执行一次表达式(如range slice,只执行一次len(a))呢?就是将执行表达式的结果的赋给一个变量,究竟在这里具体发生了什么事呢?

有趣的事,说明文档提到maps增删特别案例(没有提到slices):

如果map entries还未遍历到就被删除,就不会遍历到该值,如果map 在遍历中插入,entry可能会被跳过,也有可能遍历到

我后面会再次回到maps

第二步:range支持的数据类型

我们暂时假设在进入循环之前,range表达式结果被赋一个变量,这是什么意思呢?这个答案取决于range的数据类型,所有让我们

进一步研究range支持的数据类型

在此之前,我们要铭记:在Golang,所有赋值,都是复制。 你给一个指针赋值,则复制了一份指针的值给该指针变量。如果你给一个struct赋值,则复制了一份struct。函数传参亦是如此。这里请看:

Type syntactic sugar for(语法糖)
array
string struct: len、指向数组的指针
Slice Struct: len、cap、指向数组的指针
map Pointer: struct
Channel Pointer: struct

请阅读博客底部的参考资料,了解更多有关类型的知识。

上面我们说的复制到底是什么意思呢?我门举几个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// copies the entire array
var a [10]int
acopy := a 

// copies the slice header struct only, NOT the backing array
s := make([]int, 10)
scopy := s

// copies the map pointer only
m := make(map[string]int)
mcopy := m

所以如果在进入循环之前,我们把数组表达赋值给一个变量(保证只执行赋值一次),你将会赋值整个数组。在这里我们可能发现了什么。

第三步:Go 编译器源代码

我比较懒,只在Google搜了Go编译器源代码。首先,我找到的是 GCC版的编译器,我们所关心的range 部分在statements.cc 文件,

1
2
3
4
5
6
7
// Arrange to do a loop appropriate for the type.  We will produce
//   for INIT ; COND ; POST {
//           ITER_INIT
//           INDEX = INDEX_TEMP
//           VALUE = VALUE_TEMP // If there is a value
//           original statements
//   }

现在我们理解一些东西了吧。不足为奇,range循环只是C语言的语法糖。对于每种特定类型的range都有特定的语法糖,例如arrays:

1
2
3
4
5
6
7
8
9
// The loop we generate:
//   len_temp := len(range)
//   range_temp := range
//   for index_temp = 0; index_temp < len_temp; index_temp++ {
//           value_temp = range_temp[index_temp]
//           index = index_temp
//           value = value_temp
//           original body
//   }

再例如slice

1
2
3
4
5
6
7
8
//   for_temp := range
//   len_temp := len(for_temp)
//   for index_temp = 0; index_temp < len_temp; index_temp++ {
//           value_temp = for_temp[index_temp]
//           index = index_temp
//           value = value_temp
//           original body
//   }

相同点是:

  • 所有都仅是一个C语言的循环
  • 每一个都是对复制对象的遍历

也就是说,这是一个gcc的前端。我认识的大多数人都用了Go发行版的gc编译器,这个编译器看起来也做了同样的事情

我们了解到的

  1. 循环变量被重复利用,每一个遍历都要赋值一遍
  2. 在进入循环之前,range表达式通过赋值给一个变量的方式仅执行一次
  3. 在遍历maps时,你可以增删元素,有可能不会对遍历有影响

Dave’s tweet

此结果的最终解释是,它大致被解释成一下代码

1
2
3
4
5
6
7
8
for_temp := v
len_temp := len(for_temp)
for index_temp = 0; index_temp < len_temp; index_temp++ {
        value_temp = for_temp[index_temp]
        index = index_temp
        value = value_temp
        v = append(v, index)
}

我们知道slices是一个结构体的语法糖,它包含了一个指向数组的指针。循环遍历只针对v的复制对象for_temp,任何对v的增删,不会影响for_temp结构体。v和for_temp指向的结构数组是一样的,若进行某个元素更改,for_temp也会更着更改。

例如:

1
2
3
4
5
6
arr := make([]int, 1, 10)
temp_copy := arr
arr = append(arr, 3)
fmt.Println(temp_copy) //不会更改,因为结构体不变,元素个数不变
arr[0] = 3
fmt.Println(temp_copy) //回更改

Damian’s tweet

再一次强调,和上面的例子一样,在开始循环之前这个数组先赋值给一个临时变量,即它会对整个数组的复制。

1
2
3
4
5
6
7
a := [...]int{1, 2}
fmt.Println(reflect.TypeOf(a))
// 数组是一个值,而slice只是一个结构,包含指向数组的指针
// b 会复制 a的所有元素
b := a 
fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)

而以下例中,【case2】之所以在循环中更改的原因是,它复制的是指针

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
a := [...]int{3, 4}
// 【case1】
for i, v := range a {
    a[1] = 9
    fmt.Println(i, v) //不会有9输出
}
// 【case2】
for i, v := range &a {
    a[1] = 8 
    fmt.Println(i, v) //会有8输出
}

附加:maps

In the spec we read that

  • it is safe to add to and remove from maps in a range loop
  • if you add an element it may or may not see it in an upcoming iteration

Why does it work like that? For one, we know that maps are pointers to a struct. Before the loop starts, the pointer will be copied and not the internal data structure, hence why it is possible to add or remove keys inside the loop. This makes sense!

So why might you not see the element you added in an upcoming iteration? Well if you know about how hash tables work, which is what a map really is, then you’ll know that inside the backing array for a hash table the items are in no particular order. The item you add last might hash to index zero in the backing array. So if you assume that Go reserves the right to iterate over this array in any order, it is indeed impossible to predict whether or not you will see the item you added inside the loop. After all, you might already be past index zero in the backing array. This might not be exactly what happens in the case of the Go map, but it makes sense to leave the decision to the compiler writer for this reason.

参考

原文Go Range Loop Internals

  1. The Go Programming Language Specification
  2. Go slices: usage and internals
  3. Go Data Structures
  4. Inside the map implementation: slides | video
  5. Understanding nil: slides | video
  6. string source code
  7. slice source code
  8. map source code
  9. channel source code