「硬核JS」令你迷惑的位運算

語言: CN / TW / HK

寫在前面

今天,我們來學習一下 JS 操作符中的位操作符

在 JS 這門語言的標準裏,描述了一組可以用來操作數據值的操作符,其中包括 數學操作符、位操作符、關係操作符、相等操作符、布爾操作符、條件操作符以及ES7的指數操作符 等等,為什麼叫操作符,因為它們都是符號構成。。。

在這一組操作符中,相比加減乘除、邏輯判斷、相等、布爾等這些我們經常會用到的操作符,位運算操作符好像是極其特殊的一類,由於位操作符不是那麼的直觀導致很多剛入門甚至是老程序員都不太喜歡用它

大多數人都認為在寫程序的過程中使用過多花裏胡哨的位操作符對閲讀體驗是極其不好的,儘管它可能會為性能帶來一些提升,但是相比下來一個項目要選擇閲讀體驗和些許性能的話,大家大概率的會選擇前者

話是這樣説,但是這並不是大家不學習它的理由,假如大家都懂得位運算,那麼這些 騷操作 就變成了常規操作,有的人可能會説就算懂得位運算,想要閲讀一段位操作符組成的的代碼還是需要時間來思考的,並不如通俗點寫來的直觀,嗯,也對,但是加上當量註釋的話也還行

總之,你可以不用,但一定要會,存在即合理,任何説法都不是不學習它的理由

(!(~+[])+{})[--[~+""][+[]]*[~+[]]+~~!+[]]+({}+[])[[~!+[]]*~+[]]
複製代碼

嗯,就以這一段網紅代碼為開頭吧

看此文之前,請一定要先閲讀這篇文章

重要的事情説三遍,上面這篇文章就是給此**準備的,文中講了一些數字相關的東西,二進制轉換、原碼、反碼、補碼以及 JS 中的數字存儲等等,看完上文再看此文會很 easy

也是給大家回顧下一些計算機基礎常識,估計大家久徵沙場這些基礎都忘完了,不然直接啃會有點迷 😂

位運算

來回顧一下,我們都知道,平常我們用來計算的是十進制的數值 0~9 ,但是計算機是個機器,它只能識別二進制

根據國際 IEEE 754 標準,JavaScript 在存儲數字時是始終以雙精度浮點數來存儲的,這種格式用 64 位二進制存儲數值,64 位也就是 64 比特(bit),相當於 8 個字節,其中 0 到 51 存儲數字(片段),52 到 62 存儲指數,63 位存儲符號

而在 JS 位運算中,並不會用 64 位來計算,它會先在後台把值轉換為 32 位數值,再進行位運算操作,位運算計算完成後再將 32 位轉為 64 位存儲,整個過程就像在處理 32 位數值一樣,所以我們瞭解位運算時,只需要關注這 32 位二進制整數就可以,因為 64 位存儲格式是不可見的,但是也正是因為後台這個默認轉換操作,給 JS 這門語言產生了一個副作用,即特殊值 NaN 和 Infinity 在位運算中都會直接被當作 0 來處理

其實不止是 JS ,很多語言的位運算都是如此

有符號&無符號

穿插一個小知識點, ECMAScript 整數有兩種類型,即有符號整數(允許用正數和負數)和無符號整數(只允許用正數)

在 ECMAScript 中,所有整數字面量默認都是有符號整數

有符號整數也就是上文所説,二進制左側首位是符號位來表明該數字正負

而無符號整數就是沒有符號位,沒有了符號位置也就説它表達不了負數,同時因為沒有了符號位置,它的存儲範圍也會比有符號整數存儲範圍大

現在我們我們正式開始説位運算符了,位運算符號一共分 7 個,我們一個個道來

按位非 NOT(~)

簡述

按位非操作符也可以叫按位取反,它使用 ~ 符號表示,作用是對位取反,1 變成 0 ,0 變成 1

看過上文的小夥伴們可能就會發現,這好像是取的反碼?

是的,我們就可以直接理解為按位非就是取其二進制的反碼,只不過,反碼是符號位不變其餘位置取反,而按位非則是取反碼後符號位也取反

例如:

我們以 8 位(bit)數字存儲為例

求十進制數字 2 的按位非,十進制數字 2 的二進制是 0000 0010,那麼它的二進制反碼就是 0111 1101,符號位也取反則變成了 1111 1101,當然你也可以直接將數字 2 的二進制每一位取反,結果都是 1111 1101

我們知道符號位為 1 代表是負數,而計算機中存儲負數是以補碼的方式來存儲的,所以我們對補碼 1111 1101 求原碼再轉成十進制即可,對補碼求原碼就是使用此補碼再求一遍補碼,也就是先取反碼再補 1 ,過程自算,得到了負數的二進制原碼 1000 00 11,即十進制 -3

同上所述

十進制數字 1 的按位非即十進制 -2

十進制數字 0 的按位非即十進制 -1

上面説的都是正數,我們看一個負數的例子

十進制數字 -1 由於是負數,上文我們説過計算機中二進制存儲負數為補碼方式,所以我們要先求 -1 的補碼,-1 二進制原碼是 1000 0001,再求原碼的反碼即 1111 1110 ,接着補 1 即可求補碼即 1111 1111,那麼我們得到了 -1 在二進制中存儲的的最終補碼形態即為 1111 1111 ,最終我們將此二進制每一位都取反得到 0000 0000 ,即十進制數字 0

誒!好像有規律,我們試了幾次之後發現按位非的最終結果始終是對原數值取反並減一,如下

let a = 1
console.log(~a == (-a) - 1) // true

// 得到
~x = (-x) - 1
複製代碼

知道這個之後,我們遇到按位非操作符後可以根據這個規律來算結果,會比轉二進制計算那樣方便些

那麼又有人説了,既然和 (-x) - 1 是一致的,那麼為什麼還要用按位非呢

很簡單,原因有二,第一是位運算的操作是在數值底層表示上完成的,速度快。第二是因為它只用 2 個字符,比較方便。。。

使用按位非 ~ 判斷是否等於-1

按位非在項目中的使用頻率還是蠻高的

相信大家經常看到下面這種寫法

let str = "abcdefg"

if(!~str.indexOf("n")){
	console.log("字符串 str 中不存在字符 n")
}

// 等同於

if(str.indexOf("n") == "-1"){
  console.log("字符串 str 中不存在字符 n")
}
複製代碼

如上所示,我們知道 indexOf 方法在找不到相同值時返回 -1,而 ~-1 == 0 == false ,所以 !~-1 == 1 == true ,一般來説我們使用按位非的寫法來校驗 -1 是用的最多,也是位運算中最容易令大家接受的了,是不是特別簡單方便呢

使用按位非 ~ 取整

按位非的騷操作中,還有一個比較普遍的就是位運算雙非取整了,如下所示

~~3.14 == 3
複製代碼

很多人知道這樣可以取整,但是由於不知道具體是為什麼而不敢用,所以我們來解釋下為什麼它為什麼可以取整

上面我們説過,在 JS 位運算中,並不會用 64 位來計算,它會先在後台把值轉換為 32 位整數,再進行位運算操作,位運算計算完成後再將 32 位轉為 64 位存儲,整個過程就像在處理 32 位數值一樣,所以我們瞭解位運算時,只需要關注這 32 位二進制整數就可以

這裏我們可以看到,32 位 整數,位運算操作的是整數,後台在進行 64 位到 32 位轉換時,會忽略掉小數部分,只關注整數、整數、整數,記住了

~3.14 == ~3
-5.89 == ~5
複製代碼

如上所示,接着我們再按照上面的公式

~x == (-x) - 1

~~x == -((-x) - 1) -1 == -(-x) + 1 -1 == x
複製代碼

所以位運算中的雙非 ~~ 即可取整,此取整是完全忽略小數部分

按位與 AND(&)

簡述

按位與操作符也就是符號 & ,它有兩個操作數,其實就是將兩個操作數的二進制每一位進行對比,兩個操作數相應的位都為 1 時,結果為 1,否則都為 0,如下

例如:

25 & 3 ,即求十進制 25 和 十進制 3 的與操作值

我們分別求出 25 和 3 的二進制進行比對即可

25 = 0000 0000 0000 0000 0000 0000 0001 1001
 3 = 0000 0000 0000 0000 0000 0000 0000 0011
--------------------------------------------
&  = 0000 0000 0000 0000 0000 0000 0000 0001
複製代碼

如上所示,最終我們比對的二進制結果為 0000 ... 0001,即十進制數字 1

使用按位與 & 判斷奇偶數

按位與這個東西平常用的不太多,我一般只會在判斷奇偶數的才會用到,如下:

偶數 & 1 // 0
奇數 & 1 // 1
複製代碼

因為十進制數字 1 的二進制為 0000 ... 0001,只有最後一位為 1,其餘位都是 0 ,所以任何數字和它對比除最後一位其餘都是 0,那麼當這個數字末位為 1 時,也就是奇數,那麼結果就是 1,這個數字末位為 0 時,也就是偶數,那麼結果就是 0,畢竟二進制只有 0 和 1

使用按位與 & 判斷數字是否為2的整數冪

判斷數字是否為 2 的整數冪,使用 n & (n - 1)

let a = 20;
let b = 32;

a & (a - 1) // 16 a不是2的整數冪
b & (b - 1) // 0 	b是2的整數冪
複製代碼

如上所示,套用這個小公式,當結果等於 0 時,數值就是 2 的整數冪

其實原理也很簡單,首先我們來看數值 2 的冪對應的二進制

0000 0001  -> 1  	// 2^0
0000 0010  -> 2		// 2^1
0000 0100  -> 4		// 2^2
0000 1000  -> 8		// 2^3
0001 0000  -> 16	// 2^4
複製代碼

如上,2 的冪在二進制中只有一個 1 後跟一些 0,那麼我們在判斷一個數字是不是 2 的冪時,用 n & (n-1),如果 你是 2 的冪,n 減 1 之後的二進制就是原來的那一位 1 變成 0,後面的 0 全變成 1,這個時候再和自身做按位與對比時,每一位都不同,所以每一位都是 0,即最終結果為 0

剛好適用於 👉 LeetCode 231 題

按位或 OR(|)

簡述

按位或用符號 | 來表示,它也有兩個操作數,按位或也是將兩個操作數二進制的每一位進行對比,只不過按位或是兩邊只要有一個 1 ,結果就是 1,只有兩邊都是 0 ,結果才為 0,如下

例如:

25 | 3 ,即求十進制 25 和 十進制 3 的或操作值

我們分別求出 25 和 3 的二進制進行比對即可

25 = 0000 0000 0000 0000 0000 0000 0001 1001
 3 = 0000 0000 0000 0000 0000 0000 0000 0011
--------------------------------------------
|  = 0000 0000 0000 0000 0000 0000 0001 1011
複製代碼

如上所示,最終我們比對的二進制結果為 0000 ... 0001 1011,即十進制數字 27

使用按位或 | 取整

取整的時候我們也可以使用按位或取整

1.111 | 0 // 1
2.234 | 0 // 2
複製代碼

如上所示,只需要將小數同 0 進行按位或運算即可

原理也簡單,位運算操作的是整數,相當於數值的整數部分和 0 進行按位或運算

0 的二進制全是 0 ,按位或對比時 1 和 0 就是 1 ,0 和 0 就是 0,得出的二進制就是我們要取整數值的整數部分

使用按位或 | 代替Math.round()

我們上面知道按位或可以取整,其實四捨五入也就那麼回事了,即正數加 0.5,負數減 0.5 進行按位或取整即可,道理就是這麼簡單,如下

let a1 = 1.1
let a2 = 1.6
a1 + 0.5 | 0 // 1
a2 + 0.5 | 0 // 2

let b1 = -1.1
let b2 = -1.6
b1 - 0.5 | 0 // -1
b2 - 0.5 | 0 // -2
複製代碼

按位異或 XOR(^)

簡述

按位異或使用字符 ^ 來表示,按位異或和按位或的區別其實就是在比對時,按位異或只在一位是 1 時返回 1,兩位都是 1 或者兩位都是 0 都返回 0,如下

異或的運算過程可以當作把兩個數加起來,然後進位去掉,0 + 0 = 0,1 + 0 = 1,1 + 1 = 0,這樣會好記些

例如:

25 ^ 3 ,即求十進制 25 和 十進制 3 的異或值

我們分別求出 25 和 3 的二進制進行比對即可

25 = 0000 0000 0000 0000 0000 0000 0001 1001
 3 = 0000 0000 0000 0000 0000 0000 0000 0011
--------------------------------------------
|  = 0000 0000 0000 0000 0000 0000 0001 1010
複製代碼

如上所示,最終我們比對的二進制結果為 0000 ... 0001 1010,即十進制數字 26

使用按位異或 ^ 判斷整數部分是否相等

按位異或可以用來判斷兩個整數是否相等,如下

let a = 1
let b = 1
a ^ b // 0

1 ^ 1 // 0
2 ^ 2 // 0
3 ^ 3 // 0
複製代碼

這是因為按位異或只在一位是 1 時返回 1,兩位都是 1 或者兩位都是 0 都返回 0,兩個相同的數字二進制都是一致的,所以都是 0

我們也可以用作判斷兩個小數的整數部分是否相等,如下

2.1 ^ 2.5 // 0
2.2 ^ 2.6 // 0
2.1 ^ 3.1 // 1
複製代碼

這是為什麼?牢記位運算操作的是整數、是整數、是整數,也就是説上面這幾個對比完全可以理解為同下

2 ^ 2 // 0
2 ^ 2 // 0
2 ^ 3 // 1
複製代碼

使用按位異或 ^ 來完成值交換

我們也可以使用按位異或來進行兩個變量的值交換,如下

let a = 1
let b = 2
a ^= b
b ^= a
a ^= b
console.log(a)   // 2
console.log(b)   // 1
複製代碼

道理也很簡單,我們先要了解一個東西

// 如果
a ^ b = c
// 那麼
c ^ b = a
c ^ a = b
複製代碼

現在你再品一下值交換為什麼可以交換,細品

不過這裏使用 ^ 來做值交換不如用 ES6 的解構,因為 ES6 解構更方便易懂

使用按位異或 ^ 切換 0 和 1

切換 0 和 1,即當變量等於 0 時,將它變成 1,當變量等於 1 時,將它變成 0

常用於 toggle 開關狀態切換,做開關狀態更改時,普通小夥伴會如下這樣做

let toggle = 0

if(toggle){
  toggle = 0
}else{
  toggle = 1
}
複製代碼

聰明點的小夥伴會用三目運算符

let toggle = 0

toggle = toggle ? 0 : 1
複製代碼

使用按位異或更簡單

let toggle = 0

toggle ^= 1
複製代碼

原理也簡單, toggle ^= 1 等同於 toggle = toggle ^ 1,我們知道 0 ^ 1 等於 1,而 1 ^ 1 則為 0

使用按位異或 ^ 判斷兩數符號是否相同

我們可以使用 (a ^ b) >= 0 來判斷兩個數字符號是否相同,也就是説同為正或同為負

let a = 1
let b = 2
let c = -2

(a ^ b) >= 0 // true
(a ^ c) >= 0 // false
複製代碼

原理也簡單,正數二進制左首位也就是符號位是 0,而負數則是 1

按位異或在對比時,只有一正一負時才為 1,兩位都是 0 或者都是 1 時結果為 0

所以,兩個數值符號一樣時按位異或對比後的二進制結果符號位肯定是 0,最終數值也就是 >=0,反之則 <0

左移(<<)

簡述

左移用符號 << 來表示,正如它的名字,即將數值的二進制碼按照指定的位數向左移動,符號位不變

**例如: **

2 << 5,即求十進制數 2 左移 5 位的操作

我們先將十進制數字 2 轉二進制再左移 5 位後如下圖

我們得到了一個新的二進制,轉為 10 進制即為數值 64

數字 x 左移 y 位我們其實可以得到一個公式,如下

x << y 

// 等同於

x * 2^y
複製代碼

使用左移 << 取整

使用左移也可取整

1.111 << 0 // 1
2.344 << 0 // 2
複製代碼

原理是位運算操作的是整數,忽略小數部分,等同於數值的整數部分,左移 0 位,結果還是整數部分

有符號右移(>>)

簡述

有符號右移用符號 >> 來表示,即將數值的二進制碼按照指定的位數向右移動,符號位不變,它和左移相反

例如:

64 >> 5,即求十進制數 64 有符號右移 5 位的操作

我們先將十進制數字 64 轉二進制再右移 5 位後如下圖

有符號右移時移動數位後會同樣也會造成空位,空位位於數字的左側,但位於符號位之後,ECMAScript 中用符號位的值填充這些空位

隨後,我們就得到了一個新的二進制,轉為 10 進制即為數值 2,其實就是左移的逆運算

同樣,數字 x 有符號右移 y 位我們也可以得到一個公式,如下

x >> y 

// 等同於

x / 2^y
複製代碼

使用右移 >> 取整

使用右移和左移一樣都可以取整

1.111 >> 0 // 1
2.344 >> 0 // 2
複製代碼

原理還是那一個,位運算操作的是整數,忽略小數部分,等同於數值的整數部分,右移 0 位,結果還是整數部分

無符號右移(>>>)

簡述

無符號右移使用 >>> 表示,和有符號右移區別就是它是三個大於號,它會將數值的所有 32 位字符都右移

對於正數,無符號右移會給空位都補 0 ,不管符號位是什麼,這樣的話正數的有符號右移和無符號右移結果都是一致的

負數就不一樣了,當把一個負數進行無符號右移時也就是説把負數的二進制碼包括符號為全部右移,向右被移出的位被丟棄,左側用0填充,由於符號位變成了 0,所以結果總是非負的

那麼可能就有人要問了,如果一個負數無符號右移 0 位呢,我們可以測試一下

讓十進制 -1 進行無符號右移 0 位

-1 是負數,在內存中二進制存儲是補碼即 1111 .... 1111 1111,32 位都是 1,我們在程序中寫入 -1 >>> 0 運行得到十進制數字 4294967295 ,再使用二進制轉換工具轉為二進制得到的就是 32 位二進制 1111 .... 1111 1111,所以就算無符號右移 0 位,得出的依然是一個很大的正數

使用無符號右移 >>> 取整(正數)

無符號右移和有符號右移以及左移都差不多,移 0 位都可取整,只不過無符號右移只能支持正數的取整,至於原理,説過無數遍了,相信你已經記住了,如下

1.323 >>> 0 // 1
2.324 >>> 0 // 2
複製代碼

最後

其實目前位運算的基礎使用場景並不廣,基本上大多數人用就是上文所説那樣子,取整、判斷奇偶、判斷-1、切換 0/1 等等幾個用途,如果你耐心的看完了文章,就會發現其實原理很簡單,也就那回事,沒有必要整天喊打喊殺,還是那句話,用不用全憑自己,但是不用不是不會的理由

到此就結束了,請不要吝嗇你的贊,文章如有錯誤,請指出,共同進步,也歡迎大家關注公眾號「不正經的前端」