
朝、出社してすぐSlackの通知が鳴った。経理担当のKさんだった。
「A社の今月の精算額が請求書と1ウォンずれてるんですけど、何か変わりました?」
変えていない。何も変えていない。なのに1ウォン違う。
午後いっぱいかけて掘った。原因はあっけなかった。整数除算で小数点以下が静かに切り捨てられていた。直すのは1行だった。データをもう少し見てみると、切り捨てられた小数点より、0.5をどちらへ送るかの方が大きな差を作っていた。
#round(0.5)が常に1になるとは限らない
学校で習った丸めは、5以上なら切り上げ、4以下なら切り捨てる四捨五入だ。この方式では0.5は1になるはずだ。Python 2まではそうだった。
# Python 2
round(0.5) # → 1.0
round(1.5) # → 2.0
round(2.5) # → 3.0
Python 3で実行すると変わる。
# Python 3
round(0.5) # → 0
round(1.5) # → 2
round(2.5) # → 2
0.5は0になり、2.5も2になる。Python 3でいったい何が起きたのか。
#銀行丸め
この挙動には名前がある。銀行丸め(Banker’s Rounding)、別名は偶数丸めだ。0.5のようにちょうど中間値のとき、無条件に切り上げる代わりに最も近い偶数側に丸める。
- 0.5 → 0(近い偶数は0)
- 1.5 → 2(近い偶数は2)
- 2.5 → 2(近い偶数は2)
- 3.5 → 4(近い偶数は4)
四捨五入は1件ずつ見れば問題がない。0.5を1に切り上げるだけだ。しかし数百万件の取引に繰り返し適用すると話が変わる。0.5を常に1に変えていると、切り上げの偏りが少しずつ溜まる。件数と金額が大きくなるほど、この偏りは無視できない誤差になる。
この問題を規則として整理したのが銀行丸めだ。中間値が均等に分布しているなら、0.5のとき切り上げる回数と切り下げる回数が長期的に半々になり、偏りが相殺される。1985年にIEEE 754浮動小数点標準が制定された際、この方式が浮動小数点演算のデフォルトの丸めモードとして採用され、以降ほとんどのハードウェアがこの標準に従う。言語のround()関数のデフォルトはそれとは別だ。
銀行丸めという名前の由来ははっきりせず、銀行業界の公式標準だったという証拠は見つけられなかった。名前こそ銀行家だが、出どころは金融業界ではないかもしれない。
漢字では五捨五入とも書く。四捨五入が4は捨てて5は入れるという意味であるように、五捨五入は同じ5を捨てることもあれば入れることもあるという意味で、前の桁が偶数なら捨て、奇数なら入れる。
Python 3がこの方式に変えた理由も同じだ。Python 2の丸めは0から遠ざかる方向、つまり四捨五入だったが、Python 3は統計的により公平でIEEE 754標準にも合う、最も近い偶数へ丸める方式に変えた。私の推測では、Pythonを科学計算やデータ分析で多く使う分、大量演算で偏りが溜まるのを減らす側を選んだのだ。
#言語ごとにデフォルトが違う
JavaとKotlinのMath.round()は、正の数では四捨五入と同じ結果を出すが負の数では違い、その話は後でする。Kotlinには罠がもう一つある。kotlin.math.round()は中間値を偶数側に送り、roundToInt()は正の無限大側に送る。同じ言語の中で、関数名一つでポリシーが分かれる。差が先に表に出るのは、精密な小数点計算に使うBigDecimalだ。BigDecimalのsetScale()は、丸めが必要な桁で方式を明示しないと実行を拒否する。BigDecimal("2.0").setScale(0)は捨てるものがないので通り、2.5は例外を投げる。
BigDecimal("2.5").setScale(0)
// → ArithmeticException: Rounding necessary
// (0.5を切り上げるか捨てるか分からないため)
これはバグではなく設計だ。setScale()は値が削られる瞬間に、丸め方式を開発者自身が明示するよう強制する。だからRoundingModeを一緒に使う。
import java.math.BigDecimal
import java.math.RoundingMode
BigDecimal("2.5").setScale(0, RoundingMode.HALF_UP) // 3、四捨五入
BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN) // 2、銀行丸め
BigDecimal("2.5").setScale(0, RoundingMode.DOWN) // 2、無条件切り捨て
同じ2.5なのに、どのRoundingModeを選ぶかで結果が変わる。Pythonがデフォルトを決めているのと違い、setScale()は値が削られる桁で選択を強制する。デフォルトの有無は言語よりAPIに依るので、同じBigDecimalでもMathContextはデフォルトがHALF_UPで、KotlinのBigDecimalの除算演算子/は何も聞かずにHALF_EVENを使う。
言語ごとに結果が違い、その差はそれぞれの設計から来る。
| 言語と方法 | デフォルトの動作 |
|---|---|
Python 3 round() | 銀行丸め、HALF_EVEN |
JavaとKotlin Math.round() | 最も近い整数、中間値は正の無限大側 |
JavaとKotlin BigDecimal.setScale() | 丸めが必要なら明示必須、省略すると例外 |
JavaScript Math.round() | 最も近い整数、中間値は正の無限大側 |
JavaScriptとJava、KotlinのMath.round()は厳密には四捨五入ではなく、正の数では同じだが負の数では違う。3つの言語ともMath.round(-2.5)は-3ではなく-2だ。最も近い整数に丸めるが、ちょうど中間なら正の無限大側を選ぶからだ。同じ値をBigDecimal("-2.5").setScale(0, RoundingMode.HALF_UP)で処理すると-3になる。精算システムで返金や調整のような負の金額を扱うと、この差が問題になる。
#0.5セントの行方
小数点がよく出るドル建てのシナリオを1つ見てみる。N社は広告代理店に月間広告費の15%を手数料として精算する。今月の広告費は$18,336.30が執行された。
手数料 = $18,336.30 × 0.15 = $2,750.445
2026年10月31日追記:コメントでいただいたご指摘を受けて、以下の段落とコード例を補足しました。
この値は、金額を十進数のまま計算したときに出る。同じ計算をfloatで行うと2,750.445にはならない。18,336.30も0.15も二進浮動小数点では正確に表現できないため、掛けた値は2,750.4449999…になり、0.5セントの境界にすら届かない。round(fee, 2)は2,750.44を返し、この値には四捨五入を適用しても銀行丸めを適用しても2,750.44になる。前に挙げたround(2.5)の例が説明どおりに動いたのは、0.5や2.5が二進数で正確に表現できる値だからだ。
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN
fee = 18336.30 * 0.15
print(f"{fee:.20f}") # 2750.44499999999970896170
print(round(fee, 2)) # 2750.44
fee = Decimal("18336.30") * Decimal("0.15")
print(fee) # 2750.4450
print(fee.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) # 2750.45
print(fee.quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)) # 2750.44
だから金額は、丸めポリシーを選ぶ前に計算する型から決める必要がある。PythonならDecimal、JavaやKotlinならBigDecimalのように十進数を正確に扱える型で計算し、丸めはその上でポリシーとして選ぶ。このとき値はfloatを経由せず、Decimal("18336.30")のように文字列から作る。Decimal(18336.30)で作るとfloatの誤差がそのまま付いてくる。
正確な金額の$2,750.445は小数第3位まである。ドルは小数第2位までしか表現しない。この0.5セントをどちらへ送るか決めなければならない。
| ポリシー | 請求金額 |
|---|---|
四捨五入、HALF_UP | $2,750.45 |
銀行丸め、HALF_EVEN | $2,750.44 |
切り捨て、DOWN | $2,750.44 |
四捨五入は切り上げる。銀行丸めは前の桁の4が偶数なので切り下げる。切り捨ては無条件に捨てる。同じ金額なのにポリシーによって結果が変わる。
$0.01。1件だけ見れば大きくは感じない。クライアントが数十社あり、毎月積み重なれば、年間では無視しにくい数字になる。どちらが正しいかという問いにコードは答えをくれない。ロジックには問題がない。この差が積み重なり、精算システムと会計帳簿が合わないエラーとして経理チームに発見される。
もし決済システムと精算システムがそれぞれ別の丸めポリシーを持っていたら? フロントエンドとバックエンドが別のポリシーを使い、ユーザーコンソールやアドミンが表示する金額とAPIが計算する金額が違っていたら? 個々のコードとユニットテストだけ見ればエラーはない。見えないバグはそういう隙間で増幅される。
#ポリシーはコードの外で決める
この記事を書いている2026年、丸めはコードの外でも熱い話題になった。2025年11月、アメリカで流通用の1セント硬貨、つまりペニーの最後の鋳造があった。ペニーの供給が減るにつれ、現金取引の丸めポリシーがビジネスと法律の問題に広がっている。Squareは現金取引を5セント単位で丸める機能をPOSにベータとして入れ、マクドナルドは店舗ごとに現金の丸めポリシーを導入した。顧客有利に切り下げるチェーンもあれば、切り上げるチェーンもある。
州ごとの対応も違う。インディアナ州は丸め法案を可決し、コネチカット州は無条件切り下げを勧告し、ワシントン州は3月23日、現金取引の端数を最も近い5セントに合わせる法案に署名した。1セントと2セントと6セントと7セントは切り下げ、3セントと4セントと8セントと9セントは切り上げる。電子決済は依然としてセント単位まで正確に処理されるが、現金に対する連邦レベルの統一基準はまだない。
精算システムにおいて丸めは、言語のデフォルトの動作ではなく議論すべきポリシーだ。どの方法を使うかは、契約書に明記された条件、請求書の基準、業界慣行が決める。これは技術スペックではなくビジネスの意思決定であり、その決定がポリシーとしてコードに反映されなければならない。コメントも一緒に残す。
// 2024-03-15 経理チームと合意。契約書 第4.2条 準拠。
val ROUNDING_MODE = RoundingMode.HALF_UP
これを明示しなければ、またSlackのメッセージを受け取ることになるかもしれない。今度は1ウォンではないかもしれない。
#参考資料
- Shopify Engineering、Bound to Round: 8 Tips for Dealing with Hanging Pennies: 丸めの偏り、ステークホルダーとのコミュニケーション、コードの一貫性まで扱う実務ガイドだ。
- Founding Minds、Rounding Numbers in the Financial Domain: ビリングプロジェクトで丸め戦略を固めていった経験を、IFRSとGAAPの文脈とともに書いている。
- Alipay+、Banker’s Rounding: Alipay+が銀行丸めを採用した理由と、標準的な丸めとの偏差を数値で示している。
- Gmarket技術ブログ、BigDecimal A to Z: JavaとKotlinで
BigDecimalとRoundingModeを使うときに参照できるリファレンスだ。