ビット演算
Bitwise Operation ・ ビットえんざん
数値をビットの列とみなして行うAND・OR・XOR・シフトなどの演算。低レイヤ処理の道具。
✦ 30秒で試す — 航海足し算より単純なのに、暗号の心臓部で働いているその暗号文に、同じ鍵でもう一度 XOR すると?予想してみる概要
ビット演算は、数値を「数」としてではなく「2進数のビットの列」とみなし、桁ごとに直接操作する演算です。代表的なものは AND(両方1なら1)、OR(どちらかが1なら1)、XOR(どちらか一方だけが1なら1)、NOT(0と1を反転)、そしてビット列全体を左右にずらすシフト演算です。多くの言語で & | ^ ~ << >> という演算子が割り当てられています。
足し算や掛け算が「値」を計算するのに対し、ビット演算は「特定の桁を取り出す・立てる・消す・反転する」という桁単位の外科手術を行います。日常のアプリケーション開発で毎日書くものではありませんが、ネットワークのIPアドレス計算、権限フラグ、ファイルフォーマットの解析、ハッシュ化の関数や暗号化の内部など、低レイヤに近づくほど必ず顔を出す基本の道具です。
なぜ生まれたか
ビット演算はソフトウェアの発明ではなく、ハードウェアの姿がそのまま言語に露出したものです。CPUの演算回路は、AND・OR・NOTといった論理ゲートの組み合わせでできており、ビット単位の論理演算は加算よりもさらに素朴な、回路が最も自然に実行できる操作です。機械語には最初から AND・OR の命令があり、C 言語(1972年ごろ)のような「機械に近い」言語がそれを & | として表に出したことで、高級言語でも当たり前の演算子になりました。
もうひとつの動機はメモリと通信の節約です。メモリが極端に高価だった時代、「はい/いいえ」の情報1つに1バイト(8ビット)を使うのは贅沢でした。1バイトに8個のフラグを詰め込み、ビット演算で個別に読み書きする — この「ビットを主語にした設計」は、メモリが潤沢になった現在でも、ネットワークプロトコルのヘッダやハードウェア制御のレジスタなど、1ビットを無駄にできない世界の共通言語として生き続けています。
詳細
4つの論理演算 — 桁ごとに独立して働く
ビット演算の第一の特徴は、各桁が隣の桁と無関係に、独立して計算されることです。足し算のような繰り上がりはありません。同じ入力 1100 と 1010 に対して、AND・OR・XOR がそれぞれどう働くかを並べると、性格の違いがよく見えます。
NOT(~)は1つの値の全ビットを反転します。シフト演算(<< >>)はビット列全体を左右にずらす操作で、左に1ビットずらすと値は2倍、右にずらすと2分の1(切り捨て)になります。10進数で桁をずらすと10倍・10分の1になるのと同じ理屈です。
ビットマスク — 型紙を重ねて欲しい桁だけを扱う
ビット演算の実務パターンの中心が「マスク」です。扱いたい桁だけを1にした値(マスク)を用意し、対象の値に重ねます。マスクは型紙にたとえると分かりやすくなります。AND で重ねるとき、マスクの1は「穴」、0は「板」で、穴から見える桁だけが残り、板の下は0に消えます(切り抜く)。OR で重ねるとき、マスクの1は「塗り」で、その桁が1になります(立てる)。XOR で重ねるとき、マスクの1は「反転の印」で、その桁がひっくり返ります(トグル)。
大事なのは、同じ型紙をもう一度重ねたときの違いです。AND と OR は何度重ねても結果が変わりません(冪等)。そして、一度0に消した桁・1に塗った桁が元は何だったかは、もう分かりません。情報が失われているのです。XOR だけは、同じ型紙を2回重ねると元に戻ります。型紙を実際に重ねて確かめてください。
左の取っ手をつかんで型紙を下のレジスタ r に重ねてください(取っ手を押すだけでも重なります)。いまの型紙は AND 用で、1 の桁が穴になっています。
この技法の代表例がフラグ管理です。Unix のファイルシステムの権限 755 は、読み・書き・実行の3ビットを所有者・グループ・その他の3人分並べた9ビットの値(111 101 101)で、「グループに書き込み権があるか」は該当ビット(000 010 000)との AND が0かどうかで判定できます。FLAG_A | FLAG_B という書き方は複数のフラグを1つの整数に OR で合成しており、flags & ~FLAG_B は「B の桁だけが板になった型紙」で B を消しています。
実例 — サブネットマスクはその名の通りマスク
IPアドレスの「サブネットマスク」は、ビットマスクがネットワークの根幹で使われている好例です。IPv4 アドレスは32ビットの値で、その前半が「どのネットワークか」、後半が「ネットワーク内のどの機器か」を表します。サブネットマスク(例: 255.255.255.0 = 上位24ビットがすべて1)とアドレスの AND を取ると、機器部分のビットが消えてネットワークアドレスだけが残ります。
この AND は2つの場面で使われます。1つは、パケットを送り出す機器自身の判断です。自分のアドレスと宛先のアドレスをそれぞれマスクで切り抜いて比べ、一致すれば同じネットワークなので直接届け、一致しなければデフォルトゲートウェイ(出口のルータ)に託します。もう1つはルーティングです。ルータの経路表には「このネットワークならこっち」という行が長さの違うマスクとともに並んでおり、宛先を各行のマスクで切り抜いて一致する行のうち、マスクの1が最も長い(最も具体的な)行を選びます。これを最長一致検索と呼び、実際の高速なルータは、この検索を専用の木構造や回路で一度に行っています。
XOR の面白い性質 — 可逆性と暗号
XOR には他の演算にない際立った性質があります。同じ値で2回 XOR すると元に戻る(x ^ k ^ k = x)、つまり演算そのものが可逆なのです。AND や OR は情報を潰してしまう(結果から入力を復元できない)のに対し、XOR は鍵さえあれば巻き戻せます。
この性質は暗号化の心臓部で使われています。平文と鍵ストリームの XOR で暗号化し、同じ鍵ストリームとの XOR で復号する — ストリーム暗号の基本形はまさにこれです。逆に言えば、同じ鍵ストリームを2回使うと、2つの暗号文の XOR から鍵が消えて平文どうしの XOR が漏れるため、鍵の使い回しは致命的です。また「x ^ x = 0 になる」性質は、データの誤り検出(パリティ)や、RAID のパリティによる復元、ハッシュ化の関数の内部の攪拌処理などにも応用されています。
落とし穴
実務でつまずきやすいのは、まず論理演算子との混同です。多くの言語で &(ビット AND)と &&(論理 AND)は別物です。さらに演算子の優先順位が言語によって違います。C 系の言語(C・C++・JavaScript など)ではビット演算子 & は比較演算子 == より優先順位が低く、flags & MASK == MASK は flags & (MASK == MASK) と解釈されてしまいます。これは C が && を持たなかった時代の名残です。一方、Go・Python・Rust では & のほうが先に結び付き、意図どおり (flags & MASK) == MASK になります。言語をまたいで書く人ほど間違えやすいので、括弧を惜しまないのが鉄則です。
また、符号付き整数の右シフトは言語によって「符号ビットを引き延ばす(算術シフト)」か「0で埋める(論理シフト)」かが異なり、負の数で挙動が分かれます。JavaScript に >> と >>> の2種類があるのはこのためで、しかもビット演算の前に数値が32ビットの整数へ切り詰められるので、2^31 を超える値は化けます。ビット演算を使うときは、対象を符号なし整数に揃えるか、言語仕様を確認してから使うのが安全です。
試問
8桁のレジスタ r(初めは 1011 0110)と、その上に型紙(マスク)m があります。演算(AND・OR・XOR)を選び、型紙のセルを押して効かせる桁を変え、取っ手をつかんで r に重ねます(押すだけでも重なります)。
左の取っ手をつかんで型紙を下のレジスタ r に重ねてください(取っ手を押すだけでも重なります)。いまの型紙は AND 用で、1 の桁が穴になっています。
