(Modern) C++ 小技巧汇总
众所周知, 笔者 (DarkSharpness) 是一个 Modern C++ 的狂热爱好者. 笔者自高中信息竞赛以来, 主力编程语言一直都是 C++, 在大学的学习过程中, 积累了不少的实践经验, 故开一个帖子计划长期维护. 每次更新会在头部显示.
bit-field
这个其实算不上 modern C++ 的部分, 这是 C 继承下来的一个重要 feature.
在一些偏底层且空间/性能敏感的领域, 我们可能需要把多个数据压缩存储到一起. 举个例子, int4 量化的时候, 我们可能需要把 8 个 4 bit 的数(表示范围是 -8 ~ 7)压缩到一个 int 中 (4 * 8 = 32). 再比如说, 在嵌入式开发中, 某些硬件寄存器每个 bit 可能对应不同的 flag, 我们在读出这个寄存器的值的时候, 可能需要把这些 flag 读出来.
以上这些需求, 最容易想到的做法是使用位运算, 取出一个数字的特定几位. 然而, 这样的代码难以维护, 各种左右移, 以及掩码操作, 稍微复杂一点代码就会变得难以阅读, 即使设计了对应接口, 其直观性还是一般, 如下所示.
1 | struct int4_8 { |
我们希望我们能想操纵一个普通的变量那样, 操控一些 bit. 遗憾的是, 计算机中的最小寻址单元是 byte, 我们并不存在 bit 的引用. 但是, C++ 提供了一个很好的解决方案: bit-field. 我们可以使用 bit-field 来定义一个结构体, 其中的成员变量可以指定其占用的 bit 数, 如下所示.
1 | struct int4_8 { |
通过这样的方式, 我们可以直接访问到一个 int 中的特定几位, 而不需要手动进行位运算. 我们可以在 cppreference 上查看到更多关于 bit-field 的细节.
在笔者的实践中, 一般不会太在意 cppreference 上说到的所有细节, 但是笔者认为以下这些还是比较重要的:
首先, bit-field 的类型必须是整数类型. 这还是比较好理解的, 因为其本质就是对于整数位运算的某种语法糖.
其次, 如果希望达到节约空间的目的, 被压缩在同一个 int 中的 bit-field 之和显然不能超过 int 的 bit 数量, 超过的 bit-field 部分一般来说会被放到下一个 int 中. 自然, 这中间可能存在一些 padding, 以保证对齐.
1 | struct bit_pack { |
当然, bit-field 也支持类型混用, 即不一定要是同一种整数类型, 但是要求整数的位宽相同, 否则会先把前面的类型 padding 到整数位宽, 然后再放入后面的类型.
1 | struct bit_pack_2 { |
说到这里, 就不得不提 C++ 中的 <bit>
这个头文件了. 这个头文件是 C++ 20 新增的, 其提供了一些 bit 操作的函数, 如 std::bit_cast
, std::rotl
, std::rotr
, std::countr_zero
, std::countr_one
等等. 这些函数可以帮助我们更加方便地进行 bit 操作. 基本上, 你能想到的 bit 操作, 这个头文件都有.
string switch
在 C/C++ 中, 你应该用过 switch
语句, 其可以高效而直观地表示多分支的逻辑. 但是, switch
语句只能接受整数类型的参数, 不能接受字符串类型的参数.
我们自然是无法从语言层面上改变什么, 但是我们可以基于已有的技术实现一个类似的 string_switch
. 注意到 switch
里面只能接受整数或者枚举类型, 我们的思路就是把字符串转换为整数或者枚举类型. 一个非常 naive 的思路是用 std::unordered_map
(或者 std::map
) 来实现. 但是, 这样可能存在一些问题: 首先 std::unordered_map
并不支持 constexpr
的静态对象, 因为其涉及了动态内存分配. 而且, case
里面的整数也要求是 constexpr
的, 如何在编译器就能得到具体的哈希值, 如何解决哈希冲突, 都是需要考虑的问题.
虽然 constexpr std::unordered_map
看起来是不行了, 但是这个思路是没问题的. 我们最核心的思路就是把字符串转化为可以枚举的整数类. 因此, 我们可以自己手写一个 hash
函数, 或者调用 std::hash
函数, 来得到一个 constexpr
的整数值, 然后我们只需要存储这些整数值就行了. 如下所示:
1 | template <std::size_t _Base = 131> |
这就是我们的实现的原型了, 事情似乎有点太简单了. 现实中, 可能并没有这么简单. 对于任意输入的字符串, 我们可能需要考虑哈希冲突的问题. 对于要 match 的那些字符串, 如果出现了冲突, 在编译期间就会直接出错, 而我们只需要简单的把模板中的 _Base
替换一下就行了. 比较麻烦的是, 即使我们进入了某个 case
, 我们也不能保证输入的字符串和要匹配的一样. 我们需要额外的判等.
1 | void example_1(std::string_view input) { |
这样以后, 其基本就是一个完美的 string switch
了, 有需要的话可以自行修改 my_hash
函数. 但是, 我们还是要手写一遍判等, 这样非常麻烦, 而且容易出错. 这时候, 我们可以请出 C 语言的最终杀器: 宏. 以下是作者自己的实现:
1 | void example2(std::string_view input) { |
当然, 既然都用到宏了, 自然可以再结合 VA_ARGS 来实现更加通用的 string switch
, 如果有需求可以自己定制.
简而言之, 借助 constexpr hash
函数, 以及宏, 我们可以实现一个类似于 string switch
的功能. 如果有需求, 也可以自行修改.
assert in C++
如果你写过 C, 那你可能用过 assert
这个宏, 用于在运行时检查某个条件是否满足, 如果不满足, 则会终止程序, 并且详细地输出错误信息. 但是, 既然我们都用了 C++ 了, 为什么不用 C++ 的方式来实现呢?
首先, 我们先看一下 C 的 assert
都输出了些什么. 文件, 行号, 函数名…… 这些在 C++ 里面怎么获取呢? 如果用 __LINE__
这类 C 里面的宏, 那又违背了我们的初衷. 幸运的是, C++ 20 提供了 std::source_location
类, 可以获取到文件名, 行号, 函数名等信息. 以下是一个简单的实现:
1 | template <typename _Tp> |
当然, 这样的实现可能还有一些不够完美的地方. 用户不能自定义输出信息, 光秃秃的报错信息可能不够友好. 而如果要在运行时生成输出信息字符串, 可能又会映入不小的性能开销. 因此, 我们应该支持 assert 传入多个参数来自定义输出信息. 以下是一个更加完善的实现:
1 | template <typename _Tp, typename... _Args> |
然而, 如果你真的这么写了, 你会发现这种代码无法通过编译. 这是因为在调用 assert
的时候, 类型替换会失败. 你传入的最后一个参数会被尝试与 std::source_location
匹配, 但是显然是不行的. 这听起来非常令人沮丧, 难道我们在每个调用处都必须要手写一个 std::source_location::current()
吗? 当然不是! 除了函数模板, 我们还有类模板. 配合类模板的推导模板, 我们可以实现这个功能. 以下是一个完整的实现:
1 | template <typename _Tp, typename... _Args> |
为了避免使用危险的宏, 我们最后只能选择了这种扭曲的方式实现了我们的 assert
. 但是, 这种方式也有一些优点. 首先, 我们可以自定义输出信息, 而且只有在错误时才会生成输出字符串, 而不会有性能开销. 如果你觉得太丑了, 你甚至可以借助 format
来实现更加优雅的格式化输出. 其自由度还是非常高的, 比起 C 原生的 assert
. 最后, 附上一个使用了 format
的实现:
1 | template <typename _Tp, typename... _Args> |