前言
最近,网络上出现了一道很有意思的 JavaScript 小题目:
// 如何声明变量 a 使得下面的断言成立?
console.assert(a === a + 1);
笔者稍微研究了一下,题目的核心在于===
运算符,在ECMA-262规范里这个运算符的执行过程是这样的:
The comparison x === y, where x and y are values, produces true or false. Such a comparison is performed as follows:
- If Type(x) is different from Type(y), return false.
- If Type(x) is Number or BigInt, thena. Return ! Type(x)::equal(x, y).
- Return ! SameValueNonNumeric(x, y).
也就是说,a
和a + 1
首先需要是同一种类型。那这样看来,解题的突破口在于第二点和第三点。这两点的具体含义我们在后文中结合解题思路介绍。
溢出法
溢出法利用的是执行过程的第二点,我们尝试使用 Number 的相等运算解题。
Number.MAX_VALUE
令a
为Number.MAX_VALUE
,可以使得断言成立。至于这种方法背后的原理,笔者认为可以这样阐释:
ECMAScript 的规范文档ECMA-262里说,Number.MAX_VALUE
的值大约为1.7976931348623157e+308
。这里虽然用了“大约(approximately)”一词,但是笔者测试 Node 和 Chrome 的Number.MAX_VALUE
都精确到了这个值。规范里还说,ECMAScript 在计算 Number 类型的值的加法时遵守IEEE 754-2019规范,于是根据后者我们可以知道:因为Number.MAX_VALUE
的值和1
的大小相差太过悬殊,并没有足够的位正确地表示和,因此发生了溢出,即Number.MAX_VALUE === Number.MAX_VALUE + 1
。
这里再提一个有趣的现象:我在 Chrome 上和 Node 上计算Number.MAX_VALUE + 1e+291
的结果均为1.7976931348623157e+308
;而计算Number.MAX_VALUE + 1e+292
的结果为Inifinity
。这其实也可以按照上述 IEEE 的规范解释:后者在计算本身的角度上没有发生溢出,因为小数点最后一位的确需要加一,但此时已经超出 ECMAScript 的规范里的范围了,于是被置为Infinity
。
Number.NEGATIVE_INFINITY 和 Number.POSITIVE_INFINITY
令a
为上述两个值也可以使得断言成立。
根据MDN所说,ECMAScript 的规范里规定了Infinity
值加一还是Infinity
,-Infinity
值加一还是-Infinity
(有正负两个表示“无穷大”的值)。
Getter 法
解法如下所示:
let val = 1;
Object.defineProperty(globalThis, 'a', {
get() {
return val--;
},
});
console.assert(a === a + 1);
这里需要注意,按照ECMA-262中关于===
运算符的操作数运算步骤:
EqualityExpression : EqualityExpression === RelationalExpression
- Let lref be the result of evaluating EqualityExpression.
- Let lval be ? GetValue(lref).
- Let rref be the result of evaluating RelationalExpression.
- Let rval be ? GetValue(rref).
- Return the result of performing Strict Equality Comparison rval === lval.
对于a === a + 1
而言,引擎会计算左边a
表达式的值,即通过我们定义的 getter 得到1
,然后计算右边a + 1
表达式(注意+
的优先级比===
高)的值也得到了1
。这里,反复运行a === a + 1
的结果都是true
。
另外,笔者这里使用了一个特殊的对象globalThis
,详情见规范。简单来说它提供了一个统一的访问不同环境下的全局根对象的方法,比如当上述代码运行在 Node 时,globalThis === global
成立,后者是 Node 运行环境提供的全局根对象;当上述代码运行在浏览器时,globalThis === window
成立,后者是浏览器环境提供的全局根对象。
如果是 a == a + 1
呢
其实本道题目原本是判断a == a + 1
,笔者将它魔改为了===
。如果是判断==
的话,本题的方法还会再多几个,突破口之一就是==
相比===
,多了执行各种类型转换的步骤,具体请见规范。这里笔者给出其中一个方法:
let first = true;
const a = {
[Symbol.toPrimitive]() {
const ret = first ? 'a' : 'a1';
if (first) first = false;
return ret;
},
};
console.assert(a == a + 1);
这里指的强调的是,此处和上面的===
的解答不同,对于toPrimitive
的调用是a + 1
先于a
进行。
因为引擎先对两边表达式求值,那么左边a
表达式的结果就是a
对象,右边的表达式运算按照加法运算的规范需要对a
调用toPrimitive
函数得到字符串'a'
然后与数字1
继续相加得到'a1'
。然后,引擎开始计算a
对象是否==
得到的'a1'
,按照规范中的这一条:
- If Type(x) is Object and Type(y) is either String, Number, BigInt, or Symbol, return the result of the comparison ? ToPrimitive(x) == y.
对a
对象再次调用toPrimitive
函数得到'a1'
字符串,那么'a1' == 'a1'
自然也就成立啦。
以上只是笔者想到的一个方法,网络上还有其他大牛想到的更多更精妙的解法,有兴趣的读者可以自行搜索。
后记
很多人看到a === a + 1
这样的问题的时候,第一感觉可能会是:知道这个有什么用?不过,一旦我们深入研究这道题目的解题思路,我们就会绞尽脑汁四处查找资料,往往能够获得很多收获。通过研究这道题目,笔者也学会了如何阅读 ECMAScript 的文档,同时巩固了对它的各种求值运算的细节的认识,真可谓是“绝知此事要躬行”。
其实,像是a === a + 1
这样的题目,从它的内容看上去可能的确没有什么用,为什么我要知道哪些a
能让它成立呢?谁会需要这样的功能呢?在笔者看来,我们可以将这种题目类比于数学上的哥德巴赫猜想(尽管从高度上看,后者对人类产生的影响或许更加深远),仅仅从内容上看,哥德巴赫猜想可能并没有什么实际上的作用,知道三个质数之和的这种性质难道能让人能够多吃一点饭吗?但是,自它提出以来,无数极富盛名、才华横溢的数学家为之付出了无数心血却都没能彻底解开它的谜底。这就说明人类目前还欠缺了这方面的知识,不足以撼动它作为世界近代三大数学难题之一的地位。一旦人类有朝一日解开了它的谜底,就说明人类的数学知识宝库又一次大大地被丰富了。进而,数学的知识又会辐射到物理学、化学、计算机学等,再到人类生活的方方面面,助力人类在探索未知的宇宙中迈出坚实的一步。
也许 JavaScript 的一个这样的问题远达不到哥德巴赫猜想的高度,但——你懂我意思。我们 JavaScript 世界什么时候也来点哥德巴赫猜想呢?