Note. 相等关系的“地狱”给语言设计什么教训

JavaScript 隐式转换与相等三角图 展示了 [] == 0 [] == 0 与 0 == "0" 0 == "0" 为真、 [] == "0" [] == "0" 为假的三角关系. 每一条比较的结果都严格遵循规格,丝毫不带随机性; 让人觉得“地狱”的地方在于,名字叫“相等”,却不能沿用相等最基本的推理. 我们原本希望“𝑎 等于 𝑏、𝑏 等于 𝑐”能让第三次比较省下来, 现在却必须重新跑一遍转换规则. 定义得精确与设计得容易理解是两件事; 形式化能让问题无处藏身,却不会自动把糟糕的约定变成好约定.

隐式转换确实能少写一些代码,例如让输入得到的字符串 "0" "0" 直接和数字 0 0 比较. 然而 == == 的转换方向取决于两边的种类组合,没有先把每个值各自规范化为同一表示再作比较. 三角图里,同一个 [] [] 遇到数字时走到 0 0 ,遇到字符串时却停在 "" "" . 这份便利的代价是:读者不能只看一个值就知道比较会怎样进行, 必须同时记住另一边以及两者触发的规则.

这会变成实际的算法问题. 假如自己写去重函数:按输入顺序扫描,只要新值与某个已保留的值满足 == == ,就把新值丢掉. 输入 [[], 0, "0"] [[], 0, "0"] 时,先保留 [] [] ,丢掉与它“相等”的 0 0 , 再保留与它“不相等”的 "0" "0" ,结果留下两个值; 换成 [0, [], "0"] [0, [], "0"] ,后两个值都与 0 0 “相等”,结果只留一个值. 普通去重也可能因顺序不同保留不同的代表, 但若依据的真是等价关系,不应连分成几类都随顺序改变. 这里讨论的是上面这段自定义的 == == 去重算法(JavaScript 标准库的 Set Set 使用另一套比较规则).

更容易推理的设计,是把“验证输入”“转换表示”和“比较”分开: 在需要数字的边界先检查哪些输入可接受,再显式转成数字, 最后使用不做隐式转换的比较. 仅仅把 == == 换成 Number(a) === Number(b) Number(a) === Number(b) 也不够, 因为 Number("") Number("") 与 Number([]) Number([]) 都是 0 0 ; 如果空输入或数组本来就是错误,仍应直接拒绝,以免转换后掩盖问题. 相等操作也要讲清楚是在比较对象身份、结构内容,还是领域中的某个键, 并且检查算法需要的自反性、对称性与传递性. 不同任务可以有不同规则,但不能只靠“相等”这个名字暗示它们全都成立.

设计新语言时,除了考虑“这个常见例子能不能少写一次转换”, 还要追问“加了这条便利规则以后,原有的推理性质是否还在,和其他规则组合会怎样”. 隐式转换本身未必都要禁止; 需要判断的是转换保留了什么信息,以及它是否会掩盖应当暴露的错误. 对已经部署的语言,直接改掉旧规则又可能破坏依赖它的代码, 因而常常需要显式提供更清楚的操作,并用工具约束旧操作的使用. 少写一次转换的局部便利,可能变成整个语言长期承担的理解与兼容成本.

因此,补上约定结果时必须完整规定转换顺序,并检查这些约定保留了哪些性质、放弃了哪些性质. 这也是 显式错误规则与 Kotlin 的底类型 中显式报错方案与隐式转换方案的真正取舍: 有时拒绝一次操作,比给它一个出乎意料却合法的结果更有帮助.