2643 words
13 minutes
JavaScriptで金額計算するときの浮動小数点対策

JavaScriptで金額計算を書くとき、

const price = 123456;
const rate = 7.3;
const discounted = Math.floor(price * (1 - rate / 100));

みたいに書きたくなる。

計算式としては自然。

ただ、値引率・消費税・金額みたいな「1円ズレると困る値」を扱うなら、JavaScriptの浮動小数点は一度意識しておいた方がいい。

この記事では、

  • なぜ小数計算で誤差が出るのか
  • 金額をどう持つか
  • 小数の率をどう扱うか
  • 値引率から値引額を計算する方法
  • 消費税を計算する方法
  • Django / Python側ではどうするか

を整理する。

実際にこの問題に気づいた経緯は別記事にまとめている。

その金額計算、本当に合ってる? JavaScriptの浮動小数点とAIコピペの怖い話

JavaScriptの小数計算では誤差が出ることがある#

有名なのがこれ。

0.1 + 0.2
// 0.30000000000000004

JavaScriptのNumberはIEEE 754の倍精度浮動小数点で扱われる。

そのため、0.1のような10進数の小数を内部で正確に表現できない場合がある。

普段の計算なら問題にならない程度の差でも、

Math.floor(...)

のような端数処理と組み合わさる金額計算では、なるべく「そもそも不要な小数計算をしない」方が扱いやすい。

基本方針:金額は円単位の整数で持つ#

たとえば12万3,456円なら、

const price = 123456;

とする。

わざわざ、

const price = 123456.00;

のように小数として考える必要はない。

日本円を円単位までしか扱わないシステムなら、金額は整数として扱う。

JavaScriptのNumberはすべて同じ数値型だが、±(2^53−1)までの整数は安全に表現できる。

通常の見積金額なら十分収まる範囲だが、巨大な集計値などを扱う場合はNumber.isSafeInteger()で確認することもできる。

Number.isSafeInteger(price);

消費税率も整数で扱えるなら整数で扱う#

たとえば税率10%なら、

const TAX_RATE = 10;

としておく。

こういう計算は避けたい。

const tax = Math.floor(price * TAX_RATE * 0.01);

0.01を使う必要がないからだ。

代わりに、

const tax = Math.floor(price * TAX_RATE / 100);

と書ける。

たとえば、

const price = 100001;
const TAX_RATE = 10;
const tax = Math.floor(price * TAX_RATE / 100);
console.log(tax);
// 10000

計算している内容も、

100001 × 10 ÷ 100

なので追いやすい。

小数の値引率は整数スケールにする#

問題は、

7.3%

のように率自体に小数が必要な場合。

今回は値引率を小数1桁まで扱うとする。

その場合、

7.3% → 73

と10倍した整数として扱える。

たとえば入力値から変換するなら、

const rateX10 = Math.round(Number(rate) * 10);

7.3%なら、

rateX10 === 73

になる。

ここで重要なのは、

73 = 7.3 × 10

なので、実際の割合に戻す場合は、

73 / 1000 = 0.073

になること。

100ではなく1000なのがポイント。

値引率から割引後価格を出す#

定価123,456円から7.3%値引きするとする。

const listPrice = 123456;
const rateX10 = 73;

7.3%引きなので、残る割合は92.7%。

1000倍スケールなら、

1000 - 73 = 927

になる。

したがって、

const discountedPrice = Math.floor(
listPrice * (1000 - rateX10) / 1000
);

と計算できる。

まとめると、

const listPrice = 123456;
const rate = 7.3;
const rateX10 = Math.round(rate * 10);
const discountedPrice = Math.floor(
listPrice * (1000 - rateX10) / 1000
);
console.log(discountedPrice);

という形。

ここで、

1000 - rateX10 * 10

とはしない。

rateX10はすでに、

7.3 → 73

と10倍されている。

さらに10倍すると7.3%ではなく73%として計算してしまう。

このへんの事故が実際に起きた話は本編に書いた。

値引額は「定価 − 割引後価格」で出す#

値引額も表示したい場合は、

const discountAmount = listPrice - discountedPrice;

とする。

つまり、

const listPrice = 123456;
const rate = 7.3;
const rateX10 = Math.round(rate * 10);
const discountedPrice = Math.floor(
listPrice * (1000 - rateX10) / 1000
);
const discountAmount = listPrice - discountedPrice;

という流れ。

これなら、

  1. 割引後価格を仕様どおり確定する
  2. 定価との差額を値引額として表示する

という関係になる。

「値引額を先に計算して丸める」のか、「割引後価格を丸めて差額を値引額にする」のかでは1円違う場合がある。

どちらが正しいかは浮動小数点の問題ではなく、システムの仕様。

先に決めておく必要がある。

値引額から値引率を逆算する#

逆方向も考える。

定価と値引額から、小数1桁の値引率を表示したい場合。

計算式は、

値引額 ÷ 定価 × 100

になる。

小数1桁の百分率を整数として求めるなら、1000倍スケールを使える。

const rateX10 = Math.round(
discountAmount * 1000 / listPrice
);
const rate = rateX10 / 10;

たとえば、

rateX10 = 73

なら、

rate = 7.3

となる。

ただしここでも、

逆算した率を四捨五入するのか、切り捨てるのか

は仕様として決める必要がある。

切り捨てなら、

const rateX10 = Math.floor(
discountAmount * 1000 / listPrice
);

になる。

「浮動小数点対策」と「丸め仕様」は別の話なので、ここをごちゃ混ぜにしない方がいい。

共通関数にしておく#

値引計算を何箇所でも使うなら、処理を散らさない方がいい。

たとえば小数1桁の値引率を前提にするなら、

function calcDiscount(listPrice, rate) {
const rateX10 = Math.round(Number(rate) * 10);
const discountedPrice = Math.floor(
listPrice * (1000 - rateX10) / 1000
);
return {
discountedPrice,
discountAmount: listPrice - discountedPrice,
};
}

使用側は、

const result = calcDiscount(123456, 7.3);
console.log(result.discountedPrice);
console.log(result.discountAmount);

だけにする。

さらに実運用なら、

Number.isSafeInteger(listPrice)

や、

rateX10 >= 0 && rateX10 <= 1000

など、入力値のチェックも入れておいた方が安全。

税計算も関数にする#

税率が整数ならシンプル。

function calcTax(price, taxRate) {
return Math.floor(price * taxRate / 100);
}

たとえば、

const tax = calcTax(100001, 10);
// 10000

ただしここでも重要なのは、

何に対して税率を掛けるか。

商品ごとに税額を出して合計するのか。

税抜合計に対して最後に一度だけ税額を計算するのか。

丸める回数が変われば、最終金額が変わる場合がある。

これはJavaScriptの問題ではなく見積仕様の問題なので、実装前に決めておく。

「整数計算なら何でも安全」ではない#

ここも注意。

JavaScriptのNumberで安全に扱える整数には上限がある。

Number.MAX_SAFE_INTEGER
// 9007199254740991

これを超える整数では精度が保証されない。

また、

listPrice * 1000

のように途中計算で倍率を掛ける場合、最終値だけでなく途中値も安全な整数範囲に収まっている必要がある。

一般的な商品見積の金額ならまず問題にならない大きさだが、

「整数にしたから浮動小数点のことは一生忘れていい」

というわけではない。

Django / Python側はDecimalを使う#

バックエンドがPythonの場合、小数を正確に扱いたいならDecimalが使える。

from decimal import Decimal

ここで注意したいのが初期化。

Decimal(0.01)

とすると、先にPythonのfloatとして作られた0.01をDecimalへ変換することになる。

そのため、

Decimal("0.01")

のように文字列から作る。

税率10%なら、

tax_rate = Decimal("10")

として、

tax = price * tax_rate / Decimal("100")

のように計算できる。

端数処理まで明示するなら、

from decimal import Decimal, ROUND_DOWN
tax = (
Decimal(str(price))
* Decimal(str(tax_rate))
/ Decimal("100")
).quantize(
Decimal("1"),
rounding=ROUND_DOWN,
)

のようにする。

Decimalを使う場合も、「どのタイミングで丸めるか」という仕様自体が不要になるわけではない。

ちなみにmath.floor(Decimal)は?#

今回調べ直していて、一つ訂正があった。

途中でAIから、

math.floor()にDecimalを渡すとfloatに変換されるので危険

という説明が出ていた。

これは正しくなかった。

Pythonのmath.floor()は、引数がfloatでない場合、そのオブジェクトの__floor__()へ処理を委譲する。

そのため、

math.floor(Decimal("10000.1"))

だからといって、一度floatに変換されて精度が失われる、という説明は間違い。

ただ、金額計算でDecimalを使うなら、

.quantize(...)

で丸め方までコード上に明示する方が、意図は分かりやすい。

ここは実装方針の話として分けて考えたい。

金額計算を書く前に決めておくこと#

最終的には、コードを書く前にこれを決めておくのが一番大事。

  • 金額は何円単位まで扱うか
  • 率は小数何桁まで扱うか
  • 率をどの整数スケールで持つか
  • 値引後金額と値引額のどちらを先に確定するか
  • 四捨五入・切り捨て・切り上げのどれか
  • 消費税をどの単位で計算するか
  • どのタイミングで端数処理するか

浮動小数点対策だけしても、丸め仕様が決まっていなければ金額はズレる。

逆にここが決まっていれば、

金額 → 円単位の整数
率 → 必要な桁数まで整数化
丸め → 決めた場所だけ

という形に落とし込みやすい。

金額計算で一番怖いのは、コードがエラーになることじゃない。

普通に動いて、普通に数字が表示されて、その数字がちょっとだけ間違ってること。

画面が真っ赤になってくれた方が、まだ親切まである。

だから金額まわりを書いたときは最後に一回、

「この計算、本当に合ってる?」

と具体的な数字を入れて検算しておく。

今回の件以来、ここだけは省略せん方がいいと思ってる。