JavaScript | a+b:动态类型是灾难之源还是最好的特性?(下)

语言: CN / TW / HK

theme: smartblue highlight: dark


持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情

a+b:动态类型是灾难之源还是最好的特性?(下)

上一篇,我们说到如何将复杂的类型转换缩减到两条简单的规则,以及两种主要类型。这两条简单规则是:

  1. 从值 x 到引用:调用 Object(x) 函数。
  2. 从引用 x 到值:调用 x.valueOf() 方法;或,调用四种值类型的包装类函数,例如Number(x),或者 String(x) 等等。

两种主要类型则是字符串和数字值。

当类型转换系统被缩减成这样之后,有些问题就变得好解释了,但也确实有些问题变得更加难解。例如 graybernhardt 在讲演中提出的灵魂发问,就是:如果将数组跟对象相加,会发生什么?

如果你忘了,那么我们就一起来回顾一下这四个直击你灵魂深处的示例:

```js

[] + {} '[object Object]' {} + [] 0 {} + {} NaN [] + [] '' ```

而这个问题,也就是这两讲的标题中“a + b”这个表达式的由来。也就是说,如何准确地解释“两个操作数相加”,与如何全面理解 JavaScript 的类型系统的转换规则,关系匪浅!

转换过程

一般来说,运算符很容易知道操作数的类型,例如“a - b”中的减号,我们一看就知道意图,是两个数值求差,所以 a 和 b 都应该是数值;又例如“obj.x”中的点号,我们一看也知道,是取对象 obj 的属性名字符串 x。

当需要引擎“推断目的”时,JavaScript 设定推断结果必然是三种基础值(boolean、number 和 string)。由于其中的 boolean 是通过查表来进行的,所以就只剩下了number 和 string 类型需要“自动地、隐式地转换”。

但是在 JavaScript 中,“加号(+)”是一个非常特别的运算符。像上面那样简单的判断,在加号(+)上面就不行,因为它在 JavaScript 中既可能是字符串连结,也可能是数值求和。另外还有一个与此相关的情况,就是object[x]中的x,其实也很难明确地说它是字符串还是数值。因为计算属性(computed property)的名字并不能确定是字符串还是数值;尤其是现在,它还可能是符号类型(symbol)。

由于“加号(+)”不能通过代码字面来判断意图,因此只能在运算过程中实时地检查操作数的类型。并且,这些类型检查都必须是基于“加号(+)运算必然操作两个值数据”这个假设来进行。于是,JavaScript 会先调用ToPrimitive()内部操作来分别得到“a 和 b 两个操作数”可能的原始值类型。

所以,问题就又回到了在上面讲的Value vs. Primitive values这个东西上面。对象到底会转换成什么?这个转换过程是如何决定的呢?

这个过程包括如下的四个步骤。

步骤一

首先,JavaScript 约定:如果x原本就是原始值,那么ToPrimitive(x)这个操作直接就返回x本身。这个很好理解,因为它不需要转换。也就是说(如下代码是不能直接执行的):

```js

1. 如果 x 是非对象,则返回 x

_ToPrimitive(5) 5 ```

步骤二

接下来的约定是:如果x是一个对象,且它有对应的五种PrimitiveValue内部槽之一,那么就直接返回这个内部槽中的原始值。由于这些对象的valueOf()就可以达成这个目的,因此这种情况下也就是直接调用该方法(步骤三)。相当于如下代码:

```js

2. 如果 x 是对象,则尝试得到由 x.valueOf() 返回的原始值

Object(5).valueOf() 5 ``` 但是在处理这个约定的时候,JavaScript 有一项特别的设定,就是对“引擎推断目的”这一行为做一个预设。如果某个运算没有预设目的,而 JavaScript 也不能推断目的,那么 JavaScript 就会强制将这个预设为“number”,并进入“传统的”类型转换逻辑(步骤四)。

所以,简单地说(这是一个非常重要的结论):如果一个运算无法确定类型,那么在类型转换前,它的运算数将被预设为 number

于是,这里会发生两种情况(步骤三、步骤四)。

步骤三

其一,作为原始值处理。

如果是上述的五种包装类的对象实例(它们有五种PrimitiveValue内部槽之一),那么它们的valueOf()方法总是会忽略掉“number”这样的预设,并返回它们内部确定(即内部槽中所保留的)的原始值。

所以,如果我们为符号创建一个它的包装类对象实例,那么也可以在这种情况下解出它的值。例如:

```js

x = Symbol()

obj = Object(x)

obj.valueOf() === x true ```

正是因为对象(如果它是原始值的包装类)中的原始值总是被解出来,所以:

```js

Object(5) + Object(5) 10 ```

这个代码看起来是两个对象“相加”,但是却等效于它们的原始值直接相加。由于“对象属性存取”是一个“有预期”的运算——它的预期是“字符串”,因此会有第二种情况。

步骤四

其二,进入“传统的类型转换逻辑”。

这需要利用到对象的valueOf()和toString()方法:当预期是“number”时,valueOf()方法优先调用;否则就以toString()为优先。并且,重要的是,上面的预期只决定了上述的优先级,而当调用优先方法仍然得不到非对象值时,还会顺序调用另一方法。

这带来了一个结果,即:如果用户代码试图得到“number”类型,但x.valueOf()返回的是一个对象,那么就还会调用x.toString(),并最终得到一个字符串。

到这里,就可以解释前面四种对象与数组相加所带来的特殊效果了。

解题 1:从对象到原始值

在a + b的表达式中,a和b是对象类型时,由于“加号(+)”运算符并不能判别两个操作数的预期类型,因此它们被“优先地”假设为数字值(number)进行类型转换。这样一来:

```js

在预期是'number'时,先调用valueOf()方法,但得到的结果仍然是对象类型;

[typeof ([].valueOf()), typeof ({}.valueOf())] [ 'object', 'object' ]

由于上述的结果是对象类型(而非值),于是再尝试toString()方法来得到字符串

[[].toString(), {}.toString()] [ '', '[object Object]' ] ```

在这里,我们就看到会有一点点差异了。空数组转换出来,是一个空字符串,而对象的转换成字符串时是’[object Object]’。

所以接下来的四种运算变成了下面这个样子:

```js

[] + {}

'' + '[object Object]' '[object Object]'

{} + []

??? 0

{} + {}

??? NaN

[] + []

'' + '' '' ```

好的,你应该已经注意到了,在第二和第三种转换的时候我打了三个问号“???”。因为如果按照上面的转换过程,它们无非是字符串拼接,但结果它们却是两个数字值,分别是 0,还有 NaN。

怎么会这样?

解题 2:“加号(+)”运算的戏分很多

现在看看这两个表达式。

{} + []

{} + {}

你有没有一点熟悉感?嗯,很不幸,它们的左侧是一对大括号,而当它们作为语句执行的时候,会被优先解析成——块语句!并且大括号作为结尾的时候,是可以省略掉语句结束符“分号(;)”的。

所以,你碰到了 JavaScript 语言设计历史中最大的一块铁板!就是所谓“自动分号插入(ASI)”。这个东西的细节我这里就不讲了,但它的结果是什么呢?上面的代码变成下面这个样子:

{}; +[]

{}; +{}

于是后续的结论就比较显而易见了。

由于“+”号同时也是“正值运算符”,并且它很明显可以准确地预期后续操作数是一个数值,所以它并不需要调用ToPrimitive()内部操作来得到原始值,而是直接使用“ToNumber(x)”来尝试将x转换为数字值。而上面也讲到,“将对象转换为数字值,等效于使用它的包装类来转换,也就是 Number(x)”。所以,上述两种运算的结果就变成了下面的样子:

```js

+[] 将等义于

  • Number([]) 0

+{} 将等义于

  • Number({}) NaN ```

结语

今天我们更深入地讲述了类型转换的诸多细节,除了这一讲的简单题解之外,对于“+”号运算也做了一些补充。总地来讲,我们是在讨论 JavaScript 语言所谓“动态类型”的部分,但是动态类型并不仅限于此。也就是说 JavaScript 中并不仅仅是“类型转换”表现出来动态类型的特性。

参考:红宝书,犀牛书,你不知道的 JS、 JS 核心原理解析