float を解剖する

2020年1月21日

多くの C++ プログラマーにとって float というのは中途半端な存在です。この時代、ほとんどの環境では double のデメリットは皆無に等しいですし、逆に FPU のないような古典的な CPU で頑張る「マイコン屋」にとっては未だ float は贅沢品です。

ARM の Cortex-M4F や Cortex-R4F のような float に有利な命令セットを持つ CPU を使い、なおかつマイクロ秒オーダーで演算時間を切り詰めたい人たちだけが、float を必要とします(私がそうでした)。

そんな float ですが、取り扱いには注意が必要です。少なくとも float の内部表現ぐらいは知っておかないと、その挙動はまったく不可解なものに映ることになるでしょう。

内部表現

事実上の標準規格である IEEE 754 に準拠したマシンでは、float は次のように実装されます。

このことが現実にどのような問題となるのか。次の二つの事実を理解しておく必要があります。

1. 小数は常に誤差を含む

まず、コンピュータ上の浮動小数点表現は、本質的に10進数の小数と非常に相性が悪いということを知っておかなければなりません。これは double であっても同様です。なぜなら小数がどのような数であれ、それは2のべき乗数の和として表現されることになるからです。つまり、0個か1個の次のような数の和です。

$2^{-1}$0.5
$2^{-2}$0.25
$2^{-3}$0.125
$2^{-4}$0.0625

ところで、これらの数字の末尾に注目してください。位が1つずつずれ、すべて '5’ で終わっていますね。この数列を無限に並べても、この法則が途切れることはありません。ということはです。これらの和として作った小数の末尾は、必ず '5’ になることはおわかりですね?

逆に言えば、少なくとも末尾が '5’ でない小数を2進数で完全に表現することは絶対にできないということになります。つまり、必ず誤差が含まれます。もちろん '5’ で終わるからといって必ずしも誤差がないとは言い切れません。

これを見れば、むしろ2進数で誤差なく表現できる小数のほうがレアだということがおわかりいただけるかと思います。仮に私たちが8進数や16進数の世界に生きていたら、このような悩みはなかったのですが。

2. 有効数字は7桁が限界

float の仮数部は23ビットです。実際には、仮数部の最上位の1が常に省略されているため、実質的には24ビットです。これは10進数でいえば約7桁に相当します。

$$24 \times \frac{\log 2}{\log 10} \fallingdotseq 7.22472 $$

この有効桁数に由来する問題として、「丸め誤差」と「情報落ち」が知られています。これらは double であっても本質的には同じことですが、ここでは取り上げません。

a) 丸め誤差

これは直感的に理解できる概念だと思います。値を収容するビット数に限りがある以上、精度にも限界があるという話です。たとえばネイピア数 e を float で表現してみます。

float e = 2.71828182845904523536;
printf("%.20f\n", e);

実行結果は、次のようになりました。

2.71828174591064453125

値が保持できているのは有効数字が7桁であるところの 2.718282 までで、そこから先の数字はノイズ同然であることがわかりますね。

見落とされがちなのが、整数であっても誤差が生じるということです。実質的な仮数部が24ビットなので、float で正確に再現できる整数の範囲は ±16777216 までです。この範囲を超えると誤差が出ます。

float 値の小数点以下の切り捨て・切り上げを行う floorf、ceilf という標準関数がありますが、非常に誤解を招きやすい関数です。戻り値がなぜか float で、そもそも整数を表現できないからです。たとえば次のコードを実行すると――

int a = floorf(12345678.9F);
int b = ceilf(98765432.1F);
printf("a: %d\n", a); // 12345678 を期待
printf("b: %d\n", b); // 98765433 を期待

期待に反して、結果は次のようになります。

a: 12345679
b: 98765432

float で整数を扱おうとする際は、ご注意ください。

b) 情報落ち

おそらく float にまつわるバグの典型がこれではないかと思います。

有効数字に限りがあるということは、極大な値と極小な値を加算しても、その結果が失われるということです。たとえば

$$1234567 + 0.0625 = 1234567.0625$$

という式を考えます。左辺の両項はともに float で表現可能な値であるにもかかわらず、右辺は float で表現できません。実際に次のコードを実行すると――

float x = 1234567;
x += 0.0625;
printf("%f\n", x);

結果は次のようになります。

1234567.000000

これは恐怖です。もし貯金箱が float でできていたら、コツコツお金を積み立てても、途中からお金が増えなくなることを意味します。私はこの手のバグが実際に騒ぎを起こしたのも見たことがあります。float を使うプログラマーは、絶対にこの事実を理解しておいてください。