What's the result of ++[[]][+[]]+[+[]] and why?

最近在Quora上看到有人在问为什么下面的执行结果为true

++[[]][+[]]+[+[]] === '10'

个人觉得这个题目很有意思,跟ECMAScript底层实现原理有关系,值得趁机再次温故而知新,希望能把它给讲个简单明白。

如果你是第一次看到如下的表达式,可能首先会让你觉得一头雾水,无从下手。

++[[]][+[]]+[+[]]

所以,为了简单清楚起见,我们可以先把它分成二部分,然后逐个击破,如下所示:

(++[[]][+[]]) + ([+[]])

(++[[]][+[]]) // Left side

([+[]])      // Right side

+[]

首先,我们先来看看右侧部分+[]。总的来说,它的运算步骤如下:

  1. []赋予expr临时变量
  2. expr上调用GetValue方法,它会返回它所指向的对象,在这里就是expr
  3. 在第二步的执行结果上调用ToNumber($2)
  4. 之后,ToNumber会调用ToPrimitive,并传入Number作为hint参数值,即ToPrimitive(input argument, hint Number)
  5. 紧接着,ToPrimitive会调用expr对象的内部方法[[DefaultValue]]并带上Number作为hint值,即[[DefaultValue]] (hint)
  6. 由于我们传入的hintNumber,那么[[DefaultValue]]首先会先调用expr对象上的valueOf方法,并将当前expr对象作为this
  7. 如果第六步的中结果为原生数据类型,那么就把该值赋予primValue临时变量,否则会继续调用expr对象上的toString方法,并将当前对象作为this
  8. 如果第七步的中结果为原生数据类型,那么就把该值赋予primValue临时变量,否则抛出TypeError异常
  9. 调用ToNumber(primValue)并返回结果

虽然上面步骤很多,但是一目了然,再清楚不过了。

但是,这里仍然需要对ToPrimitive这个核心函数多做一下解释。ToPrimitive主要是用于将任意类型的值转换为对应的原生类型的值。如果ToPrimitive函数的输入已经是原生类型值,那么会原样返回该值,不会做任何类型转换。但是,如果该输入值是非原生数据类型,那么它就会通过调用内部方法[[DefaultValue]]以便找到与该对象相对应的默认值。

其中,[[DefaultValue]]方法是每一个对象都有的一个内部方法。[[DefaultValue]]可以接受一个可选参数hinthint必须是Number或是String

如果可选参数hint没有提供,那么它的默认值即是Number。但是这里有一个例外,如果对象是Date类型的,那么hint的默认值就会是String

如果可选参数hint已经确定好了,那么[[DefaultValue]]会根据hint参数依次选择调用toStringvalueOf,直到找到该对象的原生数据类型值为止,这里就是我们为什么需要hint的原因,因为它决定了调用这二个方法的顺序。如果hintNumber,那么valueOf方法会首先被调用,但是如果它是String,那么toString方法会首先被调用。运行过程如下:

  1. 如果第一个方法存在,且是callable,那么就执行它并返回它的执行结果,否则跳到第三步
  2. 如果第一步的结果是原生数据类型值,那么直接返回它
  3. 如果第二个方法存在,且是callable,那么就执行它并返回它的执行结果,否则跳到第五步
  4. 如果第三步的结果是原生数据类型值,那么直接返回它
  5. 抛出TypeError异常

由上我们可以看出,[[DefaultValue]]的返回结果必定会是原生数据类型,如若不是,则会抛出TypeError异常。

所以,对于+[]来说,首先会调用[].valueOf方法,它的返回仍然是[],由于它不是原生数据类型,所以会接着再调用[].toString,此时返回的是空空字符串'',之后再调用ToNumber(''),即+[]计算的第九步,最后返回运算结果0

那么,到此为止,右侧部分[+[]]的结果就是[0]

++[[]]+[]

从前面对右侧表达式[+[]]的分析,我们已经知道了如下的结论:

[+[]] // [0]

那么,++[[]][+[]]就可以进一步拆解为++[[]][0]

我们再来看看++[[]][0]是如何一步一步求值的:

  1. 由于[]优先级最高,所以会先执行右侧的Property Accessors,即[[]][0]
  2. 第一步执行的返回结果是类型为Reference的空数组,它的base[[]],而它的referenced name0
  3. 在第二步的结果上执行Prefix Increment Operator,即++操作

我们假设[[]][0]的执行结果为v,那么++操作的执行步骤如下:

  1. v赋予临时变量expr
  2. expr上调用GetValue方法,它会返回它所指向的对象,即v
  3. 在第二步执行结果的基础上调用ToNumber($2),并将它赋予oldValue
  4. oldValue的基础上加1,即oldValue + 1,并将结果赋予newValue
  5. 调用PutValue(v, newValue)
  6. 最后返回newValue

在这里,我们使用+操作符来执行oldValue + 1操作,默认它会首先将oldValue转换为原生数据类型再执行+操作。由于oldValue的值为0,本身已经是原生数据类型了,所以此处不会做类型转换操作。

oldValue     // 0
oldValue + 1 // 

++[[]][+[]]+[+[]]

现在我们已经得到了左侧和右侧的值,从而就简化为了如下表达式求值:

1 + [0]

我们先说说+操作符的运算步骤

  1. 先计算左侧值
  2. 再计算右侧值
  3. 在第一步和第二步的结果上调用ToPrimitive,且没有hint
  4. 如果第三步中任何一个结果是String类型,则跳到第七步
  5. 在第三步的结果上分别调用ToNumber方法
  6. 返回第五步中两者结果的和
  7. 在第三步结果的基础上分别调用ToString方法
  8. 拼接第七步结果返回的两个字符串值并返回

从而我们按照+操作符的运算步骤进行计算,由于我们没有传入hint值,所以hint值默认为Number(如果它是Date类型,则为String)。这也就意味着在[0].valueOf方法会先调用,而不是toString方法。由于[0].valueOf方法返回的仍然是[0],它并不是原生数据类型,所以接着调用[0].toString方法,得到结果'0',那么就变成了这样:

1 + '0'

由于0String,则接着会执行字符串拼接操作,即先将+两侧的操作数转换为String,然后再返回两个字符串拼接的值,即10,至此,我们已经知道了++[[]][+[]]+[+[]] === '10'的整个求值过程。

FAQ

为什么++[[]][0]返回1,而++[]会抛ReferenceError

首先,执行++[]时抛出的ReferenceError是在11.4.4 Prefix Increment Operator中的第五步中执行PutValue时抛出的。

执行PutValue(expr, newValue)时第一步就会去检测参数expr是否是Reference类型,如果不是,则会抛出ReferenceError

知道了这点就可以很好解释了,因为从++[[]][0]执行步骤我们可以知道,[[]][0]其实是一种MemberExpression [ Expression ] ,它返回的是一个Reference类型,而[]不是,则抛异常。

参考

  1. What’s a valid left-hand-side expression in JavaScript grammar?

Related Posts

Xin(Khalil) Zhang 24 August 2015
blog comments powered by Disqus