多字段排序的风险不在语法,而在比较关系是否自洽。本文以 JavaScript 任务列表为例,从代码审查视角检查自反性、对称方向与传递性,给出稳定决胜键和属性测试。读完可以把“偶尔顺序漂移”改造成可复现、可定位的比较器缺陷。
一次排行榜改版后,同一批数据在浏览器刷新时偶尔换位。开发者第一反应是怀疑排序不稳定,可日志显示输入数组完全一致。真正的问题藏在一行比较器里:分数相同时返回 -1,也就是同时声称 a 应排在 b 前面、b 也应排在 a 前面。
审查对象
需求很普通:任务按优先级降序,优先级相同时按预计耗时升序,仍相同时按唯一编号升序。一个常见坏实现如下:
function badCompare(a, b) {
if (a.priority >= b.priority) return -1;
return 1;
}
当 a 和 b 优先级相等时,badCompare(a,b) 与 badCompare(b,a) 都小于零;当把同一对象与自身比较时,结果也不是零。排序算法可以假设比较器满足严格弱序,一旦契约被破坏,具体结果就取决于实现细节、输入初始排列和优化路径。
第一轮:把业务规则写成决胜链
可靠写法不是堆叠含糊的 >=,而是逐字段比较,每一层只在真正不相等时返回。最后使用唯一编号作为稳定决胜键,使业务结果不依赖排序实现是否稳定。
"use strict";
function compareTask(a, b) {
if (a.priority !== b.priority) return b.priority - a.priority;
if (a.duration !== b.duration) return a.duration - b.duration;
return a.id.localeCompare(b.id);
}
function sign(value) {
return value === 0 ? 0 : value < 0 ? -1 : 1;
}
function auditComparator(items, compare) {
for (const a of items) {
if (compare(a, a) !== 0) throw new Error(`自比较失败: ${
a.id}`);
}
for (const a of items) {
for (const b of items) {
if (sign(compare(a, b)) !== -sign(compare(b, a))) {
throw new Error(`方向不对称: ${
a.id}, ${
b.id}`);
}
}
}
for (const a of items) {
for (const b of items) {
for (const c of items) {
if (compare(a, b) <= 0 && compare(b, c) <= 0
&& compare(a, c) > 0) {
throw new Error(`传递性失败: ${
a.id}, ${
b.id}, ${
c.id}`);
}
}
}
}
}
const tasks = [
{
id: "T3", priority: 2, duration: 8 },
{
id: "T1", priority: 3, duration: 5 },
{
id: "T2", priority: 3, duration: 2 },
{
id: "T4", priority: 3, duration: 2 },
];
auditComparator(tasks, compareTask);
const sorted = [...tasks].sort(compareTask);
const ids = sorted.map(task => task.id);
console.log(ids.join(","));
if (ids.join(",") !== "T2,T4,T1,T3") throw new Error("排序结果错误");
console.log("comparator tests passed");
程序不仅断言最终顺序,还枚举小样本中的一元、二元和三元关系。这样测试失败时,我们知道比较器违反了哪条性质,而不是只看到一个难以解释的数组差异。
第二轮:三条性质分别保护什么
自比较返回零,表示对象与自身等价。方向对称要求 a<b 时 b>a,这里说的是符号相反,不要求绝对值相同。传递性要求 a 不晚于 b 且 b 不晚于 c 时,a 不能排到 c 后面。它们共同保证排序关系没有环。
“优先级降序”很容易写成减法,但数值可能超过安全整数范围时,直接相减也有溢出或精度风险。更通用的写法是显式返回 -1/0/1。本文示例的数据范围受控,因此减法清楚且安全;边界说明必须与实现前提一起出现。
第三轮:稳定排序不是万能补丁
现代 JavaScript 规范要求 Array.prototype.sort 稳定:比较器返回零的元素保持输入相对次序。但稳定性只处理“比较器认为相等”的对象,无法修复矛盾关系。若页面输入来自无序集合或并行请求,即使稳定排序也会稳定地保留一个不稳定输入。增加唯一编号决胜键,才使结果与输入到达顺序解耦。
另一个审查点是不要在比较器内读取当前时间、随机数或可变全局配置。排序过程会多次比较同一对元素,动态返回值会让关系在一次排序中发生变化。应先把计算结果冻结到待排对象,再执行纯比较。
复杂度与测试成本
对 n 个任务排序通常耗时 O(n log n),复制数组需要 O(n) 空间,具体排序器还可能使用额外工作区。本文属性审计包含三重循环,时间复杂度是 O(n^3),只适合小型代表样本或随机抽样,不应直接对百万条生产数据运行。测试阶段可以生成几十个边界对象反复审计,线上只保留排序。
边界条件
- 空数组和单元素数组应自然通过。
- 完全相同的业务字段必须由唯一键决胜;若业务允许真正相等,则返回零并接受输入相对顺序。
NaN会让所有数值比较变得反常,进入排序前应校验或归一化。localeCompare受区域规则影响;机器间要求字节级一致时,可使用限定字符集和普通字符串比较。- 字段可能缺失时先定义缺失值排前还是排后,不要依赖
undefined的隐式转换。
常见审查意见
“把 >= 改成 > 就好了”只修复一部分自比较问题,没有补齐第二、第三决胜字段。“加一个随机数打散同分项”会直接破坏传递性。“多跑几次快照测试”只能碰运气捕获输入排列。更有效的审查意见应指向比较器契约,并要求一个能构造反例的属性测试。
可复制测试
将代码保存为 comparator.js 后执行 node comparator.js,预期先输出 T2,T4,T1,T3,再输出 comparator tests passed。把 compareTask 替换成前面的坏实现,审计会在自比较阶段立即报错。还可以新增两个同优先级、同耗时但编号不同的任务,验证编号决胜顺序。
从手写样例到反例生成
四个固定对象可以验证已知分支,却未必覆盖字段组合。更强的测试会从一个很小的离散域生成对象,例如优先级取 0、1、2,耗时取 0、1,编号取三个短字符串,然后对生成集合运行同一套性质审计。状态数仍然可控,却能自动拼出相等字段、单字段差异和多字段冲突。
发现失败后,测试框架应继续缩小反例:先删除与失败无关的对象,再把数值向零收缩,最终给出最短三元组。与“某次一万条随机数据排序不同”相比,三个对象形成的传递性环更容易进入代码评审,也更容易变成永久回归用例。属性测试的价值不是随机本身,而是把数学契约变成可搜索的错误空间。
空值顺序必须成为产品规则
业务数据经常出现缺失预计耗时。若比较器直接执行 a.duration-b.duration,结果会变成 NaN,排序器得到的含义接近“这两个对象相等”,后续字段可能永远没有机会决胜。正确做法是先确定规则,例如缺失值统一排在末尾,再比较有效数值,最后比较编号。
这里没有全行业通用答案。报表可能希望未知值在末尾,告警列表反而希望未知值置顶以便处理。关键是规则必须写进比较器、测试名称和接口说明,不能依赖数据库、前端框架各自的默认空值顺序。跨层排序时还要用同一组夹具核对 SQL 与客户端结果。
文本比较并非简单的字符大小
编号使用 localeCompare 只是示例。若字段是用户名称,“文件2”和“文件10”是否按自然数字排序,大小写和重音是否等价,简繁体是否折叠,都会影响关系。区域配置变化甚至可能让服务器和浏览器顺序不同。需要全局一致时,应固定区域、敏感度和数字选项,或者预先生成规范化排序键。
规范化也不能在比较器里反复执行。排序会调用比较器很多次,若每次都做 Unicode 归一化和分词,成本会被放大到 O(n log n) 次昂贵处理。更合理的是在排序前装饰对象,保存排序键;排序后再取回原对象,这就是常见的 decorate-sort-undecorate 思路。
审查排序链的变更风险
增加一个决胜字段看似兼容,实际可能改变分页边界。若后端使用游标分页,游标必须包含所有排序字段,否则同分对象可能跨页重复或丢失。数据库索引也要与新顺序匹配,前端本地二次排序则可能破坏后端游标语义。代码审查不能只盯比较器函数,还要追到分页、缓存键和索引。
上线前可以保存一组包含同分、空值和极端数值的固定数据,分别在目标浏览器、Node 版本和数据库查询中运行。比较完整编号序列,而不是只看前几名。这样既验证算法契约,也验证不同执行环境对字符串和数值的处理一致。
审查结论
比较器不是一个“能返回正负数”的回调,而是一套全局关系。先把字段优先级写成确定的决胜链,再用性质测试寻找环和矛盾,排序漂移就从偶发界面问题变成了可验证的算法契约。