imu-note
いむたろ
いむたろ
@imutaroh
新卒エンジニア / データ基盤 × AI

ビットとバイト

ビットは、0 か 1 のどちらかを持つデータの最小単位である。ビット自体に意味は無い。並んだビットを「数」として読むか「文字」として読むかは、型や文字コードという読む側の約束で決まる。

ビット列読み方結果
01000001符号なし整数65
0100000116進で書く0x41
01000001文字(ASCII)A
11111111uint8255
11111111int8−1

バイトは8ビットの組で、2^8 = 256 通りを表せる。値の最大が 256 ではなく 255 なのは、0 も1通りに数えるからである(128+64+32+16+8+4+2+1 = 255)。

16進数1桁は4ビットぶん(16通り)なので、1バイトはちょうど16進2桁になる。0x は16進の印で、0xFF は 11111111 の別の書き方。SHA-256 のハッシュ(32バイト)が64文字なのも、UTF-8 の € が E2 82 AC と書かれるのもこの理由。

気づき

ビットは「文字などを表すための数値」ではない。最小単位であって、意味は持たない。11111111 を見て反射的に 255 と思うのは、「符号なしの2進数として読む」という約束を無意識に当てはめているから。

整数と2の補数

整数型はビット数が固定されている。INT64 は64ビットで、範囲は −9223372036854775808〜9223372036854775807。範囲の中なら誤差は無く、1 + 2 は必ず 3 になる。整数で起きる事故は範囲の事故だけである。

負の数の約束

符号付き整数は2の補数で負の数を表す。考え方は2通りある。

  • 巻き戻し:00000000 から 1 を引くと 11111111 になる。これを −1 と呼ぶ。走行距離メーターの 000 を巻き戻すと 999 になるのと同じ
  • 先頭の重みだけがマイナス:各桁の重みを -128, 64, 32, 16, 8, 4, 2, 1 として足す
ビット列uint8int8int8 の計算
11111111255−1−128 + 127
10000001129−127−128 + 1
10000000128−128−128 + 0
011111111271270 + 127

先頭ビットは「マイナス記号」ではなく、重み −128 を持つ桁である。記号として読むと 10000001 が −1 に見えるが、実際は −127。

8ビットの256通りを先頭ビットで半分に分けると、先頭が1の128通りは全部マイナス(−1〜−128)、先頭が0の128通りは 0 が1席使うのでプラスは 1〜127。int8 の範囲が −128〜127 で、プラス側が1つ少ないのはこのため。

負の数の作り方は「反転して +1」。5 = 00000101 → 反転 11111010 → +1 11111011 = −5。この約束の利点は、足し算の回路を正負で共用できること(00000011 + 11111111 = 1 00000010、はみ出した9桁目を捨てて 2)。

−1 と 255 はビットでは区別できない

11111111 だけを見て、−1 か 255 かを判定する方法は無い。コンピュータにも分からない。区別しているのは、変数に付いた型というラベルである。差は、計算によって表に出る。

操作uint8 の 255int8 の −1結果
中身1111111111111111同じ
+10000000000000000同じ
> 0 かtruefalse割れる
÷21270割れる
16ビットへ広げる0000000011111111(255)1111111111111111(−1)割れる
気づき

−1 と 255 の違いが分からないのは、理解が足りないからではなく、ビットの中に区別が無いから。意味はビットではなく型に付いている。

符号拡張

int8 を int16 や int64 に広げるときは、先頭ビットを左に複製する(符号拡張)。16ビットの −1 は 1 が16個なので、8ビットの −1 を広げるなら左を 1 で埋めるしかない。0 で埋めると値が変わる。

int8ビット列0 で埋める先頭ビットで埋める(符号拡張)
−1111111110000000011111111 = 2551111111111111111 = −1
−5111110110000000011111011 = 2511111111111111011 = −5
5000001010000000000000101 = 50000000000000101 = 5

uint8 を広げるときは左を 0 で埋める(ゼロ拡張)。同じ 11111111 でも、型によって広げ方の命令が変わる。仕様が「0〜255」の1バイトを int8 で受けると、広げた時点で 255 が −1 に化ける。

一周(ラップアラウンド)

箱が全部埋まった状態で 1 を足すと、はみ出した桁は捨てられる。

型計算結果繰り上がりの行き先
uint811111111(255)+ 100000000(0)9桁目に出て捨てられる
int801111111(127)+ 110000000(−128)先頭の桁(重み −128)に入る

int8 では、繰り上がりが先頭の桁に 1 を運び、その桁の重みが −128 なので、プラスの端からマイナスの端に飛ぶ。繰り上がりは先頭に 1 が入る原因であって、先頭ビットの意味(重み −128)とは別のもの。

Go は整数のあふれを止めない

同じあふれでも、処理系によって振る舞いが違う。

処理系計算結果
GoMaxInt64 + 1−9223372036854775808(一周して続行)
BigQuerySELECT 9223372036854775807 + 1Error: Integer Overflow
BigQuerySAFE_ADD(9223372036854775807, 1)NULL

理由は速さである。CPU の足し算命令はもともと一周する。足し算のたびにあふれを確かめると、すべての整数計算が遅くなる。Go はチェックをしない代わりに、一周した結果を仕様で確定させた。C 言語では符号付き整数のあふれは未定義動作で、コンパイラがあふれのチェックを最適化で消してしまうことがある。Go はこれを禁じている。

気づき

Go は「毎回あふれを確かめると全部の整数計算が遅くなるから、あふれてマイナスになっても仕様として通す」。その代わり、結果はいつ動かしても同じになる。

言語符号付き整数があふれたとき
Go一周する(仕様で定義)
C / C++未定義動作
Rustデバッグビルドでは panic、リリースビルドでは一周
Swift実行時エラー
BigQueryエラー(SAFE_ 系は NULL)

浮動小数点の構造

FLOAT64(Go の float64)は IEEE 754 の倍精度で、数を 仮数 × 2^指数 の形で持つ。10進の指数表記(123000 = 1.23 × 10^5)を2進でやっている。10 のべき乗は一切使わない。

部品ビット数役目
符号10 なら正、1 なら負
指数11小数点をどこまで動かすか → 範囲
仮数52(+暗黙の1)数字の並びを何桁持てるか → 精度

数を float64 にする手順(6 の場合)

  1. 2進に直す:6 = 110
  2. 先頭が 1. になるように小数点をずらす:110. → 1.10 × 2^2
  3. 仮数:先頭の 1. は必ず 1 なので保存しない(暗黙の1)。仮数は実質53ビット
  4. 指数:2 に 1023 を足して 1025 = 10000000001 として書く
数2進指数ビット箱の値1023 を引いた指数
110111111111110230
2101000000000010241
61101000000000110252
1010101000000001010263
0.50.1011111111101022−1

指数は「2進で書いたときの桁数」に対応する。2進で3桁の数(4〜7)は指数2、4桁の数(8〜15)は指数3。

指数は 1023 を足して書く(バイアス)

11ビットの箱に書けるのは 0〜2047 の正の数だけだが、0.5 = 1.× 2^−1 のように負の指数も書きたい。そこで「書くときは +1023、読むときは −1023」と約束する。1023 は 0〜2047 のほぼ真ん中なので、プラス側とマイナス側を半分ずつ使える。正の float どうしなら、ビット列を整数として比べるだけで大小が正しく出るという利点もある。

負の数の約束は1つではない。整数は2の補数、float の指数はバイアス、float の符号は「符号ビット+絶対値」を使う。

指数の範囲は −1022〜+1023

計算上は 0 − 1023 = −1023 から 2047 − 1023 = +1024 だが、両端は特別な値の予約席になっている。

箱の中身01〜20462047
意味ゼロ・非正規化数指数 −1022〜+1023+Inf・NaN

下の端が無いと 0 が書けない。暗黙の1があるので、普通の float は必ず 1.xxx × 2^n になり、0 にはならないからである。

指数ビット箱の値表す数
111111111102046最大 MaxFloat64 = 1.7976931348623157e+308
0000000000112^−1022 = 2.2250738585072014e-308
(2^1024 を計算)2047+Inf
気づき

11ビットで表せる最大は 2047。1023 を引いて指数にするので、計算上は −1023〜+1024 になる。ただし一番端の2つは特別な値に取っておくので、普通の数で使えるのは −1022〜+1023。範囲は「ビット数 → 書ける通り数 → 約束 → 表せる範囲」の順で導ける。暗記はいらない。

float32 は「符号1・指数8・仮数23」で、範囲は約 10^38、有効桁は約7桁。ビットの配分を変えると、範囲と精度の取り合いが変わる。

x := 0.1 の時点で 0.1 ではなくなる

float64 に正確に入るのは「整数 ÷ 2のべき乗」の形の数だけ。0.5(1/2)、0.25(1/4)、0.75 はぴったり入るが、0.1 は入らない。1/10 の分母 10 = 2 × 5 の因数 5 が、2進では作れないからである。

0.1 を2進に直す筆算(2倍して、1 を超えたら 1 を書いて引く):

計算書く桁残り
0.1 × 2 = 0.200.2
0.2 × 2 = 0.400.4
0.4 × 2 = 0.800.8
0.8 × 2 = 1.610.6
0.6 × 2 = 1.210.2
0.2 × 2 = 0.400.4(2行目と同じ状態。ここから繰り返し)

0.1 = 0.0001100110011…(2進。0011 が永遠に続く)

仮数は52ビットで打ち切られ、近い値に丸められる。

x := 0.1 の中身を、見方を変えて取り出すとこうなる。

見方結果
型float64
正確な分数3602879701896397 / 36028797018963968(分母は 2^55)
%.30f で表示0.100000000000000005551115123126
fmt.Println で表示0.1(最短表示が中身を隠す)

fmt.Println は「読み戻すと同じ float64 になる最短の10進表記」を出すので、ずれていても 0.1 と表示される。0.1 と 0.2 を足すと、隠しきれなくなる。

値float64 の中身ずれ
0.10.1000000000000000055511151少し大きい
0.20.2000000000000000111022302少し大きい
x + y0.3000000000000000444089210Println でも 0.30000000000000004 と出る
0.30.29999999999999998889776980.3 に一番近い float64 は少し小さい側

x+y と 0.3 は別の float64 なので、== は false になる。誤差は足し算で生まれるのではなく、箱に入れた時点で生まれ、表示が隠し、足し算で表に出る。浮動小数を比べるときは math.Abs(a-b) < 1e-9 のように差で見る。

気づき

x := 0.1 で型が float64 になった時点で、中身はもう 0.1 ではない。

Go の数値リテラルは型の無い定数で、コンパイル時に高い精度で計算される。fmt.Println(0.1 + 0.2) は 0.3 を出し、0.1 + 0.2 == 0.3 も true になる。誤差を観察するときは変数に入れる。

目盛り:大きい数ほど粗くなる

桁数の決まった電卓

数字を3桁しか覚えられない電卓で考える。小数点の位置は自由に動かせる。

計算3桁電卓の結果理由
1.23 + 0.011.243桁に収まる
123 + 11243桁に収まる
12300 + 11230012301 は5桁の並びが要るので覚えられない
12300 + 10012400並びは 124 の3桁で済む

次に覚えられる数までの距離(間隔)は、1.23 なら 0.01、123 なら 1、12300 なら 100。覚えられる桁数が決まっているので、数が大きいほど間隔が広がる。間隔より小さい足し算は消える。float64 は、これが10進でおよそ16桁(2進で53桁)の電卓である。

末尾の 0 は指数が巻き取る

2進で3桁しか覚えられない電卓(仮数3ビット)に、1〜17 を入れる。

数2進桁数結果
71113覚えられる
810004覚えられる(1 × 2^3)
910014覚えられない → 8 に丸め
1010104覚えられる(101 × 2^1)
1110114覚えられない → 12 に丸め
16100005覚えられる(1 × 2^4)
17100015覚えられない → 16 に丸め

4桁の数を3桁の箱に入れるには、はみ出した末尾が 0 でなければならない。末尾の 0 は指数(2^x)が巻き取れるが、末尾の 1 は巻き取れない。2進では奇数の末尾は必ず 1 なので、4桁の区間(8〜15)では偶数しか持てない。5桁の区間(16〜31)では末尾2桁が 00、つまり4の倍数しか持てない。

範囲2進の桁数覚えられる数
1〜73桁以内全部
8〜154桁偶数だけ
16〜315桁4の倍数だけ
気づき

最後の桁が 0 なら覚えられる。末尾の 0 は 2^x のところで巻き取れるから。仮数に残るのは、0 を指数に預けたあとの数字の並びで、それが箱に収まるかどうかで決まる。

本物の float64 では 2^53 から

float64 の仮数は53桁なので、2進で54桁になる 2^53 = 9007199254740992 から先は、奇数が持てない。

数2進の桁数2進の末尾結果
1000000000000000354…0011末尾が 1。巻き取れないので持てない
1000000000000000454…0100末尾が 0。指数が巻き取るので持てる

隣の float64 との間隔は、2のべき乗の区間ごとに倍になる。どの区間にも同じ数(2^52 個)の目盛りがあるからである。

数の大きさ隣の float64 との間隔区間
12.2e-16
4.5e150.5
9.0e1512^53 の手前
1.0e1622^53〜2^54
2.0e1642^54 を超えた

(4.5e15 は 4.5 × 10^15 の意味。この e は表示用の10進の指数表記で、float の中の2のべき乗の指数とは別物)

1e16 の近くでは2刻みしか持てないので、奇数を足すと目盛りの真ん中に落ち、近い偶数に寄せられる。真ん中のときは、仮数の末尾が偶数になる側へ寄せる(偶数丸め)。切り上げと切り捨てが交互になり、誤差が一方向に溜まらない。

計算実際に増えた量理由
1e16 + 1+00 と +2 の真ん中 → 0 へ
1e16 + 2+2目盛りの上
1e16 + 3+4+2 と +4 の真ん中 → +4 へ
1e16 + 5+4+4 と +6 の真ん中 → +4 へ
1e16 + 7+8+6 と +8 の真ん中 → +8 へ
1e16 + 10+10目盛りの上

足す順番で結果が変わるのも同じ理由。(1e16 + 1) - 1e16 = 0、(1e16 - 1e16) + 1 = 1。BigQuery の FLOAT64 の SUM は足す順番が実行ごとに変わりうるので、末尾の桁が揺れることがある(公式ドキュメントの記述による。手元では再現していない)。

JSON の数値を Go の map[string]any や JavaScript で読むと float64 になるので、2^53 を超える ID は化ける(9007199254740993 → 9007199254740992)。JavaScript の Number.MAX_SAFE_INTEGER が 2^53 − 1 なのはこのため。計算しない ID は文字列で受け渡す。

丸めと型変換

「丸め」は、端数を処理して桁を減らす操作の総称。四捨五入・切り捨て・切り上げ・偶数丸めは、その種類である。どれが使われるかは関数と処理系で違う。

操作2.5 →2.9 →−2.5 →規則
BigQuery ROUND(x)33−3四捨五入(.5 は 0 から遠い方)
BigQuery ROUND(n, 0, "ROUND_HALF_EVEN")23偶数丸め(NUMERIC のみ)
BigQuery CAST(x AS INT64)33−3四捨五入
Go math.Round33−3四捨五入
Go math.RoundToEven23偶数丸め
Go int(x)22−20 方向へ切り捨て
気づき

BigQuery の CAST は四捨五入で近い方に寄せる。Go の int() は切り捨て。負の数では「下へ」ではなく「0 の方へ」切り捨てるので、−2.9 は −2 になる。

整数の割り算も処理系で違う。Go の 7/2 は 3(整数どうしは整数)、BigQuery の 7 / 2 は 3.5(FLOAT64 を返す)で、整数が欲しければ DIV(7, 2)。

事故は「範囲」と「精度」の2種類

数は、有限のビットに詰めた近似である。だから数値の事故は2種類しかない。

事故何が起きるか典型
範囲(桁あふれ)入れ物に入りきらないINT64 の最大値 + 1、uint8 の 255 + 1
精度(ずれ)入るが正確に書けない0.1 + 0.2 ≠ 0.3、1e16 + 1 = 1e16、2^53 を超える ID が JSON で化ける

型を選ぶことは、どちらの事故を受け入れるかを選ぶことである。整数は精度を守る代わりに範囲が狭い。浮動小数点は範囲が広い代わりに精度が有限。NUMERIC は10進で正確に持つ代わりに、範囲と小数桁が固定される。

関連