Golang range 内部实现原理

3,103 阅读10分钟

在深入了解 range 之前,我们来讲一下什么是 “语法糖” ?

语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·约翰·兰达(Peter J. Landin)发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

之所以叫「语法」糖,不只是因为加糖后的代码功能与加糖前保持一致,更重要的是,糖在不改变其所在位置的语法结构的前提下,实现了运行时的等价。可以简单理解为,加糖后的代码编译后跟加糖前一毛一样。

之所以叫语法「糖」,是因为加糖后的代码写起来很爽,包括但不限于:代码更简洁流畅,代码更语义自然...写着爽,看着爽,就像吃了糖。效率高,错误少,老公回家早...

据说还有一种叫做「语法盐」的东西,主要目的是通过反人类的语法,让你更痛苦的写代码。其实它同样能达到避免代码书写错误的效果,但编程效率应该是降低了,毕竟提高了语法学习门槛,让人咸到忧伤...


Golang 的语法糖

第一种语法糖 ...

... 是 Go 语言中的一种语法糖,主要有两个用法:

① 用于函数有多个不定参数的情况,可以接受多个不确定的参数。

func firstFunc(arg ...string) {
     // 可以接受任意的 string 参数
     // arg 被传到函数内可以当做数组使用
     for _, value := range arg {
        fmt.println(value)
     }
}

② 可以用于将 slice 打散进行传递。

func main(){
    var strss= []string{"qwr", "234", "yui", "cvbc",}
    firstFunc(strss...) # 切片被打散传入函数
}

第二种语法糖 :=

:=是go的赋值与声明语法糖,它的功能是赋值、声明和类型判断

//第一种方法
var number1  number2  number3 int
number1, number2, number3 = 1, 2, 3 
//第二种方法
var number1, number2, number3 = 1, 2, 3 
//第三种方法
number1, number2, number3 := 1, 2, 3

注意:

  • := 只有在左侧有未定义变量时才能使用(有定义的变量也可以)

  • := 只进行语义上的检查,循环内也可以使用,第一次之后的效果和=相同

  • := 和 = 都要求左右的值和变量一一对应,不会截断

  • 特殊情况,map、chan、类型推定可以返回一个值,也可以是两个值

  • 当map中该键不存在时,总是返回零值和false,否则后者返回true

  • 当chan关闭以后,总是返回零值和false,否则后者返回true


第三种语法糖 for range

首先看一下下面代码会死循环吗?

func main() {
    v := []int{1, 2, 3}
    for i := range v {
        v = append(v, i)
    }
}

上面的代码先初始化了一个内容为1、2、3的slice,然后遍历这个slice,然后给这个切片追加元素。随着遍历的进行,数组v也在逐渐增大,那么这个for循环是一个死循环么?

答案是否。只会遍历三次,v的结果是[0, 1, 2]。并不是死循环,原因就在于for range实现的时候用到了语法糖。

现在,我可以考虑这些事实并尝试记住它们,但我可能会忘记。为了更好地记住这一点,我们需要找出范围循环为何以这种方式运行。那么怎么做呢?

步骤一:RTM

首先要做的是阅读for range文档。Go语言规范文档在“带子句的语句” 下的for语句部分中列出了范围循环range。

首先,让我们提醒我们自己在这里看什么:

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

范围变量

你们大多数人都知道,在range子句的左侧(i在上面的示例中),您可以使用以下方法分配循环变量:

  • 分配(=)

  • 简短的变量声明(:=)

您也可以选择不放置任何内容以完全忽略循环变量。如果您使用简短的变量声明样式分配(:=),则Go将在循环的每次迭代中重复使用变量(仅在循环内的作用域内)。


范围表达

在range子句的右侧(a在上面的示例中),您可以找到它们所谓的range表达式。它可以包含任何计算结果为以下之一的表达式:

  • array

  • pointer to an array (指向数组的指针)

  • slice

  • string

  • map

  • channel eg chan int 、 chan<- int

在开始循环之前,将对范围表达式进行一次评估。请注意,此规则有一个例外:如果您在数组(或指向)的范围内并且仅分配索引:则仅len(a)求值。只求值len(a)意味着a可

以在编译时对表达式求值,并由编译器将其替换为常量。该len功能的规格说明:

如果s的类型是数组或指向数组的指针并且表达式s不包含通道接收或(非恒定)函数调用,则表达式len和cap是常量。
在这种情况下,不会评估s。
否则,len和cap的调用不是恒定的,并且将评估s。

现在,“评估”到底是什么意思?不幸的是,在规范中找不到此信息。当然,我们可以猜测这意味着完全执行该表达式,直到无法进一步简化为止。无论如何,这里的高位是范围表达式在循环开始之前被评估一次。您如何一次评估一个表达式?通过将其分配给变量!难道这就是这里发生的事情?

有趣的是,该规范提到了一些有关在 map 上添加和删除的具体说明(未提及切片):

如果在迭代过程中删除尚未到达的映射条目,则不会生成相应的迭代值。
如果映射条目是在迭代过程中创建的,则该条目可能在迭代过程中产生或可以被跳过。

稍后我将返回map。

步骤二:范围支持的数据类型

如果我们假设有一分钟范围表达式在循环开始之前被分配给一个变量,那是什么意思?答案是:它取决于数据类型,因此让我们仔细看看所支持的数据类型range。

在执行此操作之前,请记住以下几点:在Go中,您分配的所有内容都将被复制如果分配了指针,则复制该指针。如果分配结构,则复制该结构。将参数传递给函数时也是如此。无论如何,这里是这样:

类型句法糖
数组
数组
持有len +指向后备数组的指针的结构
切片
包含len,cap和指向后备数组的指针的结构
map
指向结构的指针
渠道
指向结构的指针

那么这是什么意思?这些示例来说明这些差异:

# 复制整个数组
var a [10]intacopy := a
# 仅复制 slice 的头结构,不是复制数组
s := make([]int, 10)scopy := s
# 仅复制 map 的指针
m := make(map[string]int)
mcopy := m

因此,如果在range循环开始时将一个数组表达式分配给一个变量(以确保它只求值一次),那么您将复制整个数组。我们可能在这里有些事。

步骤三:转到编译器源码

在Google搜索Go编译器源代码。我发现的第一件事是编译器的GCC版本。就此range子句而言,有趣的位在 statements.cc,如以下注释所示

// 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 支持的类型做专门的 “语法糖还原”。比如,


数组

// 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
//   }


切片

//   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
//   }


map

// Lower a for range over a map.
// The loop we generate:
//   var hiter map_iteration_struct
//   for mapiterinit(type, range, &hiter); hiter.key != nil; mapiternext(&hiter) {
//           index_temp = *hiter.key
//           value_temp = *hiter.val
//           index = index_temp
//           value = value_temp
//           original body
//   }


string

// Lower a for range over a string.
// The loop we generate:
//   len_temp := len(range)
//   var next_index_temp int
//   for index_temp = 0; index_temp < len_temp; index_temp = next_index_temp {
//           value_temp = rune(range[index_temp])
//           if value_temp < utf8.RuneSelf {
//                   next_index_temp = index_temp + 1
//           } else {
//                   value_temp, next_index_temp = decoderune(range, index_temp)
//           }
//           index = index_temp
//           value = value_temp
//           
// original body
//   }


channel 

// Lower a for range over a channel.
// The loop we generate:
//   for {
//           index_temp, ok_temp = <-range
//           if !ok_temp {
//                   break
//           }
//           index = index_temp
//           original body
//   }

他们的共同点是:

  • 所有类型的 range 本质上都是 C 风格的循环

  • 遍历到的值会被赋值给一个临时变量

这是 gofrontend里的情况,据我所知大多数人使用的是 Go 发行版自带的 gc 编译器,看上去他们在这一点的处理上有着完全相同的行为。

我们所知道的

  1. 循环变量在每一次迭代中都被赋值并会复用。

  2. 可以在迭代过程中移除一个 map 里的元素或者向 map 里添加元素。添加的元素并不一定会在后续迭代中被遍历到。

  3. 明确了这些之后,我们再回到开篇列出的例子上。


我们再来看一下之前得程序

func main(){
    v:= [] int {1,2,3}
    for i:= range v {
        v = append(v,i)
    }
}

这段代码之所以会终止是因为它其实可以粗略的翻译成类似下面的这段:


我们知道切片实际上是一个结构体的语法糖,这个结构体有着一个指向数组的指针成员。在循环开始前对这个结构体生成副本然后赋值给 for_temp,后面的循环实际上是在对 for_temp 进行迭代。任何对于原始变量 v 本身(而非对其背后指向的数组)的更改都和生成的副本 for_temp 没有关系。但其背后指向的数组还是以指针的形式共享给 vfor_temp,所以 v[i] = 1这样的语句仍然可以工作。


附: maps

在规范文档里我们读到:

  • range 循环里对 maps 做添加或删除元素的操作是安全的。

  • 如果在循环中对 maps 添加了一个元素,那么这个元素并不一定会出现在后续的迭代中。

对于第一点 ,为什么这样工作?首先,我们知道 map 是指向结构的指针。在循环开始之前,将复制指针,而不是内部数据结构,因此为什么可以在循环内部添加或删除键。

为什么在后续的迭代中不一定能遍历到当前添加的元素?如果你知道哈希表是如何工作的(map 本质上就是哈希表),就会明白哈希表内部数组里的元素并不是以特定顺序存放。最后一个添加的元素有可能经过哈希后被放到了内部数组里的第一个索引位,我们确实没有办法预测当前添加的元素是否会出现在后续的迭代中,毕竟在添加元素的时候很可能已经遍历过了第一个索引位。因此,当前添加的元素是否能在后续迭代中遍历到,还是看编译器的心情吧 :-D 。

感谢阅读!


温馨提示

活在当下,这是唯一的意义。然后应该忘记,继续往前走。——安妮宝贝


喜欢本文的朋友,欢迎关注“isevena