C#の浮動小数点型の丸め誤差でハマったので対策を考える

2022年3月15日

はじめに

最近、C#の実装で浮動小数点の減算処理で丸め誤差に起因するバグで数時間ハマったので調べてみようと思います。
これが正解化はさておき、対応策をいくつか提示します。

ハマったコード

double a = 0.410;
double b = 0.200;

Console.WriteLine(a - b); // 0.20999999999999996. 本当は0.210が返却されてほしい

結論から申し上げますと、これは浮動小数点型の丸め誤差が発生しているため期待する結果を得られていません。

数値型

数値を表すデータ型には、 整数型実数型があります。
さらに整数は、符号の情報をデータに含めるかどうかで分けられます。
整数型には実数型はデータを格納する内部のしくみによって分けられます。
正負の符号情報も含んでいる型 (sbyte, short, int, long) と、 正のデータ のみの型 (byte ushort, uint, ulong) があります。
sbyte の 「s」 は 「signed (符号付き)」 を示し、
ushort, uinte ulong の 「u」 は 「unsigned (符号なし)」 を示しています。
これらのデータ型のサイズは、8ビットから64ビットです。
1ピットで2つの状態を表すことができる ので、8ビットなら2の8乗まで表現することができます。
ただし、符号の情報にもピットが必要なため、符号付きの場合は、表現できるデータの範囲が1ビット分だけ少なくなります。

実数型は、小数点がある数値を表すデータ型です。 どれも符号が付いています。
float 型と double 型は、浮動小数点と呼ばれる方式で値を格納します。
内部的には符号部、 仮数部、指数部に分かれており、2進数で小数点を表現します。
この方式では、10進数の小数 を正確に表現できない場合があり、 誤差 (丸め誤差) が発生する可能性があります。
ただし、大きな値を少ない領域で格納できるという利点があります。

それに対して decimal 型は、 内部的にも10進数のまま、 整数と小数点の位置で値を格納する 方式になっています。
そのため、有効桁数の範囲内という条件はありますが、誤差は生じません。
ただし、 他のデータ型に比べてサイズがとても大きくなります。
財務や金融計算など、比較的大きな桁数で誤差が許されないプログラムでは、 decimal 型が使用されます。

整数をリテラルで指定する場合、 10進数や、 0~Fを使った16進数、C# 7 から可能となった、 0とⅠを使った2進数で表します。
次の2つは同じ整数値を代入しています。

(※リテラル)
プログラムの中で、文字したデータのことをリテラルと呼びます。

int a 12341 // 10進数
int b 0x4D2;// 16 進数

16進数は、債の先頭に 「0x」 (ゼロとエックス) または 「OX」 というプレフィックス (接頭文字) を付けて表記します。
「x」 は 「heXadecimal (16進数)」 を意味します。
16進数のA~Fは、 小文字で指定しても問題ありません。

2進数の場合は、 値が0と1のみとなり、 先頭に 「0b」 (ゼロとビー) または 「0B」を付けて 表記します。
「b」 は 「binary number (2進数)」 を意味します。
なおC#7からは、区切り文字に 「_」 (アンダースコア) を使って、数値のリテラルをグルー 化することができます。
たとえば、 1_234_567は、 1234567と同じ数値となります。

この機能は、長くなりがちなのリテラルには次のように4ピットごとに区切ってわかりやすく表記ようになります。

int b = 0b00011001;
int c = 0b001_1001; // 16進数なら0x19

区切り文字数に制限はありませんが、末尾には付加できません。
またC#7.2からはフィックスの後にも書けるようになりました (C# 7.0.7.1 では不可)。

int c = 0b_0001_1001; // C2からOK C#7.0~7.1はNG

実数をリテラルで指定するには、小数点を使った10進数の表記と、指数表記があります。
指数表記は10の累乗を示す「e」 もしくは 「E」 を使った表記で、
たとえば 「123000.0」 は 「123 ×10の5乗 なので、 「123e5」 と表記します。
なお、実数リテラルは10進数しか扱えません。

double a = 0.12
double b = 1,23e5 // 指数表記(123000)

C#のコンパイラは、小数点があるか指数表記であるリテラルをdouble 型と見なし、
それ以 外は数値の大きさによってint, uint, ulong long と見なします。

データ型を自動で決められると困る場合は、 「U」 や 「F」 などのサフィックス(接尾文字) のに付けることによって、明示的に指定することもできます。 次の表に、サフィックスの種類と例を示します。

サフィックスは小文字でも大文字でもかまいませんが、
小文字だと数字と紛らわしい場合があるので (Lの小文字と数字の1など)、大文字で表記するとよいでしょう。

(参照:「基礎からしっかり学ぶ C#の教科書 改定新版 C#8対応」 3.2.1 数値型 より抜粋)

対策実装

10進数型にキャストしてから比較する

double a = 0.410;
double b = 0.200;

Console.WriteLine((double)a - (double)b); // 0.210

文字列にキャストしてフォーマットしてしまう

文字列型にキャストしてフォーマットしてからDouble型に再変換する方法です。
これは確実にa,b に数値型が渡る前提で、NULLが渡ったりする場合危険な方法です。

double a = 0.410;
double b = 0.200;

Console.WriteLine(Double.Parse((a - b).ToString("F3")); // 0.210. F3は小数点以下3桁でフォーマットする

まとめ

2パターンほど丸め誤差の対策をして減算処理を行う実装案を提示しましたが、これが良いコードかはわかりません。参考にされる場合はご注意ください。