2進数の小数は、 1 2 , 1 4 , 1 8 \frac 1 2, \frac 1 4 , \frac 1 8 , ……と桁を下る。整数と違い、足し引きでキリの良い数字にできない。しかも、多くは無限に続く小数になってしまう。

これでは使えないので、内部で有能なダンゴムシが近似値に丸める。ダンゴムシは丸まるだけだ。丸めるのはフンコロガシだった。

ともかく、この結果として生まれるのが丸め誤差である。

0.1+0.2は0.3にならない

丸め誤差のサンプルとして、0.1 + 0.2という式がよく挙げられる。ハッピー・ウェディング。この式をプログラムで実行すると、結果が0.3にならない。そんな話がまことしやかに囁かれている。

例えば以下のコードを試してみる。

c
#include <stdio.h>

int main() {
    printf("0.3であれ: %f\n", 0.1 + 0.2);
    // 0.3であれ: 0.300000

    return 0;
}

ふつうに0.300000が出てくる。

「この嘘つきどもめが!」と怒ってはいけない。これはC言語の%fがデフォルトでは小数点以下6桁までしか表示しないためである。桁数を増やすと結果が変わる。

c
#include <stdio.h>

int main() {
    printf("0.3であれ: %.17f\n", 0.1 + 0.2);
    // 0.3であれ: 0.30000000000000004
}

17桁目に4が現れる。

これは、浮動小数点数の仕様を標準化する規格に準ずる多くの言語が共有する挙動である。

js
console.log(`0.3であれ: ${0.1 + 0.2}`);
// 0.3であれ: 0.30000000000000004

2進数では10進数の小数を正確に表現できない

整数でうまくやれているのだから、小数を整数として解釈し直せば、誤差なく計算できそうである。

例えば0.1を2進数で表現しようとすると無限循環小数となり、丸め誤差が生まれる。最初に10を掛けて1にし、必要な演算をした後に10で割れば、誤差は生まれない。

などと考えると迷宮に迷い込み、時間を無駄にする。いつから0.1が10進数の正確な0.1だと錯覚していた?

コンピュータは2進数しか処理できず、整数も小数も、演算自体は何も変わらない。ただ、2進数を10進数に、10進数を2進数に変換すると、必ず小数で丸め誤差が発生する。2進数できっちり表現できる小数もあるが、小数全体からすれば微々たるものである。

そもそもコンピュータの資源は有限であり、無限の小数を表現できない。どこかで丸めて扱うしかない。さもないと亀も追い抜けない。

固定小数点数

小数は、最小の桁が一定ではない。例えば32.53.25では最小の桁が異なる。正しく演算するには、どの桁がどの桁に対応しているかを知る必要がある。こうした処理は、初期のコンピュータには重すぎた。

そのため、計算の前に小数点の位置を定義する方法が取られた。この方法で表現される小数は、固定小数点数と呼ばれる。固定小数点数は、全体を1つの整数として処理し、小数点の位置を戻す。

つまり、先に触れたような処理を、もっとスマートに実現する。ただ、これだと計算できる桁数が限られる。整数部分を増やすと小数部分が減るし、小数部分を増やすと整数部分が減る。演算ごとに小数点の位置を定義する手間もある。

浮動小数点数

浮動小数点数は、固定小数点数よりも精度の高い数値の計算を行うために発明された。

現在では、IEEE 754-2008によってその仕様が標準化されている。IEEE 754の浮動小数点数では、2進数を符号部、指数部、仮数部と分けて表現する。

各部の名称 概要
符号ビット 最初のビット。0で正、1で負を表す。
指数部 仮数部を元に戻すために必要なビットシフト数。
……と見せかけて、シフト数に対して仮数部の最初のビット以外を1埋めした値を足した値が入る。正負を表現することが主な目的。
仮数部 対象の値を2進数に直し、1.で始まるようにビットシフトした値。
……のお尻を所定のビット数分0で埋めた値。

これらに割り振られるビット数は、小数の精度によって異なる。IEEE 754標準で規格化され、広く普及しているのは以下の2つのパターンである。

格納形式 符号部 指数部 仮数部 全体
単精度 1 8 23 32
倍精度 1 11 52 64

例えば0.0625という小数を、単精度(32ビット)の浮動小数点数で考える。0.0625は、2進数だと0.0001である。

まずこれを1.0の形に直す。 2 4 2^{4} したので、元の値に戻すには 2 4 2^{-4} してやる必要がある。指数部は-4なので、2進数で1111 1100としたい。

ただ、指数部に正と負の値があると演算が大変だ。全部の正の値であれば演算が楽になる。このために、バイアス値と呼ばれる値が足される。つまり元の正負の値の範囲を丸ごと0以上にズラす。これは、最初の1桁以降を0で埋めたビット列である。

32ビットの浮動小数点数は指数部が8ビットであるので、0111 1111が足される。

また、仮数部は23ビットであるから、1に22個の0を繋げた値を格納したくなる。しかし、有効桁数を増やすために最初の1は省略される。1であることが前提であるから保存する必要はない、ということだ。この隠される1ビットは、隠れビット(hidden bit)と呼ばれる。

このような仕組みのため、今回のケースでは仮数部に23個の0が並ぶ。

c
#include <stdio.h>

typedef union {
  float target;
  struct {
    unsigned int mantisa : 23;
    unsigned int exponent : 8;
    unsigned int sign : 1;
  } parts;
} float_cast;

void printBinary(unsigned int num, int bits) {
    for (int i = bits - 1; i >= 0; i--) {
        printf("%u", (num >> i) & 1);
    }
}

void printFloatParts(float val){
  float_cast f = { .target = val };
  printf("対象値: %f", val);
  printf("\n符号部: ");
  printBinary(f.parts.sign, 1);
  printf("\n指数部: ");
  printBinary(f.parts.exponent, 8);
  printf("\n仮数部: ");
  printBinary(f.parts.mantisa, 23);
}
int main(void) {
  printFloatParts(3.25);
}

/* 結果
対象値: 0.062500
符号部: 0
指数部: 01111011
仮数部: 00000000000000000000000
*/

わけわかんないよね。

浮動小数点数と丸め誤差

丸め誤差は、主に浮動小数点数で生まれる。精度が期待される状況では固定小数点数を使うのが一般的だ。

ただ、固定小数点数では丸め誤差が生じないわけではない。全体を整数として解釈しても、2進数を10進数に直すと小数部分では丸め誤差が生じる。

精度が期待される状況で固定小数点数を使うのは、丸め誤差の範囲を予測して切り捨てるためである。数値の細かさという意味の精度であれば、浮動小数点数の方がずっと高い。

これはなんの記事だったか。

0.1 + 0.20.3にならないのは、2進数と10進数で、表現できる小数が完全に対応していないためである。小数って不思議。

参考資料