问题历史
在 C++20 之前,计算两个整数或指针的算术平均值需要手动实现,通常通过简单的表达式 (a + b) / 2。然而,这种方法在求和超过类型可表示的最大值时会引发未定义行为。std::midpoint 在 C++20 中于 <numeric> 头文件中标准化,提供了一个可移植的、constexpr 的、noexcept 的解决方案,保证在整个整数和指针类型的域中得出正确的结果,而不会触发有符号整数溢出。
问题说明
有符号整数溢出在 C++ 中是未定义行为,即便是 C++20 的二进制补码规定。在对两个大正有符号整数(例如,接近 INT_MAX)取平均时,它们的和会溢出。同样,对于两个大负整数,总和可能会下溢。尽管指针算术在溢出时也会有未定义行为,但进入同一数组的两个指针之间的差值保证适合 std::ptrdiff_t,从而消除了在整数加法中存在的溢出风险。问题在于实现一个避免加法本身的溢出同时对混合符号输入保持正确性的平均算法。
解决方案
对于有符号整数,std::midpoint 通过在减法之前将操作数转换为其无符号对应类型来避免溢出。当 a > b 时,它计算平均值为 a - (unsigned_type(a) - b) / 2,或者在其他情况下的对称等式。这利用了无符号类型的良好定义的模算术,以在不创建任何中间值的情况下计算数字之间的半距离,且该中间值的大小不会超过输入值。对于指针,实施简单地使用 first + (last - first) / 2 ,因为差值 (last - first) 是良好定义且可表示的,且对其进行二分确保随后的加法保持在数组的有效范围内。
#include <numeric> #include <limits> #include <iostream> int main() { int a = std::numeric_limits<int>::max() - 10; int b = std::numeric_limits<int>::max() - 2; // int naive = (a + b) / 2; // 未定义行为: 溢出 int safe = std::midpoint(a, b); // 良好定义: 返回 max - 6 int arr[] = {1, 2, 3, 4, 5}; int* mid = std::midpoint(&arr[0], &arr[4]); // 指向 arr[2] }
问题描述
在一个高频交易平台中,系统需要计算两个订单时间戳的时间中点,这两个时间戳表示为自 Unix 纪元以来的 std::int64_t 纳秒。在压力测试期间,当时间戳接近 INT64_MAX 时,传统计算 (t1 + t2) / 2 由于有符号整数溢出导致间歇性崩溃,从而破坏了订单匹配引擎的优先队列逻辑。
考虑的不同解决方案
方案 1: 使用内置的扩展精度类型
团队考虑将中间计算强制转换为 __int128_t,然后再转换回去。这种方法在支持的编译器上(GCC,Clang,MSVC)保证了正确的算术。然而,它缺乏对符合严格 C++17 的嵌入目标的可移植性,并且在 32 位架构上引入了微妙的性能回归,因为 128 位仿真成本高。
方案 2: 无符号算术转换 建议将两个时间戳转换为 std::uint64_t,进行平均,然后再转换回来。虽然这避免了对正值的溢出,但在处理历史时间戳(有符号表示下的负值)时会产生实现定义的结果,因为将负值转换为无符号值会产生大的正值(补码环绕),令跨零计算的平均值在数学上不正确。
方案 3: 手动分支和溢出检查
考虑实现一个自定义函数,检查操作数的符号,并有条件地使用 a + (b - a)/2 或位操作。这提供了正确性,但增加了分支开销和复杂性。在多个数值域(坐标、价格、时间戳)中保持此逻辑违反了 DRY 原则,并引入了维护错误的风险。
方案 4: 采用 C++20 std::midpoint 将工具链升级到 C++20 并利用 std::midpoint 提供了一个零开销的抽象,正确处理所有边界情况,包括混合符号输入和接近整数域限制的值。
选择的解决方案和结果
团队选择了 方案 4,将计算层迁移到 C++20。此更改消除了生产中的溢出崩溃,减少了 200 行自定义安全数学工具的代码库,并通过消除分支逻辑改善了缓存局部性。回归测试确认在 x86-64 上的性能与简单方法相同,并且为编译时常量提供了 constexpr 评估的附加好处。
问题 1: 为什么将有符号整数转换为无符号类型不足以进行通用的中点计算?
回答 虽然无符号算术模 2^N 并且避免溢出,但将负的有符号整数转换为无符号会产生大的正值(例如,-1 变为 UINT_MAX)。通过无符号算术对负和正时间戳取平均会产生接近无符号范围中间的结果,而不是正确的有符号平均值。std::midpoint 保留了符号性,并在差值为奇数时正确向第一个参数舍入,适当地处理符号位,而不依赖于无符号环绕得到最终结果。
问题 2: 在什么特定对象模型约束下,std::midpoint 对指针调用会引发未定义行为?
回答
std::midpoint 要求两个指针指向同一数组对象的元素(或最后一个元素之后)。如果指针引用不同的数组或不相关的内存,则行为是未定义的,因为表达式 last - first(内部使用)仅在同一数组内的指针中定义。这与整数中点存在微妙的区别;候选人常常假设它像简单的数值平均一样工作,而忽略了指针算术的严格别名和对象模型要求。
问题 3: std::midpoint 对浮点类型的行为与对整数的有所不同,特别是在 NaN 值方面?
回答
对于浮点类型,std::midpoint 通过使用一种实现定义的策略(通常等效于 a/2 + b/2)来防止总和的溢出和下溢(这可能会错误地产生无穷大或零)。至关重要的是,如果任一参数为 NaN,std::midpoint 返回 NaN。如果一个参数为正无穷大,另一个为负无穷大,它返回 NaN(不确定),而整数中点则只会溢出或环绕。候选人经常假设它进行简单的算术,而不考虑 IEEE 754 特殊值传播规则。