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