前言
最近回顾 javascript
的一些基础知识点时,引起的思考确实颠覆了我之前的一些认知。我清楚地记得曾多次在网上看到一些奇奇怪怪的表达式,它们的运算结果着实让人懵逼。就比如我在 js 数据类型很简单,却也不简单这一篇笔记中提到的 [] == ![]
这样一个表达式,它的运算结果是true
。如果你不细致地去研究它背后的运算逻辑,你只会惊呼”这是什么鬼“?相反,当你静下心来看清楚它的运算逻辑后,你会感叹“妙哉妙哉”!没错,本文的主角就是这些容易让人小觑的运算符。
加法运算符 +
首先说的是加法运算符 +
,这是一个很容易被人忽视的运算符。我们知道,+
可以用来做数字运算,也可以用作字符串拼接,但是还有一些细节可能是大家不知道的。如果 +
运算符的两个操作数类型不一致,或者说两个操作数既不是字符串也不是数字,那么它的运算规则是什么?
先举几个例子,你可以先思考下这些运算结果分别是什么。
var a = 1 + "1";
var b = 1 + {};
var c = 1 + [];
var d = 1 + true;
var e = {name: '飞白'} + [1, 2];
var f = null + undefined;
var g = true + null;
其实规则很简单,我们只要简单地列举出数据类型的可能性,就几乎得到了完整的答案。
- 如果操作数都是数字,进行数字的加法运算。
- 如果操作数都是字符串,进行字符串的拼接。
- 如果操作数是对象,会转换为原始值(一般是先调用
valueOf()
,日期对象比较特殊,会调用toString()
),得到的原始值不再被强制转换为数字或字符串。在这种约束下,对象转为原始值基本都是字符串(如果你没有重写valuOf()
或者toString()
方法),根据下面的第四点,会执行字符串拼接操作。 - 如果其中一个操作数是字符串,另一个操作数也会被转为字符串,
+
运算符执行字符串拼接操作。 - 如果两个操作数都不是字符串或对象,则会进行算术加法运算(非数字的操作数会被强制转为数字)。
所以,不难得出上面列举的表达式的运算结果。
var a = 1 + "1"; // "11"
var b = 1 + {}; // "1[object Object]"
var c = 1 + []; // "1"
var d = 1 + true; // 2
var e = {name: '飞白'} + [1, 2]; // "[object Object]1,2"
var f = null + undefined; // NaN
var g = true + null; // 1
要记住这些规则并不简单,一个记忆技巧是:+
运算符偏爱字符串拼接操作。
相等运算符 ==
这个运算符的运算规则,在 js 数据类型很简单,却也不简单这篇笔记中已经简单地解释过了。其实只要记住一条规则:对于 ==
运算符,如果两个操作数是 null
或undefined
,运算结果是 true
;否则,不管操作数的类型如何转换,==
运算符最后都是数字的比较。
举几个简单的例子说明下:
null == undefined; // true
[1] == 1; // true
1 == true; // true
1 == "1" // true
new Date(2020, 0, 1, 0, 0, 0) == 1577808000000 // false
比较运算符
大于 >
,大于等于>=
,小于<
,小于等于<=
,用于比较数字的大小或字符在字母表中的排序。要注意的是,在ASCII
中,大写字母排在小写字母前面。
这些比较运算符更偏爱数字的比较,除非两个操作数都是字符串。
对于字符串比较的情况,如果两个字符串的第一个字符是相同的,则会比较第二个字符,以此类推。
这里有一个比较特殊的NaN
,它与任何值做比较都会返回false
。
NaN < 1; // false
NaN > 1; // false
位运算符
位运算符很少用到,但是弄明白它们的运算逻辑是很有必要的。位运算符主要分为与 &
、或|
、非~
、异或^
以及左移 <<
、带符号右移>>
、无符号右移>>>
等。
位运算符都是二进制的运算,并且是基于 32 位整数运算。所以十进制,十六进制的操作数都会先转为 32 位的二进制后再进行运算。这里以 0x1234 & 0x00FF = 0x0034
为例说明下流程:
-
0x123
转为二进制是0000 0000 0000 0000 0001 0010 0011 0100
,0x00FF
转为二进制是0000 0000 0000 0000 0000 0000 0011 0100
。 - 进行按位与操作,结果是
0000 0000 0000 0000 0000 0000 0011 0100
,最后转为十六进制就是0x0034
。
移位运算符
在复习到移位运算符这块时,我不由得提出了一个疑问:“javascript 中为什么没有无符号左移运算符?”要解答这样一个疑问,首先还是要看看左移和右移分别是怎么运算的。
摘取《计算机组成原理教程》书中的一段描述:
计算机中机器数的字长往往是固定的,当机器数左移 n 位或右移 n 位时,必然会使其 n 位低位或 n 位高位出现空位。那么,对空出的空位应该添补 0 还是 1 呢?这与机器数采用有符号数还是无符号数有关。对无符号数的移位称为逻辑移位,对有符号数的移位称为算术移位。
注意 :在 javascript 中,移位运算符只支持移动 0~31 位,如果移动的位数超过了31
位,位数会取模MOD 32
。也就是说:
1 << 32
// 等价于
1 << 0
带符号右移 >>
对于带符号右移(算术右移)运算而言,如果第一个操作数是有符号数,那么它的最高位代表符号位,在移位后的符号位不改变。简单总结就是“低位舍弃,高位补 0,符号位保留”。
var a = -1;
a >> 2; // -1
// 如果用负数的原码形式进行算术右移,高位补 0,符号位置 1
如果你自己写几个右移运算表达式做试验,你就会产生一个疑惑,为什么有的正数在带符号右移后却变成了负数,比如下面这个:
2147483648 >> 31 // -1
这是因为 32
位的最大带符号正整数是 2 31 – 1,即 2147483647
,转换为二进制是0111 1111 1111 1111 1111 1111 1111 1111
。正数的补码与原码相同,2147483648
相当于在此基础上加 1
,就得到补码1000 0000 0000 0000 0000 0000 0000 0000
,而这个补码是一个非常特殊的码,它没有对应的原码和补码,代表32
位能表示的带符号数中最小的负数 2 31 – 1,即 -2147483648
。而2147483648
在32
位带符号正数中是无法表示的,其值已经溢出了。
计算机只理解二进制,与人类所理解的十进制之间永远存在一个精度问题,需要足够的精度才能更加准确地表示十进制,而计算机的位数永远都是有限的,这就是矛盾存在的地方,所以会出现溢出这种现象。
就好比时钟一般,23
时结束了又从 0
时开始。在带符号二进制表示法中,正数和负数首尾相连,形成一个环,在计算机可表示的范围内,溢出的那个数字在某种意义上能在另一个起点找到。
所以,下面的位运算表达式也是等价的:
2147483649 >> 1 // -1073741824
-2147483647 >> 1 // 可以理解为:2147483649 溢出的值为 2,所以在位运算中,等价于第二小的负数 -2147483647
无符号右移 >>>
无符号右移也称为逻辑右移。无符号右移的移位过程中,符号位可能会改变。因此移位后,原来的负数可能变成正数。可以简单记忆为“低位舍弃,高位补 0”。
-1 >>> 2; // 1073741823
// 1000 0000 0000 0000 0000 0000 0000 0001 右移两位变成 0010 0000 0000 0000 0000 0000 0000 0000
// 也就是 2 的 30 次方减去 1,等于 1073741823
左移 <<
翻阅《计算机组成原理教程》可以发现,书中有描述到算术左移和逻辑左移。也就是说,左移也分带符号左移和无符号左移。经测试,javascript
中的左移运算符 <<
一般不会改变符号位,意味着它是算术左移(其实对比 <<
和>>
也能知道,<<
是带符号左移)。
但是左移也要注意溢出的情况,比如:
1 << 31; // -2147483648
那么为什么 javascript
中却没有逻辑左移呢?我找了一些资料,比如 es5
规范和注解,还有一些 javascript
的书籍,都没有找到解释。所以这里也没有一个权威的答案(如果有大佬知道的话,请不吝赐教)。
我个人的想法是,应该是要回到移位运算的本质。
二进制表示的机器数在相对于小数点作 n 位左移或右移时,其实质就是该数乘以或除以 2n(n=1,2, …, n)。
而在左移过程中,如果把符号位都丢了,就失去了乘以 2n
的意义了。所以不只是 javascript
,其他编程语言如java
等也没有逻辑左移运算符。
最后
不得不说,大学课程真的很重要。如果一直都保持对计算机基础课程的关注,相信理解这些编程语言背后的本质会变得轻松很多。