16、编译期数学库:编译期三角函数近似、编译期平方根(牛顿迭代法)
说实话,编译期算数学函数这事儿,我早年觉得挺鸡肋的。那时候想的是:运行时算一下不就完了?直到我在一个嵌入式项目里遇到性能瓶颈——那个项目每秒要算几千次三角函数,CPU 都快冒烟了。后来我把这些计算全部挪到了编译期,编译时间多了几秒,但运行时的速度直接翻倍。
嗯,从那天起,我就开始认真研究编译期数学库了。今天咱们就聊聊怎么在编译期实现平方根和三角函数的近似计算。
16.1 编译期平方根:牛顿迭代法
先说说平方根。你可能会问:C++ 标准库不是有 std::sqrt 吗?没错,但那是运行时的。我们要的是 constexpr 版本的。
牛顿迭代法,说白了就是猜一个数,然后不断修正。公式很简单:
x_{n+1} = (x_n + S / x_n) / 2
其中 S 是要求平方根的数。每次迭代,结果都会更精确一点。
我个人的习惯是,迭代 5 到 6 次就够用了。再多的话,编译时间会变长,收益却很小。
来看看代码:
template <typename T, int Iterations = 6>
struct Sqrt {
static_assert(Iterations > 0, "迭代次数必须大于0");
constexpr static T apply(T x) {
if (x == 0) return 0;
T guess = x > 1 ? x : 1; // 初始猜测
for (int i = 0; i < Iterations; ++i) {
guess = (guess + x / guess) / T(2);
}
return guess;
}
};
// 使用示例
constexpr double val = Sqrt<double>::apply(2.0);
// val ≈ 1.414213562
这里有个小细节:初始猜测的选择。如果 x 小于 1,我习惯从 1 开始迭代,这样收敛更快。如果 x 大于 1,直接用 x 本身作为初始值也行。
Iterations 的值。但说实话,6 次迭代对于 double 类型已经足够了,误差通常在 1e-12 以内。
16.2 编译期三角函数:泰勒展开
三角函数在编译期实现,最常用的方法就是泰勒展开。拿正弦函数来说:
sin(x) = x - x³/3! + x⁵/5! - x⁷/7! + ...
这个级数收敛得很快,尤其是当 x 比较小的时候。所以,我建议先把输入值规约到 [-π, π] 范围内,然后再展开。
我在项目中遇到过一个问题:如果直接展开到 10 项,编译时间会明显增加。后来我改成展开到 5 项,精度已经能满足大部分需求了。
来看看实现:
template <typename T, int Terms = 5>
struct Sin {
constexpr static T apply(T x) {
// 规约到 [-π, π]
constexpr T pi = T(3.14159265358979323846);
x = x - T(2) * pi * T(static_cast<long long>(x / (T(2) * pi)));
T result = x;
T term = x;
for (int n = 1; n < Terms; ++n) {
term = -term * x * x / T((2*n) * (2*n + 1));
result += term;
}
return result;
}
};
// 使用示例
constexpr double sin_val = Sin<double>::apply(1.0);
// sin_val ≈ 0.8414709848
为什么用 term = -term * x * x / T((2*n) * (2*n + 1))?
嗯,这是递推公式。每次迭代只需要乘一个系数,不用重新算阶乘,效率高很多。
16.3 编译期余弦和正切
有了正弦,余弦就好办了:
cos(x) = sin(x + π/2)
正切也一样:
tan(x) = sin(x) / cos(x)
不过要注意,当 cos(x) 接近 0 时,正切会趋向无穷大。我在代码里加了一个检查:
template <typename T, int Terms = 5>
struct Tan {
constexpr static T apply(T x) {
T s = Sin<T, Terms>::apply(x);
T c = Cos<T, Terms>::apply(x);
if (c == 0) {
// 返回一个很大的数,或者抛出编译期错误
return T(1e10);
}
return s / c;
}
};
16.4 知识体系总览
下面这张图展示了编译期数学库的核心逻辑:
16.5 精度与性能的权衡
你可能会问:到底迭代多少次才够?我整理了一个表格,供你参考:
| 函数 | 迭代/项数 | 误差范围 | 适用场景 |
|---|---|---|---|
| 平方根 | 4 次 | ~1e-8 | float 精度 |
| 平方根 | 6 次 | ~1e-15 | double 精度 |
| 正弦/余弦 | 5 项 | ~1e-10 | 大部分工程场景 |
| 正弦/余弦 | 8 项 | ~1e-15 | 高精度计算 |
我个人建议,除非你的项目对精度有变态要求,否则 5 项泰勒展开 + 6 次牛顿迭代就足够了。编译时间不会太长,运行时的性能也拉满了。
sin(1e10),结果因为没做角度规约,泰勒展开死活不收敛。后来加上了模 2π 的规约,问题就解决了。记住:先规约,再展开。
16.6 总结
编译期数学库,说白了就是把数学计算塞给编译器。牛顿迭代法求平方根,泰勒展开算三角函数,这两招用好了,你的代码在运行时几乎不花时间在数学计算上。
嗯,今天的分享就到这里。记住:编译期计算不是银弹,但在性能敏感的场景下,它确实是一把利器。
公众号:蓝海资料掘金营,微信deep3321