プログラムで金額を扱いはじめたころ、電卓と答えが合わなくて焦った経験はありませんか。合計が1円ずれる、あるいは末尾に見慣れない数字が並ぶ、といった場面です。
私も昔、請求書の合計金額が1円だけ合わない不具合を追いかけたことがあります。原因はコードの書き間違いではなく、Pythonが数を持つ仕組みそのものにありました。
この記事では、その原因と、標準ライブラリのdecimalを使った対処を順番に見ていきます。追加のインストールは要りません。
0.1 + 0.2 が 0.3 にならない¶
まず、いちばん有名な例から確かめてみましょう。手元のPython 3.11で実行した結果をそのまま載せます。
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
print(sum([0.1] * 10)) # 0.9999999999999999
0.1を10回足しても1.0になりません。これはPythonのバグでも、あなたのコードのミスでもないのです。
原因は、小数を2進数で持っている点にあります。10進数の0.1は2進数では割り切れず、ぴったり同じ値を作れません。
どのくらいずれているのかは、目で見ることもできます。Decimalに0.1をそのまま渡すと、実際に保存されている値が出てきます。
from decimal import Decimal
print(Decimal(0.1))
# 0.1000000000000000055511151231257827021181583404541015625
末尾にごみのような数字が続いていますね。0.1と書いたつもりでも、コンピュータが持っているのは0.1より少しだけ大きい値なのです。
ずれ自体はごくわずかですが、足し算を繰り返すと積み上がります。誤差がどこから生まれるのかをもう少し掘り下げたい方は、こちらもあわせてどうぞ。【関連記事】Pythonの丸め誤差とは?初心者にもわかる原因と対策をエンジニア歴10年の視点で解説
decimalモジュールを使うと何が変わるのか¶
では本題です。decimalは、数を2進数ではなく10進数のまま扱ってくれるモジュールです。
私たちが紙に書くときと同じ形で計算するので、10進数で表せる値はそのまま正確に持てます。さきほどの式を書き換えてみましょう。
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2')) # 0.3
print(Decimal('0.1') + Decimal('0.2') == Decimal('0.3')) # True
print(sum([Decimal('0.1')] * 10)) # 1.0
今度はきれいに0.3になりました。10回足した結果も、期待どおりの1.0です。
ここで気づいた方もいるかもしれません。数字を文字列で渡している点が、この書き方の肝になります。
Decimalに渡すのは文字列¶
Decimalの引数には、数値ではなく文字列を渡してください。理由は、さきほど見た挙動そのものです。
from decimal import Decimal
print(Decimal('1.1')) # 1.1
print(Decimal(1.1)) # 1.100000000000000088817841970012523233890533447265625
Decimal(1.1)と書くと、まずPythonが1.1をfloatとして読み込みます。誤差を含んだ値ができてからDecimalに渡るので、正確さが失われたままになるわけです。
つまりfloatを経由した瞬間に負けが決まるということです。文字列か整数から作る、と覚えておけば事故は防げます。
floatとDecimalは足し算できない¶
もうひとつ、初心者の方がつまずきやすい点があります。Decimalとfloatを混ぜて計算しようとすると、Pythonは拒否します。
from decimal import Decimal
Decimal('0.1') + 0.1
# TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'
手元で動かすと、このTypeErrorが出ました。不親切に見えますが、これは親切な設計です。
黙って混ぜてしまうと、せっかくの正確さが静かに壊れます。エラーで止めてくれるおかげで、どこで型が混ざったのかがすぐわかります。
なお、整数との計算は問題なく通ります。Decimal('1.5') * 3のような書き方はそのまま使えます。
quantizeで桁をそろえる¶
金額を扱うなら、小数点以下の桁数をそろえたい場面が必ず出てきます。そこで使うのがquantize()です。
引数には、そろえたい桁を見本の形で渡します。0.01を渡せば小数第2位まで、1を渡せば整数になります。
from decimal import Decimal, ROUND_HALF_UP
price = Decimal('2.665')
print(price.quantize(Decimal('0.01'))) # 2.66
print(price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) # 2.67
print(price.quantize(Decimal('1'), rounding=ROUND_HALF_UP)) # 3
同じ数なのに、1行目と2行目で結果が違います。ここが次の話につながります。
既定の丸めは四捨五入ではない¶
多くの方は、桁を落とすと四捨五入されると思っているはずです。ところがdecimalの既定は違います。
既定は偶数への丸めと呼ばれる方式です。ちょうど半分のときだけ、隣の偶数側へ寄せます。
手元で試した結果を並べてみます。整数へ丸めたときの違いがわかりやすいはずです。
from decimal import Decimal, ROUND_HALF_UP
for n in ['0.5', '1.5', '2.5']:
d = Decimal(n)
print(n, d.quantize(Decimal('1')), d.quantize(Decimal('1'), rounding=ROUND_HALF_UP))
# 0.5 0 1
# 1.5 2 2
# 2.5 2 3
0.5が0になり、2.5が2になりました。これは統計的な偏りを減らすための方式で、決して間違いではありません。
ただ、請求書で0.5円が0円になると説明がつきません。金額の四捨五入が要るなら、rounding=ROUND_HALF_UPを明示するのが安全です。
ちなみに組み込みのround()も同じ偶数丸めです。round(0.5)は0、round(2.5)は2になります。
丸めモードの使い分け¶
decimalには丸め方がいくつも用意されています。実務でよく使うものを表にまとめました。
| 定数 | 動き | 使いどころ |
|---|---|---|
ROUND_HALF_EVEN |
半分のとき偶数側へ(既定) | 統計や科学計算 |
ROUND_HALF_UP |
半分のとき大きい側へ(四捨五入) | 金額の端数処理 |
ROUND_DOWN |
0に近い側へ(切り捨て) | 消費税、ポイント付与 |
ROUND_UP |
0から遠い側へ(切り上げ) | 送料や必要個数の計算 |
ROUND_CEILING |
常に大きい側へ | 上限を決めたいとき |
ROUND_FLOOR |
常に小さい側へ | 下限を決めたいとき |
切り捨てが2種類あるのは、マイナスの値で動きが分かれるためです。ROUND_DOWNは0へ寄せ、ROUND_FLOORは小さいほうへ寄せます。
プラスの値しか扱わないうちは違いが出ません。返金やポイント減算が入ってきたときに、ここで差が出ます。
消費税の計算を書いてみる¶
実際の場面で組み立ててみましょう。税抜1980円の商品に、10%の消費税を切り捨てで加える例です。
from decimal import Decimal, ROUND_DOWN
price = Decimal('1980')
tax = (price * Decimal('0.1')).quantize(Decimal('1'), rounding=ROUND_DOWN)
print(tax) # 198
print(price + tax) # 2178
計算の途中では丸めず、最後に一度だけ丸めています。ここが地味ですが大事なところです。
途中で何度も丸めると、丸めた分の誤差が積み重なります。100件の明細があれば、最大で100回分ずれる可能性があるわけです。
計算した金額を画面に出すときは、桁をそろえた表示にしておくと親切です。書式の指定については、こちらが参考になります。【関連記事】Pythonのf-stringとは?デバッグが驚くほど楽になる!
Decimalが苦手なこと¶
万能に見えるDecimalですが、弱点もあります。使いどころを間違えないために、3つだけ押さえておきましょう。
ひとつめは速度です。手元で100万回の掛け算を計測したところ、floatが約0.014秒だったのに対し、Decimalは約0.041秒でした。
およそ3倍という差です。金額の計算なら誤差のほうがずっと重いので気にしなくてよいのですが、大量の数値をぐるぐる回す処理には向きません。
ふたつめはJSONです。Decimalをそのままjson.dumps()に渡すと、Object of type Decimal is not JSON serializableというTypeErrorになります。
保存や送信のときは、文字列に直すか整数(円単位)に直してから渡します。JSONの扱いそのものが不安な方は、こちらもどうぞ。【関連記事】PythonでJSONデータの扱いをマスター!API連携に必須のjsonモジュールの使い方
みっつめはデータベースです。SQLiteにはDecimalに対応する型がないため、そのまま入れると実数として保存されてしまいます。
金額を整数の最小単位で持つか、文字列として持つのが無難です。テーブル設計の基本を確かめたい方は、こちらから。【関連記事】Pythonからデータベースを操作するsqlite3の使い方を解説
float・Decimal・整数のどれを選ぶか¶
ここまで読むと、すべてDecimalにすればよいと思えてきます。ただ、選択肢はもうひとつ二つあります。
金額を整数の円単位で持つ方法と、fractionsモジュールのFractionで分数として持つ方法です。特徴を並べてみます。
| 方法 | 正確さ | 速さ | 向いている場面 |
|---|---|---|---|
| float | 誤差あり | 速い | 測定値、グラフ、機械学習 |
| Decimal | 10進数の範囲で正確 | やや遅い | 金額、税率、桁の決まった値 |
| 整数(円単位) | 正確 | 速い | 通貨が1種類だけのシステム |
| Fraction | 分数として正確 | 遅い | 割合の計算を積み重ねるとき |
整数で持つのは、実はかなり有力な手です。1円を1として持てば誤差はゼロですし、計算もfloatと同じ速さになります。
弱点は、小数点の位置を自分で覚えておかなければならない点です。1000円なのか1000銭なのかが、コードを読んだだけでは伝わりません。
Fractionは分数のまま持つので、3分の1を3回足すとぴったり1になります。ただし分母が増え続けるため、繰り返し計算では重くなっていきます。
桁数を増やしたいときはgetcontext¶
Decimalの有効桁数は、既定で28桁です。この設定はgetcontext()から変えられます。
from decimal import Decimal, getcontext
getcontext().prec = 50
print(Decimal(1) / Decimal(3))
# 0.33333333333333333333333333333333333333333333333333
手元で動かすと、たしかに50桁になりました。ただし設定はプログラム全体に効くので、一部分だけ変えたいときはlocalcontext()を使うほうが安全です。
実務で気をつけていること¶
私は10年ほどエンジニアとして開発に関わってきましたが、金額の不具合はいちばん報告が来るのが遅い種類のバグだと感じています。1円のずれは、誰かが月末に集計するまで誰も気づかないからです。
冒頭で触れた請求書の件も、リリースから3か月後に経理の方から連絡をいただきました。原因は税額をfloatで計算し、明細ごとに丸めていたことです。
直したのは2か所だけでした。金額をすべてDecimalにしたことと、丸めるタイミングを合計後の1回に寄せたことです。
それ以来、金額が絡む処理では最初の1行を決めています。入力の受け口で文字列からDecimalにし、出口まで型を変えないという方針です。
もうひとつ、割り算には注意が必要です。Decimal(1) / Decimal(3)は0.3333333333333333333333333333となり、既定の28桁で打ち切られます。
正確な数だからといって、無限に持てるわけではないのです。割り勘や按分の処理では、余りを誰が引き受けるのかを先に決めておくと安全です。
まとめ¶
floatは2進数で小数を扱うため、10進数の値をぴったりには持てません。0.1+0.2が0.3にならないのは、その性質がそのまま出た結果です。
decimalモジュールのDecimalは、10進数のまま計算してくれます。金額のように1円のずれも許されない場面で頼りになります。
使うときの決まりは3つです。文字列から作ること、floatと混ぜないこと、丸めはquantize()で明示することです。
そして既定の丸めは四捨五入ではありません。金額ならROUND_HALF_UP、消費税ならROUND_DOWNと、目的に合わせて指定してください。
まずはお手元のコードで、金額を扱っている変数がfloatになっていないか探してみてはいかがでしょうか。ここまでお読みいただきありがとうございました。