CppCon 2015 学习:Comparison is not simple, but it can be simpler.
What is comparison?
这段文字是从计算机科学、编译器设计或系统优化的角度来定义和评价“比较(comparison)”这个操作:
1. Pervasive(无处不在)
比较操作在编程中极为常见,存在于:
- 分支语句(
if
,switch
) - 循环控制(
for
,while
) - 数据结构操作(搜索、排序)
- 算法逻辑(分治、剪枝)
- 优化判断(向量化是否可行)
即:程序中几乎每个决策点都涉及“比较”。
2. Expensive(代价高)
尽管比较看起来是个简单操作,但在高性能场景中:
- 它会导致 分支预测失败(branch misprediction)
- 导致 指令流水线中断(pipeline flush)
- 影响 向量化 或 SIMD 并行效率
- 增加了 CPU 的条件判断路径,难以并行
在编译器设计中,减少比较次数或替代成无分支计算(如 mask/bitwise 计算)是常见优化手段。
3. Critical(关键性)
比较操作往往决定程序的行为:
- 决定执行路径(逻辑分支)
- 决定输出结果(排序、选择)
- 决定性能瓶颈(复杂度判断)
编译器需要正确理解比较的语义,才能安全地进行重排序、合并、替换等优化。
4. A query against an equivalence or order relation
从理论角度来说,比较操作其实是一个 数学关系判断:
- Equivalence(等价关系):例如
a == b
- Order relation(序关系):例如
a < b
,a >= b
在类型系统、逻辑编译、形式验证中,这类关系有着严格定义(自反性、传递性、对称性等)。
总结:
“比较”是计算中无处不在、不可或缺、但又代价高昂的操作,其本质是对等价或序关系的查询。
它是优化的核心点,尤其在高性能编程(如向量化、GPU、并行化)和编译器设计中,如何处理比较决定了程序性能与行为的关键路径。
**“等价关系(equivalence relation)”**,它是数学和计算机科学中非常基础、非常重要的概念,特别在类型系统、编译器优化、形式验证、逻辑推理等方面广泛应用。
等价关系的定义
一个等价关系(记作 ~)是在一个集合上的二元关系,满足以下三个基本性质:
1. Reflexive(自反性)
- 对于任意元素
a
:
a ~ a - 意思是:每个元素和它自己是等价的。
2. Symmetric(对称性)
- 如果 a ~ b,那么也有 b ~ a
- 即:如果 a 等价于 b,那么 b 也等价于 a。
3. Transitive(传递性)
- 如果 a ~ b 且 b ~ c,那么也有 a ~ c
- 即:等价关系可以“串起来”,形成闭环。
这三个条件一起,就定义了一个弱等价关系(weak equivalence)。
强等价:Congruence(全等 / 可替换性)
除了基本的等价关系,**“相等(=)”**在程序里通常还意味着 更强的等价性,叫作 一致性(congruence),它除了满足上面三条,还要求:
4. Substitutability(可替代性)
- 如果 a = b,那么对于任何函数
f()
:
f(a) = f(b) - 意思是:你可以在任何上下文中把 a 替换成 b,程序行为不会变。
这在程序分析、编译器优化中很重要,比如:
int x = 5;
int y = 5;
// 如果 x == y,并且是“可替代”的等价,
// 那么 x + 1 == y + 1 是合法的优化
总结
性质 | 描述 |
---|---|
自反性 | a ~ a |
对称性 | a ~ b → b ~ a |
传递性 | a ~ b 且 b ~ c → a ~ c |
可替代性 | a = b → f(a) = f(b)(等价在任意上下文中成立) |
在编译器/程序优化中的应用:
- 公共子表达式消除(CSE)
如果 a = b,可以避免重复计算。 - 内联/常量传播
如果函数参数值相同,可以直接传播。 - 循环不变代码移动(LICM)
可替换的值可以移出循环体。 - 类型系统判断
类型之间是否可转换、安全替换。
“序关系(order relation)”的分类与定义,特别是严格的偏序关系(strict partial order)、**严格的弱序(strict weak order)以及严格全序(strict total order)**的性质。
下面为你详细解释这些概念:
什么是 Order Relation(序关系)?
序关系是用来对元素“排序”的一种二元关系(通常记作 <
)。我们关心的排序可以是:
- 偏序(partial order)
- 全序(total order)
- 弱序(weak order)
1. Strict Partial Order(严格偏序)
一个严格偏序关系 <
必须满足以下 3 个性质:
性质 | 含义 |
---|---|
Irreflexive(反自反性) | 不存在元素 a 使得 a < a。 |
Asymmetric(非对称性) | 如果 a < b,那么绝不可能 b < a。 |
Transitive(传递性) | 如果 a < b 且 b < c,那么一定有 a < c。 |
示例:
集合 {1, 2, 3}
中的 <
就是严格偏序:
- 1 < 2,2 < 3 → 1 < 3(传递)
- 不存在 2 < 2(反自反)
- 1 < 2 ⇒ not 2 < 1(非对称)
2. Strict Weak Order(严格弱序)
它是在严格偏序的基础上,增加了“可比较性”的某种弱形式:
- 元素之间的比较可以形成“有序分区”。
- 所有元素可以分成不相交的等价类(用
~
表示),这些等价类之间是有序的。
新增性质:
性质 | 含义 |
---|---|
Ordered partitions | 如果 a < b,则对于任何 c,不是 a < c,就是 c < b(或两者都) |
用途: | |
标准库排序函数(如 std::sort )要求的比较函数必须满足严格弱序(例如小于 < )。 |
3. Strict Total Order(严格全序)
这是最强的序关系 —— 所有元素都可以被比较。
新增性质:
性质 | 含义 |
---|---|
Trichotomy(三分律) | 对于任意 a 和 b,恰好有以下三个关系之一成立: |
- a < b
- a = b
- b < a |
所有基本类型(如 int、float)默认使用的是严格全序。
对比总结
特性 | Partial Order | Weak Order | Total Order |
---|---|---|---|
自反性 | |||
非对称性 | |||
传递性 | |||
三分律 | |||
可比较性 | (部分可比) | (类间可比) | (全部可比) |
在编程和算法中的应用:
应用场景 | 所需关系类型 |
---|---|
std::sort() 的比较函数 | Strict Weak Order |
数据结构中的优先级(如 heap) | Partial or Total Order |
排序网络或拓扑排序 | Partial Order |
编译器依赖图分析、任务调度 | Partial Order |
不同算法和应用场景中所需的比较顺序(comparison orders)类型。下面是对每一条的详细解释与理解:
“What comparison orders are needed?” 的详细解释:
1. Topological sort → Partial Order(偏序)
解释:
拓扑排序是用于有向无环图(DAG)中的节点排序,表示“先做谁,后做谁”的依赖关系。
需要的顺序类型:
- 只要求部分可比(并非任意两个元素都可比)
- 关系必须传递、非对称、反自反
→ 所以是严格偏序(strict partial order)
示例:
Task A < Task B < Task C
But Task D is unrelated (not comparable)
2. Normal sort → Weak Order(弱序)
解释:
标准排序算法(如
std::sort
)依赖比较器(如<
)来确定元素之间的顺序。
需要的顺序类型:
- 排序中,不同元素可能“等价”(排序时不强制区分)
- 元素分成等价类,类之间可以排序
→ 所以需要严格弱序(strict weak order)
特点:
- a < b → a 排在 b 前面
- 若 a 和 b 互不小于 ⇒ 它们是等价的(不是相等)
- 要求传递性、非对称性、有序分区
3. Indexing → Total Order(全序)
解释:
用于搜索结构(如二分查找、平衡树、哈希树等)时,需要所有元素可以比较出大小先后。
需要的顺序类型:
- 所有元素必须两两可比较
- 满足三分律(trichotomy):要么 a < b,要么 a = b,要么 b < a
→ 所以需要严格全序(strict total order)
4. Memoization → Weak Order + Equality
解释:
Memoization(记忆化)会缓存之前计算过的输入与结果。
需要的顺序类型:
- 要比较“输入是否重复” → 需要等价比较(a == b)
- 若使用结构化输入,通常用 map/set 来查找缓存,依赖于一个弱序来组织
具体要求:
- Weak Order: 把等价的输入组织在一起
- Equality: 判断缓存命中是否成立(键是否相等)
5. Partitioning the domain → Type-specific needs
解释:
将问题空间(如一个数组、输入域)划分为子区域(如分区处理、并行计算),具体做法依赖于元素类型。
需要的顺序类型:
- 不固定!需要根据类型特性来决定
- 可能是基于范围的全序(例如:数值分区)
- 或基于标签、属性的等价关系(例如:分组)
总结对照表:
应用场景 | 所需比较关系类型 |
---|---|
拓扑排序(Topological Sort) | Strict Partial Order |
一般排序(std::sort) | Strict Weak Order |
索引结构(二叉树、B树) | Strict Total Order |
记忆化缓存(Memoization) | Equality + Weak Order |
计算域分区(Partitioning) | Type-specific(依类型而定) |
你提供的内容是在讨论比较操作中的异常行为,尤其是当比较对象是**浮点数(floating-point)**时,尤其涉及 NaN(Not a Number) 的情况。下面是详细的解释和代码层面上的理解。
“What can go wrong?” 理解总结:
1. 算法可能失败
原因: 算法往往基于某种假设(如弱序、等价关系、总序等),一旦比较操作不满足这些假设,就可能导致算法行为不正确或不确定。
重点问题:NaN 破坏排序的有序性
问题描述:
浮点数中的 NaN 不满足常规的比较规则:
1 < 3 true
1 < NaN false
NaN < 3 false
NaN == NaN false
结论:
由于 NaN
和任何数比较都不成立,因此 operator<
不满足弱序(weak order)所需的条件,导致排序逻辑出错。
弱序(Weak Order)要求:
为能用于排序算法(如 std::sort
),比较函数 <
必须满足严格弱序(strict weak ordering):
- 非自反性:没有
a < a
- 传递性:如果
a < b
且b < c
,则a < c
- 等价类传递性:如果
!(a < b)
且!(b < a)
,则 a 和 b 处于同一等价类
但NaN
无法满足这些,因为:
!(NaN < x)
对所有 x 成立!(x < NaN)
也对所有 x 成立- 但
NaN != x
和NaN != NaN
⇒ 它无法构成等价类,也不等于任何值
示例:NaN排序结果不确定
你给的几组可能的排序结果都不同:
-NaN, -1.0, +0.0, +1.0, +NaN
+NaN, -1.0, +0.0, +1.0, -NaN
-1.0, -NaN, +0.0, +NaN, +1.0
...
说明排序不具备稳定性、确定性、可重复性,因为 NaN 与其它值“不可比”。
举个代码例子 (C++ or Python)
C++ 示例(NaN 参与排序):
#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>
int main() {std::vector<double> v = { -1.0, std::nan(""), 0.0, 1.0, std::nan("") };std::sort(v.begin(), v.end());for (double d : v) {if (std::isnan(d))std::cout << "NaN ";elsestd::cout << d << " ";}
}
可能输出结果会因编译器或排序策略而异。
Python 示例:
import math
arr = [float('-nan'), -1.0, 0.0, 1.0, float('nan')]
arr.sort()
print(arr)
也会得到不确定排序,比如
[nan, -1.0, 0.0, 1.0, nan]
,具体位置不一定相同。
解决方法建议:
1. 排序前剔除或特殊处理 NaN:
std::sort(vec.begin(), vec.end(), [](double a, double b) {if (std::isnan(a)) return false;if (std::isnan(b)) return true;return a < b;
});
2. 明确约定 NaN 排在开头或结尾
总结:
项目 | 是否满足 |
---|---|
1 < NaN 、NaN < 3 | |
构成弱序(weak order) | |
NaN == NaN | |
导致排序不稳定 | |
推荐处理方式 | 自定义比较器或剔除 NaN |
带符号的零(signed zero)在排序和等价比较中的特殊行为。我们来逐步分析这段话的含义,并结合代码示例说明。
问题解析:Sorting with Signed Zero
背景知识(IEEE 754 浮点标准)
- 在 IEEE 754 中:
+0.0
和-0.0
是两个不同的位模式+0.0 == -0.0
是 true- 但在某些数学运算中它们行为不同,例如:
1.0 / +0.0 == +∞ 1.0 / -0.0 == -∞
问题核心:为什么 ==
不是真正的等价(congruence)?
一个“等价关系”必须满足:
- 自反性:a == a
- 对称性:a == b → b == a
- 传递性:a == b, b == c → a == c
- 可替代性(substitutability):
如果a == b
,则对所有函数f
,应有f(a) == f(b)
但是:
+0.0 == -0.0 // true
1.0 / +0.0 == +∞ //
1.0 / -0.0 == -∞ //
- 因此:虽然
+0.0 == -0.0
,但它们不能替代使用 → 不满足 substitutability - 所以,这种
==
不是一个等价关系(不是 congruence)
对排序的影响:
你举例的排序结果如:
-1.0, -0.0, -0.0, +0.0, +1.0
-1.0, -0.0, +0.0, -0.0, +1.0
-1.0, +0.0, -0.0, ...
说明即使 -0.0 == +0.0
,排序时仍可能因符号差异不稳定,导致多种有效但不一致的排序顺序。
C++ 示例:带符号零排序
#include <iostream>
#include <vector>
#include <algorithm>
int main() {std::vector<double> values = { -1.0, -0.0, +0.0, +1.0 };std::sort(values.begin(), values.end());for (double x : values) {if (std::signbit(x))std::cout << "-0.0 ";else if (x == 0.0)std::cout << "+0.0 ";elsestd::cout << x << " ";}std::cout << "\n";
}
输出可能是:
-1.0 -0.0 +0.0 +1.0
但顺序不保证,除非你加上自定义比较器。
自定义比较器解决排序一致性问题:
bool less_with_sign(double a, double b) {if (a == b)return std::signbit(a) && !std::signbit(b); // -0.0 comes before +0.0return a < b;
}
总结归纳:
观察项 | 是否满足 |
---|---|
-0.0 == +0.0 | true |
1 / -0.0 != 1 / +0.0 | true |
== 提供 substitutability | 不满足 |
== 是数学意义上的等价关系? | 不是 congruence |
会影响排序稳定性? | 是 |
推荐的排序方式? | 使用自定义 comparator |
你这部分内容是探讨:在使用带符号的零(-0.0
和 +0.0
)时,为什么常见的比较(如 ==
)在**“记忆化(memoization)”场景下是危险的**,其核心在于——==
不具备“可替代性(substitutability)”,所以不能被当作数学意义上的等价关系(congruence)来使用。
理解关键:什么是 Memoization?
Memoization(记忆化) 是一种将函数调用结果缓存起来以避免重复计算的技术。
典型逻辑如下:
std::unordered_map<double, double> cache;
double compute(double x) {if (cache.find(x) != cache.end())return cache[x];double result = 1.0 / x;cache[x] = result;return result;
}
问题:-0.0 == +0.0
,但值不同?
在 IEEE 754 中:
-0.0 == +0.0 // true
1.0 / -0.0 == -∞ // true
1.0 / +0.0 == +∞ // true
所以你这段话的含义是:
-0.0, +0.0, -0.0 →
1/x
→ -Inf, +Inf, -Inf
但是如果用
==
匹配缓存,会变成:
-0.0, +0.0, -0.0 → 结果全部缓存为第一次结果(可能是 -Inf)
或者全部为 +Inf,取决于先缓存哪个!
这就是违背了**“相等值可替代”**原则:如果 a == b
,则 f(a) == f(b)
应该成立。但这里却:
-0.0 == +0.0
f(-0.0) != f(+0.0)
所以 ==
不是一个等价关系,不能用于 memoization 的 key!
正确做法(区分 -0.0 与 +0.0)
你可以使用:
方法一:使用 std::bit_cast
或 memcmp
进行 bit-level 比较
#include <bit>
#include <unordered_map>
#include <cmath>
struct FloatBitsHash {std::size_t operator()(double x) const {return std::bit_cast<std::size_t>(x);}
};
struct FloatBitsEqual {bool operator()(double a, double b) const {return std::bit_cast<std::size_t>(a) == std::bit_cast<std::size_t>(b);}
};
std::unordered_map<double, double, FloatBitsHash, FloatBitsEqual> cache;
这样 -0.0
和 +0.0
将会被区分缓存,确保 memoization
正确性。
总结
项目 | 解释 |
---|---|
-0.0 == +0.0 | 是 true,但只表示“数值上相等” |
1/-0.0 != 1/+0.0 | 行为不同(-∞ vs +∞) |
不能用于 memoization key | 因为不满足 substitutability,不是真正的“等价” |
正确的比较方法 | 使用 bit-level 比较(如 std::bit_cast )以区分符号 |
结论 | == 在浮点上下文中不是数学意义上的 congruence,因此有风险 |
为什么 NaN 会破坏 memoization 的效果。下面是详细的理解与代码分析总结:
问题背景:Memoization with NaN
什么是 Memoization?
记忆化是一种缓存机制,通过“输入 → 输出”映射避免重复计算。
但对浮点类型(如 double
),当输入是 NaN
时,memoization
会失效!
问题解释:NaN != NaN
在 IEEE 754 浮点规范中:
double a = std::nan("");
a == a; // false
这导致的问题:
在使用 std::unordered_map<double, double>
做 memoization 时,哈希表使用 operator==
作为默认比较函数。
因为:
NaN != NaN
➡ 每一个 NaN 都无法与已有 NaN 匹配
➡ 所以每次新 NaN 输入都 认为是“新的”请求
➡ 所有 NaN 请求都 无法命中缓存,造成:
两个严重后果:
- 计算无法复用 —— 明明计算过,但缓存没生效
- 内存泄漏风险 —— 每个 NaN 会存一个独立 key,缓存持续增长
示例演示
#include <iostream>
#include <unordered_map>
#include <cmath>
std::unordered_map<double, double> cache;
double compute(double x) {if (cache.find(x) != cache.end()) {std::cout << "Cache hit\n";return cache[x];}std::cout << "Cache miss\n";double result = 1.0 / x;cache[x] = result;return result;
}
int main() {double nan1 = std::nan("1");double nan2 = std::nan("2");compute(nan1); // misscompute(nan1); // miss again!compute(nan2); // also a miss!
}
即使 nan1
是同一个变量,两次调用也会是“miss”。
解决方案:自定义 Hash 和 Equal
方法一:基于位模式
#include <bit>
struct FloatHash {std::size_t operator()(double x) const {return std::bit_cast<std::size_t>(x);}
};
struct FloatEqual {bool operator()(double a, double b) const {return std::bit_cast<std::size_t>(a) == std::bit_cast<std::size_t>(b);}
};
std::unordered_map<double, double, FloatHash, FloatEqual> cache;
这样做的好处:
+0.0
和-0.0
被区分NaN
和NaN
可以匹配(如果 bit pattern 一样)- 可以避免“NaN 存不住”的问题
总结表格
问题点 | 原因 | 后果 | 解决方式 |
---|---|---|---|
NaN != NaN | 不满足 reflexivity | 无法命中缓存 | Bit-level 比较方式 |
== 不是等价关系(equiv) | 缺乏 substitutability | 导致缓存系统逻辑错误 | 使用自定义 == 或 bit pattern |
多个 NaN 被分别缓存 | NaN 无法彼此相等 | 内存持续增长,计算无法复用 | 自定义 hash 和 equal |
内容是在进一步说明 NaN(Not a Number) 的复杂性和对计算行为的影响,尤其是在排序和记忆化(memoization)等需要比较的场景中。以下是详细理解与分析:
有八千万亿(8 quadrillion)种 NaN?
是真的!
在 IEEE 754 双精度浮点数(double
) 中:
- 有 64 位,其中 11 位用于 exponent,52 位用于 fraction(尾数)
- NaN 的定义是:所有 exponent 位为全 1(2047),fraction ≠ 0
由于 fraction 有 52 位,理论上可组合出:
2^52 - 1 ≈ 4.5 × 10^15 ≈ 4.5 quadrillion
再考虑符号位(正/负 NaN),总数达到 约 9 quadrillion(千兆)NaN 值。
所以你看到的「8 quadrillion NaNs」是合理估计。
IEEE 754 的规则:操作保留 NaN 身份
IEEE 754 要求:
运算中传递下来的 NaN,不改变其 payload(有效载荷)。
举例:
double nan1 = std::nan("1");
double nan2 = std::nan("2");
std::vector<double> v = { nan1, 0.0, nan2 };
for (double x : v) {std::cout << 1.0 / x << "\n";
}
结果:
1.0 / nan1
→nan1
1.0 / 0.0
→+Inf
1.0 / nan2
→nan2
所以:NaN 的 identity 在传播中被保留
Sorting Behavior Breakdown
排序策略 | 行为说明 |
---|---|
default | 通常实现无法处理 NaN,可能把它们移动或混乱处理。示例:NaN1, NaN2, +Inf, NaN2(注意 NaN2 重复) |
weak order | 所有 NaN 被视为“等价”,会全部归类为 NaN1(如:NaN1, NaN1, +Inf, NaN1) |
total order | 明确保留每个 NaN identity,遵循完整比较规则(如:NaN1, NaN2, +Inf, NaN2) |
问题关键点总结
问题点 | 原因 | 后果 |
---|---|---|
存在海量 NaN 值 | NaN 的尾数部分可编码大量值 | NaN identity 会影响排序、比较、缓存行为 |
IEEE 保留 NaN identity | 为保留调试信息、错误传播标识 | 在排序或映射操作中行为难预测 |
NaN 不可比较 | NaN == NaN 为 false | 无法用于哈希、等价判断、memo key |
默认排序无法稳定处理 NaN | 排序算法假定 weak/total order | 导致结果不一致或重复 |
推荐实践建议
- 处理 NaN 排序:
- 使用自定义比较函数,例如把所有 NaN 放到列表最后或统一为一个 sentinel。
- 用于缓存场景(memoization)时:
- 使用
bitwise
比较(例如memcmp()
)或强制统一所有 NaN(canonical NaN)。
- 使用
- 避免重复传播 NaN:
- 若不需要 payload,可清理为统一值(
std::nan("")
)或返回默认数值。
- 若不需要 payload,可清理为统一值(
浮点数并不是唯一导致比较失败的问题,其他类型和程序员的假设也可能带来问题。
具体理解如下:
1. 其他基本类型通常也是全序的,但不一定总是
- 例如,整数类型通常有全序关系(
<
总是定义良好) - 但某些奇怪或特殊平台,可能出现不符合预期的比较行为(比如某些嵌入式平台或非标准硬件)
2. 程序员假设比较字段有全序关系
- 很多代码写比较操作时,默认字段是完全排序的(total order)
- 但如果比较函数或运算符不满足全序性质,就会导致算法失败或逻辑bug
3. 用户自定义类型的比较关系很少有明确文档说明
- 很多类或结构体,尤其用户定义的类型,没严格规定比较操作的关系性质(是否满足反射性、传递性、对称性等)
- 这会导致排序或查找等算法结果不可预测
4. 多字段比较算法容易出错
- 比较多个字段时,如果比较逻辑不符合全序,排序或数据结构(如有序集合、哈希表)可能失败
- 例如:部分字段比较无序,或者运算符不满足三段论(传递性)
5. 有些情况下程序员故意让 operator< 不符合全序
- 可能为了性能,或者业务逻辑特殊需求
- 但这会破坏基于比较的算法假设,导致潜在隐患
总结
浮点数不是唯一“坑”——程序员对比较的设计、平台的实现,以及类型本身的定义,都可能导致比较不满足全序,进而影响算法正确性。
真正的问题是什么?
- 工具算法(utility algorithms)默认使用运算符(比如
<
、==
)来做比较, - 但这些运算符本身可能并不满足算法所需的数学性质(如全序关系或等价关系),
- 而且也不应该强制要求这些运算符必须满足这些数学性质。
详细解释:
- 算法依赖于比较操作符:
许多标准算法(排序、查找、哈希、memoization等)默认直接调用对象的比较运算符。 - 比较运算符不保证数学性质:
但实际中,运算符可能不满足反射性、对称性、传递性等条件,特别是像浮点数中的NaN
情况,或用户自定义类型没有严格定义比较规则。 - 不应强加保证给运算符:
由于运算符可能有不同的语义或实现限制,不能硬性要求它们一定满足算法所需的所有性质。
结论
问题在于算法设计和运算符设计之间的脱节。算法默认“信任”运算符符合数学性质,但现实中并非如此。需要更灵活和明确的比较接口设计,或者在算法层面引入更健壮的比较策略。
代码分析
代码1:
bool operator>=(T a, T b) {return !(a < b);
}
- 该实现基于
operator<
,认为a >= b
等价于 “不是a < b
”。 - 这在全序关系下是正确的。
- 但当
T
仅是部分有序(partial order)时,比如浮点数(float)中存在 NaN,operator<
不能形成全序。 - 因此,这种实现会失败。例如:
- 对于
float
中的 NaN,a < b
和a >= b
都可能为false
,导致逻辑错误。
代码2:
- 对于
bool operator>=(T a, T b) {return a > b || a == b;
}
- 该实现明确用
operator>
和operator==
来实现“a >= b
”。 - 在部分有序的情况下,
operator>
和operator==
仍然可以更可靠地区分情况。 - 例如,浮点数中对于 NaN,
a == b
是false
,但是不会错误地判断a >= b
。 - 这使得该实现对部分有序类型更健壮。
进一步理解
- **部分有序(Partial order)**的类型,比较操作不满足所有全序的性质,例如浮点数中 NaN 导致比较不完全。
- 使用基于单一
operator<
的逻辑(!(a < b)
)不总是正确的。 - 需要结合
operator>
和operator==
来确保比较符合实际语义。
总结
实现方式 | 适用范围 | 是否可靠 | 备注 | ||
---|---|---|---|---|---|
return !(a < b); | 全序 | 是 | 对全序关系有效 | ||
`return a > b | a == b;` | 部分有序 | 更加健壮 | 适用于包含 NaN 的浮点数等 |
现在可以做什么?
- 主动排查已知问题的缺陷
找出代码中因比较运算导致的潜在 bug,特别是涉及浮点数 NaN 和特殊值的地方。 - 显式检查数据中的 NaN
在比较之前,主动检测数据是否包含 NaN,避免异常比较导致错误结果。 - 验证运算符的性质
确保你写的比较运算符满足你需要的数学性质(如反射性、对称性、传递性等),不要盲目依赖默认运算符。 - 完善文档说明
明确记录自定义比较函数的行为和预期性质,便于团队理解和维护。 - 编写新代码避免比较错误
在新代码中采用设计良好的比较逻辑,避免使用默认的运算符,减少错误风险。 - 避免与实用算法结合使用不可靠的默认运算符
不要用不保证数学性质的默认运算符和std::less
来驱动标准库算法。 - 编写和传递明确定义好的比较器
为标准算法(如std::sort
、std::set
等)明确提供符合预期的比较函数,保证算法行为正确。
关键点
- 认识到默认运算符可能不满足算法需求,需自定义清晰的比较策略。
- 对浮点数等特殊类型特别小心,尤其处理 NaN 和 ±0.0。
- 文档和测试同样重要,保障代码健壮和可维护。
以下是针对浮点数 double
实现的六个布尔比较函数示例,分别体现了部分、有序(弱)、全序以及相应的等价性判断。重点处理 NaN 和 ±0.0 等特殊情况:
#include <cmath> // std::isnan, std::signbit
// 判断两个数是否都是零(包含正负零)
bool both_zero(double a, double b) {return a == 0.0 && b == 0.0;
}
// 1. 部分序(partial order)小于
// 如果任一参数是NaN,则返回false(表示不可比较)
// 否则返回 d < f
bool partial_less(double d, double f) {if (std::isnan(d) || std::isnan(f)) {return false; // 含NaN时不可比较}return d < f;
}
// 2. 弱序(weak order)小于
// 将所有NaN视为相等,不认为NaN小于或大于其他数
// ±0.0被视为相等,不存在大小关系
bool weak_less(double d, double f) {bool d_nan = std::isnan(d);bool f_nan = std::isnan(f);if (d_nan && f_nan) return false; // NaN间相等if (d_nan) return false; // NaN不小于任何数if (f_nan) return true; // 任何数小于NaNif (both_zero(d, f)) return false; // ±0.0视为相等return d < f;
}
// 3. 全序(total order)小于
// 定义对所有浮点数(含NaN和±0.0)的完整排序关系
// NaN被视为大于所有非NaN数,区分±0.0(-0.0 < +0.0)
bool total_less(double d, double f) {if (std::isnan(d)) {if (std::isnan(f)) {// 简单处理,认为所有NaN相等return false;}return false; // NaN > 非NaN}if (std::isnan(f)) {return true; // 非NaN < NaN}// 对±0.0区分符号,-0.0被认为小于+0.0if (both_zero(d, f)) {bool d_neg = std::signbit(d);bool f_neg = std::signbit(f);return d_neg && !f_neg;}return d < f;
}
// 4. 部分无序判断(partial unordered)
// 如果任一参数为NaN,则认为两者不可比较(无序)
bool partial_unordered(double d, double f) {return std::isnan(d) || std::isnan(f);
}
// 5. 弱等价关系(weak equivalence)
// NaN之间相等,±0.0视为相等,其他按==判断
bool weak_equivalence(double d, double f) {bool d_nan = std::isnan(d);bool f_nan = std::isnan(f);if (d_nan && f_nan) return true; // NaN等价if (d_nan || f_nan) return false; // NaN与非NaN不等价if (both_zero(d, f)) return true; // ±0.0等价return d == f;
}
// 6. 全等价(total equality)
// 严格比较位级相等,但区分±0.0符号
// NaN统一视为相等(也可扩展为比较NaN payload)
bool total_equal(double d, double f) {if (std::isnan(d) && std::isnan(f)) {return true; // 简单处理,所有NaN相等}if (both_zero(d, f)) {// ±0.0只有符号相同时才等价return std::signbit(d) == std::signbit(f);}return d == f;
}
说明
-
partial_less
:对NaN和非比较情况返回false,不满足全序。 -
weak_less
:将所有NaN视为相等(非排序),同时把±0.0视为相等。 -
total_less
:强制对所有浮点数(包括NaN和±0.0)排序,保证全序。 -
partial_unordered
:判断两个数是否无法比较(包含NaN)。 -
weak_equivalence
:弱等价关系,NaN都视为相等,±0.0相等。 -
total_equal
:严格等价,包括区分±0.0,NaN则默认相等(也可扩展为按payload区分)。 -
保持一致性
在定义比较和等价关系时,应保持逻辑一致性,避免混淆。 -
用“小于”定义等价
等价关系(a~b)可以用“小于”操作符来定义:a~b 当且仅当 不是 a < b 且 不是 b < a
-
跨层级保持一致
比如,全序(total_less)成立时,弱序(weak_less)也应成立,保证层级间关系不冲突。 -
与操作符保持一致
用户自定义的 operator< 应该与 weak_less 保持一致,避免在不同代码路径出现冲突。 -
遵循标准
例如,遵守 IEEE 754 的 totalOrder 规范,保证浮点数比较的标准化和可预测性。
这些原则有助于确保比较函数的正确性、稳定性以及算法行为的预期。
处理浮点数比较的方法:
- operator< 实现了部分序(partial_less)
- 标准的
<
运算符只保证部分序性质,不能处理 NaN 等特殊情况。
- 标准的
- 基于 partial_less 写 partial_unordered
- 利用 partial_less 实现一个判断“无序”(unordered,通常指包含 NaN)的方法。
- 编写 total_less 遵守 IEEE 754 totalOrder
- total_less 是一个全序比较函数,必须严格按照 IEEE 754 totalOrder 规则,保证所有浮点数(包括 NaN、正负零等)都能排序。
- 利用 total_less 实现 total_equal
- total_equal 通过 total_less 组合定义,实现全序下的相等判断。
- 定义 weak_less
- 弱序比较,适用于某些排序和分区场景。
- 利用 weak_less 实现 weak_equivalence
- 弱等价关系,基于 weak_less 实现。
总结:
用分层方法(partial、weak、total)实现浮点比较,逐步完善,避免 operator< 直接作为唯一标准,保证对所有浮点值(特别是特殊值)的一致且正确的处理。
- 弱等价关系,基于 weak_less 实现。
描述了 IEEE 754 totalOrder 标准中浮点数排序的严格顺序,具体顺序如下:
- 正的静默NaN(positive quiet NaNs)
- 正的信号NaN(positive signaling NaNs)
- 正无穷(positive infinity)
- 正实数(positive reals)
- 正零(positive zero)
- 负零(negative zero)
- 负实数(negative reals)
- 负无穷(negative infinity)
- 负的信号NaN(negative signaling NaNs)
- 负的静默NaN(negative quiet NaNs)
总结:
IEEE 754 totalOrder 给出了一个从“最大”到“最小”或从“正向”到“负向”浮点数的全序排序,包含所有普通数和特殊值(如NaN和正负零),这是实现浮点数全序比较的标准依据。
这段内容是把 IEEE 754 totalOrder 中的顺序划分成若干个 弱序(weak order) 的等价类(partition),使得某些浮点值在弱序下被视为等价。具体划分如下:
- 所有正的NaN等价(无论是静默NaN还是信号NaN,都被视为同一个等价类)
- 正无穷
- 正实数
- 所有零等价(包括+0.0和-0.0,视为等价)
- 负实数
- 负无穷
- 所有负的NaN等价
总结:
通过这种划分,可以在弱序的上下文里,避免NaN和±0的复杂差异,将它们归到等价类,简化比较和排序的逻辑,同时保证排序算法可以使用这个弱序关系。
这段内容包含两部分:
1. 利用常见情况优化 weak_less
函数
示例代码:
bool weak_less(double d, double e) {if (d < e) return true; // 常见情况直接比较if (e >= d) return false; // 排除另一种常见情况// 处理其它特殊情况
}
- 这里的思路是利用浮点数比较中最常见的情况(正常比较)快速返回结果。
- 避免每次都进入复杂判断,提升性能。
- 只有在常规比较不确定时才进行更复杂的处理(例如NaN、±0等特殊情况)。
2. 如何处理邮政编码(Zip+4)排序
- Lexical sort(字典序排序)可以提供一个总序(total order),即每个字符串都有一个唯一的位置。
- 但字典序排序不能解决“同一个地址不同写法”的问题,比如“5位邮政编码”和“9位邮政编码”的关系。
- 5位邮政编码应该和它的所有9位扩展码视为等价(ordered partition),否则不能表示“等价”的分组。
- 只有把所有9位邮政编码和它对应的5位邮编视为同一个等价类,才构成严格弱序(strict weak order),便于排序和分区。
- 这种处理方式在比较完整地址时尤其有用。
总结: - 浮点数比较中,先处理常见情况提升效率。
- 邮政编码排序需要分层处理,确保等价分组和排序逻辑的合理性,满足严格弱序要求。
这段内容讲的是**复合类型(composite types)**的比较方法,重点如下:
如何比较复合类型?
1. Unanimous(全体一致)比较法
- 意思是:所有字段都必须满足某种关系(比如都小于)才能得出结论。
- 产生部分序(partial order)。
- 只有当所有字段都同意时,才能得出“前后”关系。
2. Uncontended(不争议)比较法
- 如果任何字段是部分有序的,这种方法就失败了(不能保证结果)。
- 否则仍然产生部分序。
- 这方法类似于对字段比较结果的“非争议”判断。
3. Lexicographical(字典序)比较法
- 这是最常用的复合类型比较方式。
- 如果任何字段是部分有序,这方法失败(不能保证全部比较正确)。
- 否则结果是和所有字段中最弱的顺序相同。
- 字段的顺序决定比较顺序,先比较第一个字段,如果相等才比较第二个,依此类推。
总结:
- 复合类型比较很大程度上依赖于字段之间的比较关系。
- 如果字段比较是部分序,那么复合类型的比较也只能是部分序,某些方法甚至失败。
- 字典序方法在字段顺序上很重要,会影响最终的排序结果。
标准库(C++标准)可以做什么?
- 保持向后兼容,不破坏现有代码。
- 支持迁移,从直接用操作符比较,转向使用工具(utility)中的比较函数。
- 为所有标准类型提供顺序(order)和等价(equivalence)函数。
- 但模板类型参数使实现变得复杂。
还有什么问题?
- 现有代码常常写成:
但如果用字符串比较函数:if (s < t) { ... } else if (s > t) { ... } else { ... }
int cmp = s.compare(t); if (cmp < 0) { ... } else if (cmp > 0) { ... } else { ... }
- 多层抽象时,这种两两判断的复杂度会是指数级的,比如O((2 * fields)^layers),非常低效。
- 应该支持三值比较器(trinary comparators),用一个函数直接返回“less, equal, greater”三种状态,降低复杂度至O(fields^layers)。
比较器种类及函数
- 有三种枚举类型对应比较等级:
partial_ordering
: { less, unordered, greater }weak_ordering
: { less, equivalent, greater }total_ordering
: { less, equal, greater }
- 提供对应函数接口:
partial_order(const T&, const T&)
weak_order(const T&, const T&)
total_order(const T&, const T&)
如何简化编码?
- 标准库提供:
- partial_order 的显式默认实现(Unanimous方法总是可用)
- weak_order 的显式默认实现(Lexicographical方法,需要弱顺序字段)
- total_order 的显式默认实现(Lexicographical方法,需要全顺序字段)
- 从高级比较(total_order)隐式推导出低级比较(weak_order, partial_order)。
未来发展方向
- 比较器归属工具算法(utility algorithms),由算法层面来调用比较器。
- 开发者编写所有布尔比较函数(bool comparators)。
- 标准库提供标准布尔比较器与三值比较器(trinary comparators),并重载算法以支持它们。
- 操作符属于应用领域(application domain),不强制标准定义操作符必须符合所有比较性质。
- 标准库逐步将算法默认从使用操作符,迁移到使用三值比较器。
相关标准提案
- 参考 C++ 提案 N4367 及其后续文档,致力于引入和推广三值比较和新的比较支持。