Pythonによる2進数変換とビット演算:2の補数表現・オーバーフロー・シフト演算の仕組み

Pythonでの2進数変換とビット演算(OR・XOR・NOT・シフト)の方法を解説。bin()関数や演算子の使い方に加え、2の補数表現の仕組み、Pythonの多倍長整数がC言語と異なりオーバーフローしない理由、算術右シフトと論理右シフトの違いを実行結果付きで説明します。

Pythonには、整数と2進数文字列の変換や、ビットごとの論理演算を行うための組み込み関数や演算子が用意されています。ここでは、それらの基本的な使い方とカスタム関数での実装例に加えて、負の数がビットレベルでどう表現されているか(2の補数表現)、オーバーフローの有無、シフト演算の符号の扱いといった、C言語などの固定長整数に慣れた人がPythonでつまずきやすいポイントを、実際に実行したコードの出力とともに掘り下げます。

2進数変換

Pythonの組み込み関数 bin() を使うと、整数を “0b” プレフィックス付きの2進数文字列に変換できます。

num = 10
binary_str = bin(num)
print(f"整数 {num} の2進数表現: {binary_str}") # 出力: 整数 10 の2進数表現: 0b1010

カスタム関数での2進数表現の準備

ビットごとの論理演算を行うために、2つの数値を同じ長さの2進数リストに変換し、短い方に0をパディングする関数を実装します。

def prepare_binary_lists(num1, num2):
    # bin()で2進数文字列に変換し、'0b'プレフィックスを除去
    bin_str1 = bin(num1)[2:]
    bin_str2 = bin(num2)[2:]

    # 長さを揃えるために短い方に0をパディング
    max_len = max(len(bin_str1), len(bin_str2))
    bin_list1 = [int(bit) for bit in bin_str1.zfill(max_len)]
    bin_list2 = [int(bit) for bit in bin_str2.zfill(max_len)]

    return bin_list1, bin_list2

# 例
b1, b2 = prepare_binary_lists(3, 5)
print(f"3の2進数リスト: {b1}") # 出力: 3の2進数リスト: [0, 1, 1]
print(f"5の2進数リスト: {b2}") # 出力: 5の2進数リスト: [1, 0, 1]

10進数変換

2進数リストを10進数に変換する関数です。

def binary_list_to_decimal(binary_list):
    decimal_num = 0
    power = 0
    # 2進数リストを右から左へ処理
    for bit in reversed(binary_list):
        if bit == 1:
            decimal_num += (2 ** power)
        power += 1
    return decimal_num

# 例
print(f"[1, 0, 1] を10進数に変換: {binary_list_to_decimal([1, 0, 1])}") # 出力: [1, 0, 1] を10進数に変換: 5

ビットごとのOR演算

PythonにはビットごとのOR演算子 | が組み込まれています。

result_or = 3 | 5
print(f"3 (0b011) OR 5 (0b101) = {result_or} (0b{bin(result_or)[2:]})") # 出力: 3 (0b011) OR 5 (0b101) = 7 (0b111)

カスタム関数でのOR演算

def custom_or(num1, num2):
    bin_list1, bin_list2 = prepare_binary_lists(num1, num2)
    or_result_list = []
    for i in range(len(bin_list1)):
        if bin_list1[i] == 1 or bin_list2[i] == 1:
            or_result_list.append(1)
        else:
            or_result_list.append(0)
    return binary_list_to_decimal(or_result_list)

# 例
print(f"カスタムOR関数: 3 OR 5 = {custom_or(3, 5)}") # 出力: カスタムOR関数: 3 OR 5 = 7

ビットごとのXOR演算

PythonにはビットごとのXOR演算子 ^ が組み込まれています。

result_xor = 3 ^ 5
print(f"3 (0b011) XOR 5 (0b101) = {result_xor} (0b{bin(result_xor)[2:]})") # 出力: 3 (0b011) XOR 5 (0b101) = 6 (0b110)

カスタム関数でのXOR演算

def custom_xor(num1, num2):
    bin_list1, bin_list2 = prepare_binary_lists(num1, num2)
    xor_result_list = []
    for i in range(len(bin_list1)):
        if bin_list1[i] != bin_list2[i]: # ビットが異なる場合
            xor_result_list.append(1)
        else:
            xor_result_list.append(0)
    return binary_list_to_decimal(xor_result_list)

# 例
print(f"カスタムXOR関数: 3 XOR 5 = {custom_xor(3, 5)}") # 出力: カスタムXOR関数: 3 XOR 5 = 6

負の数はどう表現されるか:2の補数表現

ここまでは正の整数だけを扱ってきました。しかし bin() に負の数を渡すと、C言語などとは違う結果が返ってきます。

print(bin(5))   # 出力: 0b101
print(bin(-5))  # 出力: -0b101
print(bin(0))   # 出力: 0b0

bin(-5) の結果は -0b101 ── つまり「マイナス符号 + 5の2進数表記」という文字列です。C言語で int 型の -5 を出力すると、32ビットの2の補数表現である 11111111111111111111111111111011 に相当するビットパターンが得られるのに対し、Pythonは符号付きのビット列を返しているわけではありません。これは、Pythonの整数が**多倍長整数(arbitrary-precision integer)**であり、C言語の intlong のような固定ビット幅を持たないことに起因します。「符号 + 絶対値の2進表記」という素直な表現をそのまま文字列にしているだけで、2の補数のビットパターンではない点に注意が必要です。

とはいえ、&|^~<<>> といったビット演算子は、Pythonの負の整数に対してもあたかも無限桁の2の補数表現であるかのように振る舞います(後述)。「表示は符号付き絶対値、演算は2の補数」という二重構造がPythonの負整数の実体です。

なぜ2の補数表現が使われるのか

多くのCPUや、C言語のような固定長整数を持つ言語では、負の数を2の補数表現でビット列にエンコードします。理由は、これにより加算・減算を正負問わず同じ回路で処理できるからです。

負の数の表現方法には他にも次のような選択肢がありますが、いずれも2の補数より不便です。

  • 符号絶対値表現(sign-magnitude): 最上位ビットを符号、残りを絶対値とする方式。人間には直感的ですが、+0-0 の2通りのゼロが生まれ、加算回路も符号ごとに場合分けが必要になります。
  • 1の補数表現(one’s complement): 全ビットを反転するだけで負数を作る方式。こちらも +000000000)と -011111111)が両方存在してしまい、桁上げの扱いも煩雑です。
  • 2の補数表現(two’s complement): 「全ビット反転して1を足す」ことで負数を作る方式。ゼロは 00000000 の1通りだけで、加算器は符号を意識せず単純なビット加算だけで正しい結果を返します(減算 a - ba + (-b) として同じ加算回路で計算できます)。

2の補数はどう導出されるか

\(n\) ビット幅で数 \(x\) の2の補数表現 \(-x\) を作る手順は次の通りです。

  1. \(x\) のビット列をすべて反転する(ビット反転はNOT演算、~x に相当)
  2. 反転した値に1を加える

これは数式で書くと \(-x = (\lnot x) + 1\) に対応します。さらに、\(n\) ビットでは全ビット反転が \(\lnot x = (2^n - 1) - x\) を意味するため、

\[ -x \equiv (2^n - 1 - x) + 1 = 2^n - x \pmod{2^n} \]

という関係になります。つまり2の補数表現とは「\(2^n\) を法とした剰余表現」であり、最上位ビット(符号ビット)だけ重みが \(-2^{n-1}\) (正ではなく負)になっている点が通常の2進数と異なります。8ビットの場合、最上位ビットの重みは \(-2^7 = -128\) 、残り7ビットは通常の2進数と同じ \(+64, +32, \dots, +1\) の重みを持ちます。

次の図は、8ビット2の補数表現で \(-19\) を表す 0b11101101 の例です。符号ビット(赤)が \(-128\) の重みを持ち、残り7ビット(青)が通常通りの重みを持つことで、全体の合計が \(-19\) になる様子を示しています。

8ビット2の補数表現の例。符号ビット(赤、bit 7)の重みが-128、残り7ビット(青)は通常の2進数と同じ重みを持ち、合計すると-19になる

実際にPythonで8ビット2の補数を再現してみましょう。& 0xFF で下位8ビットにマスクし、最上位ビット(0x80)が立っていれば \(2^8 = 256\) を引くことで、8ビット符号付き整数の値を再現できます。

def to_int8(n):
    n = n & 0xFF
    if n & 0x80:
        n -= 0x100
    return n

for v in [0, 1, 127, 128, 200, 255, -1, -128]:
    masked = v & 0xFF
    print(f"{v:>5} & 0xFF = {masked:>3} (0b{masked:08b}) -> to_int8 = {to_int8(v)}")

# 出力:
#     0 & 0xFF =   0 (0b00000000) -> to_int8 = 0
#     1 & 0xFF =   1 (0b00000001) -> to_int8 = 1
#   127 & 0xFF = 127 (0b01111111) -> to_int8 = 127
#   128 & 0xFF = 128 (0b10000000) -> to_int8 = -128
#   200 & 0xFF = 200 (0b11001000) -> to_int8 = -56
#   255 & 0xFF = 255 (0b11111111) -> to_int8 = -1
#    -1 & 0xFF = 255 (0b11111111) -> to_int8 = -1
#  -128 & 0xFF = 128 (0b10000000) -> to_int8 = -128

-1 & 0xFF2550b11111111)になる点に注目してください。Pythonの整数は無限桁ですが、負の数に対するビット演算は「上位ビットがすべて1で無限に続く」ものとして扱われるため、下位8ビットだけを取り出すと、C言語の8ビット符号付き整数における -1 のビットパターンと一致します。これがPythonの負整数が「無限桁の2の補数」として振る舞うと言われる理由です。

「全ビット反転して1を足す」という導出も、実際に確認できます。

def negate_int8(n):
    inverted = (~n) & 0xFF
    return to_int8(inverted + 1)

for v in [19, -19, 1, -128, 0]:
    print(f"negate_int8({v}) = {negate_int8(v)}")

# 出力:
# negate_int8(19) = -19
# negate_int8(-19) = 19
# negate_int8(1) = -1
# negate_int8(-128) = -128

最後の negate_int8(-128) の結果が -128 のまま変わらない点は重要な境界ケースです。8ビット符号付き整数が表現できる範囲は \(-128\) から \(127\) までであり、\(+128\) はこの範囲に存在しません。そのため \(-128\) を反転しようとすると表現できる範囲を超えてしまい、符号が反転せず \(-128\) に戻ってきます(C言語で INT_MIN を単項マイナスで反転すると未定義動作になるのと同じ理由の、8ビット版の縮図です)。

オーバーフロー:CとPythonの決定的な違い

固定ビット幅の整数では、表現できる範囲を超える演算は「オーバーフロー」としてラップアラウンド(折り返し)します。先ほどの to_int8 を使って、8ビット符号付き整数の加算をシミュレーションしてみましょう。

def add_int8(a, b):
    return to_int8(a + b)

print("127 + 1 =", add_int8(127, 1))     # 出力: 127 + 1 = -128
print("-128 + -1 =", add_int8(-128, -1)) # 出力: -128 + -1 = 127

127 + 1 は数学的には 128 ですが、8ビット符号付き整数の範囲(-128127)を超えるため -128 に折り返されます。これはC言語で int8_t の変数に 127 + 1 を代入したときに実際に起こる挙動そのものです。

一方、Python自身の整数演算にはこのようなオーバーフローは一切存在しません。Pythonの int はメモリの許す限り桁数を自動的に拡張する多倍長整数だからです。

x = 2**64
print(x)              # 出力: 18446744073709551616
print(type(x))        # 出力: <class 'int'>
print(x * x)           # 出力: 340282366920938463463374607431768211456
print(x * x == 2**128) # 出力: True

2**64 はC言語の64ビット符号なし整数の範囲を超えていますが、Pythonでは何の警告もなく正確な値が得られ、さらにその2乗(2**128 に相当する規模の数)も誤差なく計算できます。C言語であれば uint64_t 同士の掛け算は64ビットの範囲でオーバーフローし、下位ビットだけが残った誤った値になりますが、Pythonの int は「必要なだけ桁を増やす」ことで、この種のオーバーフローを構造的に起こしません。

言い換えると、Pythonでビット演算のオーバーフロー・ラップアラウンドを再現したい場合は、上の to_int8 のように自分で & 0xFF のようなマスク処理を書く必要があります。NumPyの int8 / uint32 型など固定ビット幅の型を使う場合も同様にオーバーフローが発生するため、Pythonの int とは挙動が異なる点に注意してください。

負の数に対するビット演算子

NOT演算(~

Pythonの ~x は「ビット反転」ですが、Pythonの int は符号なしではないため、常に ~x == -x - 1 という関係が成り立ちます。

print(~5)    # 出力: -6
print(~0)    # 出力: -1
print(~-1)   # 出力: 0
print(~127)  # 出力: -128

これは前述の「2の補数 = 全ビット反転 + 1」の関係、つまり \(-x = (\lnot x) + 1\) を移項した \(\lnot x = -x - 1\) そのものです。

シフト演算(<<, >>):Pythonの右シフトは算術シフト

正の数に対する <<(左シフト)・>>(右シフト)はそれぞれ2倍・2で割った商(切り捨て)に相当しますが、負の数に対する >> の挙動は、C言語などにおける**算術右シフト(sign-extending shift)**と一致します。つまり、符号ビットを保ったまま右にシフトするため、負の数を右シフトしても符号は負のまま維持されます。

print(-8 >> 1)     # 出力: -4
print(-7 >> 1)     # 出力: -4
print(-1 >> 100)   # 出力: -1
print(-8 << 1)      # 出力: -16

-7 >> 1-4(切り捨て方向は負の無限大側)になる点、-1 はどれだけ右シフトしても -1 のまま変化しない点(符号ビットが無限に続くと考えれば自然な結果)に注目してください。

これに対して、固定ビット幅の整数でよく使われるもう1つのシフトが**論理右シフト(logical right shift)**です。論理右シフトは符号を無視し、空いた上位ビットに常に0を詰めます。Python自身にはこの演算を行う専用演算子はありませんが、& 0xFF で8ビットに切り詰めてから >> すれば、8ビット符号なし整数に対する論理右シフトを再現できます。

def logical_rshift8(n, k):
    return (n & 0xFF) >> k

n = -8
print(f"arithmetic n >> 1        = {n >> 1}")                              # 出力: arithmetic n >> 1        = -4
print(f"logical (8-bit) n >> 1   = {logical_rshift8(n, 1)} (0b{logical_rshift8(n,1):08b})")
# 出力: logical (8-bit) n >> 1   = 124 (0b01111100)

同じ -8 を1ビット右シフトしても、算術シフトでは符号が保たれて -4-8 / 2 の切り捨て)になるのに対し、論理シフトでは -8 を8ビットのビットパターン 0b11111000(10進数では 248)とみなし、空いた最上位ビットに0を詰めるため 1240b01111100)というまったく異なる値になります。C言語の >> 演算子は、オペランドが符号付き型か符号なし型かによってこの2つの挙動が切り替わる(符号付きなら多くの処理系で算術シフト、符号なしなら論理シフト)ため、Pythonで固定ビット幅の論理シフトを再現したい場合は、このように明示的にマスクする必要があります。

よく使われるビット操作イディオム

2のべき乗判定

ある整数 \(n\) が2のべき乗かどうかは、n & (n - 1) == 0 という1行で判定できます。2のべき乗はビット表現が「1つの1と残りすべて0」(例: 0b1000)になっており、1を引くとその1の位置より下がすべて1に反転する(例: 0b0111)ため、ANDを取ると必ず0になるという性質を利用しています。

def is_power_of_two(n):
    return n > 0 and (n & (n - 1)) == 0

for n in [0, 1, 2, 3, 4, 16, 18, 1024]:
    print(n, is_power_of_two(n))

# 出力:
# 0 False
# 1 True
# 2 True
# 3 False
# 4 True
# 16 True
# 18 False
# 1024 True

n > 0 の条件を忘れると、n = 0 のとき 0 & -1 == 0 が真になってしまい、0 を誤って2のべき乗と判定してしまう点に注意してください。

立っているビット数(popcount)を数える

整数の2進数表現で1になっているビットの数(ポピュレーションカウント、popcount)を数える処理も頻出です。文字列操作で素朴に書くなら次のようになります。

n = 0b1011010111
print(n, bin(n))              # 出力: 727 0b1011010111
print(bin(n).count('1'))      # 出力: 7

Python 3.10以降では、int.bit_count() という専用メソッドが標準で用意されており、文字列変換を経由しないぶん高速かつ簡潔です。

print(n.bit_count())  # 出力: 7

bin(n).count('1') は文字列を経由するため負の数(bin()-0b... という文字列を返す)に対しては素直に動作しませんが、int.bit_count() は絶対値に対するビット数を返す点も含めて挙動が明確です。Python 3.10未満のバージョンをサポートする必要がなければ、bit_count() を使うのが現在の推奨です。

まとめ

Pythonでは、ビットごとの論理演算は組み込みの演算子 |(OR)・^(XOR)・&(AND)・~(NOT)・<<>>(シフト)を使うのが最も効率的で推奨されます。カスタム関数は、これらの演算の仕組みを理解するための学習目的として役立ちます。

負の数を扱う際は、次の2点を押さえておくと混乱しません。

  1. bin() などの表示は符号付き絶対値(-0b101 のような文字列)だが、&>> などのビット演算は無限桁の2の補数として振る舞う。
  2. Pythonの int は多倍長整数のためオーバーフローが起こらないが、固定ビット幅(8ビット・32ビットなど)の挙動を再現したいときは & 0xFF のような明示的なマスク処理が必要。

なお、指数を2進数として捉えてビットごとに処理する考え方は、 バイナリ法(繰り返し二乗法)による高速べき乗剰余計算のPython実装 で扱った繰り返し二乗法(バイナリ法)による高速べき乗剰余計算にも直接応用されています。あちらの実装はexp % 2 == 1 / exp //= 2という剰余・整数除算ベースの書き方が中心ですが、これはexp & 1 / exp >>= 1というビット演算と完全に等価です(実際、モンゴメリラダー版の実装では(exp >> i) & 1というビット演算そのものが使われています)。指数の各ビットを1つずつ読み進めることで\(O(\log k)\) 回の乗算だけで巨大なべき乗を計算しており、本記事で扱ったビット演算はその基礎になっています。