千万别小看这些运算符背后的逻辑

1,867 阅读9分钟

前言

最近回顾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;

其实规则很简单,我们只要简单地列举出数据类型的可能性,就几乎得到了完整的答案。

  1. 如果操作数都是数字,进行数字的加法运算。
  2. 如果操作数都是字符串,进行字符串的拼接。
  3. 如果操作数是对象,会转换为原始值(一般是先调用valueOf(),日期对象比较特殊,会调用toString()),得到的原始值不再被强制转换为数字或字符串。在这种约束下,对象转为原始值基本都是字符串(如果你没有重写valuOf()或者toString()方法),根据下面的第四点,会执行字符串拼接操作。
  4. 如果其中一个操作数是字符串,另一个操作数也会被转为字符串,+运算符执行字符串拼接操作。
  5. 如果两个操作数都不是字符串或对象,则会进行算术加法运算(非数字的操作数会被强制转为数字)。

所以,不难得出上面列举的表达式的运算结果。

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数据类型很简单,却也不简单这篇笔记中已经简单地解释过了。其实只要记住一条规则:对于==运算符,如果两个操作数是nullundefined,运算结果是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为例说明下流程:

  1. 0x123转为二进制是0000 0000 0000 0000 0001 0010 0011 01000x00FF转为二进制是0000 0000 0000 0000 0000 0000 0011 0100
  2. 进行按位与操作,结果是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

带符号右移>>

对于带符号右移(算术右移)运算而言,第一个操作数是有符号数,它的最高位代表符号位,在移位后的符号位不改变。简单总结就是“低位舍弃,高位补符号位”。

var a = -1;
a >> 2; // -1
// 用负数的补码形式进行算术右移,高位补1

如果你自己写几个右移运算表达式做试验,你就会产生一个疑惑,为什么有的正数在带符号右移后却变成了负数,比如下面这个:

2147483648 >> 31 // -1

这是因为32位的最大带符号正整数是231 - 1,即2147483647,转换为二进制是0111 1111 1111 1111 1111 1111 1111 1111。正数的补码与原码相同,2147483648相当于在此基础上加1,就得到补码1000 0000 0000 0000 0000 0000 0000 0000,而这个补码是一个非常特殊的码,它没有对应的原码和补码,代表32位能表示的带符号数中最小的负数231 - 1,即-2147483648。而214748364832位带符号正数中是无法表示的,其值已经溢出了。

二进制真值表参考

计算机只理解二进制,与人类所理解的十进制之间永远存在一个精度问题,需要足够的精度才能更加准确地表示十进制,而计算机的位数永远都是有限的,这就是矛盾存在的地方,所以会出现溢出这种现象。

就好比时钟一般,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等也没有逻辑左移运算符。

最后

不得不说,大学课程真的很重要。如果一直都保持对计算机基础课程的关注,相信理解这些编程语言背后的本质会变得轻松很多。

欢迎交流