なぜ 2・8・16 なのか:任意の選択ではなくビットのまとまりとしての基数
プログラマーがまさにこれらの基数を使うのは、それぞれがビットにきれいに対応するからです。16 進数の 1 桁はちょうど 4 ビット(ニブル)なので、16 進 2 桁が 1 バイト — 0xFF は 11111111 で 255、32 ビット値は常に 16 進 8 桁です。メモリアドレス、ハッシュダイジェスト、MAC アドレス、色の値が 16 進で書かれるのはこのためで、テキストからバイト境界を直接読み取れます。8 進数はビットを 3 つずつまとめ、これは Unix のパーミッションモデルと完全に一致します。rwx が 3 ビットなので rwxr-xr-x は 111 101 101、すなわち 755 です。
対照的に 10 進数はビット境界と何の関係もありません — 255 という数字は、どのビットが立っているかを視覚的にまったく教えてくれません。10 進から 2 進への変換は 2 での割り算の繰り返しが必要ですが、16 進から 2 進へは桁ごとの表引きです。熟練エンジニアが 16 進⇔2 進を暗算でこなす一方、大きな数の 10 進⇔2 進変換を誰もやらない理由です。
役立つ記憶の錨は累乗のはしごです:2^8 = 256、2^10 = 1024、2^16 = 65,536、2^32 = 4,294,967,296。ここから実務で出会う制限の大半を導けます — バイトの 0〜255、TCP ポートの最大値 65,535、32 ビットアドレス空間の 4GiB 上限。なお Base64 はこの意味での基数ではありません。6 ビットのグループを文字に対応させるバイナリ→テキストのエンコーディングです。Base64 で算術はできず、基数変換器で変換しようとするのはカテゴリー錯誤です。
hex F F octal 7 5 5
| | | | |
bin 1111 1111 bin 111 101 101
= 0xFF = 255 = rwxr-xr-x (chmod 755)
# read a 32-bit value byte by byte
0xDEADBEEF = DE AD BE EF = 222 173 190 239
# the powers ladder
2^8 = 256 2^10 = 1,024 2^16 = 65,536 2^32 = 4,294,967,2962 の補数:符号はビット幅に宿る
ビットパターン 11111111 に固有の符号はありません。符号なし 8 ビット整数として解釈すれば 255、符号付き(2 の補数)8 ビット整数として解釈すれば -1。同じバイト、2 つの契約です。2 の補数が勝ち残ったのは算術が一様だからです — 符号反転は「全ビット反転して 1 を足す」であり、同じ加算器回路が正負両方を処理します。n ビット符号付き整数の範囲は -(2^(n-1)) から 2^(n-1)-1 まで — 8 ビットなら -128 から 127。この非対称は現実で、時々噛みつきます。8 ビットレジスタで -128 を符号反転するとオーバーフローして -128 に戻ります。C で最小の負数の abs() が未定義動作なのはこのためです。
これが変換ツールで重要な理由:ビット幅を宣言しない変換器は負数を表示できません —「-5 の 2 進数」というものは存在せず、「-5 の 8 ビット(11111011)あるいは 32 ビット(…11111011)の 2 の補数」だけが存在します。
JavaScript には独自のひねりがあります。ビット演算子は数値を符号付き 32 ビット整数に強制変換します。そのため 0xFFFFFFFF はリテラルとしては 4294967295 ですが、0xFFFFFFFF | 0 は -1 です — 31 番ビットが符号ビットになるからです。ゼロによる符号なし右シフト(value >>> 0)は値を符号なし 32 ビットとして再解釈する標準イディオムで、型付き配列(Int8Array と Uint8Array)は同じバイトの 2 通りの読みを明示化します。レジスタダンプやパケットキャプチャの 16 進値が「巨大で奇妙」に見えたら、符号なしで出力された負数ではないか確認してください。
(0xFF | 0) // 255 — fits in int32, stays positive
(0xFFFFFFFF | 0) // -1 — bit 31 became the sign bit
(-1 >>> 0).toString(16) // 'ffffffff'
(-1 >>> 0).toString(2) // '11111...' (32 ones)
// same byte, two contracts
new Int8Array([0xFF])[0] // -1
new Uint8Array([0xFF])[0] // 255
// negate = flip bits, add one
// 5 = 00000101
// ~5 = 11111010
// +1 = 11111011 = -5 in 8-bit two's complementIEEE 754 と 2^53 の崖:JavaScript が整数を静かに丸めるとき
JavaScript の数値型は 1 つだけ:IEEE 754 倍精度浮動小数点です。double は 52 ビットの仮数部に暗黙の 1 ビットを加え、2^53(9,007,199,254,740,991 — Number.MAX_SAFE_INTEGER)まではすべての整数を正確に表現します。その 1 つ先から、表現可能な値は飛び飛びになります。2^53 + 1 は 2^53 に丸められ、9007199254740992 === 9007199254740993 の比較は true になります。何も throw されません。丸めが静かであることこそが危険なのです。
これは現実のシステムを絶えず待ち伏せします。64 ビット整数 ID がどこにでもあるからです — データベースの bigint カラム、Twitter/X のスノーフレーク ID、Discord の ID、注文番号。JSON 自体に整数サイズの制限はありませんが、JSON.parse が 19 桁の ID を Number として実体化した瞬間、下位の桁は消えます — そして壊れた ID はもっともらしい顔でコードの中を回り続けます。対策:大きな ID は文字列で送るか、BigInt を扱える reviver でパースすること。BigInt は任意精度を扱い、文字列形式では基数プレフィックスも受け付けます。
同じ IEEE 754 の話が有名な 0.1 + 0.2 !== 0.3 も説明し、これは本質的に基数変換の問題です。0.1 は 10 進では有限ですが 2 進では無限循環小数(0.0001100110011...)であり、52 ビットに収めるため丸めが必要です。分母が純粋な 2 の累乗でないすべての 10 進小数がこの性質を持ちます。金額を整数のセントで扱うべき理由であり、小数を扱う基数変換器なら循環展開を表示する必要がある理由でもあります。
Number.MAX_SAFE_INTEGER // 9007199254740991 (2^53 - 1)
9007199254740992 === 9007199254740993 // true (!)
// 64-bit ID from an API, silently corrupted
JSON.parse('{"id": 900719925474099267}').id
// -> 900719925474099300
// exact alternatives
BigInt('900719925474099267') // 900719925474099267n
BigInt('0xff') // 255n — radix prefix works
(255n).toString(16) // 'ff'
// the fraction side of the same coin
0.1 + 0.2 // 0.30000000000000004
// 0.1 in binary = 0.000110011001100... (repeats forever)言語ごとの基数リテラルとパースの罠
現代の言語はプレフィックスで合意しています:16 進は 0x、2 進は 0b、8 進は 0o(JavaScript、Python、Rust、Swift すべてが受け付けます)。さらに可読性のためのアンダースコア区切り 1_000_000 もあります。歴史的な傷跡は C スタイルの 8 進数 — 先頭のゼロ 1 つで、010 は 8 を意味します。この記法は現実のバグを十分に生みました(ゼロ埋めされた列をコードに貼り付ける、chmod の値を 10 進で渡す)。そのため strict モードの JavaScript はレガシー 8 進リテラルを構文エラーにし、代わりに 0o を導入しました。ファイルモードを素の数値で受け取る API では、644 と 0o644 はまったく別の値です(10 進の 644 は 0o1204)。
JavaScript のパース関数はそれぞれ癖があります。parseInt(str, radix) は常に明示的な radix 付きで呼ぶべきです。古いエンジンは先頭ゼロを 8 進と解釈しましたし、今日でも parseInt は 0x は自動検出しますが 0b や 0o はしません。Number('0b101') は 3 つの現代プレフィックスをすべて理解しますが、Number('') は 0 になり、parseInt は失敗する代わりに最初の不正な文字で止まります — parseInt('12px', 10) は 12 で、これは日によって機能にも地雷にもなります。parseInt はまた引数を先に文字列化するため、非常に小さい float で有名なナンセンスを生みます(parseInt(0.0000005) は文字列 '5e-7' を経て 5)。
出力側では toString(radix) が 2 進から 36 進まで対応し、padStart で固定幅ダンプが作れます:(5).toString(2).padStart(8, '0') は 00000101 を出力します。64 ビットのビット操作は BigInt で行ってください — Number ベースのビット演算子は 32 ビットに切り詰め、上位半分を壊します。
parseInt('08') // 8 today; 0 in pre-ES5 engines — always pass radix
parseInt('0x1F') // 31 — hex auto-detected
parseInt('0b101') // 0 — binary prefix NOT understood
Number('0b101') // 5 — all modern prefixes work
parseInt('12px', 10) // 12 — stops at first invalid char
parseInt(0.0000005) // 5 — via the string '5e-7' (!)
0o755 // 493 — modern octal literal
1_000_000 // digit separators, ignored by the engine
(255).toString(2).padStart(8, '0') // '11111111'
(0xDEADBEEFn << 8n).toString(16) // 'deadbeef00' — 64-bit-safe in BigInt実践的なビット操作:パーミッション・フラグ・色
現場の開発者が実際に手で基数変換をする場面は 3 つ — Unix パーミッション、機能フラグ、色 — で、それぞれ身につける価値のある小さなイディオムです。
パーミッション:8 進数の各桁は読み+書き+実行で、重みは 4-2-1。755 は 7 = 4+2+1(所有者 rwx)、5 = 4+1(グループ r-x)、5(その他 r-x)に分解できます。644 は rw-、r--、r--。setuid/setgid/sticky ビットは省略可能な先頭 4 桁目を成します — 4755 は setuid root の領域なので、監査で先頭の 4 や 2 を見たら注意が必要です。シンボリックから 8 進への暗算は、重みの足し算にすぎません。
フラグ:各フラグを 1 をインデックス分だけ左シフトした値として定義し、OR で結合、AND で判定、AND NOT で解除、XOR でトグルします。変換ツールの 2 進表示はそのままデバッグ表示です — フラグ値 13 は 1101 なので、ビット 0・2・3 が立っています。このレイアウトはファイルパーミッションのマスク、Linux のケーパビリティ、CSS の font-feature ビットフィールド、無数のネットワークプロトコルのフラグ整数と同じものです。JavaScript の注意点が 1 つ:フラグが 31 個を超えたら、32 ビット切り詰めのため BigInt かワード配列に切り替える必要があります。
色:CSS の 16 進カラーは 3 バイト(アルファ付きなら 4 バイト)の連結です。チャンネルの抽出はシフト+マスク — 赤は値を右に 16 シフトして 0xFF と AND。0xFF0000 が純粋な赤である理由であり、変換ツールの 16 進表示と 10 進表示 16711680 が同じピクセルを表す理由です。
// permissions: weights 4-2-1 per octal digit
// 755 = rwx r-x r-x 644 = rw- r-- r-- 4755 = setuid + 755
// flags: define, set, test, clear, toggle
const READ = 1 << 0; // 1
const WRITE = 1 << 1; // 2
const EXEC = 1 << 2; // 4
let flags = READ | EXEC; // 5 = 101
(flags & WRITE) !== 0 // false
flags &= ~READ; // clear -> 100
flags ^= EXEC; // toggle -> 000
// colors: bytes packed into one integer
const c = 0xff8800; // 16746496
const r = (c >> 16) & 0xff; // 255
const g = (c >> 8) & 0xff; // 136
const b = c & 0xff; // 0