JavaScriptで数値を任意の桁で丸める方法

## はじめに
JavaScriptで数値を任意の桁で丸める方法は様々ありますが、いくつかのパターンでは誤差が生じてしまいます。この記事では、任意の桁を丸める際に誤差が発生しない方法について紹介します。

## 四捨五入
丸め方は様々ありますが、まずは四捨五入について考えていきます。
JavaScriptで数値を任意の桁で四捨五入するためには[`Math.round`](https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-math.round)、もしくは[`toFixed`](https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-number.prototype.tofixed)を利用して計算するのが一般的です(標準に組み込まれているものを使って簡単に作れることから一般的と言いました)。
言語仕様等を何も考慮せずに作れば、以下のようになると思います。

```ts
// 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`](https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-number.epsilon)を導入します。これは1と1より大きな最小の浮動小数点数の差を表しているので、`1.0049999999999999`を`1.005`と誤魔化して計算を進められそうです(`radix + Number.EPSILON`は`1.0050000000000001`のようになります)。
```ts
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`の場合は値が変わらないということです)。

全く別の方法が必要そうです。次の方法を見ていきましょう。

```ts
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
  }`);
};
```

かなり複雑ですが、これを用いればこれまで出てきたすべてのケースを正しく計算できます(うまく計算できない例があればコメント等で教えて欲しいです)。

```ts
const [mantissa, exponent] = `${radix}e`.split('e');
```
この部分は数値を整数部分と指数部分で分割しています。
これは指数表記の場合のケアのために行っています。`2.55e3`は`mantissa`に`2.55`、`exponent`に`3`のように分けて格納されます。
よく見る形の`1.005`のような数値は`mantissa`に`1.005`、`exponent`に`undefined`が格納されます。ただ、`mantissa`に数値が移るだけです。
```ts
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`を作り出すことに成功しています。

これを四捨五入することで、誤差のない状態で実行することに成功しました。

```ts
const [calcedMantissa, calcedExponent] = `${value}e`.split('e');
  return Number(`${calcedMantissa}e${
    Number(calcedExponent ?? '') - fractionDigits
  }`);
```
最後の処理は、最初に見た処理を再度行って計算した数値の桁を`fractionDigits`分下げます。


この方法を用いることで、最初の`1.005`の例はもちろん`8.075`の例を計算できました。

## 切り上げ
四捨五入の`Math.round`の部分を`Math.floor`に変えるだけです。
```ts
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`に変えるだけです。
```ts
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`も浮動小数点数を使用した演算による誤差を気にせずに四捨五入を行えます。

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