18|控制结构:if的“快乐路径”原则

你好,我是Tony Bai。

1984年图灵奖获得者、著名计算机科学家尼古拉斯·沃斯(Niklaus Wirth)提出过著名的“程序=数据结构+算法”的公式。在前面的课程中,我们花了很多时间讲解了Go语言的基本数据类型和复合数据类型,这些对应的就是公式中数据结构,通过这些数据类型我们可以建立起复杂的数据结构。

那么公式中的算法呢?算法是对真实世界运作规律的抽象,是解决真实世界中问题的步骤。在计算机世界中,再复杂的算法都可以通过顺序、分支和循环这三种基本的控制结构构造出来。

顺序结构自然不用说了,我们要关注的主要是后面两个。所以,这一节课开始的连续三节课,我们都会聚焦于Go语言中的分支和循环这两种控制结构。

那么Go语言对分支与循环两种控制结构的支持是怎么样的呢?针对程序的分支结构,Go提供了if和switch-case两种语句形式;而针对循环结构,Go只保留了for这一种循环语句形式。这节课我们就先从Go语言分支结构之一的if语句开始讲起。

Go中的分支结构之认识if语句

01讲中我们提到过,Go语言是站在C语言等的肩膀之上诞生与成长起来的。Go语言继承了C语言的很多语法,这里就包括控制结构。但Go也不是全盘照搬,而是在继承的基础上又加上了自己的一些优化与改进,比如:

  • Go坚持“一件事情仅有一种做法的理念”,只保留了for这一种循环结构,去掉了C语言中的while和do-while循环结构;
  • Go填平了C语言中switch分支结构中每个case语句都要以break收尾的“坑”;
  • Go支持了type switch特性,让“类型”信息也可以作为分支选择的条件;
  • Go的switch控制结构的case语句还支持表达式列表,让相同处理逻辑的多个分支可以合并为一个分支,等等。

如果你这个时候还不是很懂我提到的这些改进点,没有关系,在后面的几节课中,我会为你详细讲解Go关于控制结构的各个优化和改进点。

那么,Go中的if语句又有什么创新点呢?我们先来认识一下Go中的if语句。

if语句是Go语言中提供的一种分支控制结构,它也是Go中最常用、最简单的分支控制结构。它会根据布尔表达式的值,在两个分支中选择一个执行。我们先来看一个最简单的、单分支结构的if语句的形式:

if boolean_expression {
    // 新分支
}

// 原分支

分支结构是传统结构化程序设计中的基础构件,这个if语句中的代码执行流程就等价于下面这幅流程图:

图片

从图中我们可以看到,代码执行遇到if分支结构后,首先会对其中的布尔表达式(boolean_expression)进行求值,如果求值结果为true,那么程序将进入新分支执行,如果布尔表达式的求值结果为false,代码就会继续沿着原分支的路线继续执行。

虽然各种编程语言几乎都原生支持了if语句,但Go的if语句依然有着自己的特点:

第一,和Go函数一样,if语句的分支代码块的左大括号与if关键字在同一行上,这也是Go代码风格的统一要求,gofmt工具会帮助我们实现这一点;

第二,if语句的布尔表达式整体不需要用括号包裹,一定程度上减少了开发人员敲击键盘的次数。而且,if关键字后面的条件判断表达式的求值结果必须是布尔类型,即要么是true,要么是false:

if runtime.GOOS == "linux" {
    println("we are on linux os")	  
}

如果判断的条件比较多,我们可以用多个逻辑操作符连接起多个条件判断表达式,比如这段代码就是用了多个逻辑操作符&&来连接多个布尔表达式:

if (runtime.GOOS == "linux") && (runtime.GOARCH == "amd64") &&
    (runtime.Compiler != "gccgo") {
    println("we are using standard go compiler on linux os for amd64")
}

除了逻辑操作符&&之外,Go还提供了另外两个逻辑操作符,我总结到了这张表里。

图片

你可能也注意到了,上面示例代码中的每个布尔表达式都被小括号括上了,这又是什么原因呢?这是为了降低你在阅读和理解这段代码时,面对操作符优先级的心智负担,这也是我个人的编码习惯。

Go语言的操作符是有优先级的。这里你要记住,一元操作符,比如上面的逻辑非操作符,具有最高优先级,其他操作符的优先级如下:

操作符优先级决定了操作数优先参与哪个操作符的求值运算,我们以下面代码中if语句的布尔表达式为例:

func main() {
    a, b := false,true
    if a && b != true {
        println("(a && b) != true")
        return
    }
    println("a && (b != true) == false")
}

执行这段代码会输出什么呢?你第一次读这段代码的时候,可能会认为输出(a && b) != true,但实际上我们得到的却是a && (b != true) == false。这是为什么呢?

这段代码的关键就在于,if后面的布尔表达式中的操作数b是先参与&&的求值运算,还是先参与!=的求值运算。根据前面的操作符优先级表,我们知道,!=的优先级要高于&&,因此操作数b先参与的是!=的求值运算,这样if后的布尔表达式就等价于a && (b != true) ,而不是我们最初认为的(a && b) != true。

如果你有时候也会记不住操作符优先级,不用紧张。从学习和使用C语言开始,我自己就记不住这么多操作符的优先级,况且不同编程语言的操作符优先级还可能有所不同,所以我个人倾向在if布尔表达式中,使用带有小括号的子布尔表达式来清晰地表达判断条件。

这样做不仅可以消除了自己记住操作符优先级的学习负担,同时就像前面说的,当其他人阅读你的代码时,也可以很清晰地看出布尔表达式要表达的逻辑关系,这能让我们代码的可读性更好,更易于理解,不会因记错操作符优先级顺序而产生错误的理解。

除了上面的最简形式,Go语言的if语句还有其他多种形式,比如二分支结构和多(N)分支结构。

二分支控制结构比较好理解。比如下面这个例子,当boolean_expression求值为true时,执行分支1,否则,执行分支2:

if boolean_expression {
	// 分支1
} else {
	// 分支2
}

多分支结构由于引入了else if,理解起来稍难一点点,它的标准形式是这样的:

if boolean_expression1 {
	// 分支1
} else if boolean_expression2 {
	// 分支2

... ...

} else if boolean_expressionN {
	// 分支N
} else {
	// 分支N+1
}

我们以下面这个四分支的代码为例,看看怎么拆解这个多分支结构:

if boolean_expression1 {
    // 分支1
} else if boolean_expression2 {
    // 分支2
} else if boolean_expression3 {
    // 分支3
} else {
    // 分支4
} 

要理解这个略复杂一些的分支结构,其实很简单。我们只需要把它做一下等价变换,变换为我们熟悉的二分支结构就好了,变换后的代码如下:

if boolean_expression1 {
    // 分支1
} else {
    if boolean_expression2 {
        // 分支2
    } else { 
        if boolean_expression3 {
            // 分支3
        } else {
            // 分支4
        } 
    }
}

这样等价转换后,我们得到一个层层缩进的二分支结构,通过上面我们对二分支的分析,再来理解这个结构就十分容易了。

支持声明if语句的自用变量

无论是单分支、二分支还是多分支结构,我们都可以在if后的布尔表达式前,进行一些变量的声明,在if布尔表达式前声明的变量,我叫它 if 语句的自用变量。顾名思义,这些变量只可以在if语句的代码块范围内使用,比如下面代码中的变量a、b和c:

func main() {
    if a, c := f(), h(); a > 0 {
        println(a)
    } else if b := f(); b > 0 {
        println(a, b)
    } else {
        println(a, b, c)
    }
}

我们可以看到自用变量声明的位置是在每个if语句的后面,布尔表达式的前面,而且,由于声明本身是一个语句,所以我们需要把它和后面的布尔表达式通过分号分隔开。

这里又涉及到了代码块与作用域的概念,这是我们在第11讲中学习到的内容。如果你觉得概念有些模糊了,可以回过头去复习一下。根据第11讲中的讲解,我们知道,上面代码中声明的变量a、b、c都位于各级if的隐式代码块中,它们的作用域起始于它声明所在的代码块,并一直可扩展至嵌入到这个代码块的所有内层代码块中。

在if语句中声明自用变量是Go语言的一个惯用法,这种使用方式直观上可以让开发者有一种代码行数减少的感觉,提高可读性。同时,由于这些变量是if语句自用变量,它的作用域仅限于if语句的各层隐式代码块中,if语句外部无法访问和更改这些变量,这就让这些变量具有一定隔离性,这样你在阅读和理解if语句的代码时也可以更聚焦。

不过前面我们第11讲也重点提到过,Go控制结构与短变量声明的结合是“变量遮蔽”问题出没的重灾区,你在这点上一定要注意。

到这里,我们已经学过了if分支控制结构的所有形式,也了解了if语句通过短变量声明形式声明自用变量的优点与不足。那么在日常开发中,这些if分支控制结构形式是随意使用的吗?有什么优化方案吗?

if语句的“快乐路径”原则

我们已经学了if分支控制结构的三种形式了,从可读性上来看,单分支结构要优于二分支结构,二分支结构又优于多分支结构。那么显然,我们在日常编码中要减少多分支结构,甚至是二分支结构的使用,这会有助于我们编写出优雅、简洁、易读易维护且不易错的代码

我们用一个具体的例子直观地体会一下我的这个观点,下面是两段逻辑相同但形式不同的伪代码段:

//伪代码段1:

func doSomething() error {
	if errorCondition1 {
		// some error logic
		... ...
		return err1
	}
	
	// some success logic
	... ...

	if errorCondition2 {
		// some error logic
		... ...
		return err2
	}

	// some success logic
	... ...
	return nil
}

// 伪代码段2:

func doSomething() error {
	if successCondition1 {
		// some success logic
		... ...

		if successCondition2 {
			// some success logic
			... ...

			return nil
		} else {
			// some error logic
			... ...
			return err2
		}
	} else {
		// some error logic
		... ...
		return err1
	}
}

即便你是刚入门的Go新手,你大概也能看出上面代码的优劣。

我们看看只使用了单分支控制结构的伪代码段1,我们看到代码段1有这几个特点:

  • 没有使用else分支,失败就立即返回;
  • “成功”逻辑始终“居左”并延续到函数结尾,没有被嵌入到if的布尔表达式为true的代码分支中;
  • 整个代码段布局扁平,没有深度的缩进;

而另外一个实现了同样逻辑的伪代码段2,就使用了带有嵌套的二分支结构,它的特点如下:

  • 整个代码段呈现为“锯齿状”,有深度缩进;
  • “成功”逻辑被嵌入到if的布尔表达式为true的代码分支中;

很明显,伪代码段1的逻辑更容易理解,也更简洁。Go社区把这种if语句的使用方式称为if语句的“快乐路径(Happy Path)”原则,所谓“快乐路径”也就是成功逻辑的代码执行路径,它的特点是这样的:

  • 仅使用单分支控制结构;
  • 当布尔表达式求值为false时,也就是出现错误时,在单分支中快速返回;
  • 正常逻辑在代码布局上始终“靠左”,这样读者可以从上到下一眼看到该函数正常逻辑的全貌;
  • 函数执行到最后一行代表一种成功状态。

Go社区推荐Gopher们在使用if语句时尽量符合这些原则,如果你的函数实现代码不符合“快乐路径”原则,你可以按下面步骤进行重构:

  • 尝试将“正常逻辑”提取出来,放到“快乐路径”中;
  • 如果无法做到上一点,很可能是函数内的逻辑过于复杂,可以将深度缩进到else分支中的代码析出到一个函数中,再对原函数实施“快乐路径”原则。

小结

好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。

分支控制结构是构造现实中复杂算法的三大基础控制结构之一,Go语言通过if与switch语句对分支控制结构提供了支持。在这节课中,我们重点讲解了if语句,我建议你记住以下几点:

第一,if语句是Go语言中最常用的分支控制语句,也是最简单的分支控制结构。if语句通过对布尔表达式的求值决定了后续代码执行要进入的哪条分支。当需要复杂条件判断时,我们可以使用逻辑操作符连接多个布尔表达式,作为if语句的判断条件表达式。如果这么做了,我们还要注意各个操作符的优先级,我个人建议尽量用小括号对各个布尔表达式进行清晰地隔离,这样可以提升代码可读性。

第二,Go的if语句提供了多种使用形式,包括单分支、双分支以及多分支。多分支理解起来略有难度,我们可以将它等价转换为双分支来理解。

第三,if语句支持在布尔表达式前声明自用变量,这些变量作用域仅限于if语句的代码块内部。使用if自用变量可以一定程度简化代码,并增强与同函数内其他变量的隔离,但这也十分容易导致变量遮蔽问题,你使用时一定要注意。

最后一点,if语句的三种使用形式的复杂度与可读性不一,我们建议在使用if语句时尽量符合“快乐路径”原则,这个原则通常只使用最容易理解的单分支结构,所有正常代码均“靠左”,这让函数内代码逻辑一目了然,提升了代码可读性与可维护性。

思考题

今天,我依然出了一个思考题:如果一个if语句使用了多分支结构,如下面代码这样,那么if语句中的几个布尔表达式如何排列能达到最好的效果呢?

提示一下,几个布尔表达式能够被命中的概率是不同的,你在答案中可以自行假设一下。期待在留言区看到你的分析。

func foo() {
    if boolean_expression1 {

    } else if boolean_expression2 {

    } else if boolean_expression3 {

    } else {

    }
}

欢迎你把这节课分享给更多对Go语言中的if语句感兴趣的朋友。我是Tony Bai,我们下节课见。

精选留言

  • Darren

    2021-11-22 16:23:52


    如果加上“布尔表达式3在这段代码中实际被命中的机会更多,布尔表达式2次之,布尔表达式1最少”,这个条件,那么最优的性能最好的写法应该是最大概率的放到最前面,因此可以改成如下:

    func foo() {
    if boolean_expression3{

    return
    }
    if boolean_expression2 {

    return
    }
    if boolean_expression1 {

    return
    }

    else代码
    return
    }

    那为什么命中的最多,写到前面,是最好的呢,这里面主要涉及到2个技术点:流水线技术和分支预测
    流水线技术:简单的说,一条 CPU 指令的执行是由 取指令-指令译码-指令执行-结果回写组成的(简单的说哈,真实的流水线是更长更复杂的);第一条指令译码的时候,就可以去取第二条指令,因此可以通过流水线技术提高CPU的使用率。
    分支预测:如果没有任何分支预测,那么就是按照程序的代码顺序执行,那么执行到if上一句的时候,指令译码就是if语句,取指令就是if语句块的第一句,那么if如果不满足的话,就会执行JMP指令,跳转到else,因此流水线中的取指令与指令译码其实是无用功。因此在没有任何分支预测优化的情况下,if语句需要把概率更高的条件写到最上面,更能体现流水线的威力。

    但是现代计算机都有分支预测的优化,比如动态分支预测等技术,但是不管怎么说,把概率最大的放到最上面,还是很有必要的。

    问题:在C语言中,有类似这样的宏定义,可以使用 __builtin_expect函数,主动提示那个分支的代码的概率更高,在go中是否也有类似的方法?还是说现在的编后端编译技术已经比较智能,不需要甚至禁止程序员手动指定分支预测了。
    #define likely(x) __builtin_expect(!!(x), 1)
    #define unlikely(x) __builtin_expect(!!(x), 0)
    作者回复

    厉害!👍

    2021-11-29 17:44:26

  • Darren

    2021-11-22 09:21:46

    可以改成这样子吧

    func foo() {
    if boolean_expression1 {

    return
    }
    if boolean_expression2 {

    return
    }
    if boolean_expression3 {

    return
    }

    else代码
    return
    }
    作者回复

    改成快乐路径方式是ok的。

    可能思考题没说清楚。这里说的“效果最好”,指的是这段代码的执行性能最好。

    提示一下:如果从统计概率而言,布尔表达式3在这段代码中实际被命中的机会更多,布尔表达式2次之,布尔表达式1最少,那么排列应该如何变
    化才能让这段代码执行性能最好呢?

    2021-11-22 14:31:43

  • mikewoo

    2022-04-22 15:48:02

    依据被命中的概率,依次由高到低,把命中概率最高的放在最前面。
    作者回复

    👍

    2022-04-24 06:09:55

  • 用0和1改变自己

    2021-11-23 23:23:58

    把命中率高的依次放前面,性能会好些。需要注意的是,并不是所有if都遵从快乐原则,毕竟很多逻辑只是简单确定一个值,return的逻辑在下面。Happy Path还是更是适合错误判断,而不是单纯选择
  • lesserror

    2021-11-23 22:57:23

    对于if的“快乐路径”原则深表认同,每次看见同事写的四五层以上的 if else 语句,有种“想死” 的冲动。

    自以为逻辑能力很强,殊不知自己一时爽,后来者却无法维护这样的代码了。
    作者回复

    是的。尽量写简洁易读的代码才是王道。

    2021-11-30 15:21:49

  • 温雅小公子

    2022-10-16 00:20:58

    在“代码之丑”专栏里也看到快乐路径的写法了,使用起来真的很快乐。
  • Empty

    2022-02-19 14:44:40

    func foo() {
    if boolean_expression1 {

    return
    }
    if boolean_expression2 {

    return
    }
    if boolean_expression3 {

    return
    }

    else代码
    return
    }

    从代码的可读性来讲应该写成这个样子,但是多个if条件的排列顺序要综合命中概率、条件判断复杂度、业务优先级等方面去考虑
    作者回复

    2022-02-23 16:14:30

  • 🐎

    2022-09-05 17:08:25

    这个其他语言里一般叫做 early return(尽早返回)😁
    作者回复

    👍

    2022-09-06 18:52:35

  • qinsi

    2021-11-22 17:09:13

    还是之前的问题,happy path似乎让comma ok不再简洁

    comma ok:
    ```go
    m := map[string]int {
    "v1": 1,
    "v2": 2,
    "v3": 3,
    }

    if v1, ok := m["v1"]; ok {
    if v2, ok := m["v2"]; ok {
    if v3, ok := m["v3"]; ok {
    fmt.Println(v1 + v2 + v3)
    }
    }
    }
    ```

    happy path:
    ```go
    v1, ok := m["v1"]
    if !ok {
    return
    }

    v2, ok := m["v2"]
    if !ok {
    return
    }

    v3, ok := m["v3"]
    if !ok {
    return
    }

    fmt.Println(v1 + v2 + v3)
    ```

    换种写法也一样:
    ```go
    if _, ok := m["v1"]; !ok {
    return
    }
    v1 := m["v1"]

    if _, ok := m["v2"]; !ok {
    return
    }
    v2 := m["v2"]

    if _, ok := m["v3"]; !ok {
    return
    }
    v3 := m["v3"]

    fmt.Println(v1 + v2 + v3)
    ```
    作者回复

    相对于第一个深层嵌套的“不易读”,下面虽然verbose一些,但代码一目了然啊。不知你是否有同样感觉。

    2021-11-29 17:43:43

  • 子杨

    2023-03-29 22:48:23


    func main() {
    if a, c := f(), h(); a > 0 {
    println(a)
    } else if b := f(); b > 0 {
    println(a, b)
    } else {
    println(a, b, c)
    }
    }

    这个例子是不是有点问题,为啥 f() 第一个 if 里赋值给了 a,第二个又赋值给了b。
    作者回复

    这个只是为了演示“if语句的自用变量”的用法和作用域,不用太在意条件逻辑的合理性:)。

    2023-03-30 12:27:07

  • Tristana

    2022-06-10 15:08:24

    假设每个表达式被命中概率为 boolean_expression1 > boolean_expression2 > boolean_expression3 , 可以将程序逻辑调整为命中率高的表达式放在最前面,命中后直接返回,调整后的逻辑如下

    ```
    func foo() {
    if boolean_expression1 {
    return 结果1
    }

    if boolean_expression2 {
    return 结果2
    }

    if boolean_expression3 {
    return 结果3
    }


    return 结果5
    }
    ```
    作者回复

    2022-06-10 17:34:08

  • aoe

    2021-11-23 00:05:26

    跟着老师的风格走,多个 if 条件组合的时候加上括号提高可读性。快乐路径很有意思,比 Fail-fast 更吸引眼球
  • 进化菌

    2021-11-22 19:41:47

    if快乐路径,其实就是减少不必要的嵌套,让代码结构简单明了。
    思考题里,条件的优先级应该是命中率高的排前面,else 有时候似乎没太必要写。
  • 长林啊

    2021-11-22 10:09:11

    使用switch case;
    func foo() {
    switch expression {
    case condition1:

    case condition2:

    case condition3:

    default:

    }
    }
    作者回复

    可能思考题没说清楚。这里说的“效果最好”,指的是这段代码的执行性能最好。

    提示一下:如果从统计概率而言,布尔表达式3在这段代码中实际被命中的机会更多,布尔表达式2次之,布尔表达式1最少,那么排列应该如何变化才能让这段代码执行性能最好呢?

    btw,使用switch-case,说明你基础很好。

    2021-11-22 14:01:46

  • My.life

    2021-11-22 09:18:35


    func main() {
    a, b := true, false
    if a && b != true {
    println("(a && b) != true")
    return
    }
    println("a && (b != true) == true")
    }


    为什么我输出的是(a && b) != true
    作者回复

    笔误,后续编辑会改一下。不影响后续的理解。感谢指出。

    2021-11-22 14:26:22

  • 终夏

    2024-09-26 00:38:56

    假设命中率boolean_expression3>boolean_expression2>boolean_expression1
    【原代码】
    func foo() {
    if boolean_expression1 {

    } else if boolean_expression2 {

    } else if boolean_expression3 {

    } else {

    }
    }

    【优化代码】
    func foo(){
    if boolean_expression3 {
    ......
    return
    }

    if boolean_expression2 {
    ......
    return
    }

    if boolean_expression1 {
    ......
    return
    }
    }

    【理由】这段程序的初衷是对符合条件的数据进行处理,命中率高也就意味着大多的数据的符合这个条件的。那么把命中率最高的boolean_expression放前面,可避免出现大量的经过“三关”后才找到“真命天子”的情况
    作者回复

    👍。回答的太形象生动了😁

    2024-09-26 18:58:09

  • 安石

    2024-07-28 09:19:30

    这种使用策略模式代替return map[boolean_expression] || default
    作者回复

    “return map[boolean_expression] || default” 这种我还真没用过:)

    2024-08-04 22:21:35

  • 人言有力

    2024-05-14 18:09:00

    本节介绍了重要的控制语句if的使用,逻辑表达式优先级、括号使用、if语句自用变量、“单分支快乐原则”
    1. if使用基本相同,极简形式就是逻辑表达式且不带括号
    2.实际场景由于运算符的优先级容易增加编程负担,最好是带上小括号,或者提前声明逻辑变量再运算
    3.自用变量是if语句隐式代码块作用域中的一种语法糖,但是容易带来变量遮蔽的问题,需要慎用
    4.快乐原则其实就是短链原则,避免if语句多层嵌套

    5.思考题如何调整,其实就按语句出现概率,从高到低编写单分支结构,这样比较次数最少
    作者回复

    👍

    2024-05-16 22:36:53

  • Jayleonc

    2023-09-08 15:38:58

    func main() { a, b := false,true if a && b != true { println("(a && b) != true") return } println("a && (b != true) == false")}

    这一段代码,a = false ,就不会进 if 里面吧,一开始就跳过了,都不用看 && 后面吧?
    作者回复

    这段代码要说明的是操作符的优先级问题。很多人会将条件表达式看成( a && b) != true,而不是 a && (b != true)。

    2023-09-08 21:56:39

  • 你说的真对

    2023-01-06 15:41:08

    如果不添加统计概率的可能性的前提下, 快速失败和这种写法差别不大, 都是要自上而下依次执行比较;但是如果可以知道每个分支的概率, 把最大的概率放到前面是最优的;
    作者回复

    👍

    2023-01-08 20:36:17