ビットは、0 か 1 のどちらかを持つデータの最小単位である。ビット自体に意味は無い。並んだビットを「数」として読むか「文字」として読むかは、型や文字コードという読む側の約束で決まる。
| ビット列 | 読み方 | 結果 |
|---|---|---|
01000001 | 符号なし整数 | 65 |
01000001 | 16進で書く | 0x41 |
01000001 | 文字(ASCII) | A |
11111111 | uint8 | 255 |
11111111 | int8 | −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進数として読む」という約束を無意識に当てはめているから。
整数型はビット数が固定されている。INT64 は64ビットで、範囲は −9223372036854775808〜9223372036854775807。範囲の中なら誤差は無く、1 + 2 は必ず 3 になる。整数で起きる事故は範囲の事故だけである。
符号付き整数は2の補数で負の数を表す。考え方は2通りある。
00000000 から 1 を引くと 11111111 になる。これを −1 と呼ぶ。走行距離メーターの 000 を巻き戻すと 999 になるのと同じ-128, 64, 32, 16, 8, 4, 2, 1 として足す| ビット列 | uint8 | int8 | int8 の計算 |
|---|---|---|---|
11111111 | 255 | −1 | −128 + 127 |
10000001 | 129 | −127 | −128 + 1 |
10000000 | 128 | −128 | −128 + 0 |
01111111 | 127 | 127 | 0 + 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)。
11111111 だけを見て、−1 か 255 かを判定する方法は無い。コンピュータにも分からない。区別しているのは、変数に付いた型というラベルである。差は、計算によって表に出る。
| 操作 | uint8 の 255 | int8 の −1 | 結果 |
|---|---|---|---|
| 中身 | 11111111 | 11111111 | 同じ |
| +1 | 00000000 | 00000000 | 同じ |
| > 0 か | true | false | 割れる |
| ÷2 | 127 | 0 | 割れる |
| 16ビットへ広げる | 0000000011111111(255) | 1111111111111111(−1) | 割れる |
−1 と 255 の違いが分からないのは、理解が足りないからではなく、ビットの中に区別が無いから。意味はビットではなく型に付いている。
int8 を int16 や int64 に広げるときは、先頭ビットを左に複製する(符号拡張)。16ビットの −1 は 1 が16個なので、8ビットの −1 を広げるなら左を 1 で埋めるしかない。0 で埋めると値が変わる。
| int8 | ビット列 | 0 で埋める | 先頭ビットで埋める(符号拡張) |
|---|---|---|---|
| −1 | 11111111 | 0000000011111111 = 255 | 1111111111111111 = −1 |
| −5 | 11111011 | 0000000011111011 = 251 | 1111111111111011 = −5 |
| 5 | 00000101 | 0000000000000101 = 5 | 0000000000000101 = 5 |
uint8 を広げるときは左を 0 で埋める(ゼロ拡張)。同じ 11111111 でも、型によって広げ方の命令が変わる。仕様が「0〜255」の1バイトを int8 で受けると、広げた時点で 255 が −1 に化ける。
箱が全部埋まった状態で 1 を足すと、はみ出した桁は捨てられる。
| 型 | 計算 | 結果 | 繰り上がりの行き先 |
|---|---|---|---|
| uint8 | 11111111(255)+ 1 | 00000000(0) | 9桁目に出て捨てられる |
| int8 | 01111111(127)+ 1 | 10000000(−128) | 先頭の桁(重み −128)に入る |
int8 では、繰り上がりが先頭の桁に 1 を運び、その桁の重みが −128 なので、プラスの端からマイナスの端に飛ぶ。繰り上がりは先頭に 1 が入る原因であって、先頭ビットの意味(重み −128)とは別のもの。
同じあふれでも、処理系によって振る舞いが違う。
| 処理系 | 計算 | 結果 |
|---|---|---|
| Go | MaxInt64 + 1 | −9223372036854775808(一周して続行) |
| BigQuery | SELECT 9223372036854775807 + 1 | Error: Integer Overflow |
| BigQuery | SAFE_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 のべき乗は一切使わない。
| 部品 | ビット数 | 役目 |
|---|---|---|
| 符号 | 1 | 0 なら正、1 なら負 |
| 指数 | 11 | 小数点をどこまで動かすか → 範囲 |
| 仮数 | 52(+暗黙の1) | 数字の並びを何桁持てるか → 精度 |
1101. になるように小数点をずらす:110. → 1.10 × 2^21. は必ず 1 なので保存しない(暗黙の1)。仮数は実質53ビット10000000001 として書く| 数 | 2進 | 指数ビット | 箱の値 | 1023 を引いた指数 |
|---|---|---|---|---|
| 1 | 1 | 01111111111 | 1023 | 0 |
| 2 | 10 | 10000000000 | 1024 | 1 |
| 6 | 110 | 10000000001 | 1025 | 2 |
| 10 | 1010 | 10000000010 | 1026 | 3 |
| 0.5 | 0.1 | 01111111110 | 1022 | −1 |
指数は「2進で書いたときの桁数」に対応する。2進で3桁の数(4〜7)は指数2、4桁の数(8〜15)は指数3。
11ビットの箱に書けるのは 0〜2047 の正の数だけだが、0.5 = 1.× 2^−1 のように負の指数も書きたい。そこで「書くときは +1023、読むときは −1023」と約束する。1023 は 0〜2047 のほぼ真ん中なので、プラス側とマイナス側を半分ずつ使える。正の float どうしなら、ビット列を整数として比べるだけで大小が正しく出るという利点もある。
負の数の約束は1つではない。整数は2の補数、float の指数はバイアス、float の符号は「符号ビット+絶対値」を使う。
計算上は 0 − 1023 = −1023 から 2047 − 1023 = +1024 だが、両端は特別な値の予約席になっている。
| 箱の中身 | 0 | 1〜2046 | 2047 |
|---|---|---|---|
| 意味 | ゼロ・非正規化数 | 指数 −1022〜+1023 | +Inf・NaN |
下の端が無いと 0 が書けない。暗黙の1があるので、普通の float は必ず 1.xxx × 2^n になり、0 にはならないからである。
| 指数ビット | 箱の値 | 表す数 |
|---|---|---|
11111111110 | 2046 | 最大 MaxFloat64 = 1.7976931348623157e+308 |
00000000001 | 1 | 2^−1022 = 2.2250738585072014e-308 |
| (2^1024 を計算) | 2047 | +Inf |
11ビットで表せる最大は 2047。1023 を引いて指数にするので、計算上は −1023〜+1024 になる。ただし一番端の2つは特別な値に取っておくので、普通の数で使えるのは −1022〜+1023。範囲は「ビット数 → 書ける通り数 → 約束 → 表せる範囲」の順で導ける。暗記はいらない。
float32 は「符号1・指数8・仮数23」で、範囲は約 10^38、有効桁は約7桁。ビットの配分を変えると、範囲と精度の取り合いが変わる。
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.2 | 0 | 0.2 |
| 0.2 × 2 = 0.4 | 0 | 0.4 |
| 0.4 × 2 = 0.8 | 0 | 0.8 |
| 0.8 × 2 = 1.6 | 1 | 0.6 |
| 0.6 × 2 = 1.2 | 1 | 0.2 |
| 0.2 × 2 = 0.4 | 0 | 0.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.1 | 0.1000000000000000055511151 | 少し大きい |
| 0.2 | 0.2000000000000000111022302 | 少し大きい |
| x + y | 0.3000000000000000444089210 | Println でも 0.30000000000000004 と出る |
| 0.3 | 0.2999999999999999888977698 | 0.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.01 | 1.24 | 3桁に収まる |
| 123 + 1 | 124 | 3桁に収まる |
| 12300 + 1 | 12300 | 12301 は5桁の並びが要るので覚えられない |
| 12300 + 100 | 12400 | 並びは 124 の3桁で済む |
次に覚えられる数までの距離(間隔)は、1.23 なら 0.01、123 なら 1、12300 なら 100。覚えられる桁数が決まっているので、数が大きいほど間隔が広がる。間隔より小さい足し算は消える。float64 は、これが10進でおよそ16桁(2進で53桁)の電卓である。
2進で3桁しか覚えられない電卓(仮数3ビット)に、1〜17 を入れる。
| 数 | 2進 | 桁数 | 結果 |
|---|---|---|---|
| 7 | 111 | 3 | 覚えられる |
| 8 | 1000 | 4 | 覚えられる(1 × 2^3) |
| 9 | 1001 | 4 | 覚えられない → 8 に丸め |
| 10 | 1010 | 4 | 覚えられる(101 × 2^1) |
| 11 | 1011 | 4 | 覚えられない → 12 に丸め |
| 16 | 10000 | 5 | 覚えられる(1 × 2^4) |
| 17 | 10001 | 5 | 覚えられない → 16 に丸め |
4桁の数を3桁の箱に入れるには、はみ出した末尾が 0 でなければならない。末尾の 0 は指数(2^x)が巻き取れるが、末尾の 1 は巻き取れない。2進では奇数の末尾は必ず 1 なので、4桁の区間(8〜15)では偶数しか持てない。5桁の区間(16〜31)では末尾2桁が 00、つまり4の倍数しか持てない。
| 範囲 | 2進の桁数 | 覚えられる数 |
|---|---|---|
| 1〜7 | 3桁以内 | 全部 |
| 8〜15 | 4桁 | 偶数だけ |
| 16〜31 | 5桁 | 4の倍数だけ |
最後の桁が 0 なら覚えられる。末尾の 0 は 2^x のところで巻き取れるから。仮数に残るのは、0 を指数に預けたあとの数字の並びで、それが箱に収まるかどうかで決まる。
float64 の仮数は53桁なので、2進で54桁になる 2^53 = 9007199254740992 から先は、奇数が持てない。
| 数 | 2進の桁数 | 2進の末尾 | 結果 |
|---|---|---|---|
| 10000000000000003 | 54 | …0011 | 末尾が 1。巻き取れないので持てない |
| 10000000000000004 | 54 | …0100 | 末尾が 0。指数が巻き取るので持てる |
隣の float64 との間隔は、2のべき乗の区間ごとに倍になる。どの区間にも同じ数(2^52 個)の目盛りがあるからである。
| 数の大きさ | 隣の float64 との間隔 | 区間 |
|---|---|---|
| 1 | 2.2e-16 | |
| 4.5e15 | 0.5 | |
| 9.0e15 | 1 | 2^53 の手前 |
| 1.0e16 | 2 | 2^53〜2^54 |
| 2.0e16 | 4 | 2^54 を超えた |
(4.5e15 は 4.5 × 10^15 の意味。この e は表示用の10進の指数表記で、float の中の2のべき乗の指数とは別物)
1e16 の近くでは2刻みしか持てないので、奇数を足すと目盛りの真ん中に落ち、近い偶数に寄せられる。真ん中のときは、仮数の末尾が偶数になる側へ寄せる(偶数丸め)。切り上げと切り捨てが交互になり、誤差が一方向に溜まらない。
| 計算 | 実際に増えた量 | 理由 |
|---|---|---|
| 1e16 + 1 | +0 | 0 と +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) | 3 | 3 | −3 | 四捨五入(.5 は 0 から遠い方) |
BigQuery ROUND(n, 0, "ROUND_HALF_EVEN") | 2 | 3 | 偶数丸め(NUMERIC のみ) | |
BigQuery CAST(x AS INT64) | 3 | 3 | −3 | 四捨五入 |
Go math.Round | 3 | 3 | −3 | 四捨五入 |
Go math.RoundToEven | 2 | 3 | 偶数丸め | |
Go int(x) | 2 | 2 | −2 | 0 方向へ切り捨て |
BigQuery の CAST は四捨五入で近い方に寄せる。Go の int() は切り捨て。負の数では「下へ」ではなく「0 の方へ」切り捨てるので、−2.9 は −2 になる。
整数の割り算も処理系で違う。Go の 7/2 は 3(整数どうしは整数)、BigQuery の 7 / 2 は 3.5(FLOAT64 を返す)で、整数が欲しければ DIV(7, 2)。
数は、有限のビットに詰めた近似である。だから数値の事故は2種類しかない。
| 事故 | 何が起きるか | 典型 |
|---|---|---|
| 範囲(桁あふれ) | 入れ物に入りきらない | INT64 の最大値 + 1、uint8 の 255 + 1 |
| 精度(ずれ) | 入るが正確に書けない | 0.1 + 0.2 ≠ 0.3、1e16 + 1 = 1e16、2^53 を超える ID が JSON で化ける |
型を選ぶことは、どちらの事故を受け入れるかを選ぶことである。整数は精度を守る代わりに範囲が狭い。浮動小数点は範囲が広い代わりに精度が有限。NUMERIC は10進で正確に持つ代わりに、範囲と小数桁が固定される。