oldme 博客

锲而舍之,朽木不折;锲而不舍,金石可镂

位运算实现枚举

oldme create: 2023-04-23

计算机底层是使用二进制存储数据的,对于直接计算二进制的操作都可以称之为位运算。本文会使用 Go 语言来了解位运算,并且通过一个例子来举例位运算是怎么实现枚举的,并且通过 Go 的一个框架:GoFrame 来看看实际的应用。

位运算基础语法

这里以 Go 语言举例子,其他语言大同小异。位运算基本规则:

p q p & q p | q p^q
0 0 0 0 0
0 1 0 1 1
1 1 1 1 0
1 0 0 1 1

示例:

// 与运算,两个位都为1时,结果才为1
fmt.Printf("%b\n", 0b110&0b100) // 100
// 或运算,两个位都为0时,结果才为0
fmt.Printf("%b\n", 0b110|0b100) // 110
// 异或运算,两个位相同为0,相异为1
fmt.Printf("%b\n", 0b110^0b100) // 010
// 左移运算,全部左移若干位,高位丢弃,低位补0
fmt.Printf("%b\n", 0b110<<1) // 1100
// 右移运算,全部右移若干位,高位丢弃,低位补0
fmt.Printf("%b\n", 0b110>>1) // 11

基础应用

判断奇偶数

在二进制中,末位值如果是1,那么这个数必然是奇数,0必然为偶数,利用这一点,可以使用与 1 位运算,来判断一个数的奇偶性:

i & 1 = 1 // 奇数
i & 1 = 0 // 偶数

fmt.Println(11 & 1) // 输出1
fmt.Println(10 & 1) // 输出0

2 的次方运算

在左右移运算中,如果一个数m左移了n位,最后的结果就等于这个数乘以 2^n(^代表次方运算),右移则正好相反,等于除以 2^n:

fmt.Printf("%d\n", 1<<16) // 1 * 2^16 = 65536
fmt.Printf("%d\n", 2<<16) // 2 * 2^16 = 131072
fmt.Println(65536 >> 8)   // 65536 / 2^8 = 256

异或特性

两个相同的数异或结果为 0,一个数与 0 异或为他本身:

i ^ i = 0
i ^ 0 = i

实现枚举

假如现在某视频网的 UP 主发布了一个视频,用户可以对该视频点赞、收藏、评论、分享四种操作。最直观的做法是在程序中会创建四个变量:hasThumb,hasStore,hasCmt,hasShare,当赋值为 true 时代表已经完成这种操作,反之代表未完成。

能不能换一种思路来设计呢?

经过一番思考后,我们发现可以使用一个字符串同时保存四种状态,比如 1101,1001。这个字符串的某一位上的 1 就代表已经完成了某种操作,0 就代表未完成。那么 1101 这个字符串就可以代表已点赞,未收藏,已评论、已分享(反过来的)。这个字符串每一位上只会保存 0 和 1,那么它或许应该表示为二进制数更为合适。在大多数的编程语言中,使用 0b 开头表示这个数是二进制的,0b1101 就可以保存四种不同的状态。

思想已经有了,那么在实际的程序上我们还需要对 0bxxxx 的每一位做出相应的赋值与判断,该怎么操作呢?这个时候就可以请出我们的主角:位运算。在使用了位运算后,就会发现这一切都是那么的顺其自然。

首先我们定义点赞、收藏、评论、分享四种操作的二进制值:

// 这里采用的是 Go 语法,实际上生成的值放在注释后
const (
	hasThumb = 1 << iota // 1<<0 = 0b1
	hasStore // 1<<1 = 0b10
	hasCmt // 1<<2 = 0b100
	hasShare // 1<<3 = 0b1000
)

或许眼尖的里已经发现,这四个值都有且只有一个 1,且它们的 1 错开在不同的位数上,1 就是他们的标记位。那么当它们之间就行位运算就会发生神奇的化学反应:

hasThumbAndStore = hasThumb | hasStore
hasCmtAndShare = hasCmt | hasShare
Printf(hasThumbAndStore)                // 11
Printf(hasCmtAndShare)                  // 1100
Printf(hasThumbAndStore|hasCmtAndShare) // 1111

可以看到,任意的值进行位运算后,结果总是继续保存着它的标记位。假如一个用户进行了点赞、评论、分享操作,那么,就可以对它们进行位或运算:

Printf(hasThumb|hasCmt|hasShare) // 1101

hasThumb 的标记位在第一位,hasCmt 在第三位,hasShare 在第四位,结果的位数和他们保持一致。

保存信息已经完成了,怎么从信息中判断是否已经点赞、评论或者分享呢,这个时候就可以使用位与运算:

hasRes = hasThumb | hasCmt | hasShare
Printf(hasRes&hasThumb == hasThumb) // true
Printf(hasRes&hasStore == hasStore) // false
Printf(hasRes&hasCmt == hasCmt)     // true
Printf(hasRes&hasShare == hasShare) // true

发现了吗,一切就是这么顺其自然。

如果这个时候用户取消了点赞,该应该怎么做呢,我们已经使用了位与和位或,还剩下异或没有使用,我们来看一下使用异或后会发生什么:

hasRes = hasThumb | hasCmt | hasShare
Printf(hasRes)                      // 1101
Printf(hasRes&hasThumb == hasThumb) // true
hasRes = hasRes ^ hasThumb
Printf("%b\n", hasRes)              // 1100
Printf(hasRes&hasThumb == hasThumb) // false

可以看到使用 hasRes ^ hasThumb 后,hasThumb 的标记位就从 hasRes 上移除了,这一切就是这么的巧夺天工!

Gf 框架巧用

这里我们通过 Go 开源框架:GoFrame(简称gf) 是怎么巧妙的通过位运算来实现 ORM 链式操作的空值过滤的。

前文介绍

gf 框架的 ORM 链式操作中提供了 OmitEmpty 空值过滤的方法。它用作来过滤 ORM 操作时传入的空值字段,如果链式调用了该方法,则会将传入进来的空字段过滤,不会生成该字段的 SQL 子句。这里通过伪代码对比一下:

Table.Data({
    "name"        : "john",
    "update_time" : "",
}).Where("id", 1).Update()
// 生成的 SQL 语句:
// UPDATE `TableName` SET `name`='john',update_time=null WHERE `id`=1

// 加入 OmitEmpty 方法来过滤掉 update_time 对应的空字符串
Table.Data({
    "name"        : "john",
    "update_time" : "",
}).Where("id", 1).Update()
// 生成的 SQL 语句:
// UPDATE `TableName` SET `name`='john' WHERE `id`=1

OmitEmpty 有两个子方法:OmitEmptyWhereOmitEmptyData。其中 OmitEmptyWhere 只会对查询语句生效,OmitEmptyData 只会对写入语句生效。而 OmitEmpty 则同时包含两种方法,也就是说 OmitEmptyWhere + OmitEmptyData = OmitEmpty。 

源码解析

这里会将源码翻译成伪代码,有兴趣的可以访问 https://github.com/gogf/gf 查看更为详细的信息。

在 gf 框架中,存在一个 Model 结构体,其中有一个字段:option。当链式语句调用 OmitEmpty,OmitEmptyWhere,OmitEmptyData 这三种方法的任意一种时,option 会赋值该字段的特征值,特征值符合以下规律:

  1. 当链式调用了 OmitEmpty 方法时,无论时查询还是写入语句都要过滤空值;
  2. 当链式调用了 OmitEmptyWhere 方法时,查询语句过滤空值,写入不过滤;
  3. 当链式调用了 OmitEmptyData 方法时,写入语句过滤空值,查询不过滤;

这里如果按照常规做法,可以在调用 OmitEmpty 方法时,赋值 option 为 [Read, Write] 数组;调用 OmitEmptyWhere 方法时,赋值 option 为 [Read];调用 OmitEmptyData 方法时,赋值 option 为 [Write]。最后在组成 SQL 语句时,来判断当前 option 值,决定要不要过滤空值。

这种做法显然不够优雅,那么 gf 框架是怎么实现该需求的呢。gf 框架先是定义了三个常量:

// 实际上的源码不是这样,这里是简化的写法
const (
	optionOmitEmptyWhere = 1                                          // 二进制: 0b1
	optionOmitEmptyData  = 2                                          // 二进制: 0b10
	optionOmitEmpty      = optionOmitEmptyWhere | optionOmitEmptyData // 值应该是 0b1 | 0b10 = 0b11,即十进制3
)

然后在调用空值过滤方法时,会将对应的特征值赋值给 option。在调用 option 的地方这样判断:

option&optionOmitEmptyData > 0 // 如果大于0代表写入数据需要空值过滤
option&optionOmitEmptyWhere > 0 // 如果大于0代表查询数据需要空值过滤

很自然而然,不是吗?

评论

欢迎您的回复 取消回复

您的邮箱不会显示出来,*必填

本文目录