はじめに
業務アプリなど、金額計算はdecimalでやれ(.NET系)と言われ続けていました(誤差が出るから)
でもJavaScriptでは存在しないので注意が必要です
ちょっと気になったのでChatGPTに聞いてまとめてみました
浮動小数の誤差について
浮動小数点は、有限のビット数で実数を近似的に表現する方式のため正確な値を表せないことが多く、わずかな誤差が含まれます。
この誤差の最小単位のことを「計算機イプシロン(machine epsilon)」と呼びます。
たとえば、JavaScript では次のように定義されます(2^-52、つまり仮数部で表せる値)
Number.EPSILON // ≒ 2.220446049250313e-16
0.1 や 0.3 は、2進数では循環小数となるため、本来の値に最も近い値に丸められます。
そのため、0.1は少し大きく、0.2は少し小さくなります。
> (0.1).toFixed(20)
'0.10000000000000000555'
> (0.3).toFixed(20)
'0.29999999999999998890'
本来の値がちょうど中間の位置にある場合には、誤差の方向が偏らないよう、偶数丸め(round to nearest, ties to even)が行われます。
これにより正の丸めと負の丸めが相互に打ち消しあい、計算を繰り返したときの誤差蓄積が抑えられます。
※ただし、偶数丸めが発生するのは非常に特殊なので、単純な小数(0.1 や 0.3 など)では発生しない
Number.prototype.toFixed() について
固定小数点表記を用いて整形する関数です、と書いてありますが指定桁に「丸める」関数です
ドキュメントには正確に書いてないですが
偶数丸めではなく「四捨五入」をしています(仕様とのこと)
> (1.5).toFixed(0)
'2'
> (2.5).toFixed(0)
'3'
ただ、この関数は思った値を返さないことがあります(四捨五入なのに切り捨てが発生)
- 四捨五入であれば全て切りあがるはずだが、切りあがらない場合がある
> (1.05).toFixed(1);
'1.1'
> (1.15).toFixed(1);
'1.1'
> (1.25).toFixed(1);
'1.3'
> (1.35).toFixed(1);
'1.4'
> (1.45).toFixed(1);
'1.4'
これは、表面上の値ではなく浮動小数の内部的な値を使って「四捨五入」しているためです
1.15 -> 1.14999 になるので、2桁目を四捨五入すると切り下がる
- 昔これでバグったことあるので、注意しましょう
- 言語によって動作が異なるので要確認(.NETの四捨五入(MidpointRounding.AwayFromZero)では想定した値になってくれる)
> (1.05).toFixed(20);
'1.05000000000000004441'
> (1.15).toFixed(20);
'1.14999999999999991118'
> (1.25).toFixed(20);
'1.25000000000000000000'
> (1.35).toFixed(20);
'1.35000000000000008882'
> (1.45).toFixed(20);
'1.44999999999999995559'
> (1.5).toFixed(0)
解決しなかった疑問
> [...Array(10)].reduce((acc) => acc + 0.1, 0).toFixed(20)
'0.99999999999999988898'
> (0.1*10).toFixed(20)
'1.00000000000000000000'
>
加算の場合は、計算誤差が積みあがった結果1より少し小さくなった
乗算の場合は、1 回の演算で計算された結果誤差が小さく「最も近いビットパターン」が「1」になるから
とChatGPTは答えてくれたけど、乗算が誤差が出ないのはなぜか?がいまいち納得できない・・・