IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    聊聊Go语言的控制语句

    bigwhite发表于 2023-05-27 00:21:00
    love 0

    本文永久链接 – https://tonybai.com/2023/05/27/control-flow-statement-in-go

    在高级编程语言中,控制流语句(control-flow statement)是一类用于控制程序执行流程的语句,以下简称为控制语句。它们可以根据条件或循环执行相应的代码块,或者跳转到指定位置执行代码。

    常见的控制语句包括:

    • 条件语句:根据条件执行不同的代码块,如if语句、switch语句等。
    • 循环语句:根据条件重复执行相应的代码块,如for语句、while语句等。
    • 跳转语句:跳转到指定位置执行代码,如break、goto语句。
    • 异常处理语句:处理程序运行过程中出现的异常,如try-catch语句、throw语句等。

    控制语句是编程语言中实现程序逻辑的重要手段,它们可以帮助程序员实现复杂的算法和逻辑。不同的编程语言支持的控制语句的种类和用法可能会有所不同,但它们的基本作用都是相似的,即控制程序的执行流程。

    Go语言中的控制语句语法在主流编程语言中算是极少的了!掐指算来,主要的包括if、for和switch。当然goto、defer、panic/recover语句也应归类于控制语句,并且后面这些控制语句也都是Go语言实现程序逻辑的重要手段。但后面这几个并非本篇讲述的重点,在这篇文章中,我将聚焦于Go的if、for和switch语句。

    1. if语句

    首先我们先来看看if语句。

    if语句用于根据一个条件执行相应的代码块,是Go语言中最常用的控制语句。

    if语句的基本语法如下:

    if condition {
        // code block
    } else if condition {
        // code block
    } else {
        // code block
    }
    

    关于if语句,我主要说下面三点:

    1.1 隐式代码块(block)

    我们看下面代码:

    func bar() {
        if a := 1; false {
        } else if b := 2; false {
        } else if c := 3; false {
        } else {
            println(a, b, c)
        }
    }
    

    看完这段代码后,你觉得这段代码可以被正常编译吗?如果可以,那么它会输出什么信息呢?Go编译器告诉我们:上面这段可以正常编译并运行!但很多人会质疑:为何在第一个if语句中声明的变量a、第二个if中的变量b以及第三个if中的变量c,在最后的else语句中都可以有效访问呢?

    要想解答这个问题,必须要搞清楚if语句的隐式代码块和作用域规则。上述代码等价于下面代码:

    func bar() {
        { // 等价于第一个if的隐式代码块
            a := 1 // 变量a作用域始于此
            if false {
    
            } else {
                { // 等价于第一个else if的隐式代码块
                    b := 2 // 变量b的作用域始于此
                    if false {
    
                    } else {
                        { // 等价于第二个else if的隐式代码块
                            c := 3 // 变量c作用域始于此
                            if false {
    
                            } else {
                                println(a, b, c)
                            }
                            // 变量c的作用域终止于此
                        }
                    }
                    // 变量b的作用域终止于此
                }
            }
            // 变量a作用域终止于此
        }
    }
    

    通过这段展开后的代码,我们可以清楚地看到第一段代码中的最后的else语句实质上是一个最内层的else,变量a、b、c的作用域范围是可以覆盖到else的。

    注:极客时间的《Go语言第一课》专栏的第11讲对代码块与作用域有着更为全面的讲解,欢迎大家订阅学习。

    1.2 使用自用变量

    在Go中使用if语句时,开发者常常纠结于到底使用下面哪种形式:

    if a, ok := foo(); a < 10 && ok{ //使用if表达式自用变量
    
    } 
    
    vs.
    
    a, ok := foo()
    if a < 10 && ok {
    
    }
    

    这里建议采用第一种,即“使用if表达式自用变量”,而不是在if外部定义临时变量。因为前者除了简洁,可读性略好的优点外,还有一点优势,那就是将a放在if隐式代码块中,将变量a的作用域限制到最小范围,这样可以避免不同代码段中变量命名相同而引起的冲突问题。进而让代码实现更加清晰和易于理解。

    上述代码还有一个可能让初学者疑惑的点,那就是a < 10 && ok的运算符优先级问题,是(a < 10) && ok 还是 a < (10 && ok),为了避免给后续代码阅读者带去理解上的困惑,建议使用小括号明确求值时的计算顺序。

    1.3 happy path原则

    Go语言中,if语句使用的一个惯例就是遵循happy path(快乐路径)原则,所谓happy path是指通过将缩进程度降到最低,避免if语句或else-if语句的过度嵌套,使代码更易读和可维护。遵循快乐路径原则可以让你的代码变得更容易阅读和理解,执行的流程也变得更加清晰。

    happy path意味着代码要尽量左对齐,减少嵌套,如下图所示:

    注:上图中原始素材来自于网络。

    在编码实践中,要满足happy path有几个技巧:

    • 减少else、else if的使用;
    • 避免if语句的嵌套使用;
    • 快速返回。在if语句的body中使用return从函数中返回,而不是继续后续的处理。

    注:极客时间的《Go语言第一课》专栏的第18讲对if语句做了更为全面的讲解,欢迎大家订阅学习。

    2. for语句

    印象中,for语句在使用频度方面是仅次于的if语句的控制流语句了。这里谈谈Go对于循环语句的支持的特点。

    2.1 仅此一种for循环

    Go信奉“做一件事只有一种方法”,不知道这是不是Go仅提供一种形式for语句的最初原因(相较于其他主流编程语言提供while、loop、do…while等)。

    Go经典的for语句有如下一些典型使用形式:

    // 最常规的for循环
    for i := 0; i < 10; i++ {
        fmt.Println(i)
    }
    
    // 模拟while循环
    i := 0
    for i < 10 {
        fmt.Println(i)
        i++
    }
    
    // 死循环
    for {
        // do something
    }
    

    2.2 for range不是可有可无

    如果说go只有for语句,也不够准确,go还有一个for range变体。不过这个for range变体不是可有可无的,有些遍历没有for range无法完成,比如:

    // 遍历map
    for k, v := range aMap {
    }
    
    // 遍历string中的字符(非字节遍历)
    for i, r := range s {
      // rune
    }
    

    2.3 带label与不带label的continue和break

    在Go语言中,for循环语句中可以使用带label的continue和break语句,也可以使用我们通常认知中的不带label的continue和break语句。不过它们之间的差别应该牢记:

    • 不带label的continue和break语句

    不带label的continue和break语句只能用于当前for循环语句中,它们的作用范围仅限于当前循环体内部。当执行continue语句时,会跳过本次循环,直接进入下一次循环;当执行break语句时,会结束当前循环,直接跳出循环体。

    • 带label的continue和break语句

    带label的continue和break语句可以用于多层嵌套的for循环语句中,它们可以跳出指定的循环体。当执行带label的continue语句时,会跳过指定的循环体中的本次循环,直接进入下一次循环;当执行带label的break语句时,会结束指定的循环体,直接跳出循环。

    下面是一个使用带label的break语句的示例:

    package main
    
    import "fmt"
    
    func main() {
        outerLoop:
        for i := 1; i <= 3; i++ {
            for j := 1; j <= 3; j++ {
                if i == 2 && j == 2 {
                    // 跳出指定循环体
                    fmt.Println("跳出外层循环")
                    break outerLoop
                }
                fmt.Printf("i=%d, j=%d\n", i, j)
            }
        }
    }
    

    在这个例子中,我们使用带label的break语句跳出了外层循环,从而避免了继续执行外层循环。如果使用不带label的break语句,仅会跳出内层循环,而不会跳出外层循环。

    2.4 坑

    虽然Go只有一种for语句形式,但可能遇到的“坑”却并不少,这里列出一些典型的“坑”:

    • 循环变量重用

    看一下下面代码:

    func main() {
        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)
    }
    

    你预期的输出是什么呢?实际输出是什么呢?在go playground中执行一下,得到如下结果:

    4 5
    4 5
    4 5
    4 5
    4 5
    

    为什么会输出这个结果呢?我将上述代码做一个等价变换你就明白了:

    func main() {
        var m = []int{1, 2, 3, 4, 5}  
    
        {
          i, v := 0, 0
            for i, v = range m {
                go func() {
                    time.Sleep(time.Second * 3)
                    fmt.Println(i, v)
                }()
            }
        }
    
        time.Sleep(time.Second * 10)
    }
    

    我们看到:i, v两个变量不是在每次循环时重新声明,而是在整个循环过程中只定义了一份,这就是为何所有goroutine输出的都是“4 5”的原因。Go团队针对这个问题正在设计优化方法,在后续的Go版本中,这个坑可能会被自然“修复”。

    • range表达式副本

    我们再来看一段代码:

    func main() {
        var a = [5]int{1, 2, 3, 4, 5}
        var r [5]int
    
        fmt.Println("original a =", a)
    
        for i, v := range a {
            if i == 0 {
                a[1] = 12
                a[2] = 13
            }
            r[i] = v
        }
    
        fmt.Println("after for range loop, r =", r)
        fmt.Println("after for range loop, a =", a)
    }
    

    在你的预期中,上面程序的输出结果是这样的:

    original a = [1 2 3 4 5]
    after for range loop, r = [1 12 13 4 5]
    after for range loop, a = [1 12 13 4 5]
    

    不过实际运行一下,你会看到真正的输出是这样的:

    original a = [1 2 3 4 5]
    after for range loop, r = [1 2 3 4 5]
    after for range loop, a = [1 12 13 4 5]
    

    究其原因,是因为参数range循环的是a的副本,我们用a’来表示,将上面代码等价变换为下面后,就更容易理解了:

    for i, v := range a' { //a'是a的一个值拷贝
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }
    

    这样变换后,我们知道for range遍历的是a的副本,对a的修改不会影响后续的遍历。

    因此,当使用数组、切片作为range后的待遍历的容器集合时,要十分小心。

    • break未跳出for

    当for与switch语句联合使用时,也要注意避坑,看一下下面代码:

    func main() {
        var sl = []int{5, 19, 6, 3, 8, 12}
        var firstEven int = -1
    
        // find first even number of the interger slice
        for i := 0; i < len(sl); i++ {
            switch sl[i] % 2 {
            case 0:
                firstEven = sl[i]
                break
            case 1:
                // do nothing
            }
        }
        println(firstEven)
    }
    

    执行这个代码,输出结果为12,与我们预期的第一个偶数6不符。原因是什么呢?从输出结果为12来看,应该是break并未跳出for循环,导致循环继续进行到最后。

    记住:Go语言规范中明确规定,不带label的break语句中断执行并跳出的,是同一函数内break语句所在的最内层的for、switch或select。所以,上面这个例子的break语句实际上只跳出了switch语句,并没有跳出外层的for循环,这也就是程序未按我们预期执行的原因。

    注:极客时间的《Go语言第一课》专栏的第19讲对for语句做了更为全面的讲解,欢迎大家订阅学习。

    3. switch语句

    最后聊聊switch语句。在Go语言中,switch语句也是一种常用的控制流语句,它可以根据不同的条件执行不同的代码块:

    switch expression {
    case value1:
        // 执行代码块1
    case value2:
        // 执行代码块2
    default:
        // 执行默认代码块
    }
    

    由于Go switch语句执行语义不会默认执行下一个case,因此上述switch语句等价于一个多个if-else的语句,但从可读性上来说,比多层的if else更易理解,可读性更好。在这样的场景下,我们是推荐使用switch替代多个if-else语句的。

    3.1 case语句求值顺序

    switch语句通常会有很多表达式,这些表达式的求值顺序是有明确规定的,即从switch表达式开始求值,然后各个case语句的求值顺序是从上到下,从左到右的。记住这个顺序,有助于你分析switch语句的执行语义。

    3.2 switch case的灵活性

    Go switch语句在语法语义方面相对于其先祖C语言的Switch语句来说,做了很多优化,结果是更加灵活,坑几乎填平,主要的优化包括:

    • switch支持任何值的case比较,而不像C语言只能用int或枚举

    • 支持case表达式列表

    package main
    
    import "fmt"
    
    func main() {
        num := 3
        switch num {
        case 1, 3, 5: // case支持表达式列表
            fmt.Println("奇数")
        case 2, 4, 6:
            fmt.Println("偶数")
        default:
            fmt.Println("其他")
        }
    }
    
    • 不会默认执行下一个case语句

    C语言中那种默认执行下一个case语句的执行语义导致我们需要在每个case中都使用break跳出switch,Go修复了这个语义,看下面这个例子:

    package main
    
    import "fmt"
    
    func main() {
        num := 2
        switch num {
        case 1:
            fmt.Println("第一个 case 块")
        case 2:
            fmt.Println("第二个 case 块")
        case 3:
            fmt.Println("第三个 case 块")
        }
    }
    

    这个例子只会输出“第二个 case 块”,不会执行case 3中的代码。

    如果要显式告知执行下一个case代码块,需要使用fallthrough。显然Go将常见执行逻辑作为默认语义,即每个case执行完跳出;而C语言恰做反了。

    3.3 type switch

    这个是其他语言所没有的,又或者说是Go特有的,type switch是针对接口类型表达式的特殊语法,语法格式也比较固定:

    var x interface{} = 3
    switch i := x.(type) {
    case nil:
        // x 的类型为 nil
        println(i) // 输出x中存储的动态类型值
    case int:
        // x 的类型为 int
    case string:
        // x 的类型为 string
    default:
        // x 的类型为其他类型
    }
    

    如果不需要接口变量中存储的动态类型值的话,也可以简化为:

    var x interface{} = 3
    switch x.(type) {
    case nil:
        // x 的类型为 nil
    case int:
        // x 的类型为 int
    case string:
        // x 的类型为 string
    default:
        // x 的类型为其他类型
    }
    

    注:极客时间的《Go语言第一课》专栏的第20讲对switch语句做了更为全面的讲解,欢迎大家订阅学习。

    4. 小结

    Go语言的控制流语句虽然种类不那么丰富,但足够帮助开发者实现各种不同类型的程序逻辑了。在编写代码时,需要根据具体的需求选择合适的控制语句,并注意遵循使用各种控制语句的惯例和规范,避免掉入各种“坑”中。


    “Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

    img{512x368}
    img{512x368}

    img{512x368}
    img{512x368}

    著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

    Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

    我的联系方式:

    • 微博(暂不可用):https://weibo.com/bigwhite20xx
    • 微博2:https://weibo.com/u/6484441286
    • 博客:tonybai.com
    • github: https://github.com/bigwhite

    商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

    © 2023, bigwhite. 版权所有.



沪ICP备19023445号-2号
友情链接