你真的明白为何 1+"1" 等于“11”吗?

370 阅读5分钟

(原文发于我知乎专栏,故图片有水印)

第一层

写过 JS 人会一定明白,1 + "1" 的执行结果是 "11"

原理是因为当二元操作符 + 号一侧有字符串时,执行的是字符串拼接逻辑。OK,这没问题,算是第一层原理。

第二层

如果追究得再深入一点,数字 1 是如何转成字符串的?我的第一印象是 Number 的原型拥有一个叫 toString 方法,所以此时应当是通过类似 1..toString() 逻辑,上溯找原型方法并执行 Number.prototype.toString,然后输出字符串 "1"。我相信很多 JS 开发者和我的第一印象都是这个思路,毕竟原型链、原型继承等等我们太熟悉了,而且 JS 高程(红宝书)也是这么写的。这总没问题了吧?这算是所谓的“第二层原理”。 以上JS高程第4版截图

也就是说我们预期以下两个表达式是等价的,内部走的是相同的逻辑:

1 + "1" // "11"
1..toString() + "1" // "11"

然而事实真是如此吗?

不,这是错误的理解!

我们可以很方便地证明“第二层原理”是错的。

我们重写 Number 原型上 toString 方法,就能看出,两者执行逻辑并不同。

let a = 1;
Number.prototype.toString = function () {
    return "over write num";
};

console.log(a + "1"); // "11"
console.log(a.toString() + "1"); // "over write num1"

实际上经我尝试,就算把 a -> String -> Object 整个原型链上的所有 toString 方法都重写掉,让它们返回一个奇怪的字符串,1 + "1" 的结果仍然保持是 "11" 不变。也就是说,这个加法过程中并没有调用任何对象或原型对象的 toString 方法。这是非常违反我们直觉的现象。

我一度怀疑是不是新版本的浏览器实现中默认不允许客户代码去覆盖原型内建(built-in)方法了?但是我查出来是技术上可以覆盖的,这一点没变化,只是出于性能考虑一般我们推荐不要覆盖罢了。

实现当中,既然原型和原型方法可以被覆盖,但是为什么我重写的原型 toString 方法没有执行呢?只能莽查文档,ECMA-262 Living Standard tc39.es/ecma262/

第三层

文档翻来覆去,终于把清晰的算法逻辑整理出来了(省略了无关紧要的步骤):

一、首先标准中 6.1.6 Numeric Types 定义了二元加号操作符,名叫 The Addition Operator (+)

二、它的既可以执行字符串拼接,也可以执行数字相加,它的内部执行的是 EvaluateStringOrNumericBinaryExpression

三、EvaluateStringOrNumericBinaryExpression 是一个抽象操作(abstract operation),它先整理左右两个操作数,然后在调用 ApplyStringOrNumericBinaryOperator。

四、ApplyStringOrNumericBinaryOperator 也是一个抽象操作,开始做类型判断,如果操作数中有字符串,则把另一侧的操作数同样转为字符串,然后两个字符串去拼接。请注意,这里它调用的是 ToString

五、ToString 也是抽象操作,如果遇到数字,则调用数字标准类型的 toString 方法。这是重点,我们先埋个伏笔。此处的 Number::toString 方法也是一个抽象操作。

六、查找到 Number::toString,它的算法很长,我们不用去抠细节。总之它会把我们传给它的数字转化为字符串,并返回。外面接到返回值之后,去做字符串的拼接。

以上就是整个 1+"1" 执行逻辑中我们要关注的部分,剔除了针对对象类型的转化逻辑等等(本文不讨论)的内容。总之,除了上面的一、二阶段之外,其余操作全都是抽象操作(abstract operation)。抽象操作的意思是,它是定义在标准内部的算法逻辑和操作步骤,ES 标准的实现者(比如 V8 等 JS 引擎)有义务去在内部实现这些操作,但是仅仅是在内部,不可贸然暴露在外部。这意味着我们作为 JS 程序的开发者,我们写的代码无法“感知”到这些抽象操作,更不可能干预它们。抽象操作是不会直接暴露在外的,它只可能通过标准定义的接口(若有),通过一些中间过程而间接对外暴露。

那么,以上过程和 Number 原型的 toString 有关吗?我在重写 Number.prototype.toString 时是否会干预到上面的这个过程?

答案是显而易见的,不会。ES 标准定义的抽象操作永远不可能也不应该被客户代码(就是我们 JS 开发者写的代码)所更改。我们只能更改环境提供给我们的、且允许我们更改的东西,比如 Number.prototype.toString

讲到这里其实问题的答案和原理已经明确了,如果还没看懂,那么可以再更具体的描绘一下问题的全貌,来帮助理解。

当我们在修改 Number.prototype.toString 时,我们只能修改 Number.prototype.toString (废话),我们不能修改以上一到六个步骤的任何一个步骤。而 Number.prototype.toString 到底和上面的步骤有何关系?为了解释这个问题,可以顺手瞄一眼 Number.prototype.toString 的具体逻辑,并画一个示意图,这样就非常容易理解了。

Number.prototype.toString 定义如下,它是标准暴露在外的原型方法,因此我们可以重写、覆盖这个方法,甚至可以覆盖整个原型。

请注意它内部也是调用了标准内的抽象操作 ToString,然后通过 Number::toString 来获取返回值。

因此,我们的两行代码

1 + "1"; // "11"
1..toString() + "1"; // "11"

实际走的逻辑是大致示意图如下(省略了无关紧要的步骤):

代码 1+"1" 这里完全不涉及 Number.prototype.toString 原型方法,所以所以重写它不会改变 1+"1" 的逻辑。红宝书也是错的。