Go 语言实战(4):运算符

接下来的两期分别介绍 运算符 和 控制结构,然后准备引入实战项目。

运算符(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 比 商 -32 ,商更靠近 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。 (实践中 +55 没有任何区别,所以很少会用到单目的 +

  • + 加号和 += 也可以应用在字符串上,严格来说这不是加法,而是连接(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) ,那为什么需要组合成一个独立的运算符呢?看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const a = ^1 // untyped int, -2(补码)
const b uint64 = ^1 // !如果不想得到负数,指定类型为无符号数,得到溢出错误
var c uint64 = ^1 // !同样的错误,问题出在常量(字面量)在转换类型时,不能改变值
// 正确的做法是先指定了类型再取反
const d uint64 = 1 // 关键是先指定类型,避免转换
const e = ^d // 正常
var f = ^uint64(1) // 也可以
// 同样道理,在清除指定位时
var x uint64 = 2
var y = x & (^1) // !`^1` 试图转换为 uint64 参与运算时报错。
// 只能先指定为无符号整型吗?
var z = x &^ 1 // 正常

可以看到,独立的 &^ 运算符,跟两个运算符组合使用相比,规避掉了溢出错误,省略了将字面量先指定类型,还是有差别的。需要注意的是,并没有 |^ 运算符。

移位(shift)运算

分为左移位 << 和右移位 >> ,就是把左操作数的二进制位(连符号位一起),向对应方向,移动右操作数指定的位数。

移位时,超出范围的位丢弃,空缺的位左移补零、右移补符号位(无符号数还是补零):

1
2
3
4
5
6
7
8
9
10
int8:
7(00000111) >> 2
1(00000001) // 右移超出范围的两个 1 丢弃,左边补 0(符号位为 0)
7(00000111) << 5
-32(11100000) // (低位数起)第三位的 1 一直左移,直到符号位,变成了负数,右边补 0
-128(10000000) >> 5
-4(11111100) // 右移过程中左边补 1 (符号位为 1)

移位运算是少有的允许左右操作数不同类型的运算。两个操作数都可以是任意整型,但右操作数的值不能为负数(只能为 0 或 正数):

1
2
3
4
5
6
7
8
var a int = 2
y = 1 << a // a 虽然是有符号数,但值为正数
const b = -2
y = 1 << b // !编译错误:negative shift count
var c = -3
y = 1 >> c // !运行时错误:negative shift amount

不过移位运算也可能涉及类型转换:如果移位表达式不是一个常量表达式(换言之,不可以编译期求值),而且左操作数是无类型常量(untyped const),左操作数会隐式转换为 移位表达式替换为左操作数时它要转换的类型

这句话非常拗口,需要举个例子。 var y int8 = 1 << x 这个例子里面,如果 x 是常量,那么 1 << x 就会在编译时求值,1untyped 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)值可比较。布尔类型只有 truefalse 两个值,要么相同,要么不同。

  • 数字类型都是可比较的。

    • 整型数很好理解,相等需要值完全一致。
    • 浮点数涉及精度问题,详细规定需要参考 IEEE-754 规范。总的来说,因为涉及精度损失,浮点数直接比较的结果可能会出现不符合预期的情况:对于精度要求比较低的比较,可以将差值小于精度视作相等;如果精度要求超出可以表示的范围,只能考虑使用其它实现,例如 math/big 包。
    • 复数的相等,就是实部和虚部的浮点数分别相等。
  • 字符串(string)可比较。就是逐个字节比较。

  • 指针可比较。两个指针指向同一个变量,或者都是 nil 被认为相等。

  • 通道(chan)可比较。两个通道变量指向同一个通道(来自同一个 make 调用创建),或者都是 nil 被认为相等。

  • 接口(interface)可比较。两个接口变量拥有相同的动态类型、而且值也是相等(具体怎么比较根据动态类型决定),又或者都是 nil ,被认为相等。

    如果动态类型不同,不相等,不会报错。如果动态类型相同,但是该类型不可比较,会产生一个运行时 panic。(对不可比较的静态类型进行比较,编译时就不会通过;但是接口的实际类型需要运行时确定,所以变成了 panic 报错。)

  • 非接口类型 X 的变量 x 与 接口类型 T 的变量 t ,满足以下条件时可比较:X 类型可比较,且,X 类型满足 T 接口。

    xt 相等需要满足: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 ,如果 anil,就会产生一个 “nil pointer dereference” 的空指针 panic。这时如果改为 if (a != nil) && (a.val > 10) (括号非必要,只是方便看清左右操作数的范围),当 anil 时,a != nilfalse ,对于逻辑与来说,无论右操作数的值是什么,整个表达式都一定是 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)的常量模拟枚举。三种状态最直接的做法是:

1
2
3
4
5
6
7
type State int
const (
Todo State = iota // 零值。只声明不赋值就会得到零值。如果想区分零值和有效值,可以在前面增加一个 Unknown 作为零值,并增加一个对 Unknown 的判断。
Ongoing
Done
)

如果只是个别地方使用,完全没有必要纠结底层类型(underlying type)。考虑到有内存对齐,底层使用 int8 也不见得可以节约多少内存,反而可能增加类型转换的麻烦。

不过如果要用到一个很大的 []State 切片,这时底层类型就值得考虑一下了。三个(或者加上 Unknown 四个)状态, bool 无法表示,就只能选择 int8 或者 uint8 了。

问题2:

true

这里有一个刻意的误导。str1str2 指向不同的字符串;这点都不需要 str2 是拼接得到的,即使 var str2 = "hello world" ,甚至 var str2 = str1,它们都不会指向同一个字符串。这意味着,&str1 == &str2& 是取址操作,得到的是指针)总是 false

但是,比较操作符 == 在字符串上比较的是内容。两个字符串长度一致,对应的每个字节都相等,就会被认为相等,所以是 true。这个问题其实超出了上期的内容,对错没有关系,能引发思考和留下印象就好。


知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。