[附注] 相等关系的“地狱”给语言设计什么教训

[例] 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 ; Set Set 使用的是另一套比较规则。

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

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

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

References

[例] JavaScript 隐式转换与相等三角图 [javascript-coercion-and-equality-triangle]

[附注] 显式错误规则与 Kotlin 的底类型 [explicit-errors-and-kotlin-bottom-type]

Backlinks

Based on Typsite