###### tags: `Python`
# Python 的位元運算
如果你嘗試過 Python 的[位元運算](https://docs.python.org/3/library/stdtypes.html?highlight=bit_length#bitwise-operations-on-integer-types), 可能會感到有點困惑, 比如說:
```python
>>> bin(4)
'0b100'
>>> ~4
-5
>>>
```
咦, 4 的 2 進位表示是 0b100, 那 `~` (not) 運算不就應該是 0b011, 是 3 嗎?既然結果怪怪的, 那我們看一下 ~5 的 2 進位表示好了:
```python
>>> bin(~5)
'-0b101'
>>>
```
不看還好, 一看更怪了, 這個表示法怎麼跟我想像的不一樣?
## Python 的整數沒有止盡
其實是這樣的, Python 的整數沒有限制, 端看你電腦的記憶體有多大, 因此雖然 Python 的整數是[採用 2 的補數](https://docs.python.org/3/reference/datamodel.html#index-10)來表示, 但是要想像左邊有無限多個正負號位元, 以剛剛的 4 為例, 你要想像它是:
```
0b0000000...0000100
```
不過實際上要進行位元運算, 我們只要[在左側多擺一個正負號位元](https://docs.python.org/3/library/stdtypes.html?highlight=bit_length#bitwise-operations-on-integer-types)即可, 所以 4 要表示成:
```
補上正負號位元
|
V
0b0 100
```
把它當成是一個 4 位元的有號整數, 再進行 `~` 運算, 就會是:
```
這是正負號位元
|
v
0b1 011
```
由於是以 [2 的補數](https://zh.wikipedia.org/wiki/%E4%BA%8C%E8%A3%9C%E6%95%B8)表示, 而且正負號位元是 1, 表示是負數, 所以只要減 1 後再做一次反向運算, 就可以得到該數的絕對值了:
```
這是正負號位元
|
v
0b1 011 以 2 的補數表示的負數
- 1 減 1
--------
~0b1 010 反向
--------
0b0 101 得到 5, 所以原負數是 -5
```
這個負數的絕對值是 5, 所以原來的負數就是 -5, 因此得到 ~4 的結果是 -5 沒錯。
我們可以進一步測試:
```python
>>> 4 & ~4
0
>>> 4 | ~4
-1
>>>
```
這是因為:
```
幫 4 (0b100) 補上的正負號位元
|
______|______
| |
v v
0b0 100 0b0 100 4
& 0b1 011 | 0b1 011 ~4 (-5)
--------- ---------
0b0 000 0b1 111 以 2 的補數表示的負數
- 1 減 1
---------
~ 0b1 110 反向
---------
0b0 001 原負數是 -1
```
## 用 2 的補數表示負的整數
還記得剛剛使用 [`bin()`](https://docs.python.org/3/library/functions.html#bin) 內建函式顯示 2 進位結果時, 負數的奇怪表示法嗎?
```python
>>> bin(-5)
'-0b101'
>>>
```
它並不是顯示實際上以 2 的補數表示的結果, 而是以 Python 中書寫 2 進位整數的格式顯示, 也就是開頭加上正負號, 0b 之後是以 2 進位表示該整數的絕對值, 例如:
```python
>>> -0b101
-5
>>>
```
使用格式化字串的 'b' 轉換規格也可以得到一樣的結果:
```python
>>> "{:b}".format(-5)
'-101'
>>> "{:#b}".format(-5)
'-0b101'
>>>
```
可是若是要進行位元運算, 我們其實比較想知道實際上以 2 的補數表示的結果。要做到這件事, 我們可以利用剛剛所學, 只要使用一個和要顯示的負數佔相同位元數, 但所有位元都是 1 的正整數和該負數先進行 `&` 運算, 由於會在左側補上正負號位元, `&` 運算後就可以得到一個保留原來負數個別位元的正整數, 再用 `bin()` 即可顯示實際以 2 進位表示的結果了。以 -5 為例, 剛剛描述的過程就是:
1. 取得 -5 佔用的位元數:
```
-5 -> 0b1011 佔 4 個位元
```
2. 使用 4 個位元但所有位元都是 1 的正整數, 也就是 0b1111 和 -5 進行 & 運算, 這會在 0b1111 左側多加一個正負號位元並填上 0 變成 5 個位元的正整數;而 -5 因為是負數, 原來的正負號位元是 1, 所以同一位置多加的正負號位元也要填 1 才會維持是負數:
```
多加一個正負號位元
|
V
0b0 1111 相同位元數但全是 1 的正整數
& 0b1 1011 -5 (左側捕的正負號位元要填 1)
----------
0b0 1011 變成 +11
```
3. 再用 `bin()` 顯示 2 進位表示:
```python
>>> 0b1111 & -5
11
>>> bin(0b1111 & -5)
'0b1011'
>>>
```
注意 `bin()` 顯示的是 Python 書寫 2 進位整數的格式, 所以不會顯示左側代表正負號位元的 0。
## 取得整數佔用的位元數
如果想要將上述過程寫成一個函式, 就必須先知道佔用的位元數, 還好整數物件就有 [`bit_length()`](https://docs.python.org/3/library/stdtypes.html?highlight=bit_length#int.bit_length) 可以提供這項資訊, 例如:
```python
>>> (4).bit_length()
3
>>> (-5).bit_length()
3
>>>
```
要注意的是, 它提供的是整數絕對值佔用的位元數, 不含正負號位元, 所以實際佔用的位元數要多加 1 位。因此, 若是要得到相同位元數, 但每一個位元都是 1 的正整數, 可以這樣計算:
```python
>>> (1 << ((4).bit_length() + 1)) - 1
15
>>> bin((1 << ((4).bit_length() + 1)) - 1)
'0b1111'
>>> (1 << ((-5).bit_length() + 1)) - 1
15
>>> bin((1 << ((-5).bit_length() + 1)) - 1)
'0b1111'
```
## 撰寫以 2 的補數表示整數的函式
有了以上的基礎, 可以 2 的補數正確顯示整數的函式就可以寫成:
```python
>>> def bin2(x):
... bits = x.bit_length() + 1 # 計算位元數
... n = (1 << bits) - 1 # 相同位元數全為 1 的數
... x2 = n & x # & 運算
... if x < 0:
... return f"{x2:#{bits+2}b}" # 2 進位表示
... else:
... return f"{x2:#0{bits+2}b}" # 正數補上正負號位元
>>>
>>> bin2(5)
'0b0101'
>>> bin2(-5)
'0b1011'
>>> bin2(-9)
'0b10111'
>>>
```
我們特別改用格式化字串為正數也加上正負號位元, 以便能清楚區別正負數。我們用 `~(-9)` 來驗證結果到底正不正確:
```
正負號位元
|
v
~0b1 0111
---------
0b0 1000 -> 8
```
實際用程式試看看:
```python
>>> bin2(~(-9))
'0b01000'
>>> ~(-9)
8
>>>
```
表示 `bin2()` 函式的結果是正確的。
## 小結
很多時候我們不會注意到小細節, 但是一旦遇到奇怪的結果時, 往往會被弄得暈頭轉向, 希望本文能夠讓大家更清楚 Python 的整數以及位元運算, 同時我們也設計了一個簡單的函式, 可以顯示負數實際的 2 進位表示內容, 希望可以對大家有幫助。