我们常说的 “js的赋值运算=从右向左运行”, 这个表述正确吗?

3,518 阅读11分钟

注:这里的观点主要参考自:ECMAScript5.1规范(以下简称为 5.1规范),MDN 和 你不知道的js中卷,外加上一些自己的理解。如有错误还望指出。

以下代码均经过测试,测试环境为win10 1903 64位,chrome 76.0.3809.132 (64-bit)

1. 一般情况下

在许多教程或课程中,讲到js的 赋值运算符= 时,常喜欢说“js的赋值运算=从右向左运行”,并加以举例:

比如: a = 3; 赋值运算符= 从右向左运行, 把3赋值给变量a。

或:a = 1 + 2; 赋值运算符= 从右向左运行, 所以先运行1 + 2, 得到3, 然后把3赋值给变量a。

或:a = b = c = 3; (假设a, b, c变量已经声明过了),赋值运算符= 从右向左运行,所以先运行c = 3,然后运行b = 3, 最后运行a = 3。

这些例子,用“js的赋值运算=从右向左运行”来解释,似乎都能解释得通,但事实上:

上面三个例子背后对应的其实是三种规则。

而当前版本的js中究竟有没有“从右向左运行”这种说法,也是存疑的。至少在5.1规范中, 可以找到下面这段话:

ECMAScript 总体上是以从左到右的顺序解释执行,但是第 3 版规范中 > 和 <= 运算符的描述语言导致了局部从右到左的顺序。本规范已经更正了这些运算符,现在完全是从左到右的顺序解释执行

--ECMAScript5.1规范, 附录D

那上例该如何解释呢?

2. 优先级

许多人都知道js的运算符存在优先级,比如 乘法 的优先级高于 加法,所以同一个表达式中,如果既有乘法,又有加法,则优先执行乘法:

1 + 2 * 3 // 7

运算符的优先级决定了表达式中运算执行的先后顺序,优先级高的运算符最先被执行。--MDN

3. 结合性

除了有优先级外,js的运算符还具有结合性:结合性也叫做关联性,分为左结合右结合。

结合性决定了拥有相同优先级的运算符的执行顺序。--MDN

下面这个例子也来自MDN:

考虑这个表达式:a OP b OP c

其中 OP表示某一个运算符。

左结合(左到右)相当于把左边的子表达式加上小括号: (a OP b) OP c

右结合(右到左)则相当于给右边的子表达式加上小括号: a OP (b OP c)

所有运算符的优先级和结合性汇总表, 参见:MDN 运算符优先级

4. 仔细理解一下

看过上面的简单描述之后,我们还需要仔细的理解一下。

首先:只有在多个运算符共享一个操作对象时,优先级,结合性才起作用。

举例: 1 + 2 * 3

* 和 + 共享操作数2, 此时就需要参考优先级规则了。 * 的优先级大于 +,所以先执行 2 * 3, 后执行1 + 6。结果为7。

等效于式子变成了1 + (2 * 3)

其次:只有在优先级相同时,结合性才发挥作用。

举例:3 * 7 % 5

*和%优先级相同,此时就需要参考结合性了,*和%都是左结合,简单来说就是从左往右添加圆括号,等效于式子变成了((3 * 7)% 5)。3 * 7 = 21,相对5取余,结果为1。

如果*和%是右结合,就会从右边开始添加圆括号,式子就会变为(3 * (7 % 5)),结果应该是6。

可能有人会问:如果*和%一个是左结合,一个是右结合呢?该怎么添加圆括号? 左边右边都括起来吗:(3 * (7) % 5)

答案是否定的, 事实上,观察mdn给出的运算符汇总表可以看出:如果多个运算符优先级相同,那么他们的结合性必然也相同:


↑14表示这些运算符的优先级都是14,从左到右表示这些运算符的结合性都是从左到右



所以当你看到: 6 * 5 / 3 % 4, 这样的式子时:式子中涉及到的运算符优先级相同,需要参考结合性。那么这些运算符的结合性必然是相同的,要么都是从左到右,要么都是从右到左。上例都是从左到右,等效于从左往右添加圆括号:(((6 * 5) / 3) % 4), 结果是2。

最后:其实结合性并不改变从左到右的执行顺序

这是最容易产生混淆的地方,如果说一个运算符OP的结合性是从右到左,那么容易让人误认为:a OP b OP c中,会先执行c,再执行b。但事实上,结合性只控制如何进行组合,或者说如何如何添加圆括号,上式等效于:a OP (b OP c), b依旧在c之前执行。

注意:事实上这里的规则更加复杂,但可以暂时这样理解。

所以,回过头来看今天讨论的话题:我们常说的“赋值运算=从右向左运行”,其实更多时候想表达的是:赋值运算符的结合性是从右到左。而前面已经说过了:事实上结合性只控制如何组合,并不改变从左向右的执行顺序.


5. 再次理解开篇的三个例子

开篇给出的三个例子:

a = 3;

a = 1 + 2;

a = b = c = 3; (假设a, b, c变量已经声明过了)

如果不笼统的用 “js的赋值运算=从右向左运行”来解释,该如何解释呢?


对于例1:a = 3;

应该直接用 赋值运算符=本身的算法规则 来解释。

因为此时只出现了一个运算符,没有出现多个运算符共享一个操作对象的现象,所以自然也就不涉及运算符优先级规则,更不涉及左右结合性规则。事实上这里直接用5.1规范中“=运算符的运行规则”来解释会更好一些:

根据5.1规范:简单来说,这里执行的操作就是:求出等号右侧表达式的值(3), 然后把该值放入左侧容器(PutValue(lref, rval))。然后将整个赋值表达式(a = 3)的返回值设为右侧的值(3)。仅此而已。


对于例2:a = 1 + 2;

应该用优先级规则来解释。

这里出现了多个运算符共享一个操作对象的现象,所以需要根据运算符优先级规则来决定执行顺序,因为+的优先级高于=, 所以会先执行1+2得3, 然后再赋值给a。 等效于a = (1+ 2);

这里没有出现相同优先级的多个运算符,所以不涉及结合性规则。


对于例3:a = b = c = 3; (假设a, b, c变量已经声明过了)

应该用结合性规则来解释。

a = b = c = 3; 运算符优先级相同,此时应该参考结合性规则,=的结合性是从右到左,所以上式应该等效于: a = (b = (c = 3))。

剩下的就是=运算符的运行规则在发挥作用了:把3赋给c,c = 3这个赋值表达式的返回值是4, 再把 4 赋值给b,b = 4也返回4,再把4赋值给a...

6. 题外话:从某个角度来看

从某个角度来看:优先级,结合性这套规则,就是一套决定在哪里自动添加圆括号的规则而已

优先级规则:等效于在优先级高的地方自动添加圆括号:1 + 2 * 3; 等效于 1 + (2 * 3);

结合性规则:等效于控制从左向右还是从右向左添加圆括号:6 * 5 / 3 % 4 等效于 (((6 * 5) / 3) % 4)

如果你对运算符的优先级,结合性足够熟悉,再看到复杂的表达式时,就好像它们已经自动添加好了圆括号一样,读起来就清晰明了了:



↑《你不知道的js 中卷》 中的例子

7. 最后还有一个疑问 

其实在《你不知道的js 中卷》和5.1规范中,都简单提到了“js表达式只能从左向右执行,不能从右向左执行”这个规则,但没有找到更详细的说明,这里仅举例:


上例的返回值应该是什么呢?

按理说,按优先级规则, f1() + f2() * f3()应该先执行f2() * f3();

f2()返回a,即10。f3()返回3。10 * 3 = 30。

再加上f1()返回的1, 结果应该是31。

但实际结果是30001。

实际执行顺序是先从左向右执行f1(), f2(), f3(), 然后它们的执行结果(返回值)再按优先级规则或结合性规则进行运算。

下例也是如此:


推测:可能是js会忽略优先级,结合性规则,先从左向右做一遍求值操作。然后求出来的值再按优先级,结合性规则进行运算。

但这仅仅是推测,还没有在规范中找到具体的说明...

不过这也足够警醒我们,“js的赋值运算=从右向左运行”这句话可能是错的,比如下例:


第13行:如果:"js的赋值运算=从右向左运行"是正确的话,那么:

首先应该先计算表达式999, 得到999;

再计算obj[f2()], f2()打印出来2,然后返回i, 此时i=1,所以obj[1] = 999;

再计算obj[f1()], f1()打印出来1,让i=10,然后返回5,所以obj[5] = 999;

最终结果:先打印2,后打印1,obj[1] = 999, obj[5] = 999, obj[10] = undefined;

但实际上:根据我们前文的说法,对于任意表达式,js会忽略优先级,结合性规则,先从左向右做一遍求值操作。然后求出来的值再按优先级,结合性规则进行运算。 所以其实会:

先执行f1(), 打印1,让i=10,然后返回5。

再执行f2(), 打印2, 返回i,即返回10。

最后按优先级和结合性执行一遍表达式:obj[5] = obj[10] = 999,等效于(obj[5] = (obj[10] = 999))

所以最终结果是:先打印1,后打印2,obj[1] = undefined, obj[5] = 999, obj[10] = 999;

从这个角度来看,"js的赋值运算=从右向左运行" 这句话是错误的...

8. 总结

其实“js的赋值运算=从右向左运行”这句常见的表述,是一个十分模糊而笼统的说法,它用简单的概括性语句屏蔽了js底层细碎繁琐的规则。

这其实不是一件坏事,毕竟这条规则足够简单,且以它为指导来写代码,大部分情况下也不会出错。

但如果想对背后真正起作用的规则有所了解,还是需要进行更深入的挖掘和探索的...这些探索往往是有价值的,比如,可以让你更快的找出代码中的bug,或让你在学习别的的编程语言时可以触类旁通(c语言运算符的优先级与结合性规则,就和上文介绍的规则大同小异)


(下面的这些讨论,都可以参见《你不知道的js 中卷》)

此外,还有一些我们常见的表述,也和本篇讨论的话题一样:具有良好的概括性,但说法过于笼统,或暗含某些错误...

比如,我们经常看到这样一句话:“如果+前后的操作数中包含字符串的话, +就进行字符串拼接操作, 否则+就进行加法运算”。 其实这其中也包含了一些模糊和错误的地方,不深入理解的话可能就会对 [1] + 1 // "11" 这样的运算结果感到讶异。

再比如,许多人认为,js中的String(), toString() 和 some+''(一个非字符串加上一个空字符串) 等方法都是字符串转型函数或都用于实现字符串转型效果,但其实它们的规则各不相同。不深入理解的话可能就会对下面的例子感到讶异:



还有很多人认为 js中的Number(), parseInt(), parseFloat()等函数都是数值转型函数,但其实只有Number()是转型函数,剩下的两个都是解析函数,它们的语义和设计目的各不相同,只有深入理解才能做到在正确的场合使用正确的工具...