はじめに
JavaScriptで数値を任意の桁で丸める方法は様々ありますが、いくつかのパターンでは誤差が生じてしまいます。この記事では、任意の桁を丸める際に誤差が発生しない方法について紹介します。
四捨五入
丸め方は様々ありますが、まずは四捨五入について考えていきます。
JavaScriptで数値を任意の桁で四捨五入するためにはMath.round、もしくはtoFixedを利用して計算するのが一般的です(標準に組み込まれているものを使って簡単に作れることから一般的と言いました)。
言語仕様等を何も考慮せずに作れば、以下のようになると思います。
// by Math.round
const roundByMath = (radix: number, fractionDigits = 1): number => {
const factor = Math.pow(10, fractionDigits);
return Math.round(radix * factor) / factor;
};
// by toFixed
const roundByFiexed = (radix: number, fractionDigits = 1): number => {
// toFixedの仕様によりfractionDigitsは0~100の値
return parseFloat(radix.toFixed(fractionDigits));
};
これらの関数を用いれば、ほとんどのケースで問題なく計算ができます。
しかし、例えばradixが1.005でfractionDigitsが2のケースでは1が返ってきてしまいます(計算環境はChromeのバージョン131.0.6778.86です)。
我々はこの結果が1.01となることを期待していましたが、どこで歯車が狂ってしまったのでしょうか。
toFixedで計算を済ませている後者は完全に計算が見えないので、前者のroundByMathを分割して確認します。
まず、Math.powを計算した時点でのfactorの値は100になっているので正しいです。
次に、Math.roundに渡しているradix * factorの結果を見てみると、100.49999999999999となっていました。確かに、この値にMath.roundを行うと100になってしまい、それを100で割ると1になってしまいますね。
これはよく知られた丸め誤差による影響で、浮動小数点数を使用した演算であることが原因です。0.1 + 0.2 === 0.30000000000000004のような結果になるのと同じ理由です。
つまり1.005は計算する際に2進数で正確に表現できないので、計算の際は内部的に1.0049999999999999のように近似値として行われるのが原因というわけです(逆に1.0049999999999999は1.005と同じように計算されます)。
この差を埋めるためにNumber.EPSILONを導入します。これは1と1より大きな最小の浮動小数点数の差を表しているので、1.0049999999999999を1.005と誤魔化して計算を進められそうです(radix + Number.EPSILONは1.0050000000000001のようになります)。
const round = (radix: number, fractionDigits = 1): number => {
const factor = Math.pow(10, fractionDigits);
return Math.round((radix + Number.EPSILON) * factor) / factor;
};
先ほど確認した、radixが1.005でfractionDigitsが2のケースで、正しく1.01が帰ってくるようになりました。
この方法にも問題があります。radixに8.075を代入すると、8.08を期待するところ8.07が返ってきます(改善前から同じ値が返ります)。
これは先ほどNumber.EPSILONを用いた誤魔化しが効かないパターンがあるということです(つまり8.075 + Number.EPSILONの場合は値が変わらないということです)。
全く別の方法が必要そうです。次の方法を見ていきましょう。
const round = (radix: number, fractionDigits = 1): number => {
const [mantissa, exponent] = `${radix}e`.split('e');
const value = Math.round(
Number(`${mantissa}e${Number(exponent ?? '') + fractionDigits}`)
);
const [calcedMantissa, calcedExponent] = `${value}e`.split('e');
return Number(`${calcedMantissa}e${
Number(calcedExponent ?? '') - fractionDigits
}`);
};
かなり複雑ですが、これを用いればこれまで出てきたすべてのケースを正しく計算できます(うまく計算できない例があればコメント等で教えて欲しいです)。
const [mantissa, exponent] = `${radix}e`.split('e');
この部分は数値を整数部分と指数部分で分割しています。
これは指数表記の場合のケアのために行っています。2.55e3はmantissaに2.55、exponentに3のように分けて格納されます。
よく見る形の1.005のような数値はmantissaに1.005、exponentにundefinedが格納されます。ただ、mantissaに数値が移るだけです。
const value = Math.round(
Number(`${mantissa}e${Number(exponent ?? '') + fractionDigits}`)
);
続いての部分は、先ほど分解したものとfractionDigitsを組み合わせて四捨五入まで実行しています。
Number(exponent ?? '')は数値への変換を行っています。undefinedの時は0になります。
その後fractionDigitsを足してMath.roundで四捨五入を行う前の桁調整を行なっています。
最終的にMath.roundに渡される値は元のradixをfractionDigits桁あげたものになります。
例としてradixが1.005で、fractionDigitsが2の場合を考えると、1.005e(0+2)を計算して1.005e2、つまり100.5になります。
ここまでがroundByMathにおけるradix * factorの処理になります。
1.005を100.5に変換できていますよね?直接100を掛け合わせるのではなく、桁を上げるだけの処理に変えることで浮動小数点数を使用した演算を避けて、100.5を作り出すことに成功しています。
これを四捨五入することで、誤差のない状態で実行することに成功しました。
const [calcedMantissa, calcedExponent] = `${value}e`.split('e');
return Number(`${calcedMantissa}e${
Number(calcedExponent ?? '') - fractionDigits
}`);
最後の処理は、最初に見た処理を再度行って計算した数値の桁をfractionDigits分下げます。
この方法を用いることで、最初の1.005の例はもちろん8.075の例を計算できました。
切り上げ
四捨五入のMath.roundの部分をMath.floorに変えるだけです。
const floor = (radix: number, fractionDigits = 1): number => {
const [mantissa, exponent] = `${radix}e`.split('e');
const value = Math.floor(
Number(`${mantissa}e${Number(exponent ?? '') + fractionDigits}`)
);
const [calcedMantissa, calcedExponent] = `${value}e`.split('e');
return Number(`${calcedMantissa}e${
Number(calcedExponent ?? '') - fractionDigits
}`);
};
切り下げ
四捨五入のMath.roundの部分をMath.ceilに変えるだけです。
const ceil = (radix: number, fractionDigits = 1): number => {
const [mantissa, exponent] = `${radix}e`.split('e');
const value = Math.ceil(
Number(`${mantissa}e${Number(exponent ?? '') + fractionDigits}`)
);
const [calcedMantissa, calcedExponent] = `${value}e`.split('e');
return Number(`${calcedMantissa}e${
Number(calcedExponent ?? '') - fractionDigits
}`);
};
Intlで実行する
IntlのNumberFormatも浮動小数点数を使用した演算による誤差を気にせずに四捨五入を行えます。
const round = (radix: number, fractionDigits = 1): string => {
const formatter = new Intl.NumberFormat('ja-JP', {
// 数値の整形
style: 'decimal',
// 少数部分の最大桁数
maximumFractionDigits: fractionDigits,
// 少数部分の最小桁数
minimumFractionDigits: fractionDigits,
});
return formatter.format(radix);
};
文字列が返ってくることと、Denoでは一部対応していないことに気をつけてください。
終わりに
結局Intlを使えば四捨五入の場合は解決してくれます。
いくつか遠回りをして見てきましたが、数値の扱いは実装の考慮漏れなどが起きやすいので、背景を理解して利用していきたいです。