返品で −1,045円、消費税10%、端数は四捨五入。消費税はいくらでしょうか。
−104.5円を四捨五入するので、答えは −105円 です。ところが、同じ仕様を PHP・Python・Go・JavaScript で書いて動かすと、答えは2つに割れました。
| 言語 | 返した消費税 | |
|---|---|---|
| PHP | −105円 | 正解 |
| Python | −104円 | 不正解 |
| Go | −105円 | 正解 |
| JavaScript | −104円 | 不正解 |
どの言語でも、組み込みの「四捨五入らしい関数」をそのまま使っただけです。バグを仕込んだわけではありません。
この連載では、同じ計算仕様を4言語で書き、言語に依存しない1つの正解表で突き合わせた結果から、実際に答えが割れたケースを1つずつ取り上げます。検証の結果は CODE Polyglot で、毎朝自動で検証し直したものを公開しています。
「四捨五入」の関数は、0.5 ちょうどの扱いが言語ごとに違う
割れる原因は、0.5 ちょうどの数をどちらへ丸めるかです。
| 言語 | 関数 | 104.5 | −104.5 | 0.5 ちょうどの扱い |
|---|---|---|---|---|
| PHP | round() | 105 | −105 | 0 から遠い方へ |
| Python | round() | 104 | −104 | 偶数の方へ(銀行丸め) |
| Go | math.Round() | 105 | −105 | 0 から遠い方へ |
| JavaScript | Math.round() | 105 | −104 | 大きい方(正の無限大の方)へ |
日本語でいう「四捨五入」は、PHP と Go の動きです。
Python は「偶数の方へ」丸める
Python 3 の round() は、0.5 ちょうどを偶数の方へ丸めます。
round(104.5) # 104
round(2.5) # 2
round(110.5) # 110
これは不具合ではなく、仕様として決められた動きです(偶数丸め・銀行丸めと呼ばれます)。丸めを何度も繰り返したときに、切り上げ側に偏らないための方式です。ただ、日本の消費税や料金の計算で「四捨五入」と言われたときに期待される動きではありません。
正の数でもずれるので、Python は今回の検証で、ほかの3言語が正解した 104.5円(c004)でも 104円を返しました。水道料金の仕様でも、5m³ × 22.1円 = 110.5円(c005)を 110円に、消費税 4.5円(c010)を 4円にして、どちらも1円少なくなりました。
JavaScript は負の数でだけずれる
JavaScript の Math.round() は、0.5 ちょうどを常に大きい方へ丸めます。
Math.round(104.5) // 105
Math.round(-104.5) // -104
Math.round(-0.5) // -0
正の数では四捨五入と同じ結果になるので、ふだんの金額では気づけません。マイナスの金額(返品・値引き・返金)が来たときにだけ、0 に近い方へ寄ります。
どう直したか ― 丸め方を「名前で」指定する
直し方の考え方は1つです。言語の既定に任せず、どの丸め方を使うかをコードに書く。
PHP
PHP 8.4 からは、丸め方を名前で渡せます。
$value->round(0, RoundingMode::HalfAwayFromZero); // 0.5 は 0 から遠い方へ
PHP の round() は、もともと0から遠い方へ丸めるので今回のケースは正解しています。それでも、意図を名前で書いておけば、読む人が「この round はどちらに丸めるのか」を調べずに済みます。
Python
decimal を使い、丸め方に ROUND_HALF_UP を指定します。
from decimal import Decimal, ROUND_HALF_UP
Decimal("104.5").quantize(Decimal("1"), ROUND_HALF_UP) # 105
Decimal("-104.5").quantize(Decimal("1"), ROUND_HALF_UP) # -105
ROUND_HALF_UP は名前に UP と入っていますが、負の数でも 0 から遠い方へ丸めます(−104.5 → −105)。また、Decimal には文字列で値を渡します。Decimal(104.5) のように小数(float)を渡すと、2進数で表したときの誤差まで一緒に持ち込みます。
Go と JavaScript
どちらにも、丸め方を指定できる十進数の型が標準にありません。そこで、金額を分数(分子と分母の整数)のまま持ち、商と余りから自分で四捨五入しました。JavaScript の例です。
// n/d(d > 0)を 1円単位に四捨五入する。BigInt の / と % は 0 の方向に切り捨てる
function roundHalfAwayFromZero(n, d) {
const q = n / d;
const r = n % d;
const absR = r < 0n ? -r : r;
if (2n * absR >= d) return q + (n < 0n ? -1n : 1n); // 余りが半分以上なら 0 から遠い方へ
return q;
}
余りの絶対値で判定しているので、正の数と負の数で同じ規則が働きます。Go では math/big の big.Rat(分数)で同じことをしています。
直した後は、4言語とも正解表の全ケースで正解と一致しました(請求書の仕様は19件中19件、水道料金の仕様は13件中13件)。
まとめ
- 「四捨五入」の関数は、0.5 ちょうどの扱いが言語ごとに違う。Python の
round()は偶数の方へ、JavaScript のMath.round()は大きい方へ丸める - 正の数だけで確かめると、JavaScript のずれは見つからない。マイナスの金額をテストに入れる
- 丸め方は言語の既定に任せず、名前で指定する(PHP の
RoundingMode、Python のdecimal)。指定できない言語では、商と余りから自分で書く
この記事のケースの正解表・4言語の結果・直す前と後のコードの違いは、次のページで見られます。
- 請求書の消費税と端数処理(c004・c005・c016)
- 水道料金(上水道・下水道・消費税)(c005・c009・c010・c013)
次回は、0.1 や 1.1 が2進数で正確に表せないことから起きる「1円ずれ」を取り上げます。
- 第1回:「四捨五入」と書いたのに、4言語で答えが割れた(この記事)
- 第2回:1,100 ÷ 1.1 が 1,000 にならない
- 第3回:負の数を入れたら、Go は止まり、JavaScript は NaN を返した
- 第4回:大きな整数で、PHP は黙って上限に張りつき、JavaScript は桁が化けた
- 第5回:日本で動かしたときだけ、JavaScript の日付が1日ずれた
- 第6回:1月31日の「1か月後」はいつか
Analyzegear


