Go进阶控制语句惯用法及注意事项

0 阅读8分钟

Go并发编程与诗词内容整合图.png

1.使用if控制语句时应遵循"快乐路径"原则:

对比下面两段伪代码:

//伪代码段1

func doSomething() error{

if errorCondition1{

//错误逻辑.

...

return err1

}

//成功逻辑

...

if errorCondition2{

//错误逻辑.

...

return err2

}

//成功逻辑

return nil

}

//伪代码段2

func doSomething() error{

if  successCondition1{

//成功逻辑.

...

if  successCondition2{

//成功逻辑.

...

return nil

}else{

//错误逻辑.

...

return err2

}

}else {

//错误逻辑.

...

return err1

}

}

伪代码段1:

1).没有使用else.失败就立即返回.

2).成功逻辑始终居左并延续到函数结尾.没有被嵌入if语句.

3).整个代码段布局扁平.没有深度缩进.

4).代码逻辑一目了然.可读性好.

伪代码段2:

1).整个代码呈锯齿状.有深度缩进.

2).成功逻辑被嵌入if-else代码块中.

3).代码逻辑曲折宛转.可读性较差.

代码段1的if控制语句使用方法符合Go语言惯用的"快乐路径"原则.

"快乐路径"原则

1).当出现错误时,快速返回.

2).成功逻辑不要嵌入if-else语句中.

3).快乐路径的执行逻辑在代码布局上始终靠左.这样可以一眼看到该函数的正常逻辑流程.

4).返回值一般在函数最后一行.

2.for range的闭坑指南:

1).小心迭代变量的重用:

for range的惯用法是使用短变量声明方式(:=)在for的initStmt中声明迭代变量.需要注意的是.这些迭代变量在for range的每次循环中都会被重用.而不是重新声明.

func main() {
    var m = [...]int{1,2,3,4,5,6,7,8,9}
    for i, v := range m {
       ...
    }
}

上述代码可等价转换为:

func main() {
    var m = [...]int{1, 2, 3, 4, 5, 6, 7, 8, 9}
    {
       i, v := 0, 0
       for i, v = range m {
          ...
       }
    }
}

可以清晰看到迭代变量的重用.

2).注意参与迭代的是range表达式的副本:

for range语句中.range后面接受的表达式类型可以是数组 指向数组的指针 切片 字符串 map和channel(至少具有读权限).

func main() {
    arrayRangeExpression()
}

func arrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int
    fmt.Println("arrayRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a {
       if i == 0 {
          a[1] = 12
          a[2] = 13
       }
       r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)

}

期待输出:

a = [1 2 3 4 5]

r = [1 12  13 4 5]

a = [1 12  13 4 5]

实际运行结果:

a = [1 2 3 4 5]

r = [1 2 3 4 5]

a = [1 12  13 4 5]

原以为在第一次循环过程中.也就是i=0时.对a的修改(a[1]=12,a[2]=13)会在第二次 第三次循环中被v取出.但结果却是v取出的值依旧是a被修改之前的值.出现这个结果的原因是参与循环的是range表达式的副本.

Go中数组在内部表示为连续的字节序列.虽然长度是Go数组类型的一部分.但长度并不包含在数组类型在Go运行时的内部表示中.数组长度是由编译器编译器计算出来的.这个例子中.对range表达式的复制即对一个数组的复制.是Go临时分配的连续字节序列.与原数组完全不是一块内存区域.因为无论原数组如何修改.循环的副本依旧保持原值.

func main() {
    arrayRangeExpression()
}

func arrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int
    fmt.Println("arrayRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range &a {
       if i == 0 {
          a[1] = 12
          a[2] = 13
       }
       r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)

}

执行结果:

这个例子使用指针作为range的表达式.结果是符合预期的了.

在Go中.大多数应用数组的场景都可以用切片替代.

func main() {
    arrayRangeExpression()
}

func arrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int
    fmt.Println("arrayRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a[:] {
       if i == 0 {
          a[1] = 12
          a[2] = 13
       }
       r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
}

执行结果:

切片在Go内部表示为一个结构体.由(T,len,cap)三元组组成.其中T指向切片对应的底层数组的指针.len是切片当前长度.cap为切片的容量.在进行range表达式复制时.它实际上复制的是一个切片.也就是表示切片的那个结构体.表示切片副本的结构体中的T依旧指向原切片对应的底层数组.因此对切片副本的修改也都会反映到底层数组a上.v从切片副本结构体中的T指向的底层数组中获取数组元素.也就得到了被修改后的元素值.

切片与数组还有一个不同点.就是其len在运行时可以被改变.而数组的长度可以认为是一个常量.不可改变.len的变化的切片对for range有何影响呢.看下面的例子.

func main() {
    arrayRangeExpression()
}

func arrayRangeExpression() {
    var a = []int{1, 2, 3, 4, 5}
    var r = make([]int, 0)
    fmt.Println("arrayRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a[:] {
       if i == 0 {
          a = append(a, 6, 7)
       }
       r = append(r, v)
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
}

执行结果:

在这个例子中.原切片a在for range的循环过程中被附加了两个元素6和7.其中len由5增加为7.但是对r没有任何影响.原因在于a的副本内部表示len并没有改变.依旧是5.所以for range只会循环5次.也就是只获取到了对应底层数组的前5个元素.

3).其他range表达式类型的使用注意事项.

对于range后面的其他表达式类型.比如string map 和channel.for range依旧会复制副本.

string:

当string作为range表达式的类型时.由于string在Go运行时内部表示为struct{*byte,len}.并且string本身是不可改变的.因此其行为和消耗与切片作为range表达式时类似.不过for range对于string来说.每次循环的单位是一个rune.而不是一个byte.返回的第一个值为迭代字符码点的第一字节位置.

func main() {
    var s = "中国人"
    for i, v := range s {
       fmt.Printf("%d %s 0x%x\n", i, string(v), v)
    }
}

执行结果:

如果作为range表达式的字符串s中存在非法UTF8字节序列.那么v将返回0xfffd这个特殊值.并且在下一轮中.v将仅前进一字节.

func main() {
    var s1 = []byte{0xe4, 0xb8, 0xad, 0xe5, 0x9b, 0xbd, 0xe4, 0xba, 0xba}
    for _, v := range s1 {
       fmt.Printf("0x%x", v)
    }
    fmt.Println("\n")

    //故意构造非法UTF8字节序列
    s1[3] = 0xd0
    s1[4] = 0xd6
    s1[5] = 0xb9
    for i, v := range string(s1) {
       fmt.Printf("%d %x\n", i, v)
    }
}

执行结果:

第二次循环时.由于以s1[3]开始的字节序列并非一个合法的UTF8字符.因此v的值为0xfffd.并且下一轮(第三轮)循环从i=4开始,第三轮循环找到了一个合法的UTF8字节序列0xd6,0xb9.即码点为0b59的UTF8字符.这是一个希伯来语字符.接下来第四轮循环.程序又回归正常.

map:

当map作为range表达式时.会得到一个map内部表示的副本.map在go运行时内部表示为一个hmap的描述符结构指针,因此该指针的副本也指向同一个hmap描述符.这样for range对map副本的操作即对源map的操作.

关于元素map的迭代.for range无法保证每次迭代元素的次序是一致的,如果在循环中对map进行修改.这样修改的结果是否对后面的迭代过程影响也是不确定的.

func main() {
    var m = map[string]int{
       "tony": 21,
       "tom":  22,
       "jim":  23,
    }
    counter := 0
    for k, v := range m {
       if counter == 0 {
          delete(m, "tony")
       }
       counter++
       fmt.Println(k, v)
    }
    fmt.Println("counter is ", counter)
}

执行结果:

channel:

对于channel来说.channel在Go运行时内部表示为一个channel描述符的指针.因此channel的指针副本也指向原channel.

当channel作为range表达式类型时.for range最终以阻塞读的方式阻塞在channel表达式上.即便是带缓冲的channel亦是如此.当channel无数据时.for range也会阻塞在channel上.直到channel关闭.

func main() {
    var c = make(chan int)

    go func() {
       time.Sleep(time.Second * 3)
       c <- 1
       c <- 2
       c <- 3
       close(c)
    }()

    for v := range c {
       fmt.Println(v)
    }
}

执行结果:

func main() {
    var c chan int

    //会一直阻塞在这里.
    for v := range c {
       fmt.Println(v)
    }
}

执行结果:

3.break:

func main() {
    exit := make(chan interface{})

    go func() {
       for {
          select {
          case <-time.After(time.Second):
             fmt.Println("timeout")
          case <-exit:
             fmt.Println("exit...")
             break
          }
       }
       fmt.Println("exit")
    }()

    time.Sleep(time.Second * 3)
    exit <- struct{}{}

    time.Sleep(time.Second * 3)
}

3秒后.主goroutine给子goroutine发一个退出信号(通过channel).子goroutine收到信号后通过break退出循环,主goroutine在发出信号后等待goroutine退出.等待时间为三秒.

执行结果:

从结果可以看出子goroutinebreal并未退出外层for循环.而是继续打印timeout.

这是Go break语法的一个小坑.Go语言规范中明确规定break语句(不接label的情况下)结束执行并跳出的是同一函数内的break语句所在的最内层的for switch或select的执行.上面例子虽然跳出了select语句.但并没有跳出外层的for循环.

func main() {
    exit := make(chan interface{})

    go func() {
       loop:
       for {
          select {
          case <-time.After(time.Second):
             fmt.Println("timeout")
          case <-exit:
             fmt.Println("exit...")
             break loop
          }
       }
       fmt.Println("exit")
    }()

    time.Sleep(time.Second * 3)
    exit <- struct{}{}

    time.Sleep(time.Second * 3)
}

执行结果:

何处?几叶萧萧雨。湿尽檐花,花底无人语。
掩屏山,玉炉寒。谁见两眉愁聚倚阑干。 纳兰

语雀地址www.yuque.com/itbosunmian…?

《Go.》 密码:xbkk 欢迎大家访问.提意见.

如果大家喜欢我的分享的话.可以关注我的微信公众号

念何架构之路