はじめに
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
を使えば四捨五入の場合は解決してくれます。
いくつか遠回りをして見てきましたが、数値の扱いは実装の考慮漏れなどが起きやすいので、背景を理解して利用していきたいです。