接下来的两期分别介绍 运算符 和 控制结构,然后准备引入实战项目。
运算符(operator)
运算符将操作数(operands)连接成表达式(expression)。然后表达式根据(运算符的)规则求值。
注意,Go 是区分 表达式(expression,整体可求值)和 语句(statement,不可求值)的语言。
既然运算符连接操作数会得到表达式,然后表达式一定可以求值(注意操作数可能是一个值,也可能是另一个表达式,或者返回值,总之可以求值);那么反过来说,不能求值的就不是表达式,对应的符号也不是(Go 定义的)运算符。
在这里,作为对比会提及一些容易被当成表达式的语句(官方文档称之为 operators form statements,运算符形式的语句),但不展开讲语句。
Go 是强类型语言,意味着双目运算符的两个操作数类型要一致(移位运算除外)。对于不一致的情况,要么是 untyped
自动转换,要么是显式转换,相应的规则请参考前面两期的内容。
1. 算术(arithmetic)运算符
算术运算符对数字类型(numeric)的值进行运算,并得到与第一个操作数相同类型的值。
在 Go 里,四则运算 和 位运算 都被认为是算术运算。
算术运算符都是 双目运算符(意味着需要两个操作数);三个特殊情况是用作正负号的 +
、-
和用作按位取反的 ^
,这时它们是单目运算符。
1.1 四则运算
四则运算包括我们熟知的 +
, -
, *
,/
(加减乘除),以及除法衍生的运算 %
(求余)。
+
, -
, *
,/
的操作数可以是 任意数字类型(包括整型数、浮点数 和 复数);%
则只能是整型参与运算。
加减乘除的含义和规则想必不用介绍,只提一下几个特殊情况:
我们都知道,除数不能为 0。如果整数除法里除数为 常量 0,直接编译错误;如果是 变量 0,则会引起一个运行时 panic。如果是浮点数除法,IEEE-754 没有规定,结果视具体实现而定,目前 Go 的实现是得到一个特殊浮点数『无穷』(视乎被除数的符号,得到
+Inf
或-Inf
)。因为结果的类型与操作数一致,整数除法的结果也是整数,那就可能出现『除不尽』,这时尾数向零的方向截断(truncate)。例如
7 / 4
结果为1
(尾数0.75
被截断,尽管1.75
更接近2
),-7 / 3
结果为-2
(商-2
余-1
比 商-3
余2
,商更靠近 0)。对于求余,假设
x / y
,商为q
余数为r
, 它们之间的关系满足x = q*y + r
且|r| < |y|
(余数的绝对值一定小于除数的绝对值,注意是除数不是商)。我们根据上一条规则求得整数除法的商之后,就可以根据这条规则得到余数了:| x | y | x / y | x % y |
| —- | —- | —– | —– |
| 5 | 3 | 1 | 2 |
| -5 | 3 | -1 | -2 |
| 5 | -3 | -1 | 2 |
| -5 | -3 | 1 | -2 |
+
和-
作为单目运算符时,放在值的前面表示指定数值的符号,也就是作为正负号使用,相当于省略掉作为双目运算符时前面的0
:-5
等价于0 - 5
。 (实践中+5
跟5
没有任何区别,所以很少会用到单目的+
)+
加号和+=
也可以应用在字符串上,严格来说这不是加法,而是连接(concat);当然你也可以理解为特殊的『字符串加法』。
除此以外,还要留意类型的范围和精度,看运算结果是否有超出类型的表示范围。溢出的处理可以参考数字类型的类型转换部分。
1.2 位运算
位运算只能应用于 整型数 。进一步细分,位运算又可以分为 按位逻辑运算 和 移位运算。
由于位运算是直接对二进制位进行运算,所以我们要了解整型数的二进制表示:
正数 还有 零 比较好办,只要知道如何表示二进制数,做一个进制转换即可,例如
21
二进制位为00010101
(为了方便举例,用最短的int8
,下同)。负数要麻烦一些,并不是直接在正数的基础上把符号位置为 1,因为这样正负数的加法很难实现。计算机使用二进制补码(简称补码)来表示负数。
这里不展开补码的原理,只要记住一个口诀『取反加一』,就可以从正数得到对应的负数的补码。例如
21
的二进制取反是11101010
,加一得到11101011
,就是-21
的补码;-2
的补码是11111110
(注意加一时产生了进位)。这两个数的补码,直接跟对应的正数相加,结果均为 0 (丢弃最高位的溢出);跟别的数相加也能直接得到正确的结果。
按位(bitwise)逻辑运算
按位逻辑运算的基本规则跟逻辑运算一致。只是逻辑运算对布尔值进行运算,而按位运算对二进制位逐位进行运算。
op (运算符) | 11110000 op 01010101 | 解释 | |
---|---|---|---|
& | 01010000 | AND 按位与,两边均为 1 时结果为 1,否则为 0。 | |
\ | 11110101 | OR 按位或,两边至少一个为 1 时结果为 1,均为 0 时才为 0。 | |
^(双目) | 10100101 | XOR 异或,不同的位结果为 1 ,相同的位为 0。 | |
^(单目) | - | NOT 非,或者叫按位取反,^01010101 结果为 10101010 ,相当于省略双目运算的左操作数 11111111 (对应位宽全 1,相当于无符号数的最大值,和有符号数的 -1 )。 |
|
&^ | 10100000 | bit clear(AND NOT) 位清除,注意不是与非(与非是 NAND)。只有左操作数为 1 且右操作数为 0 时结果为 1,否则均为 0。可以看作将右操作数中的 1 从左操作数中清除掉。 |
其它更复杂的逻辑运算,可以通过组合基本的逻辑运算构成。例如,同或 (XNOR)可以在 异或 的基础上 取反 ^(a ^ b)
;与非 (NAND)可以在 与 的基础上 取反 ^(a & b)
。
相信你会发现,位清除(bit clear,AND NOT)其实也是 与 和 取反 的组合,a &^ b
等价于 a & (^b)
,那为什么需要组合成一个独立的运算符呢?看下面的代码:
|
|
可以看到,独立的 &^
运算符,跟两个运算符组合使用相比,规避掉了溢出错误,省略了将字面量先指定类型,还是有差别的。需要注意的是,并没有 |^
运算符。
移位(shift)运算
分为左移位 <<
和右移位 >>
,就是把左操作数的二进制位(连符号位一起),向对应方向,移动右操作数指定的位数。
移位时,超出范围的位丢弃,空缺的位左移补零、右移补符号位(无符号数还是补零):
|
|
移位运算是少有的允许左右操作数不同类型的运算。两个操作数都可以是任意整型,但右操作数的值不能为负数(只能为 0 或 正数):
|
|
不过移位运算也可能涉及类型转换:如果移位表达式不是一个常量表达式(换言之,不可以编译期求值),而且左操作数是无类型常量(untyped const),左操作数会隐式转换为 移位表达式替换为左操作数时它要转换的类型 。
这句话非常拗口,需要举个例子。 var y int8 = 1 << x
这个例子里面,如果 x
是常量,那么 1 << x
就会在编译时求值,1
是 untyped const
,结果的类型跟它一样,然后结果会试图转换为 int8
类型;如果 x
不是常量,1
需要先转换为 int8
再参与移位运算。之所以是 int8
,是因为把表达式替换为左操作数的话( var y int8 = 1
),左操作数需要转换为 int8
。
换言之,运行时的移位操作,如果不知道左操作数应该转换为什么类型参与运算(这关系到溢出判断),可以先把移位拿掉,判断完类型再加回来。
1.3 容易当成算术运算的语句
赋值语句
Go 里面,赋值语句是没有值的。所以
a + b
是一个加法表达式,可以求值,可以放在赋值符号=
或者:=
的右边;但c = a + b
整体却是一个赋值语句,不能求值。与其它 C 家族语言类似,Go 提供了算术运算后赋值的语法糖
a op= b
,它等价于a = a op b
,其中op
可以为任意一个双目算术运算符(+=
,-=
,*=
,/=
,%=
,&=
,|=
,^=
,&^=
,<<=
,>>=
)。有些教程把这种组合称作 『赋值运算符』,这种说法不严谨,因为这些语句整体不能求值,并不是表达式。不能把它们加入别的运算(如c + (a += b)
),也不能作为右值赋值(如c = (a -= b)
)。带=
的这一系列符号不算(狭义的)运算符。自增自减语句
在 C 语言里面,
++
和--
确实是运算符,自增自减是表达式且可以求值。甚至还弄出了运算符在前和在后两种用法,分别用来表达先运算后求值和先求值后运算。Go 大概是觉得这些用法非常不直观容易出错,取消了自增自减的求值。所以在 Go 里面,它们是语句,只有写在变量后面一种用法,得单独一行先执行完,再访问变量取值。
b = a++
这样的写法是错的,a++
把a
的值加了 1,但a++
语句本身并没有值。
2 比较(comparison)运算符
比较运算又叫关系运算,应用范围比算术运算广,不在局限于数字类型。比较运算要求两个操作数的类型可以 互相赋值,以及满足两个条件:可比较(comparable) 和 有序(ordered)。其中可比较不一定有序,但有序一定是可比较的。
比较运算的结果是布尔值。
可比较(comparable)
相等运算符 ==
(相等)和 !=
(不相等)要求操作数是可比较的。不同类型的规定如下:
布尔(
bool
)值可比较。布尔类型只有true
和false
两个值,要么相同,要么不同。数字类型都是可比较的。
- 整型数很好理解,相等需要值完全一致。
- 浮点数涉及精度问题,详细规定需要参考 IEEE-754 规范。总的来说,因为涉及精度损失,浮点数直接比较的结果可能会出现不符合预期的情况:对于精度要求比较低的比较,可以将差值小于精度视作相等;如果精度要求超出可以表示的范围,只能考虑使用其它实现,例如
math/big
包。 - 复数的相等,就是实部和虚部的浮点数分别相等。
字符串(
string
)可比较。就是逐个字节比较。指针可比较。两个指针指向同一个变量,或者都是
nil
被认为相等。通道(
chan
)可比较。两个通道变量指向同一个通道(来自同一个make
调用创建),或者都是nil
被认为相等。接口(
interface
)可比较。两个接口变量拥有相同的动态类型、而且值也是相等(具体怎么比较根据动态类型决定),又或者都是nil
,被认为相等。如果动态类型不同,不相等,不会报错。如果动态类型相同,但是该类型不可比较,会产生一个运行时 panic。(对不可比较的静态类型进行比较,编译时就不会通过;但是接口的实际类型需要运行时确定,所以变成了 panic 报错。)
非接口类型
X
的变量x
与 接口类型T
的变量t
,满足以下条件时可比较:X
类型可比较,且,X
类型满足T
接口。而
x
与t
相等需要满足:t
的动态类型是X
,且,t
的值与x
相等(按X
类型的比较规则)。数组类型可比较需要满足:两个数组是相同类型(同样的长度和元素类型,
[10]int
和[10]bool
是不同类型,[10]int
和[9]int
也是不同类型),且,元素类型可比较。两个数组相等则需要满足对应的元素都相等。
结构体与数组类似,可比较需要满足:相同类型,且,所有成员字段的类型可比较。
两个结构体相等需要所有成员字段都相等。
注意以上规则不仅在直接比较时有效,还有这些类型作为数组或者结构体成员,被递归自动比较时也有效。
切片(slice)、映射(map
)和函数(func
)不可比较。但是可以跟 nil
进行比较判断是否非空。
顺便提一下,映射的键值类型必须是可比较类型,这在以后介绍的时候会提到,这里先做个一个知识关联。
有序(ordered)
顺序(ordering)运算符 <
小于,<=
小于等于,>
大于,>=
大于等于 则要求操作数的类型是有序的。
有序的类型就非常少了,只有 整型、浮点型 和 字符串 三类。
这个比较好理解。数字直接比大小,浮点数涉及精度仍然参考 IEEE-754。字符串仍然是逐个字节比较,前缀相等时,较短的字符串较小(也就是空串最小,较短的字符串相当于同一个前缀后面接了一个空串)。
由于 Go 不支持运算符重载,所以除了这几个类型以外的类型都不支持顺序运算符,也没办法使它们支持。特殊的比较,就需要引入一些工具包(如 reflect.Equal
函数),自定义类型则需要自己实现比较方法如 (a T) compareTo (b T)
。另外,如想利用 sort
包进行排序,类型就需要满足 sort.Interface
接口。
3 逻辑运算符
逻辑运算符对布尔(bool
)值进行运算,并得到一个布尔值。
逻辑运算符只有三个:
操作符 | 解释 | ||
---|---|---|---|
&& | 短路逻辑与,操作数均为 true 时结果为 true ,否则为 false 。 |
||
\ | \ | 短路逻辑或,操作数只要有一个为 true 结果即为 true ,只有均为 false 时为 false 。 |
|
! | 逻辑非,单目运算,结果跟操作数相反。 |
Go 的逻辑运算跟 C 一样是『短路逻辑』,双目运算时右操作数是『按需求值』的——意思是,如果不需要用到右操作数的值就能得到表达式的值,右操作数就不会求值。这可以用于避免一些运行时错误。
例如 if a.val > 10
,如果 a
是 nil
,就会产生一个 “nil pointer dereference” 的空指针 panic。这时如果改为 if (a != nil) && (a.val > 10)
(括号非必要,只是方便看清左右操作数的范围),当 a
为 nil
时,a != nil
为 false
,对于逻辑与来说,无论右操作数的值是什么,整个表达式都一定是 false
,所以右操作数的表达式会被直接跳过不求值,也就不会触发空指针引用了。
4 其它运算符
不好归类的运算符在这里统一介绍。
地址(address)运算符
有两个,分别是 取址运算符 &
和 解引用(dereference,又译 提取 或 取值)运算符 *
。均为单目运算符,写在操作数前面。
取址运算应用在 T
类型的操作数 x
上,会得到一个 *T
类型的指针,指向 x
的地址;x
必须是可寻址的(addressable)。
相反,解引用运算应用在 *T
类型的操作数 p
上,会得到一个 T
类型的值;p
必须是一个有效的指针,不能为 nil
,否则会引起空指针解引用的运行时 panic。
可寻址(addressable)
Go 中可寻址的范围比其它一些语言要广,除了变量(意味着有对应的可变内存)以外,还可以是:
- 指针的解引用:
p
是一个非nil
指针,则*p
可寻址,&*p
其实就是对取值的结果再取址,等于p
自身。 - 切片(slice)的索引操作:
s
是一个 切片,则s[1]
可寻址(即使切片本身不可寻址,当然需要s
非空且索引在范围内)。 - 可寻址结构体的字段:
a
是一个可寻址的结构体,则a.X
可寻址(需要有X
这个字段)。 - 可寻址数组的索引操作:
a
是一个可寻址的数组,则a[1]
可寻址(同样需要索引操作先成功)。 - 复合类型字面量:例如切片字面量
[]int{1,2,3}
,结构体字面量struct{X int}{1}
,(使用上看起来)都是可寻址的。注意这条是一条例外,实际上是一个方便使用的语法糖,本质上&T{...}
等价于tmp := T{...}; &tmp
,表面上是对字面量取址,实际上是对自动生成的变量取址。
作为对比,以下的情况均不可寻址:
- 字符串的索引操作,即字符串中的字节。
- 映射 (
map
)中的元素。 - 通过类型断言获得的接口变量的动态值。
- 常量。
- 字面量(复合字面量是个例外,背后其实是语法糖)。
- 包(package)级函数。
- 方法(当作函数值)。
- 各种中间值:
- 函数调用(可能有返回值,也可能没有,都不可寻址)。
- 显式类型转换。
- 除了指针解引用(
*p
)以外的所有运算。
如果觉得上述列举过于繁琐,可以总结为一点:取址的对象必须是安全的可修改的内存。相对应地,不可寻址的情况可以总结为三点:
不可变的。
字面量、常量、字符串的字节 都是这种情况。如果取址之后可以修改,则破坏了不可变性;如果不能修改,那么对不可变的值取址没有意义。
中间结果。
函数返回值、类型转换、类型断言、表达式结果 等都属于这种情况。中间结果的问题是,还没赋值给一个变量,没分配可访问的内存,没有地址可言。
不安全的。
映射的元素、包级的函数 等属于这种情况。这种属于底层操作上可以取址,但是取址会引起问题,语法上规定了不可取址。
映射的元素为什么不可寻址?映射(map)底层用哈希表实现,有自己的内存管理机制,当条目数量改变时可能会调整内存并重新哈希条目,将元素在内部移动,此时如果允许寻址,之前取的地址就会失效。
为什么哪怕是不可寻址的切片(例如函数返回的切片),它的索引也可以寻址?因为后面介绍到切片就会发现,切片是一个引用类型,切片本身只是切片的元数据,底层指向一个总是可寻址的数组。对切片的索引操作实际上是对底层数组的索引,自然是可寻址的。
更多关于可寻址的总结,可以参考 https://gfw.go101.org/article/unofficial-faq.html#unaddressable-values。
接收(receive)运算符
只有一个 <-
,单目运算符,用在通道(chan
)变量前面,表达式的值是从通道中接收到的值,类型则是通道的元素类型。
通道必须是可读的通道(读写 chan
或者只读 <-chan
,不能是只写 chan<-
)。接收操作会阻塞直到有值可接收,试图从 nil
通道接收会一直阻塞,而试图从关闭(closed)的通道接收则会马上返回一个对应类型的零值。
这里其实我们只需要知道 <-
是一个运算符,从通道接收是一个表达式即可。详细内容可以到介绍并发和通道时再深入了解。
5 运算符优先级
上表格,数字越大优先级越高:
优先级 | 运算符 | ||
---|---|---|---|
6 | 所有单目运算符:正负号+ /- ,按位取反^ , 逻辑非 ! ,取址& ,解引用 * ,接收<- |
||
5 | * / % << >> & &^ |
||
4 | + - |
||
3 | == != < <= > >= |
||
2 | && |
||
1 | ` | ` | |
0 | 语句 |
所有单目运算符优先级都是最高,换言之其它优先级里的都是双目运算符。像优先级为 5 的 *
只能是乘号,而不是解引用。
原本没有 0,最后一行是我加的,语句并非运算符的一部分。语句的优先级最低,在所有运算符之后,像赋值 =
和 自增 ++
自减 --
。像 *p++
等价于 (*p)++
,解引用是优先级最高的单目运算符,自增则是优先级最低的语句。
对于优先级,我个人的看法是,留个印象即可,不必细究。遇到优先级容易产生歧义的地方,直接加括号,清晰明了,适当增加括号没有任何副作用。
你愿意花时间熟悉优先级当然好。但是在实际开发中,你熟悉的不能保证其他人也熟悉,也不能保证自己未来会不会一时看错。保持程序的可读性非常重要。
练习
非常巧合,我在准备这一期的内容的前几天,刚好看见 Go 语言中文站的站长徐老师分享了两道练习题,正好作为运算符的练习题,那我就直接拿来主义(文中有解析,先自己尝试做,不要直接看答案):
上期练习答案
问题1:
一般情况下,我们用自定义类型(defined type)的常量模拟枚举。三种状态最直接的做法是:
|
|
如果只是个别地方使用,完全没有必要纠结底层类型(underlying type)。考虑到有内存对齐,底层使用 int8
也不见得可以节约多少内存,反而可能增加类型转换的麻烦。
不过如果要用到一个很大的 []State
切片,这时底层类型就值得考虑一下了。三个(或者加上 Unknown
四个)状态, bool
无法表示,就只能选择 int8
或者 uint8
了。
问题2:
true
。
这里有一个刻意的误导。str1
和 str2
指向不同的字符串;这点都不需要 str2
是拼接得到的,即使 var str2 = "hello world"
,甚至 var str2 = str1
,它们都不会指向同一个字符串。这意味着,&str1 == &str2
(&
是取址操作,得到的是指针)总是 false
。
但是,比较操作符 ==
在字符串上比较的是内容。两个字符串长度一致,对应的每个字节都相等,就会被认为相等,所以是 true。这个问题其实超出了上期的内容,对错没有关系,能引发思考和留下印象就好。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。