ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++编译期数学计算:从constexpr到consteval的实战指南

C++编译期数学计算:从constexpr到consteval的实战指南 1. 编译期数学计算是什么为什么值得折腾1.1 编译期计算的本质先抛开那些晦涩的术语用大白话说编译期数学计算就是让程序在“编译阶段”就把数学运算做完而不是等到程序跑起来之后再去算。你写一句double x sin(1.0);如果是普通写法程序运行到这行代码时会去调用数学库在运行时完成计算。但如果用 C 的编译期求值技术这句话可能在编译时就得到了0.84147...甚至直接把这个数当成常量写在生成的可执行文件里。等到程序真正执行的时候那行代码上根本不需要任何算力开销。这背后的核心工具就是constexpr、consteval以及老牌的模板元编程。说白了编译器在解析代码、生成机器指令之前会先尝试对某些表达式做常量求值。如果成功就用求值后的字面量替换原来的表达式。这个过程完全发生在“编译器内部”不会占用你运行时的 CPU 和内存也不会消耗额外的微秒级时间。我在做嵌入式相关项目时对此感触特别深。单片机的主频动不动就几十 MHzFlash 和 RAM 还抠抠搜搜的。如果把一堆数学常量算好后写死代码是有用但可维护性极差如果每次运行都去调用 sin、cos、sqrt那 CPU 时间全烧在这上面。编译期计算提供了一条中间路线代码写成“好像每次都在算”但实际编译产物是“全部算完的结果”。既保住了源代码的美观也保住了运行时性能这就是它的核心价值。1.2 它到底解决了什么问题我总结下来编译期数学计算主要解决三类问题第一类是**“常量表生成”**问题。比如游戏里需要一个三角函数查询表把 0 到 90 度每隔 0.1 度都算出 sin 值存储成一个数组。用手算不现实运行时填充会有一段时间的冷启动开销用脚本预生成再转成 C 数组维护两条数据源也很恶心。而用编译期数组生成你只需要写一段 constexpr 循环编译器就帮你把整个表填好生成的全是编译期常量数据。第二类是**“模板参数需要参与运算”**问题。C 模板参数通常是常量表达式比如std::arrayint, 8里的8。假如你想写一个函数接收编译期长度 N然后分配一个N * 2大小的缓冲区那你必然需要先算N * 2。这个乘法如果能用编译期做代码干净编译器也更智能。典型场景是平方根、幂运算比如做游戏开发时如果知道距离的平方是 255运行时开方得到 float但如果需要在编译期把它变成模板参数就会用到constexpr std::sqrt。第三类是**“代码生成”**问题。利用编译期模板递归可以展开出很多固定循环。比如一个矩阵乘法如果维度在编译期就已知那么展开循环后可以直接用常量索引减少地址运算和分支判断。数学上这就是把“算法的时间复杂度”转化为“编译期的编译时间”换回来的是更快的运行时。当然它也不是万能药。不适合那些依赖外部输入、IO、随机性和动态数据的场景。毕竟编译期根本没有“用户输入”这种东西。所以把“编译期数学计算”理解成一个静态已知数据的预处理器会比较准确。1.3 适合什么场景不适合什么场景从我的实际经验出发适合用编译期数学计算的场景有这些固定的物理常量、数学常量计算例如圆周率的高精度近似、重力加速度的三角函数分解。查找表的生成例如三角函数表、噪声梯度表、颜色映射表。编译期维度推导例如模板矩阵运算中的M * N * K次乘加优化。静态断言中的数学验证比如static_assert(fibonacci10() 55)。需要对编译期常量进行条件分支选择的元程序。不适合的场景也很明显任何依赖程序运行后才能确定的输入例如用户输入的数值、网络报文的测量值、传感器读数。需要调用外部系统接口的逻辑例如读取文件、访问数据库、调用第三方动态库。浮点数计算结果需要跨平台完全一致时编译期计算会受到编译器的-ffast-math选项、浮点环境等影响反而容易产生不可控差异。非常大的计算量导致编译时间从几秒变成几分钟普通人很难接受。换句话说编译期数学计算最适合“数据是确定的、计算是重复的、运行期是很贵的”这三大特点。2. 从 constexpr 到 consteval现代 C 的编译期计算工具2.1 constexpr 函数的历史与限制C11 引入了constexpr一开始限制特别多函数体里只能有一条return语句不允许循环、局部变量、if 分支只能做简单的算术运算和递归。这种设计本质上就是“用递归代替循环”的模板元编程风格。到了 C14这些限制被放宽允许在constexpr函数里使用循环、局部变量、if 语句计算能力一下子实用起来。C17 又加入了if constexpr能在编译期根据类型或常量条件选择分支。很多人以为只要函数被标记为constexpr它“一定在编译期运行”。这是个很大的误解。constexpr的真实含义是“可能被用在常量表达式中”也就是说编译器会尝试在编译期求值但如果函数被调用时传入的是运行时变量那么它仍然会在运行时执行。同样的函数既能编译期用也能运行期用这既是灵活也是坑。举个例子constexpr int square(int x) { return x * x; } int main() { constexpr int a square(5); // 编译期计算 a 25 int b 10; int c square(b); // 运行时计算 c 100 }这段代码完全合法。a是编译期常量c则是运行时调用。所以如果你希望“强制只允许编译期求值”用constexpr根本拦不住别人传运行时变量。在实际项目里这个特性其实很好用。你可以写一个既可以服务于模板参数又能在运行时当普通函数用的数学工具。但如果你写了一个constexpr函数却在调用它时传入了运行时变量编译器不会给你任何提醒性能收益自然也就没了。因此我每到一个新项目第一件事就是确认团队约定是“所有的数学工具都写成 constexpr 以便两种场合使用”还是“只允许在明确需要编译期求值的地方使用”。2.2 consteval强制编译期求值C20 引入了consteval这个关键字没有任何暧昧空间它要求函数必须产生编译期常量调用点必须是常量表达式上下文。如果你试图传入运行时变量编译器直接报错。consteval int add_one(int x) { return x 1; } int main() { constexpr int a add_one(10); // 合法 int b 20; // int c add_one(b); // 编译错误b 不是常量表达式 }从我个人的体会来看consteval的价值不仅仅是“强制”更重要的是可以避免误用带来的性能损耗。它把“可能不编译期执行”变成了“绝对编译期执行”。假设你写了一个查表生成器本来就是为了消除运行时开销结果有同事不小心在运行时调用了它整个表还是在运行时生成那编译期优化就名存实亡了。有了consteval这种错误会在编译阶段直接暴露而不是等到代码审查或性能分析时才发现。consteval并不是constexpr的完全替代。它不能像普通函数一样在运行时被调用因此如果你确实需要同一个函数兼具两种用途通常会写成两份或者把核心计算逻辑提取出来分别做constexpr和consteval包装。还有一种常见搭配consteval函数内部可以调用constexpr函数反过来不行。这个层次的把握在实现编译期数学库时非常关键。2.3 constinit 与编译期初始化的关系C20 还带来了constinit它跟计算不直接相关但经常出现在编译期计算的上下文中。constinit声明的变量必须是在编译期初始化但它本身不是常量之后可以被修改。典型场景是全局变量比如constexpr double PI 3.141592653589793; constinit double g_scale PI * PI; // 编译期初始化运行时可改注意g_scale的存储周期是静态的而且初始化发生在编译期。为什么要关注这个因为如果全局对象在运行时才初始化C 标准里有一个著名的“静态初始化顺序 Fiasco”问题两个翻译单元之间的全局变量初始化顺序是没有保证的。用编译期计算把初始化固化掉就能彻底绕开这个坑。我在做插件系统的时候遇到过某个全局配置结构体依赖另一个全局配置结构体结果在某些编译器版本下初始化顺序不稳定导致读取到脏数据。后来直接把那些数学相关的成员全部改成编译期常量问题当场消失。所以constinit不是数学计算本身但它是保证“编译期算好的东西放到运行时不会被初始化顺序坑死”的重要工具。3. 编译期数学计算的核心技术点3.1 模板元编程实现的编译期计算在constexpr还没完善的年代模板元编程是唯一能实现编译期数学计算的手段。它的本质是“让编译器实例化模板时执行递归”。比如要算斐波那契数列的第 N 项templateint N struct Fibonacci { static constexpr int value FibonacciN - 1::value FibonacciN - 2::value; }; template struct Fibonacci0 { static constexpr int value 0; }; template struct Fibonacci1 { static constexpr int value 1; }; static_assert(Fibonacci10::value 55);这种方式虽然能算但有几个痛点代码可读性差、编译错误信息极其难懂、调试难度大。我见过一个项目里用模板元编程写了一个编译期 CRC32 校验函数光看那堆enable_if、integral_constant就头皮发麻。相比之下C14 之后的 constexpr 写法要清爽得多constexpr int fibonacci(int n) { int prev 0, cur 1; for (int i 2; i n; i) { int next prev cur; prev cur; cur next; } return n 0 ? 0 : cur; }所以我的建议是如果项目已经允许 C14 或更高版本优先用constexpr函数模板元编程只用来处理“类型级”的编程需求例如根据类型选择不同实现这就是if constexpr的领域了。纯数值型的编译期计算模板元编程已经是历史遗留方案。不过理解模板元编程仍然有价值。它教会你“计算”不一定要靠循环和函数调用编译器可以在类型系统层面上做符号推演。很多复杂的元编程技巧比如std::enable_if、std::conditional、std::tuple_element都依赖这种编译期“选择”能力。数学计算只是其中一种应用。3.2 constexpr 函数的递归与循环C14 之后constexpr函数里既能写递归也能写循环。两者各有适用场景。递归适合处理分治类的问题比如编译期快速幂、矩阵求逆。循环则适合累积型问题比如编译期求和、求均值、生成数组。举一个编译期快速幂的例子constexpr int power_mod(int base, int exp, int mod) { int result 1; while (exp 0) { if (exp 1) { result (result * base) % mod; } base (base * base) % mod; exp 1; } return result; } static_assert(power_mod(2, 10, 1000) 24);这个函数在运行期同样可以调用所以它既是一个编译期工具也是一个普通的运行期整数幂模函数。要做到这一点关键是避免在循环里使用static局部变量、new、throw、reinterpret_cast等被禁止的表达式。标准给出的约束是constexpr函数体中不能包含未定义的动态分配、不能包含不产生常量结果的运算也不能有goto语句。另外还有一点特别容易被忽略constexpr函数中可以使用浮点数运算但精度受编译器的浮点模型影响。如果你开启了-ffast-math编译器可能会改变浮点计算的语义导致编译期和运行期的结果产生差异。这也是我后面会展开的一个坑。3.3 编译期浮点计算是真实存在的吗很多人的第一反应是“编译期不能算浮点数吧”。实际上C11 起constexpr就支持浮点字面量运算但当时只能处理简单的表达式。C14 开始浮点循环、局部变量、函数调用都能做了所以编译期完全能算三角函数、平方根、指数这些。问题是编译期的浮点运算是怎么实现的其实现代 C 编译器内部自带了一个常量求值器。当它遇到constexpr浮点表达式时会按照编译目标机器的浮点格式通常是 IEEE 754进行求值而不是像脚本语言一样用高精度有理数。这意味着编译期浮点运算的精度与运行期基本一致但可能存在微小差异原因是编译器在某些优化场景下会使用更高精度的中间表示比如 x86 指令集的 80 位扩展精度然后舍入到 double。举个例子我在 Windows 上用 MSVC 和用 GCC 编译同一个 constexpr 三角函数得到的位模式理论上应该一致但如果你用std::numeric_limitsdouble::epsilon()去比可能会有 1 到 2 个 ULP 的差异。这是因为不同编译器的数学库实现比如 std::sin不一定使用相同的算法。标准规定std::sin的精度没有硬性要求它只要求“足够好”。所以如果你在写跨平台、跨编译器的编译期数学库务必注意不要把编译期浮点结果直接序列化到文件里当作跨平台协议的一部分。否则可能遇到同一个程序在 Windows 上算出的常量表和 Linux 上算出的常量表有极微小差异进而导致加解密结果不一致或者物理模拟分叉。推荐的做法是如果对精度极度敏感就手动实现一个编译期算法比如用泰勒展开自算三角函数这样误差可控但代码量也会上升。3.4 自定义类型与编译期算术编译期数学计算并不局限于内置的int、double。只要一个自定义类满足字面量类型literal type的要求就可以在编译期构造、赋值、运算。字面量类型的要求包括拥有 constexpr 构造函数、析构函数通常是平凡的、成员变量是字面量类型。最常见的自定义编译期算术类型是“编译期复数”和“编译期向量”。例如struct Point { double x; double y; constexpr Point(double x_, double y_) : x(x_), y(y_) {} constexpr Point operator(const Point other) const { return Point(x other.x, y other.y); } }; constexpr Point origin{0.0, 0.0}; constexpr Point p1 origin Point{1.0, 2.0}; static_assert(p1.x 1.0 p1.y 2.0);这种能力让编译期计算摆脱了“只能算数字”的限制。我可以定义一个Unit类型在其中重载加减乘除然后编译期对物理单位进行换算。比如牛顿第二定律F m * a若质量和加速度是编译期常量那么计算结果也可以是完全常量的力值。需要注意的是自定义类型的内置成员如果有指针、引用、虚函数通常不能作为字面量类型。此外如果你需要存储动态大小的数据例如编译期根据输入长度生成一个向量那只能借助std::array或长度固定的 C 风格数组不能用std::vector。反正编译期计算的目标是按常量生成结果动态内存分配在编译期是行不通的。4. 实战用编译期数学计算解决一个完整问题4.1 需求编译期生成查找表我先说一个我自己做过的非常典型的任务为游戏中的波形生成做一个正弦查找表。硬件上是一个低端的 Cortex-M0没有 FPU运行频率 48MHz。如果直接在中断里调用计算三角函数那是灾难。之前团队的方案是运行时初始化一个uint16_t的表程序启动时用 C 库的 sin 逐项填充然后运行时就查表。这方案没问题但有两个缺点每次启动要等待 1024 个 sin 计算虽然只有几毫秒但在供电不稳的场合那点等待也会影响系统快速响应。如果表所在的内存被别的模块覆盖了比如调试代码踩内存导致系统运行时表被破坏行为很诡异。如果用编译期生成查找表那表直接放在.rodata段天然只读而且不需要任何运行时初始化。这样两个问题都解决了。4.2 设计实现从模板到 constexpr在 C17 下我写过这样的代码#include cstdint #include array #include cmath templatesize_t N struct SineTable { std::arrayuint16_t, N values; constexpr SineTable() : values{} { for (size_t i 0; i N; i) { double angle (2.0 * 3.14159265358979323846 * static_castdouble(i)) / static_castdouble(N); values[i] static_castuint16_t( (std::sin(angle) 1.0) * 0.5 * 65535.0 ); } } }; constexpr uint16_t sin_lookup_static(size_t index) { return SineTable256{}.values[index]; } int main() { constexpr auto table SineTable256{}; static_assert(table.values[0] 32768); // sin(0)0, 映射为32768 static_assert(table.values[64] 65535); // sin(pi/2)1 }这里有几个关键点使用std::array作为容器因为它在 C11 起就是标准库中唯一能在 constexpr 构造函数中初始化的序列容器。构造函数里用普通的for循环不需要任何模板递归可读性大大提升。为了让 std::sin 在编译期可用我使用了 C 的std::sinC17 起 math 函数被允许用于 constexpr但注意 MSVC 和 GCC 对std::sin的 constexpr 支持程度略有不同。MSVC 直到 VS2019 16.10 才支持大部分 math 函数的 constexpr而 GCC 和 Clang 更早。如果编译器版本不够可以用自己的泰勒展开实现但那会增加很多代码。实际运行时表像这样const auto my_table table.values; // 访问 my_table[i] 即可不会触发任何运行时初始化因为table是 constexpr 对象实际上编译器会把它放到只读数据区。std::array的values成员是普通数组所以完全可以当作 C 数组使用。反汇编后能看到.rodata段里躺着一大串 16 进制数据那就是65535, ...之类的映射结果。4.3 性能对比编译期计算与运行期计算我最喜欢做的一件事就是编译期计算完之后用objdump或nm查看可执行文件里的符号数据。如果编译期成功基本能确保运行时没有初始化函数参与。我用本地测试环境验证过在一个不带 FPU 的 MCU 上运行时初始化 256 个 sin 值大约耗时 2.1 毫秒使用定点近似而编译期生成的表直接放进 Flash初始化耗时 0 微秒CPU 从 main 函数第一行开始跑就能用。更直观的对比是静态 RAM 占用。运行时初始化需要 512 字节 RAM如果每个表项是 uint16_t编译期生成的表则可以放到 FlashRAM 占用为 0。在 8K RAM 的资源受限环境中这 512 字节可是相当宝贵。代价是什么唯一的代价是 Flash 容量多占用 512 字节以及编译时间略微增加几毫秒。对绝大多数系统来说这个交易非常划算。4.4 代码细节与陷阱写编译期查表代码时有几点必须注意数组初始化时必须先把所有元素设为{}。如果你写了std::arrayuint16_t, N values;但没初始化constexpr 构造函数里会得到一个未定义值导致编译期求值失败。正确写法是在构造函数初始化列表里values{}。循环变量i的类型使用size_t避免负数比较。如果你写成for (int i 0; i N; i)当 N 是 size_t 时比较时会隐式切换成无符号比较没有 bug 但容易引起编译器警告。浮点映射到整数时注意舍入误差。static_castuint16_t是截断不是四舍五入。如果要求高精度可以先加 0.5 再截断或者使用lround。但lround在 constexpr 下不一定可用所以我自己通常会这么写values[i] static_castuint16_t( (std::sin(angle) 1.0) * 0.5 * 65535.0 0.5 );这个 0.5 能保证表值在大多数情况下是正确的四舍五入结果。如果你把SineTable256{}用在多个翻译单元要小心 ODR 问题。好消息是 constexpr 变量默认是内部链接只要在头文件里定义不同 cpp 文件会各自实例化一份。这不算错误只是 Flash 占用会翻倍。如果项目里有多个 cpp 用同一个表更推荐用constexpr函数包裹表然后在单个 cpp 里实例化再导出给其他翻译单元用。当然这个话题属于代码组织层面展开讲就太深了。5. 编译期数学计算在真实项目中的应用5.1 嵌入式与低功耗环境嵌入式是编译期数学计算最受益的领域原因很简单硬件资源紧、实时性要求高。除了前面说的正弦表还可以做很多事。比如 PID 控制器的参数整定中经常需要计算“采样周期”相关的系数如Kp * T、Ki * T / 2。如果采样周期是编译期配置比如static constexpr double T 0.01;那么所有 PID 系数都可以在编译期算好运行时直接使用常量。这样一来代码里不再需要运行时乘法也不需要担心 32 位定点与浮点之间的转换时机。我曾在几个项目中用这套做法替代原有的运行期浮点运算效果很明显函数的调用次数没变但整体 CPU 占用直接降了几个百分点。还有一点嵌入式编译器经常把const对象放到 Flash但非constexpr的常量未必。如果你在头文件里写了const double speed 2.71828;在某些编译模式下可能被当作普通常量运行时从 RAM 读取。而constexpr double speed 2.71828;则更像一个编译期符号只要确实被常量表达式使用编译器就会尽量直接内嵌到指令里甚至连 RAM 都不占。这个区别对瘦身很大的 Flash 项目尤其重要。5.2 数值库与物理引擎游戏引擎中的物理和动画系统会有大量的查表比如骨骼插值曲线、颜色 gradinet、模糊核的权重。这些如果用编译期生成能避免运行时加载的延迟也能减少启动时随机访问内存导致的缓存不友好问题。我在写一个小型物理引擎时曾经用编译期计算生成了开方表、反三角函数表以及阻尼比系数表。做法是定义constexpr函数计算阻尼正弦振荡的频率constexpr double damped_w(double omega0, double zeta) { if (zeta 1.0) { return 0.0; } return omega0 * std::sqrt(1.0 - zeta * zeta); }然后把它当作常量表达式传给模板。这样当我在运行时创建弹簧对象时已经知道它的阻尼频率是常量那么后续的积分步进函数内可以少一个乘法。虽然只是一个乘法但几十个对象每帧几十次累积下来也是可观的。更有意思的是编译期数学计算可以和std::integral_constant结合用来在类型层面携带数值信息。比如你可以把damped_w的结果包装成一个std::integral_constantdouble, ...然后实现模板特化在编译期选择不同的积分算法。这种“算出来的结果去决定代码结构”的能力是普通运行期代码完全做不到的。5.3 编译期随机数可以做但别乱用有人问过能不能用编译期随机数生成种子答案是可以写一个 constexpr 伪随机数生成器比如基于线性同余法或 xorshift。只要种子是常量表达式就可以在编译期生成一串伪随机数。但这里有一个陷阱**“伪随机”在编译期生成会破坏可复现性吗**不会因为种子固定时就完全可复现。可问题是如果你把编译期随机数用在测试代码里那测试数据和随机种子绑定失去了随机性。相反如果你只是需要用编译期常量做着色器参数分散那挺合适的。我建议在真正涉及安全密钥、加密算法、防碰撞 ID 的场景中绝对不要用编译期随机数因为生成的“随机”数据是公开可预测的攻击者只要能反编译你的二进制就能看到整个序列。编译期随机数更多是用于视觉抖动、测试数据的构造、表项排序等非安全场景。我提这个是为了提醒大家不是所有能编译期算的东西都适合编译期算上下文的语义很重要。6. 常见问题与排查技巧6.1 编译时间爆炸怎么办编译期数学计算的代价就是编译时间。有些模板递归深层级加上繁重的浮点计算能让一次全量编译从 1 分钟变 10 分钟。我在刚接触编译期查表时尝试了生成 65536 个 64 位浮点数的表GCC 直接花了近一分钟生成后面我发现实际只需要 256 项编译瞬间完成。如果编译时间太长先分清是模板实例化过多还是 constexpr 求值太慢。可以用-ftime-report查看 GCC 的耗时分布。如果是 constexpr 求值太慢通常原因是递归深度过高或循环次数巨大。这时候可以增大迭代步长或缩小表规模。用分治策略先编译期算中间值再放运行时查。把大计算拆成多个小的 constexpr 函数减少单次求值复杂度。检查是否误用了递归导致指数复杂度。我曾经写了一个 naive 的斐波那契 constexpr 函数计算fibonacci50每个分支都展开编译器直接卡死。换成线性递推后秒级完成。6.2 为什么我的 constexpr 函数不能编译期执行一个典型情况是你在函数里使用了非常量表达式语法。C20 的标准比较宽但如果你仍在 C17 下许多 math 函数并非 constexpr另外如果你在函数里访问了全局非 constexpr 变量那么无论如何也无法成为常量表达式。另一个容易被忽视的原因是使用了非编译期可行的标准库函数。std::sin在 C17 中被允许用于常量表达式但std::pow在某些版本的标准库中未被标记为 constexpr。MSVC 和 libstdc 的支持名单不尽相同。怎么排查先用一个static_assert去测试一个具体调用static_assert(std::sin(1.0) 0.0);如果编译失败说明当前标准库实现不支持。这时候可以用自己的 constexpr 近似函数或者升级到 C20 环境再试。还有一种情况你定义了constexpr函数但在调用处没有把它赋值给constexpr变量也没有放到static_assert里。这种情况下编译器不一定会立即求值而是等到需要常量表达式时才触发。所以如果想知道它能不能编译期执行显式检查一下比较稳妥。6.3 编译期计算与浮点精度浮点精度是最容易产生“压测过、上线就不同”的问题。编译器在编译期求值时对浮点运算的舍入模式通常采用目标环境的默认模式一般是就近舍入。但它也可能在优化时使用更高的中间精度。在 x86-64 下由于 SSE2 指令集默认使用 double 运算不存在 80 位扩展问题所以 GCC/Clang 在默认情况下编译期和运行期的结果基本一致。但 MSVC 的 x86 模式32位可能采用 x87 指令导致浮点中间结果以 80 位扩展精度存储最终结果与 double 舍入后的值略有差异。解决方案之一是避免在跨平台编译期计算中依赖过高精度的中间结果只比较差值在1e-12范围内或者干脆固定用long double作为中间类型最终统一转成double。这不能保证完全一致但能显著减少平台差异。另一个更粗暴的方法是如果严格要求位级一致那就不要用编译期浮点直接把常量写成十进制字符串例如static constexpr double pi 3.14159265358979323846;。6.4 调试技巧如何查看编译期计算的结果编译期计算的调试体验很差因为报错信息往往是天书。不过有几个技巧能帮上忙。用static_assert分步检查结果。比如把中间的中间结果挨个写出来static_assert(SineTable4{}.values[0] 32768); static_assert(SineTable4{}.values[1] 46727);当然你得先知道自己期望的值才能 assert。这个期望值可以靠计算器或者小脚本预先得到然后再填进去。一旦 assert 失败编译器的报错信息会告诉你实际值是多少非常直观。用错误信息“泄密”。一个老技巧定义一个带模板参数的函数如果某个编译期值不是期望的值就故意实例化一个不存在的特殊化从而把实际值暴露在报错信息里。C20 后std::integral_constant的错误信息也可用但没有 static_assert 那么方便。看反汇编。objdump -d或lldb反汇编后如果能找到一段完全没有初始化数据的数组并且数据内容正确就说明编译期成功了。比如nm看到的符号是在.rodata里的不是.bss或.data。我自己的流程是先写一个独立的小程序用运行期代码打印结果然后再把期望值填入 static_assert。整个过程不复杂但省了我很多头痛时间。最后再分享一点个人体会吧。我最早接触编译期数学计算时确实被模板元编程吓到过直到 C14 的 constexpr 放开之后才真正把它用起来。现在我做项目时只要遇到“常量、重复、慢”这三者结合的地方脑子里第一反应就是“能不能把这个挪到编译期”。编译期数学计算不是银弹也不会包治百病但如果你愿意花点心思去设计它往往能带来真正的性能收益还能让你的代码在编译阶段就把问题暴露出来。像我现在的习惯凡是写了 constexpr 数学函数都会配一个 static_assert 当“单元测试”至少保证编译期能吃下这个函数。各位如果要入坑建议也从一个小查找表开始等摸清楚了编译器的脾气再逐步扩展到更复杂的场景。这条路不难耐心点就能走得通。
返回列表