运算符
# 第 4 章 运算符
Go 的运算符大体来自 C,但故意去掉了指针算术、
++i/--i表达式形式、三元?:、运算符重载。 关键词:算术、比较、逻辑、位运算、&/*、运算符优先级、短路求值、&^清位
# 目录介绍
- 4.1 本章学习目标
- 4.2 算术运算符
- 4.3 自增 / 自减:为什么是语句而不是表达式
- 4.4 比较运算符
- 4.5 逻辑运算符
- 4.6 位运算符
- 4.7 地址
&与解引用* - 4.8 赋值与多重赋值
- 4.9 为什么 Go 没有指针算术与运算符重载
- 4.10 运算符优先级表
- 4.11 综合示例:bitmap 权限系统
- 4.12 Go 新手陷阱 Top 5
- 4.13 思考题
- 4.14 推荐阅读
# 4.1 本章学习目标
- ✅ 默写 Go 的运算符优先级表(共 5 级)
- ✅ 理解为什么
i++是语句而不是表达式 - ✅ 区分
==「可比较」与<「可排序」的类型约束 - ✅ 解释短路求值,并能用它写防御性代码(如
p != nil && p.Foo()) - ✅ 默写 6 个位运算符及
&^(清位)的语义 - ✅ 写一个 bitmap 权限系统:增删查权限位
- ✅ 解释 Go 为何去掉指针算术与运算符重载
# 4.2 算术运算符
# 4.2.1 + - * / % 五件套
| 运算符 | 名称 | 适用类型 |
|---|---|---|
+ | 加 / 字符串拼接 | 数值、string |
- | 减 / 一元负号 | 数值 |
* | 乘 | 数值 |
/ | 除 | 数值 |
% | 求余 | 仅整数(浮点用 math.Mod) |
fmt.Println(7 + 3) // 10
fmt.Println("Go" + "lang") // Golang —— + 唯一被"重载"的位置
fmt.Println(-5) // -5(一元负号)
fmt.Println(7 % 3) // 1
// fmt.Println(7.0 % 3.0) // ❌ 编译错:浮点不支持 %
2
3
4
5
+ 在字符串上的"重载"是 Go 唯一的运算符多态。语言层不开放运算符重载——这是设计取舍(详见 4.9)。
# 4.2.2 整数除法与求余
整数除法向零截断(不是向下取整):
fmt.Println(7 / 2) // 3
fmt.Println(-7 / 2) // -3,不是 -4!
fmt.Println(7 % 2) // 1
fmt.Println(-7 % 2) // -1
2
3
4
💡 求余的符号取决于被除数:
-7 % 2 = -1、7 % -2 = 1。 想要"数学意义上的非负模"(mod),自己写:func mod(a, n int) int { return ((a % n) + n) % n } mod(-7, 2) // 11
2
整数除以 0 直接 panic:
defer func() { fmt.Println(recover()) }()
fmt.Println(1 / 0) // 编译错(常量除零)
var b int = 0
fmt.Println(1 / b) // runtime panic: integer divide by zero
2
3
4
# 4.2.3 浮点除法与 Inf / NaN
浮点除以 0 不 panic,按 IEEE 754 返回特殊值:
var z float64 = 0
fmt.Println(1.0 / z) // +Inf
fmt.Println(-1.0 / z) // -Inf
fmt.Println(z / z) // NaN
2
3
4
详见第 3 章 3.3.3。
# 4.2.4 溢出与 math/bits 安全运算
整数溢出静默环绕(与 C 一致):
var x int8 = 127
x++
fmt.Println(x) // -128
2
3
需要"溢出报错"时用 math/bits (opens new window):
import "math/bits"
func safeAdd64(a, b uint64) (uint64, error) {
sum, carry := bits.Add64(a, b, 0)
if carry != 0 {
return 0, fmt.Errorf("overflow: %d + %d", a, b)
}
return sum, nil
}
2
3
4
5
6
7
8
9
math/bits 还提供 Mul64、Sub64、Div64、LeadingZeros、TrailingZeros、OnesCount(popcount)等硬件指令级原语,是写 hash/crypto/压缩算法的基础工具。
# 4.3 自增 / 自减:为什么是语句而不是表达式
Go 把 i++ 与 i-- 设计成语句(statement),不是表达式(expression):
i := 0
i++ // ✅ 语句
// j := i++ // ❌ 编译错:i++ 不是表达式
// ++i // ❌ 编译错:没有前置形式
// i--; j-- // ❌ 编译错:一行只能一个语句
for i := 0; i < 10; i++ { } // ✅ for 的 post 语句位置可以放 i++
2
3
4
5
6
设计动机(来自 Go FAQ (opens new window)):
- 消除歧义:C 里
f(i++, i++)行为未定义,Go 直接语法上禁掉。 - 去掉前置 / 后置之争:只保留后置,且不返回值。
- 可读性 > 简洁性:
a[i++] = b[j++] + c[k--]在 Go 里只能拆三行写——多敲几个字符换来零歧义。
这是 Go "显式胜于隐式" 哲学的一个典型体现。
# 4.4 比较运算符
# 4.4.1 == 与 != 的可比较性规则
并非所有类型都能用 == 比较。可比较类型包括:
| 类型 | 可比较? | 比较规则 |
|---|---|---|
| 布尔 / 数值 / 字符串 | ✅ | 按值 |
| 指针 | ✅ | 比较地址(nil 也可比) |
| channel | ✅ | 同一个 channel? |
| 接口 | ✅ | 动态类型 + 动态值都相等 |
数组(如 [3]int) | ✅ | 逐元素比较 |
| 结构体 | ✅ 当所有字段都可比较时 | |
| slice | ❌ | 只能与 nil 比 |
| map | ❌ | 只能与 nil 比 |
| 函数 | ❌ | 只能与 nil 比 |
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println(a == b) // true,数组逐元素比
s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
// fmt.Println(s1 == s2) // ❌ 编译错
fmt.Println(s1 == nil) // false(仅能与 nil 比)
2
3
4
5
6
7
8
slice / map 为什么不能 ==? 因为它们是引用类型,没有"明确的相等语义"——是比较底层指针、还是逐元素?标准库没替你决定。要"逐元素比"用 slices.Equal、maps.Equal(Go 1.21+):
import "slices"
fmt.Println(slices.Equal(s1, s2)) // true
2
结构体的"传染性":
type A struct{ X int } // 可比较
type B struct{ S []int } // 不可比较(含 slice 字段)
type C struct{ A; B } // C 也不可比较
var c1, c2 C
// fmt.Println(c1 == c2) // ❌ 编译错
2
3
4
5
6
# 4.4.2 < <= > >= 的有序约束
只有"有序类型"才能用 <:
| 类型 | 有序? |
|---|---|
| 数值(整数 / 浮点) | ✅ |
string(按字节字典序) | ✅ |
| 其他(bool、指针、channel、接口、结构体、数组) | ❌ |
fmt.Println("apple" < "banana") // true
fmt.Println("Z" < "a") // true,'Z'=0x5A < 'a'=0x61
type P struct{ X, Y int }
p1, p2 := P{1, 2}, P{1, 3}
// fmt.Println(p1 < p2) // ❌ 编译错:结构体不可排序
2
3
4
5
6
要给结构体定义排序,用 sort.Slice 或 slices.SortFunc(Go 1.21+),自己写比较函数。
# 4.4.3 接口值的相等比较
接口比较有三个判断:
var a, b interface{}
a = 10
b = 10
fmt.Println(a == b) // true:动态类型 int + 值 10 都相等
a = 10 // int
b = int64(10) // int64
fmt.Println(a == b) // false:动态类型不同!
// 把"不可比较类型"塞进接口再比,会 panic
a = []int{1, 2}
b = []int{1, 2}
// fmt.Println(a == b) // runtime panic: comparing uncomparable type []int
2
3
4
5
6
7
8
9
10
11
12
13
经验:把 slice/map 塞进 interface{} 后再比 == 是常见的隐藏 panic,写库时要用 reflect.DeepEqual 或先做类型断言。
# 4.5 逻辑运算符
只有三个:&&(与)、||(或)、!(非)。只能用于 bool——和 C 不同,整数不能当条件。
# 4.5.1 短路求值
&& 和 || 都是短路的——这一点和 C/Java 一致,但比 C 更重要,因为 Go 没有三元运算符,短路是写防御代码的主要手段:
// 经典:先判 nil 再访问字段
if p != nil && p.Name != "" {
fmt.Println(p.Name)
}
// 经典:先判 ok 再用 value
if v, ok := m["key"]; ok && v > 0 {
fmt.Println(v)
}
// || 的短路:找到 cache 就不查 DB
if v := getFromCache(k); v != nil || queryDB(k) != nil {
handle(v)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
短路的副作用——副作用函数顺序敏感:
counter := 0
add := func() bool { counter++; return true }
_ = false && add() // counter 仍为 0(短路了)
_ = true && add() // counter = 1
_ = true || add() // counter 仍为 1(短路了)
2
3
4
5
6
# 4.5.2 ! 一元否定
isEmpty := len(s) == 0
isNonEmpty := !isEmpty
2
不要写 if !!x——Go 不允许(! 不能连写两个,因为 ! 后面只允许 bool 表达式,!x 是 bool,所以 !!x 其实是合法的,但 gofmt 会保留,linter 会提示"双重否定")。
# 4.6 位运算符
# 4.6.1 & | ^ << >> 与 &^(清位)
| 运算符 | 名称 | 例子 |
|---|---|---|
& | 位与(AND) | 0b1100 & 0b1010 = 0b1000 |
| | 位或(OR) | 0b1100 \| 0b1010 = 0b1110 |
^ | 位异或(XOR)/ 一元按位取反 | 0b1100 ^ 0b1010 = 0b0110;^x = ~x |
<< | 左移 | 1 << 3 = 8 |
>> | 右移 | 8 >> 2 = 2 |
&^ | 位清除(AND NOT) | 0b1100 &^ 0b1010 = 0b0100,相当于 a & (^b) |
⚠️
^一符两用:
- 二元位置
a ^ b是异或- 一元位置
^x是按位取反(C 里的~x)Go 没有专门的
~,所有"取反"都用^。
&^ 是 Go 独有的"清位"运算符,简化常见的"清掉某些位"操作:
const (
FlagRead = 1 << 0 // 0b001
FlagWrite = 1 << 1 // 0b010
FlagExec = 1 << 2 // 0b100
)
perm := FlagRead | FlagWrite | FlagExec // 0b111
// 清掉 Write 位
perm = perm &^ FlagWrite // 0b101
// 等价于 perm = perm & ^FlagWrite,但少打一个空格
2
3
4
5
6
7
8
9
10
11
# 4.6.2 算术右移 vs 逻辑右移
>> 的行为取决于左操作数的符号性:
var s int8 = -8 // 0b1111_1000(补码)
fmt.Println(s >> 1) // -4(算术右移,高位补 1)
var u uint8 = 248 // 0b1111_1000
fmt.Println(u >> 1) // 124(逻辑右移,高位补 0)
2
3
4
5
记住一句话:有符号 >> 是算术右移,无符号 >> 是逻辑右移。
移位计数为负或 ≥ 类型宽度:
var x int32 = 1
// _ = x << -1 // ❌ 编译错(常量负数)
_ = x << uint(-1) // ❌ 运行期 panic(Go 1.13+ 直接编译错,因移位计数必须非负整型)
_ = x << 33 // 结果为 0(不像 C 是 UB,Go 规范明确:超过宽度则结果归零)
2
3
4
# 4.6.3 实战:bitmap 标志位
位运算的核心套路 4 招:
// 1. 设置位
flags |= FlagX
// 2. 清除位
flags &^= FlagX // 推荐用 &^=
// flags &= ^FlagX // 等价
// 3. 检查位
if flags & FlagX != 0 { }
// 4. 切换位(toggle)
flags ^= FlagX
2
3
4
5
6
7
8
9
10
11
12
完整示例见 4.11。
# 4.7 地址 & 与解引用 *
x := 42
p := &x // 取地址:p 的类型是 *int
fmt.Println(*p) // 解引用:42
*p = 100 // 通过指针修改原变量
fmt.Println(x) // 100
2
3
4
5
| 符号 | 上下文 | 含义 |
|---|---|---|
&x | 表达式 | 取 x 的地址,结果是 *T |
*p | 表达式 | 解引用 p,结果是 T |
*T | 类型声明 | 指向 T 的指针类型 |
&T{...} | 复合字面量前 | 创建结构体并取地址 |
type Point struct{ X, Y int }
p := &Point{1, 2} // *Point,等价于 p := new(Point); *p = Point{1, 2}
fmt.Println(p.X) // 1(Go 自动 (*p).X)
2
3
💡 Go 的指针没有指针算术:
p++、p+1、p[1]全部编译错。要遍历内存,用 slice。
第 8 章会专门讲指针、逃逸分析和"为什么 Go 的指针不像 C 那么危险"。
# 4.8 赋值与多重赋值
= 是赋值,:= 是"声明+赋值"(仅在函数内):
x := 10 // 声明并初始化
x = 20 // 重新赋值
// x := 30 // ❌ 同作用域不能重复 :=
2
3
多重赋值——Go 的标志性特性之一:
a, b := 1, 2
a, b = b, a // 交换,无需中间变量
// 用于函数多返回值
v, ok := m["key"]
n, err := strconv.Atoi(s)
2
3
4
5
6
复合赋值:+=、-=、*=、/=、%=、&=、|=、^=、<<=、>>=、&^=,全部在赋值前先求值右侧:
flags &^= FlagWrite // 等价 flags = flags &^ FlagWrite
# 4.9 为什么 Go 没有指针算术与运算符重载
# 没有指针算术
// C 里
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;
printf("%d\n", *(p + 2)); // 3
2
3
4
// Go 里
arr := [5]int{1, 2, 3, 4, 5}
p := &arr[0]
// fmt.Println(*(p + 2)) // ❌ 编译错
fmt.Println(arr[2]) // ✅ 用 slice/array 索引
2
3
4
5
理由:
- GC 友好:指针算术让 GC 难以判断哪些内存"还在用"。Go 想做精准 GC,禁掉指针算术是前提。
- 内存安全:消灭 90% 的越界 / 段错误。Go 的 slice 替代了"指针 + 长度"模式,且自带边界检查。
- 真要做底层操作?用
unsafe.Pointer+unsafe.Add(Go 1.17+)显式声明"我知道我在干什么"。
# 没有运算符重载
// C++ 里
class Vec { Vec operator+(const Vec& other) { ... } };
Vec a, b; Vec c = a + b; // 调用 operator+
2
3
// Go 里
type Vec struct{ X, Y float64 }
// Go 不允许定义 (v Vec) operator+,只能写方法
func (v Vec) Add(o Vec) Vec { return Vec{v.X + o.X, v.Y + o.Y} }
c := a.Add(b)
2
3
4
5
理由(Go FAQ (opens new window)):
- 可读性:看到
a + b就是基本类型加法,看到a.Add(b)就知道有自定义逻辑。不需要跳转去看是不是被重载。 - 简单性:实现简单(编译器无需符号表查找)、调试简单(栈帧名字就是
Add)。 - 取舍:放弃数学领域 DSL 的"美感",换工程代码的可预期性——这是 Go 全程贯彻的取舍。
唯一例外:+ 在 string 上是拼接(已经是历史遗留的内置规则)。
# 4.10 运算符优先级表
Go 的优先级表只有 5 级,远比 C(15 级)简单:
| 优先级 | 运算符 |
|---|---|
| 5(最高) | * / % << >> & &^ |
| 4 | + - | ^ |
| 3 | == != < <= > >= |
| 2 | && |
| 1(最低) | || |
记忆口诀:"乘除位与高、加减位或中、比较再次、逻辑最低"。一元运算符(+x、-x、!x、^x、*p、&x、<-ch)优先级最高,永远先于二元。
⚠️ 位运算优先级低于比较——这是 C 留下来的祖坟坑:
if flag & MASK == 0 { ... } // 实际是 flag & (MASK == 0) ❌ 编译错(MASK == 0 是 bool) // 你想要的是:(flag & MASK) == 0 if (flag & MASK) == 0 { ... } // ✅1
2
3
4Go 把它"显化"成了编译错(C 里则是悄悄算错)——感谢 Go 的强类型。但习惯加括号仍是好习惯。
# 4.11 综合示例:bitmap 权限系统
把本章知识串起来——一个 Linux 风格的 rwx 权限系统:
// permission/main.go
package main
import (
"fmt"
"strings"
)
// Permission 用 uint8 的低 3 位表示 r/w/x
type Permission uint8
const (
PermRead Permission = 1 << iota // 0b001
PermWrite // 0b010
PermExecute // 0b100
PermAll = PermRead | PermWrite | PermExecute
PermNone Permission = 0
)
// Has 检查是否包含某权限
func (p Permission) Has(q Permission) bool { return p&q == q }
// Grant 授权
func (p Permission) Grant(q Permission) Permission { return p | q }
// Revoke 撤权(用 &^ 清位)
func (p Permission) Revoke(q Permission) Permission { return p &^ q }
// Toggle 切换(XOR)
func (p Permission) Toggle(q Permission) Permission { return p ^ q }
// String 自定义格式(Linux 风格 "rwx")
func (p Permission) String() string {
var b strings.Builder
b.Grow(3)
if p.Has(PermRead) {
b.WriteByte('r')
} else {
b.WriteByte('-')
}
if p.Has(PermWrite) {
b.WriteByte('w')
} else {
b.WriteByte('-')
}
if p.Has(PermExecute) {
b.WriteByte('x')
} else {
b.WriteByte('-')
}
return b.String()
}
func main() {
p := PermRead | PermWrite // rw-
fmt.Println("init :", p) // rw-
p = p.Grant(PermExecute) // rwx
fmt.Println("grant x :", p)
p = p.Revoke(PermWrite) // r-x
fmt.Println("revoke w :", p)
p = p.Toggle(PermRead) // --x
fmt.Println("toggle r :", p)
fmt.Println("has exec :", p.Has(PermExecute)) // true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
输出:
init : rw-
grant x : rwx
revoke w : r-x
toggle r : --x
has exec : true
2
3
4
5
涵盖的知识点:
| 知识点 | 体现位置 |
|---|---|
| 自定义类型 + 方法 | type Permission uint8 + func (p Permission) ... |
iota 与左移定义标志位 | 1 << iota |
| 位或 / 位清除 / 异或 / 位与 | Grant / Revoke / Toggle / Has |
Stringer 接口(fmt 自动调) | String() 方法 |
strings.Builder 拼接 | b.Grow(3) + WriteByte |
# 4.12 Go 新手陷阱 Top 5
# ❌ 陷阱 1:i++ 当表达式
i := 0
// j := i++ // 编译错:i++ 不是表达式
// f(i++, i--) // 编译错
2
3
修复:拆成两行——i++; j := i。
# ❌ 陷阱 2:整数除法丢小数
fmt.Println(5 / 2) // 2,不是 2.5
fmt.Println(float64(5)/2) // 2.5 ✅
fmt.Println(5 / 2.0) // 2.5 ✅(无类型常量 2.0 让结果变 float)
2
3
修复:至少一边显式转 float,或者写浮点字面量 2.0。
# ❌ 陷阱 3:浮点 ==
if 0.1+0.2 == 0.3 { ... } // 永远 false
修复:用容差 math.Abs(a-b) < 1e-9。详见第 3 章 3.3.2。
# ❌ 陷阱 4:位运算优先级低于比较
if flag & MASK == 0 { ... }
// 实际想法:(flag & MASK) == 0
// Go 编译器会因类型不匹配报错救你一命,但要养成加括号的习惯
if (flag & MASK) == 0 { ... } // ✅
2
3
4
# ❌ 陷阱 5:把 slice 塞进 interface 后比较 panic
var a, b interface{} = []int{1, 2}, []int{1, 2}
fmt.Println(a == b) // runtime panic: comparing uncomparable type []int
2
修复:用 reflect.DeepEqual(a, b),或先类型断言再用 slices.Equal。
# 4.13 思考题
- 为什么 Go 设计
i++是语句而不是表达式?这种设计阻止了 C 里的哪些 bug?请举两个例子。 &^与& ^在语法上的区别?写一段代码:用&^实现"清掉低 4 位",再用&^=简写。- 为什么 slice 不能用
==比较,而数组可以?请从"语义不明确"和"性能可预测"两个角度分析。 - 写一个泛型函数
Equal[T comparable](a, b T) bool——为什么参数必须是comparable而不能是任意T? - 短路求值在并发场景的"惊喜":思考
if a.Load() && b.Load(),如果两次Load之间有别的 goroutine 修改了状态,会怎样?这是 bug 还是 feature? - Go 没有运算符重载。如果你想给自定义的
BigDecimal加法体验"最像+"的 API,怎么设计?请给出函数签名与示例调用。