注:这里的观点主要参考自: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给出的运算符汇总表可以看出:如果多个运算符优先级相同,那么他们的结合性必然也相同:
所以当你看到: 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)
如果你对运算符的优先级,结合性足够熟悉,再看到复杂的表达式时,就好像它们已经自动添加好了圆括号一样,读起来就清晰明了了:
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()是转型函数,剩下的两个都是解析函数,它们的语义和设计目的各不相同,只有深入理解才能做到在正确的场合使用正确的工具...