Note.
相等关系的“地狱”给语言设计什么教训
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 的底类型 中显式报错方案与隐式转换方案的真正取舍: 有时拒绝一次操作,比给它一个出乎意料却合法的结果更有帮助.