Go问答 golang for语句完全指南

sheepbao · 2018年01月15日 · 461 次阅读

以下所有观点都是个人愚见,有不同建议或补充的的欢迎 emialaboutme
原文章地址

关于 for 语句的疑问
for 语句的规范
for 语句的内部实现-array
问题解答

关于 for 语句的疑问

我们都知道在 golang 中,循环语句只有 for 这一个,在代码中写一个循环都一般都需要用到 for(当然你用 goto 也是可以的), 虽然 golang 的 for 语句很方便,但不少初学者一样对 for 语句持有不少疑问,如:

  1. for 语句一共有多少种表达式格式?
  2. for 语句中临时变量是怎么回事?(为什么有时遍历赋值后,所有的值都等于最后一个元素)
  3. range 后面支持的数据类型有哪些?
  4. range string 类型为何得到的是 rune 类型?
  5. 遍历 slice 的时候增加或删除数据会怎么样?
  6. 遍历 map 的时候增加或删除数据会怎么样?

其实这里的很多疑问都可以看golang 编程语言规范, 有兴趣的同学完全可以自己看,然后根据自己的理解来解答这些问题。

for 语句的规范

for 语句的功能用来指定重复执行的语句块,for 语句中的表达式有三种:
官方的规范: ForStmt = "for" [ Condition | ForClause | RangeClause ] Block .

  • Condition = Expression .
  • ForClause = [ InitStmt ] ";" [ Condition ] ";" [ PostStmt ] .
  • RangeClause = [ ExpressionList "=" | IdentifierList ":=" ] "range" Expression .

单个条件判断

形式:

for a < b {
    f(doThing)
}
// or 省略表达式,等价于true
for {   // for true {
        f(doThing)
}

这种格式,只有单个逻辑表达式, 逻辑表达式的值为 true,则继续执行,否则停止循环。

for 语句中两个分号

形式:

for i:=0; i < 10; i++ {
        f(doThing)
}
// or
for i:=0; i < 10; {
        i++
        f(doThing)
}
// or 
var i int
for ; i < 10; {
        i++
        f(doThing)
}

这种格式,语气被两个分号分割为 3 个表达式,第一个表示为初始化(只会在第一次条件表达式之计算一次),第二个表达式为条件判断表达式, 第三个表达式一般为自增或自减,但这个表达式可以任何符合语法的表达式。而且这三个表达式, 只有第二个表达式是必须有的,其他表达式可以为空。

for 和 range 结合的语句

形式:

for k,v := range []int{1,2,3} {
    f(doThing)
}
// or 
for k := range []int{1,2,3} {
    f(doThing)
}
// or
for range []int{1,2,3} {
    f(doThing)
}

用 range 来迭代数据是最常用的一种 for 语句,range 右边的表达式叫范围表达式, 范围表达式可以是数组,数组指针,slice,字符串,map 和 channel。因为要赋值, 所以左侧的操作数(也就是迭代变量)必须要可寻址的,或者是 map 下标的表达式。 如果迭代变量是一个 channel,那么只允许一个迭代变量,除此之外迭代变量可以有一个或者两个。

范围表达式在开始循环之前只进行一次求值,只有一个例外:如果范围表达式是数组或指向数组的指针, 至多有一个迭代变量存在,只对范围表达式的长度进行求值;如果长度为常数,范围表达式本身将不被求值。

每迭代一次,左边的函数调用求值。对于每个迭代,如果相应的迭代变量存在,则迭代值如下所示生成:

Range expression                          1st value          2nd value

array or slice  a  [n]E, *[n]E, or []E    index    i  int    a[i]       E
string          s  string type            index    i  int    see below  rune
map             m  map[K]V                key      k  K      m[k]       V
channel         c  chan E, <-chan E       element  e  E
  1. 对于数组、数组指针或是分片值 a 来说,下标迭代值升序生成,从 0 开始。有一种特殊场景,只有一个迭代参数存在的情况下, range 循环生成 0 到 len(a) 的迭代值,而不是索引到数组或是分片。对于一个 nil 分片,迭代的数量为 0。
  2. 对于字符串类型,range 子句迭代字符串中每一个 Unicode 代码点,从下标 0 开始。在连续迭代中,下标值会是下一个 utf-8 代码点的 第一个字节的下标,而第二个值类型是 rune,会是对应的代码点。如果迭代遇到了一个非法的 Unicode 序列,那么第二个值是 0xFFFD, 也就是 Unicode 的替换字符,然后下一次迭代只会前进一个字节。
  3. map 中的迭代顺序是没有指定的,也不保证两次迭代是一样的。如果 map 元素在迭代过程中被删掉了,那么对应的迭代值不会再产生。 如果 map 元素在迭代中插入了,则该元素可能在迭代过程中产生,也可能被跳过,但是每个元素的迭代值顶多出现一次。如果 map 是 nil,那么迭代次数为 0。
  4. 对于管道,迭代值就是下一个 send 到管道中的值,除非管道被关闭了。如果管道是 nil,范围表达式永远阻塞。

迭代值会赋值给相应的迭代变量,就像是赋值语句。
迭代变量可以使用短变量声明 (:=)。这种情况,它们的类型设置为相应迭代值的类型,它们的域是到 for 语句的结尾,它们在每一次迭代中复用。 如果迭代变量是在 for 语句外声明的,那么执行之后它们的值是最后一次迭代的值。

var testdata *struct {
    a *[7]int
}
for i, _ := range testdata.a {
    // testdata.a is never evaluated; len(testdata.a) is constant
    // i ranges from 0 to 6
    f(i)
}

var a [10]string
for i, s := range a {
    // type of i is int
    // type of s is string
    // s == a[i]
    g(i, s)
}

var key string
var val interface {}  // value type of m is assignable to val
m := map[string]int{"mon":0, "tue":1, "wed":2, "thu":3, "fri":4, "sat":5, "sun":6}
for key, val = range m {
    h(key, val)
}
// key == last map key encountered in iteration
// val == map[key]

var ch chan Work = producer()
for w := range ch {
    doWork(w)
}

// empty a channel
for range ch {}

for 语句的内部实现-array

golang 的 for 语句,对于不同的格式会被编译器编译成不同的形式,如果要弄明白需要看 golang 的编译器和相关数据结构的源码, 数据结构源码还好,但是编译器是用 C++ 写的,本人 C++ 是个弱鸡,这里只讲array 内部实现

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

// 例如代码:  
array := [2]int{1,2}
for k,v := range array {
    f(k,v)
}

// 会被编译成:  
len_temp := len(array)
range_temp := array
for index_temp = 0; index_temp < len_temp; index_temp++ {
    value_temp = range_temp[index_temp]
    k = index_temp
    v = value_temp
    f(k,v)
}

所以像遍历一个数组,最后生成的代码很像 C 语言中的遍历,而且有两个临时变量index_temp,value_temp, 在整个遍历中一直复用这两个变量。所以会导致开头问题 2 的问题(详细解答会在后边)。

问题解答

  1. for 语句一共有多少种表达式格式?
    这个问题应该很简单了,上面的规范中就有答案了,一共有 3 种:

    Condition = Expression .
    ForClause = [ InitStmt ] ";" [ Condition ] ";" [ PostStmt ] .
    RangeClause = [ ExpressionList "=" | IdentifierList ":=" ] "range" Expression .
    
  2. for 语句中临时变量是怎么回事?(为什么有时遍历赋值后,所有的值都等于最后一个元素)
    先看这个例子:

    var a = make([]*int, 3)
    for k, v := range []int{1, 2, 3} {
        a[k] = &v
    }
    for i := range a {
        fmt.Println(*a[i])
    }
    // result:  
    // 3  
    // 3  
    // 3  
    

    for 语句的内部实现-array可以知道,即使是短声明的变量,在 for 循环中也是复用的,这里的v一直 都是同一个零时变量,所以&v得到的地址一直都是相同的,如果不信,你可以打印该地址,且该地址最后存的变量等于最后一次循环得到的变量, 所以结果都是 3。

  3. range 后面支持的数据类型有哪些?
    共 5 个,分别是数组,数组指针,slice,字符串,map 和 channel

  4. range string 类型为何得到的是 rune 类型?
    这个问题在 for 规范中也有解答,对于字符串类型,在连续迭代中,下标值会是下一个 utf-8 代码点的第一个字节的下标,而第二个值类型是 rune。 如果迭代遇到了一个非法的 Unicode 序列,那么第二个值是 0xFFFD,也就是 Unicode 的替换字符,然后下一次迭代只会前进一个字节。

    其实看完这句话,我没理解,当然这句话告诉我们了遍历 string 得到的第二个值类型是 rune,但是为什么是 rune 类型,而不是 string 或者其他类型? 后来在看了 Rob Pike 写的 blogStrings, bytes, runes and characters in Go 才明白点,首先需要知道runeint32的别名,且 go 语言中的字符串字面量始终保存有效的 UTF-8 序列。而 UTF-8 就是用 4 字节来表示 Unicode 字符集。 所以 go 的设计者用 rune 表示单个字符的编码,则可以完成容纳所表示 Unicode 字符。举个例子:

    s := `汉语ab`
    fmt.Println("len of s:", len(s))
    for index, runeValue := range s {
        fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
    }
    // result
    // len of s: 8
    // U+6C49 '汉' starts at byte position 0
    // U+8BED '语' starts at byte position 3
    // U+0061 'a' starts at byte position 6
    // U+0062 'b' starts at byte position 7
    

    根据结果得知,s 的长度是为 8 字节,一个汉子占用了 3 个字节,一个英文字母占用一个字节,而程序 go 程序是怎么知道汉子占 3 个字节,而 英文字母占用一个字节,就需要知道 utf-8 代码点的概念,这里就不深究了,知道 go 是根据 utf-8 代码点来知道该字符占了多少字节就 ok 了。

  5. 遍历 slice 的时候增加或删除数据会怎么样?
    for 语句的内部实现-array可以知道,获取 slice 的长度只在循环外执行了一次, 该长度决定了遍历的次数,不管在循环里你怎么改。但是对索引求值是在每次的迭代中求值的,如果更改了某个元素且 该元素还未遍历到,那么最终遍历得到的值是更改后的。删除元素也是属于更改元素的一种情况。

    在 slice 中增加元素,会更改 slice 含有的元素,但不会更改遍历次数。

    a2 := []int{0, 1, 2, 3, 4}
    for i, v := range a2 {
        fmt.Println(i, v)
        if i == 0 {
            a2 = append(a2, 6)
        }
    }
    // result
    // 0 0
    // 1 1
    // 2 2
    // 3 3
    // 4 4
    

    在 slice 中删除元素,能删除该元素,但不会更改遍历次数。

    // 只删除该元素1,不更改slice长度
    a2 := []int{0, 1, 2, 3, 4}
    for i, v := range a2 {
        fmt.Println(i, v)
        if i == 0 {
            copy(a2[1:], a2[2:])
        }
    }
    // result
    // 0 0
    // 1 2
    // 2 3
    // 3 4
    // 4 4
    
    // 删除该元素1,并更改slice长度
    a2 := []int{0, 1, 2, 3, 4}
    for i, v := range a2 {
        fmt.Println(i, v)
        if i == 0 {
            copy(a2[1:], a2[2:])
            a2 = a2[:len(a2)-2] //将a2的len设置为3,但并不会影响临时slice-range_temp
        }
    }
    // result
    // 0 0
    // 1 2
    // 2 3
    // 3 4
    // 4 4
    
  6. 遍历 map 的时候增加或删除数据会怎么样?
    规范中也有答案,map 元素在迭代过程中被删掉了,那么对应的迭代值不会再产生。 如果 map 元素在迭代中插入了,则该元素可能在迭代过程中产生,也可能被跳过。

    在遍历中删除元素

    m := map[int]int{1: 1, 2: 2, 3: 3, 4: 4, 5: 5}
    del := false
    for k, v := range m {
        fmt.Println(k, v)
        if !del {
            delete(m, 2)
            del = true
        }
    }
    // result
    // 4 4
    // 5 5
    // 1 1
    // 3 3
    

    在遍历中增加元素,多执行几次看结果

    m := map[int]int{1: 1, 2: 2, 3: 3, 4: 4, 5: 5}
    add := false
    for k, v := range m {
        fmt.Println(k, v)
        if !add {
            m[6] = 6
            m[7] = 7
            add = true
        }
    }
    // result1
    // 1 1
    // 2 2
    // 3 3
    // 4 4
    // 5 5
    // 6 6
    
    // result2
    // 1 1
    // 2 2
    // 3 3
    // 4 4
    // 5 5
    // 6 6
    // 7 7
    

    在 map 遍历中删除元素,将会删除该元素,且影响遍历次数,在遍历中增加元素则会有不可控的现象出现,有时能遍历到新增的元素, 有时不能。具体原因下次分析。

参考

https://golang.org/ref/spec#For_statements
https://github.com/golang/go/wiki/Range
https://blog.golang.org/strings

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册