18 | if语句、for语句和switch语句

在上两篇文章中,我主要为你讲解了与go语句、goroutine和Go语言调度器有关的知识和技法。

内容很多,你不用急于完全消化,可以在编程实践过程中逐步理解和感悟,争取夯实它们。


现在,让我们暂时走下神坛,回归民间。我今天要讲的if语句、for语句和switch语句都属于Go语言的基本流程控制语句。它们的语法看起来很朴素,但实际上也会有一些使用技巧和注意事项。我在本篇文章中会以一系列面试题为线索,为你讲述它们的用法。

那么,今天的问题是:使用携带range子句的for语句时需要注意哪些细节? 这是一个比较笼统的问题。我还是通过编程题来讲解吧。

本问题中的代码都被放在了命令源码文件demo41.go的main函数中的。为了专注问题本身,本篇文章中展示的编程题会省略掉一部分代码包声明语句、代码包导入语句和main函数本身的声明部分。

numbers1 := []int{1, 2, 3, 4, 5, 6}
for i := range numbers1 {
	if i == 3 {
		numbers1[i] |= i
	}
}
fmt.Println(numbers1)

我先声明了一个元素类型为int的切片类型的变量numbers1,在该切片中有6个元素值,分别是从16的整数。我用一条携带range子句的for语句去迭代numbers1变量中的所有元素值。

在这条for语句中,只有一个迭代变量i。我在每次迭代时,都会先去判断i的值是否等于3,如果结果为true,那么就让numbers1的第i个元素值与i本身做按位或的操作,再把操作结果作为numbers1的新的第i个元素值。最后我会打印出numbers1的值。

所以具体的问题就是,这段代码执行后会打印出什么内容?

这里的典型回答是:打印的内容会是[1 2 3 7 5 6]

问题解析

你心算得到的答案是这样吗?让我们一起来复现一下这个计算过程。

for语句被执行的时候,在range关键字右边的numbers1会先被求值。

这个位置上的代码被称为range表达式。range表达式的结果值可以是数组、数组的指针、切片、字符串、字典或者允许接收操作的通道中的某一个,并且结果值只能有一个。

对于不同种类的range表达式结果值,for语句的迭代变量的数量可以有所不同。

就拿我们这里的numbers1来说,它是一个切片,那么迭代变量就可以有两个,右边的迭代变量代表当次迭代对应的某一个元素值,而左边的迭代变量则代表该元素值在切片中的索引值。

那么,如果像本题代码中的for语句那样,只有一个迭代变量的情况意味着什么呢?这意味着,该迭代变量只会代表当次迭代对应的元素值的索引值。

更宽泛地讲,当只有一个迭代变量的时候,数组、数组的指针、切片和字符串的元素值都是无处安放的,我们只能拿到按照从小到大顺序给出的一个个索引值。

因此,这里的迭代变量i的值会依次是从05的整数。当i的值等于3的时候,与之对应的是切片中的第4个元素值4。对43进行按位或操作得到的结果是7。这就是答案中的第4个整数是7的原因了。

现在,我稍稍修改一下上面的代码。我们再来估算一下打印内容。

numbers2 := [...]int{1, 2, 3, 4, 5, 6}
maxIndex2 := len(numbers2) - 1
for i, e := range numbers2 {
	if i == maxIndex2 {
		numbers2[0] += e
	} else {
		numbers2[i+1] += e
	}
}
fmt.Println(numbers2)

注意,我把迭代的对象换成了numbers2numbers2中的元素值同样是从16的6个整数,并且元素类型同样是int,但它是一个数组而不是一个切片。

for语句中,我总是会对紧挨在当次迭代对应的元素后边的那个元素,进行重新赋值,新的值会是这两个元素的值之和。当迭代到最后一个元素时,我会把此range表达式结果值中的第一个元素值,替换为它的原值与最后一个元素值的和,最后,我会打印出numbers2的值。

对于这段代码,我的问题依旧是:打印的内容会是什么?你可以先思考一下。

好了,我要公布答案了。打印的内容会是[7 3 5 7 9 11]。我先来重现一下计算过程。当for语句被执行的时候,在range关键字右边的numbers2会先被求值。

这里需要注意两点:

  1. range表达式只会在for语句开始执行时被求值一次,无论后边会有多少次迭代;
  2. range表达式的求值结果会被复制,也就是说,被迭代的对象是range表达式结果值的副本而不是原值。

基于这两个规则,我们接着往下看。在第一次迭代时,我改变的是numbers2的第二个元素的值,新值为3,也就是12之和。

但是,被迭代的对象的第二个元素却没有任何改变,毕竟它与numbers2已经是毫不相关的两个数组了。因此,在第二次迭代时,我会把numbers2的第三个元素的值修改为5,即被迭代对象的第二个元素值2和第三个元素值3的和。

以此类推,之后的numbers2的元素值依次会是7911。当迭代到最后一个元素时,我会把numbers2的第一个元素的值修改为16之和。

好了,现在该你操刀了。你需要把numbers2的值由一个数组改成一个切片,其中的元素值都不要变。为了避免混淆,你还要把这个切片值赋给变量numbers3,并且把后边代码中所有的numbers2都改为numbers3

问题是不变的,执行这段修改版的代码后打印的内容会是什么呢?如果你实在估算不出来,可以先实际执行一下,然后再尝试解释看到的答案。提示一下,切片与数组是不同的,前者是引用类型的,而后者是值类型的。

我们可以先接着讨论后边的内容,但是我强烈建议你一定要回来,再看看我留给你的这个问题,认真地思考和计算一下。

知识扩展

问题1:switch语句中的switch表达式和case表达式之间有着怎样的联系?

先来看一段代码。

value1 := [...]int8{0, 1, 2, 3, 4, 5, 6}
switch 1 + 3 {
case value1[0], value1[1]:
	fmt.Println("0 or 1")
case value1[2], value1[3]:
	fmt.Println("2 or 3")
case value1[4], value1[5], value1[6]:
	fmt.Println("4 or 5 or 6")
}

我先声明了一个数组类型的变量value1,该变量的元素类型是int8。在后边的switch语句中,被夹在switch关键字和左花括号{之间的是1 + 3,这个位置上的代码被称为switch表达式。这个switch语句还包含了三个case子句,而每个case子句又各包含了一个case表达式和一条打印语句。

所谓的case表达式一般由case关键字和一个表达式列表组成,表达式列表中的多个表达式之间需要有英文逗号,分割,比如,上面代码中的case value1[0], value1[1]就是一个case表达式,其中的两个子表达式都是由索引表达式表示的。

另外的两个case表达式分别是case value1[2], value1[3]case value1[4], value1[5], value1[6]

此外,在这里的每个case子句中的那些打印语句,会分别打印出不同的内容,这些内容用于表示case子句被选中的原因,比如,打印内容0 or 1表示当前case子句被选中是因为switch表达式的结果值等于01中的某一个。另外两条打印语句会分别打印出2 or 34 or 5 or 6

现在问题来了,拥有这样三个case表达式的switch语句可以成功通过编译吗?如果不可以,原因是什么?如果可以,那么该switch语句被执行后会打印出什么内容。

我刚才说过,只要switch表达式的结果值与某个case表达式中的任意一个子表达式的结果值相等,该case表达式所属的case子句就会被选中。

并且,一旦某个case子句被选中,其中的附带在case表达式后边的那些语句就会被执行。与此同时,其他的所有case子句都会被忽略。

当然了,如果被选中的case子句附带的语句列表中包含了fallthrough语句,那么紧挨在它下边的那个case子句附带的语句也会被执行。

正因为存在上述判断相等的操作(以下简称判等操作),switch语句对switch表达式的结果类型,以及各个case表达式中子表达式的结果类型都是有要求的。毕竟,在Go语言中,只有类型相同的值之间才有可能被允许进行判等操作。

如果switch表达式的结果值是无类型的常量,比如1 + 3的求值结果就是无类型的常量4,那么这个常量会被自动地转换为此种常量的默认类型的值,比如整数4的默认类型是int,又比如浮点数3.14的默认类型是float64

因此,由于上述代码中的switch表达式的结果类型是int,而那些case表达式中子表达式的结果类型却是int8,它们的类型并不相同,所以这条switch语句是无法通过编译的。

再来看一段很类似的代码:

value2 := [...]int8{0, 1, 2, 3, 4, 5, 6}
switch value2[4] {
case 0, 1:
	fmt.Println("0 or 1")
case 2, 3:
	fmt.Println("2 or 3")
case 4, 5, 6:
	fmt.Println("4 or 5 or 6")
}

其中的变量value2value1的值是完全相同的。但不同的是,我把switch表达式换成了value2[4],并把下边那三个case表达式分别换为了case 0, 1case 2, 3case 4, 5, 6

如此一来,switch表达式的结果值是int8类型的,而那些case表达式中子表达式的结果值却是无类型的常量了。这与之前的情况恰恰相反。那么,这样的switch语句可以通过编译吗?

答案是肯定的。因为,如果case表达式中子表达式的结果值是无类型的常量,那么它的类型会被自动地转换为switch表达式的结果类型,又由于上述那几个整数都可以被转换为int8类型的值,所以对这些表达式的结果值进行判等操作是没有问题的。

当然了,如果这里说的自动转换没能成功,那么switch语句照样通不过编译。

(switch语句中的自动类型转换)

通过上面这两道题,你应该可以搞清楚switch表达式和case表达式之间的联系了。由于需要进行判等操作,所以前者和后者中的子表达式的结果类型需要相同。

switch语句会进行有限的类型转换,但肯定不能保证这种转换可以统一它们的类型。还要注意,如果这些表达式的结果类型有某个接口类型,那么一定要小心检查它们的动态值是否都具有可比性(或者说是否允许判等操作)。

因为,如果答案是否定的,虽然不会造成编译错误,但是后果会更加严重:引发panic(也就是运行时恐慌)。

问题2:switch语句对它的case表达式有哪些约束?

我在上一个问题的阐述中还重点表达了一点,不知你注意到了没有,那就是:switch语句在case子句的选择上是具有唯一性的。

正因为如此,switch语句不允许case表达式中的子表达式结果值存在相等的情况,不论这些结果值相等的子表达式,是否存在于不同的case表达式中,都会是这样的结果。具体请看这段代码:

value3 := [...]int8{0, 1, 2, 3, 4, 5, 6}
switch value3[4] {
case 0, 1, 2:
	fmt.Println("0 or 1 or 2")
case 2, 3, 4:
	fmt.Println("2 or 3 or 4")
case 4, 5, 6:
	fmt.Println("4 or 5 or 6")
}

变量value3的值同value1,依然是由从06的7个整数组成的数组,元素类型是int8switch表达式是value3[4],三个case表达式分别是case 0, 1, 2case 2, 3, 4case 4, 5, 6

由于在这三个case表达式中存在结果值相等的子表达式,所以这个switch语句无法通过编译。不过,好在这个约束本身还有个约束,那就是只针对结果值为常量的子表达式。

比如,子表达式1+12不能同时出现,1+34也不能同时出现。有了这个约束的约束,我们就可以想办法绕过这个对子表达式的限制了。再看一段代码:

value5 := [...]int8{0, 1, 2, 3, 4, 5, 6}
switch value5[4] {
case value5[0], value5[1], value5[2]:
	fmt.Println("0 or 1 or 2")
case value5[2], value5[3], value5[4]:
	fmt.Println("2 or 3 or 4")
case value5[4], value5[5], value5[6]:
	fmt.Println("4 or 5 or 6")
}

变量名换成了value5,但这不是重点。重点是,我把case表达式中的常量都换成了诸如value5[0]这样的索引表达式。

虽然第一个case表达式和第二个case表达式都包含了value5[2],并且第二个case表达式和第三个case表达式都包含了value5[4],但这已经不是问题了。这条switch语句可以成功通过编译。

不过,这种绕过方式对用于类型判断的switch语句(以下简称为类型switch语句)就无效了。因为类型switch语句中的case表达式的子表达式,都必须直接由类型字面量表示,而无法通过间接的方式表示。代码如下:

value6 := interface{}(byte(127))
switch t := value6.(type) {
case uint8, uint16:
	fmt.Println("uint8 or uint16")
case byte:
	fmt.Printf("byte")
default:
	fmt.Printf("unsupported type: %T", t)
}

变量value6的值是空接口类型的。该值包装了一个byte类型的值127。我在后面使用类型switch语句来判断value6的实际类型,并打印相应的内容。

这里有两个普通的case子句,还有一个default case子句。前者的case表达式分别是case uint8, uint16case byte。你还记得吗?byte类型是uint8类型的别名类型。

因此,它们两个本质上是同一个类型,只是类型名称不同罢了。在这种情况下,这个类型switch语句是无法通过编译的,因为子表达式byteuint8重复了。好了,以上说的就是case表达式的约束以及绕过方式,你学会了吗。

总结

我们今天主要讨论了for语句和switch语句,不过我并没有说明那些语法规则,因为它们太简单了。我们需要多加注意的往往是那些隐藏在Go语言规范和最佳实践里的细节。

这些细节其实就是我们很多技术初学者所谓的“坑”。比如,我在讲for语句的时候交代了携带range子句时只有一个迭代变量意味着什么。你必须知道在迭代数组或切片时只有一个迭代变量的话是无法迭代出其中的元素值的,否则你的程序可能就不会像你预期的那样运行了。

还有,range表达式的结果值是会被复制的,实际迭代时并不会使用原值。至于会影响到什么,那就要看这个结果值的类型是值类型还是引用类型了。

说到switch语句,你要明白其中的case表达式的所有子表达式的结果值都是要与switch表达式的结果值判等的,因此它们的类型必须相同或者能够都统一到switch表达式的结果类型。如果无法做到,那么这条switch语句就不能通过编译。

最后,同一条switch语句中的所有case表达式的子表达式的结果值不能重复,不过好在这只是对于由字面量直接表示的子表达式而言的。

请记住,普通case子句的编写顺序很重要,最上边的case子句中的子表达式总是会被最先求值,在判等的时候顺序也是这样。因此,如果某些子表达式的结果值有重复并且它们与switch表达式的结果值相等,那么位置靠上的case子句总会被选中。

思考题

  1. 在类型switch语句中,我们怎样对被判断类型的那个值做相应的类型转换?
  2. if语句中,初始化子句声明的变量的作用域是什么?

戳此查看Go语言专栏文章配套详细代码。

精选留言

  • 咖啡色的羊驼

    2018-09-21 00:21:15

    好久没留言了,

    1.断言判断value.(type)
    2.if的判断的域和后面跟着的花括号里头的域。和函数雷同,参数和花括号里头的域同一个
    作者回复

    好,继续加油吧。

    2018-09-23 16:21:58

  • Zzz

    2019-05-15 16:29:02

    个人理解: for .. range .. 实际上可以认为是方法调用的语法糖,range后面的变量就是方法参数,对于数组类型的变量,传入的参数是数组的副本,更新的是原数组的元素,取的是副本数组的元素;对于切片类型的变量,传入的参数是切片的副本,但是它指向的底层数组与原切片相同,所以取的元素和更新的元素都是同一个数组的元素。
  • 大王叫我来巡山

    2019-09-09 17:07:05

    了解这些只能证明您对这个语言足够的了解,但是实际中谁会写这么蛋疼的代码呢,这一篇通篇其实说明的还是go语言中关于类型转换的内容
    作者回复

    这些语法细节你要是不注意的话,说不定什么时候就会“踩坑”。文章中的代码是为了演示原理而设计的,因此不一定适用于生产。

    你可以去看看哪些热门开源项目的代码,里面仍然会有体现这些知识点的代码。所以不充分理解这些,可能看复杂些的项目源码都费劲。

    2019-09-10 14:00:12

  • 澎湃哥

    2018-11-29 12:57:53

    好像还没有人回答数组变切片的问题,贴一下运行结果吧:
    i:0, e:1
    i:1, e:3
    i:2, e:6
    i:3, e:10
    i:4, e:15
    i:5, e:21
    [22 3 6 10 15 21]

    每次循环打印了一个索引和值,看起来 range 切片的话,是会每次取 slice[i] 的值,但是应该还是发生了拷贝,不能通过 e 直接修改原值。
  • Felix

    2019-12-09 16:16:26

    关于数组变切片那个地方。我理解如下:切片自己不拥有任何数据,它只是底层数组的一种表示,对切片的任何操作都会被反映到底层数组中去。
    package main

    import "fmt"

    func main() {

    numbers3 := []int{1, 2, 3, 4, 5, 6}
    maxIndex3 := len(numbers2) - 1 //6-1= 5
    for i, e := range numbers3 { // 0:1 1:2 2:3 3:4 4:5 5:6
    if i == maxIndex3 { // 5
    numbers3[0] += e // 0,7
    } else {
    numbers3[i+1] += e // 1:3
    }
    // 0:1 1:(1+2)3 2:3 3:4 4:5 5:6
    // 0:1 1:3 2:(3+3)6 3:4 4:5 5:6
    // 0:1 1:3 2:6 3:(6+4)10 4:5 5:6
    // 0:1 1:3 2:6 3:10 4:(10+5)15 5:6
    // 0:1 1:3 2:6 3:10 4:15 5:(15+6)21
    // 0:(21+1)22 1:3 2:6 3:10 4:15 5:21
    // 22 3 6 10 15 21
    }
    fmt.Println(numbers3)
    }
    作者回复

    对,所以我们才称切片为引用类型。它本身只是底层数组及其存储状态的一种描述。

    2019-12-10 11:27:03

  • geraltlaush

    2018-10-22 16:41:35

    val.(type)需要提前将类型转换成interface{},一楼的留言有点问题
  • Dr.Li

    2018-09-21 00:19:08

    感觉go的语法有点变态啊
    作者回复

    要包容:)工具而已。

    2018-09-23 16:22:46

  • 博博

    2019-07-23 11:49:53

    老师遇到一个问题,希望能帮忙解答下!
    您在文章中说range表达式的结果值是会被复制的,那么是所有的都会被复制么? 我看了资料,发现字典和通道类型好像没有发生复制!

    // 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
    // }
    很是疑惑,希望能得到指点!谢谢
    作者回复

    你看的是 Go 的源码吗?我没找到这段代码。不过我看了下 mapiterinit 函数的代码。其中还是有复制的。只不过,对于这些引用类型的值来说,即使有复制也只会复制一些指针而已,底层数据结构是不会被赋值的。

    2019-07-23 22:01:08

  • 江山如画

    2018-10-08 12:11:22

    第一个问题,在类型switch语句中,如何对被判断类型的那个值做类型转换,尝试在 switch 语句中重新定义了一个 uint8 类型的变量和被判断类型的值做加法操作,一共尝试了三种方法,发现需要使用 type assertion 才可以,强转或者直接相加都会出错。

    转换语句是:val.(uint8)

    完整验证代码:

    val := interface{}(byte(1))
    switch t := val.(type) {
    case uint8:
    var c uint8 = 2

    //use type assertion
    fmt.Println(c + val.(uint8))

    //invalid operation: c + val (mismatched types uint8 and interface {})
    //fmt.Println(c + val)

    //cannot convert val (type interface {}) to type uint8: need type assertion
    //fmt.Println(c + uint8(val))

    default:
    fmt.Printf("unsupported type: %T", t)
    }

    第二个问题,在if语句中,初始化子句声明的变量的作用域是在该if语句之内,if语句之外使用该变量会提示 “undefined”。

    验证代码:

    m := make(map[int]bool)
    if _, ok := m[1]; ok {
    fmt.Printf("exist: %v\n", ok)
    } else {
    fmt.Printf("not exist: %v\n", ok)
    }

    //fmt.Println(ok) //报错,提示 undefined: ok
  • hiyanxu

    2018-12-23 20:07:33

    老师,我想问一下,range的副本,是说k、v是副本,还是被迭代的数组是副本?
    我自己测试在for的里面和外面数组地址是一样的
    作者回复

    迭代变量是副本。另外在Go程序里的变量地址是不能完全说明问题的,因为goroutine的栈空间有可能会被优化。

    2018-12-23 22:36:54

  • yandongxiao

    2018-09-27 19:36:59

    咖啡色的羊驼的答案貌似是错误的吧?
    1. 在类型switch语句中,t := value6.(type);匹配到哪个case表达式,t就会是哪种具体的数据类型;
    2. 在if语句中,子句声明的变量的作用域 比 随后的花括号内的变量的的作用域 更大
    if a := 10; a > 0 {
    a := 20
    println(a)
    }
    反证法:如果咖啡色的羊驼说的对,上面的语句不应该编译通过才对。

    关于2这个细微的差异,也适用于for i:=0; i<10; i++ 语句。
    在for语句中的使用匿名函数,很可能出现“loop variable capture”问题。根本原因也是i的作用域与{}中的变量的作用域是不同的。
  • while (1)等;

    2021-05-09 18:03:48

    文中说“被迭代的对象是range表达式结果值的副本而不是原值。”,那被迭代的对象是切片时,可以理解为指针的副本吗?也就是指针和指针的副本指向同一地址?
    作者回复

    不是指针的副本,是切片结构体的副本,切片结构体中有指向底层数组的指针。这个副本与原本会指向同一个底层数组。

    2021-05-10 13:22:55

  • 茶底

    2018-09-23 08:12:58

    老师什么时候讲逃逸分析啊
  • Rico

    2021-03-08 12:28:12

    在类型switch语句中,我们怎样对被判断类型的那个值做相应的类型转换?
    ------val.(type)

    在if语句中,初始化子句声明的变量的作用域是什么?
    -------变量作用域为if语句{}内部的范围
    作者回复

    第二个回答不太严谨,应该是:if语句块内,因为 else 的 {} 也是包含其中的。

    2021-03-08 19:54:01

  • hua

    2018-09-21 19:03:58

    把总结结论放在最前面再看主体内容会容易理解得多。
  • 后端进阶

    2018-09-21 13:04:42

    真的很喜欢go的语法与简洁的哲学
  • My dream

    2018-09-21 10:12:28

    Go1.11已经正式发布,最大的一个亮点是增加了对WebAssembly的实验性支持。老师要讲一下不?我们都不懂这个有什么意义
    作者回复

    主要是写Web端的时候有些用,不过我不觉得用处很大,因为现在大型网站都是前后端分离的。最后我视情况而定吧。

    2018-09-23 16:21:17

  • 罗峰

    2021-06-05 22:21:52

    Switch是匹配每个case,找到相等的那个case,包含常量类型转换,唯一值检验。select是先对多个case求值,然后随机选一个执行
  • jack

    2022-07-20 22:02:27

    index := 0
    var mu sync.Mutex
    fp := func(i int, fn func()) {
    for {
    mu.Lock()
    if index == i {
    fn()
    index++
    mu.Unlock()
    break
    }
    mu.Unlock()
    time.Sleep(time.Nanosecond)
    }
    }
    for i := 0; i < 10; i++ {
    go func(i int) {
    fn := func() {
    fmt.Println(i)
    }
    fp(i, fn)
    }(i)
    }
    // 这里就简单点了,不用 sync.WaitGroup 等那些结束了
    time.Sleep(time.Second)
    作者回复

    功能上可以,性能上还有待进一步推敲。比如:不用锁。

    2022-07-21 16:01:12

  • Jason

    2022-05-24 12:02:30

    老师,我在Go编译器源码的statements.cc里找到了range的原型,其实是对数组本身做了整体拷贝,后面的循环都是基于这个副本。我想问问老师,为什么range表达式要去做拷贝呢,明明代价更高了。还是说go设计range的初衷就是读取,真要修改就使用传统的for循环,期待老师的回答
    // 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
    //   }
    针对数组
    // 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
    //   }
    作者回复

    你的最后一句话其实已经带出了答案。这也是为了安全考虑。

    2022-05-30 14:27:05