「四捨五入」と書いたのに、4言語で答えが割れた ― 同じ仕様、違う答え①

Go
B!

返品で −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.50.5 ちょうどの扱い
PHPround()105−1050 から遠い方へ
Pythonround()104−104偶数の方へ(銀行丸め)
Gomath.Round()105−1050 から遠い方へ
JavaScriptMath.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言語の結果・直す前と後のコードの違いは、次のページで見られます。

次回は、0.1 や 1.1 が2進数で正確に表せないことから起きる「1円ずれ」を取り上げます。

連載「同じ仕様、違う答え」(全6回)
  1. 第1回:「四捨五入」と書いたのに、4言語で答えが割れた(この記事)
  2. 第2回:1,100 ÷ 1.1 が 1,000 にならない
  3. 第3回:負の数を入れたら、Go は止まり、JavaScript は NaN を返した
  4. 第4回:大きな整数で、PHP は黙って上限に張りつき、JavaScript は桁が化けた
  5. 第5回:日本で動かしたときだけ、JavaScript の日付が1日ずれた
  6. 第6回:1月31日の「1か月後」はいつか
B!
← 一覧へ戻る