使用位标记存储多个布尔状态

很多场景下,我们会使用 bool 值来描述实体状态,通常用实体的一个属性来表示。有时对于同一个实体有多个独立的 bool 状态,需要很多标记位,比如在文档协作工具中,用户对于一篇文档的权限,可能包含是否可读、是否可写、是否可评论、是否可删除等。

class FilePermission {
  public canRead: boolean;
  public canWrite: boolean;
  public canComment: boolean;
  public canDelete: boolean;
}

对于多个 bool 状态标记,我们可以使用位标记来替换它,仅使用一个属性来表示实体下所有的 bool 状态。这样做的好处是,一方面减少实体的属性,代码层面会更简洁;另一方面,单标记比多标记更节省空间,在数据存盘时能节约大量磁盘空间。

比如,新功能引导是一个非常适合使用位标记的场景:用户初次体验新功能时,给用户一个引导流程,后续再使用到该功能,则无需再引导。很明显,对于长期迭代的产品,不断会有新功能上线,用户是否访问过某功能,就可以使用位标记来记录。

位标记介绍

通过将每个布尔值映射到整数的一个二进制位,可以在一个整数中存储多个标志(flags)。位标记在计算机科学中广泛应用于各种场景,特别是在需要高效管理和查询多个布尔状态的情况下。

基本概念

  1. :一个位是二进制数字中的最小单位,可以是 0 或 1。
  2. 位标记:一个整数,其中每个位代表一个布尔值。
  3. 位操作:使用位运算符(如按位与 &、按位或 |、按位异或 ^、按位取反 ~、左移 <<、右移 >>)来操作位标记。

优点

  1. 内存效率:位标记占用的内存非常少。例如,一个 32 位整数可以表示 32 个布尔值。
  2. 性能高效:位操作通常非常快,因为它们直接在硬件级别执行。
  3. 简洁性:位标记可以用一个整数表示多个状态,使代码更加简洁。

原理

假设我们有一个 8 位的整数(1 字节),可以表示 8 个布尔值:00000000,每个位的位置从右到左编号为 0 到 7:7 6 5 4 3 2 1 0。我们可以用每一位表示一个标识。如对于文档权限,可读、可写、可评论、可删除分别用从右到左 0 ~ 3 位表示。则 

  • 假如用户仅可读,可以用二进制 00000001 表示,对应十进制 1。
  • 用户仅可读、可评论,可用二进制 00000101 表示,对应十进制 5。

位操作

使用位运算来完成二进制位的设置和取值。

设置位

使用按位或、按位与、位取反来设置位。假设我们要将第 0 位和第 2 位设置为 1:

// 使用按位或 ”|“ 来设置某位为 1
let bits = 0b00000000; // 初始值
bits |= (1 << 0);      // 设置第 0 位
bits |= (1 << 2);      // 设置第 2 位

console.log(bits); // 5

// 使用按位与、位取反来设置某位为 0
bits &= ~(1 << 2); // 设置第 2 位为 0
console.log(bits); // 1

检查位

使用按位与来检查位。比如检查第二位是否为 1:

const isSet = (bits & (1 << 2)) !== 0; // 检查第 2 位

生产环境中使用位标记来存储状态

实现一个通用工具类

如前所述,我们可以使用一个整数来描述多个状态,当然整数的位数是有限的,用单个整数并不能表示无限多个状态,因为会溢出。这种情况下,我们可以使用多个整数来表示状态。

运用前面介绍的原理,可以简单实现一个无限状态位的工具类 BitMarker,内部使用数组来存储所有标记:

const INT_MAX_BIT = 32;

/**
 * 位标记类,用于管理和操作位集合。
 */
export class BitMarker {
  private nums: number[] = []; // 存储位集合的整数数组
  private bitCount: number = 0; // 当前位集合的总位数

  /**
   * 构造函数,初始化位标记对象。
   * @param nums 初始的整数数组,可以包含 0 到多个正整数,用于设置位集合的初始值
   */
  constructor(nums: number[] = []) {
    this.setValues(nums);
  }

  /**
   * 设置指定位的标记。
   * @param bitIndex 指定位 0 ~ n
   * @param value 要设置的标记
   */
  public set(bitIndex: number, value: boolean) {
    if (bitIndex < 0) {
      return; // 忽略负索引
    }

    if (bitIndex >= this.bitCount) {
      this.resize(bitIndex + 1); // 如果索引超出当前容量,调整内部数组大小
    }

    const numIndex = Math.floor(bitIndex / INT_MAX_BIT); // 计算整数数组的索引
    const bitOffset = bitIndex % INT_MAX_BIT; // 计算位偏移量
    const mask = 1 << bitOffset; // 创建掩码

    if (value) {
      this.nums[numIndex] |= mask; // 设置位为 1
    } else {
      this.nums[numIndex] &= ~mask; // 清除位为 0
    }
  }

  /**
   * 根据指定位的标记
   * @param bitIndex 指定位 0 ~ n
   * @returns 返回该位的标记
   */
  public get(bitIndex: number): boolean {
    const numIndex = Math.floor(bitIndex / INT_MAX_BIT); // 计算整数数组的索引
    const bitOffset = bitIndex % INT_MAX_BIT; // 计算位偏移量
    const mask = 1 << bitOffset; // 创建掩码

    return (this.nums[numIndex] & mask) !== 0; // 检查位是否为 1
  }

  /**
   * 清空所有标记
   */
  public clear() {
    this.nums = []; // 清空整数数组
    this.bitCount = 0; // 重置位计数
  }

  /**
   * 设置位标记的值
   * @param values 初始的整数数组,用于设置位集合的初始值
   */
  public setValues(values: number[]) {
    this.nums = values; // 设置新的整数数组
    this.bitCount = values.length * 32; // 更新位计数
  }

  /**
   * 获取当前位标记的值
   * @returns 返回当前位集合的整数数组
   */
  public getValues(): number[] {
    return this.nums; // 返回整数数组
  }

  /**
   * 调整位标记数组的大小。
   * @param newSize 新数组需要支持的位数
   */
  private resize(newSize: number) {
    const numsCount = Math.ceil(newSize / INT_MAX_BIT); // 计算需要的整数数组长度
    while (this.nums.length < numsCount) {
      this.nums.push(0); // 填充新的整数
    }
    this.bitCount = newSize; // 更新位计数
  }
}

使用示例:

/*
* 有新功能上线时,出现功能引导,每个功能仅出现一次。
* 使用 BitMarker 对出现过的引导状态进行标记。
* 每一位表示一个功能的引导状态。
*/
enum FunctionGuide {
    A = 0, // 功能 A
    B = 1, // 功能 B
    C = 2,
    D = 3,
}

// 1. 初始化
const initStatus = []; // 初始标记,从数据库读出或者初始化
const bitMarker = new BitMarker(initStatus);

// 2. 设置标记
// 功能 A 已引导
bitMarker.set(FunctionGuide.A, true);

// 3. 获取标记
const isGuideA = bitMarker.get(FunctionGuide.A);
console.log(isGuideA); // true
const isGuideB = bitMarker.get(FunctionGuide.B);
console.log(isGuideB); // false

// 4. 获取标记结果
const bits = bitMarker.getValues(); // 数组格式,可以序列化后存库

直接使用 npm 包

上面的的 BitMarker 已经发布为 npm 包(bit-marker)了,可以安装后直接使用。

安装

npm install bit-marker

API

  1. constructor(initStatus: number[])
    初始化,initStatus 为初始标记,从数据库读出或者初始化
  2. set(bit: number, value: boolean)
    设置标记,bit 为标记位,value 为标记值,true 为已标记,false 为未标记。
  3. get(bit: number)
    获取标记,bit 为标记位
  4. setValues(values: number[])
    接收一个整数数组,每个元素有效值为 [0, 2 ^ 32 - 1],每个数字表示 32 个标记位,当标记数超过 32 时,会自动扩展。
  5. getValues()
    获取所有标记组成的数组。
  6. clear()
    清空所有标记。
¥赞赏