一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第18天,点击查看活动详情。
开篇
之前在准备 Golang Mutex 原理解析 的时候,感觉在位运算的部分还是有一些比较经典的用法可以分享的。今天用这篇文章,聊一下位运算的实战用法。
我个人不太喜欢讲位运算的时候,拿出来老三套,比如下面这样:
& 位运算 AND
| 位运算 OR
^ 位运算 XOR
&^ 位清空 (AND NOT)
<< 左移
>> 右移
然后拿出一堆 10110111 这样的二进制表示来说明就完事。每次看到这样的文章,心里总是五味杂陈。很多时候我们并不需要一个教程,教我们那些逻辑运算的布尔值,而是告诉我们,到底,我能怎样用位运算来帮助自己的日常开发。
今天这篇会着重在【实用性】,位运算本身是可以折腾出来很多奇技淫巧的,性能不错,但可读性极差。所以判断一个位运算的用法是否要掌握,个人感觉最关键的还是看你有没有可能真正在生产环境用到。
基础用法
如果你需要看一个数字的二进制表示,使用%b
打印即可。如 fmt.Printf("%b", 123)
将会得到 1111011
- & 与运算,两个位都是 1 时,结果才为 1,否则为 0
- | 或运算,两个位都是 0 时,结果才为 0,否则为 1
- ^ 异或运算,两个位相同则为 0,不同则为 1
- ~ 取反运算,0 则变为 1,1 则变为 0
- << 左移运算,向左进行移位操作,高位丢弃,低位补 0
- >> 右移运算,向右进行移位操作,对无符号数,高位补 0,对于有符号数,高位补符号位
A = 0011 1100
B = 0000 1101
---------------
A&B = 0000 1100
A|B = 0011 1101
A^B = 0011 0001
实战用法
1. 左右移等价于 2 的 n 次方运算
记住:左乘右除
- i << n 相当于 i 乘以 2 的 n次方;
- i >> n 相当于 i 除以 2 的 n 次方
type ByteSize float64
const(
B ByteSize = 1<<(10*iota) // 1<<(10*0)
KB // 1<<(10*1) 左移动10位 2的10次方=1024
MB // 1<<(10*2)
GB // 1<<(10*3)
TB // 1<<(10*4)
PB // 1<<(10*5)
)
2. 判断奇偶数
奇偶数的本质区别就是看是否为 2 的倍数,直白点讲,在二进制的世界里,看最低位是 0 还是 1 即可。一个 & 就能解决问题:
- 奇数:i&1 == 1
- 偶数:i&1 == 0
3. 异或的特性
- i ^ i == 0
- i ^ 0 == i
两个相同的数异或的结果是 0,一个数和 0 异或的结果是它本身。
实际上,我们经常用的 staticcheck 就内置了这里的规则,如果你写出了类似 i ^ i 的代码,会得到 identical expressions on the left and right side of the '^' operator (SA4000)
4. 枚举
很多时候我们会针对一个主体,会存入多个状态。比如一篇掘金博客,你可以对它进行【点赞】【收藏】【评论】三种操作。那么当我们要去查看一篇指定的文章,当前用户做了哪些操作时。我们应该怎么存数据呢?
最直观的方案是,我维护三个变量:hasLike, hasCollect, hasComment。如果用户做了对应的操作,我就把那个 bool 值改成 true。
有了位运算,你会发现,这样的开销是不必要的。
位运算的精髓就在于,明明只是一个数,但因为你把它拆成了二进制里的一个个位,从此它具备了存储更多信息的能力。只需要将上面的每一种枚举错位开,就可以通过检查当前位的值,判断状态。
const (
Like = 1 << iota
Collect
Comment
)
这里 Like 为 1,Collect 为 2,Comment 为 4。转为二进制以后分别为 001, 010, 100。作为 2 的 n次方,我们保证了只有一位是 1,其他都是 0。
这个前提的重要意义在于,当你按位去进行【或运算】时,不会导致二进制位上的 1 出现重叠。也就意味着,当我把它们【或】起来的时候,在一个最终的结果里,实际上包含了他们的每个标志位的信息。
func main() {
ability := Like | Collect | Comment
fmt.Printf("%b\n", ability) // 111
fmt.Println((ability & Like) == Like) // true
fmt.Println((ability & Collect) == Collect) // true
fmt.Println((ability & Comment) == Comment) // true
}
看代码就一目了然了,原来的 001, 010, 100 三个数字进行【或运算】之后,得到了 111 (按十进制的话是 7)。每一个原始为 1 的位,就是它们自己的标志位。
所以,当我们用 ability & Like
时,由于 Like 的非标志位都是 0,标志位是 1,也就意味着,若 ability 具备了 Like 的标志位能力,则这一次 & 过后,结果必然和 Like 是完全相等的。
这就是为什么,当我们将三个枚举都【或】进 ability 时,再去【与】操作,会发现相等。
假如我们稍微做一点改动,ability 不把 Collect 或进去了,你会发现结果也变了:
func main() {
ability := Like | Comment
fmt.Printf("%b\n", ability) // 101
fmt.Println((ability & Like) == Like) // true
fmt.Println((ability & Collect) == Collect) // false
fmt.Println((ability & Comment) == Comment) // true
}
ok,或进去了,就代表有能力了。那我如果想把某个能力下掉呢?比如原来 ability 是同时包含了 Like, Comment,现在这个用户不喜欢这篇文章了,把点赞去掉了,那我 ability 应该怎么搞?
思考一下,此时 aibility 应该是 111,而 Like 是 001,我的目标是把 ability 变成 110 (这样就去掉了 Like 对应的标志位)。
111
001
---
110
发现了么?这不就是【异或】嘛。若位相同则为0,若不同则为 1。 此时我们只需要执行一次
ability = ability ^ Like
或者用简洁的写法:
ability ^= Like
这样就够了。总结一下:
- 定义 int 枚举值时注意二进制错位,每个枚举有一个自己的标志位;
- 用 | 操作添加能力;
- 用 ^ 操作下掉能力;
- 用 & 操作校验是否具备对应的能力。
完整的示例代码如下:
package main
import "fmt"
const (
Like = 1 << iota // 1的0次方,1
Collect // 2
Comment // 4
)
func main() {
ability := Like | Comment
fmt.Printf("%b\n", ability) // 101
fmt.Println((ability & Like) == Like) // true
fmt.Println((ability & Collect) == Collect) // false
fmt.Println((ability & Comment) == Comment) // true
ability = ability ^ Like
fmt.Printf("%b\n", ability) // 100
fmt.Println((ability & Like) == Like) // false
}
案例:Mutex state 的设计
对 Mutex 不熟悉的同学可以参考上一篇文章 Golang Mutex 原理解析
这一节,我们直接看看 Golang 官方的 Mutex 库是怎样落地我们在上一节提到的用法的。
state 从语义上来说包含了三个状态(Locked, Woken, Starving),和 Waiter 的数量。
它的类型是个 int32,这里运用了位操作来存储信息。这一节我们来看看相关的逻辑。
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
)
将 iota 表达式解析为二进制:
mutexLocked: 001
mutexWoken: 010
mutexStarving: 100
mutexWaiterShift: 011
有没有发现,mutexWaiterShift 是个异类,它的定位和三种状态不同。
state 是 Mutex 的一个成员变量,它的值代表了 Mutex 的状态。在等待加锁的 lockSlow
函数中,我们看到了这样的用法:
old := m.state // 此处获取到锁的状态
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// Active spinning makes sense.
// Try to set mutexWoken flag to inform Unlock
// to not wake other blocked goroutines.
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
old = m.state
continue
}
这个 old&(mutexLocked|mutexStarving) == mutexLocked
很有意思,我们前面说过,要校验能力可以采用 & 操作。但前面的示例只给的需要校验单一能力的场景。那这里先【或】再跟 old 去【与】,是什么含义呢?
mutexLocked|mutexStarving 等价于 001 | 100,也就是 101。
而要做到 old & 101 的结果等于 mutexLocked(也就是 001),那么 old 只能为 001 或者 011。也就是说,中间的一位不重要,前后两位必须是 0 和 1,即:
- 有 mutexLocked 的标志位
- 没有 mutexStarving 的标志位
所以,old&(mutexLocked|mutexStarving) == mutexLocked
的含义就是,需要有 mutexLocked 的能力,但是不能有 mutexStarving 的能力。
那如果是 old&(mutexLocked|mutexStarving|mutexWoken) == mutexLocked
呢?也就是 old & 111 == 001,那么 old 只能是 001 了。也就是说,需要有 mutexLocked 的能力,但是不能有 mutexStarving 的能力,也不能有 mutexWoken 的能力。
这样就能做到对于多个状态的判断。
还有一点比较有意思,如果按照此前例子的写法,我们会用 old&mutexWoken == mutexWoken
来判断是否具有 mutexWoken 能力。但是在 lockSlow
中,官方采用了 old&mutexWoken == 0
来判断。
仔细想想,的确如此。我们定义错位的常量时的前提是,只能有一个标志位,其他位都是 0。那么,若不具备这个标志位的能力,那么【与】的结果势必是 0。所以,针对这种场景,直接一个 & 操作,跟 0 比对即可。
只有一个标志位,意味着跟它进行与的结果,要么是自身,要么是 0。
old&mutexWoken == 0 代表没有能力 old&mutexWoken == mutexWoken 代表有能力
至于 mutexWaiterShift
,其实 Mutex 并没有用到他的【位能力】,这里仅仅是把它当成 3 (因为三个状态用掉了低三位),用于左移右移操作而已。
一个 state int32 包含32位,低三位留出来作为标志位,本质是三个状态的 bool。高 29位留出来,存储被阻塞的 waiter(其实是 goroutine) 的数量。
参考资料
今天的文章Golang 位运算实战用法分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/16881.html