↓快轉到主要內容
  1. 教學文章/

Python Decimal 實戰:精度、捨入規則與金額計算契約

·6 分鐘· loading · loading · ·
Python Decimal Rounding Precision Standard-Library Pytest
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 142: 本文

一. 前言:換成 Decimal,為什麼還差一分?
#

嗨,我是拍拍君!今天來處理一個很容易吵起來的 bug。

拍拍醬的畫面顯示 0.03,後端算出 0.02。兩邊都說自己用了 Decimal,也都沒有碰 float,那到底誰錯?

答案可能是:數字型別一致,計算契約卻不一致。

逐筆捨入再相加,和全部相加才捨入,本來就不一定相同。小數位數、有效位數、遇到正負半值怎麼處理,也各自是不同的設定。

JSON 實戰已經介紹把輸入解析成 Decimal;這一篇往裡面走,專門把「解析之後怎麼算」變成明確、可重現的規則。

我們會做一個虛構加工費試算器:輸入最多兩位有效小數的非負金額,總額乘上固定比例,最後才捨入。

二. 安裝:標準庫內建,先準備測試環境
#

decimal 不需要 pip 安裝。以下範例以 Python 3.14 執行:

mkdir decimal-lab
cd decimal-lab
uv venv --python 3.14
uv pip install pytest

後面使用 uv run --no-project python,讓指令使用當前目錄的 .venv。

準備兩個檔案:pypy_decimal.py 放計算契約,test_decimal_policy.py 放測試;其他短程式可各自存成獨立腳本執行。

三. 建構時保留的是什麼數字?
#

先看一組最小實驗:

from decimal import Decimal

assert Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
assert Decimal(0.1) != Decimal("0.1")
print(Decimal(0.1))

Decimal(0.1) 不是「把人類輸入的 0.1 找回來」,而是忠實轉換已經存在的二進位浮點值。

所以精度敏感的邊界,應接收原始十進位字串、整數最小單位,或已經驗證的 Decimal。

Decimal(str(value)) 也不是時間倒流。如果 value 早已經歷浮點計算,遺失的資訊不會自動復原。

另外,Decimal 可以保存尾端零,但相等比較仍看數值:

from decimal import Decimal

left, right = Decimal("1.20"), Decimal("1.2")
assert left == right
assert left.as_tuple().exponent == -2
assert right.as_tuple().exponent == -1

數值相等、表示尺度相同,是兩個不同的條件。 想驗證輸出兩位小數,不能只寫 assert result == Decimal("1.20")。

四. prec 控制有效位數,不是小數位數
#

官方 decimal 文件把運算環境稱為 context。

這裡最容易誤讀的是 prec:它限制運算結果的有效位數,不是指定小數點後保留幾位。

from decimal import Decimal, localcontext

with localcontext() as ctx:
    ctx.prec = 4
    value = Decimal("12345.67")
    assert str(value) == "12345.67"  # 建構不先砍成四位
    assert value + Decimal("0") == Decimal("1.235E+4")
    assert Decimal("1") / Decimal("7") == Decimal("0.1429")

同一個 prec=4,整數部分很大的值會連個位數都保不住;小於一的值卻可能有多位小數。

localcontext() 在區塊結束後還原外層 context。可重用函式不要在 import 時呼叫 getcontext().prec = ...,把別人的運算政策一起改掉。

而且「最後才 quantize」無法補回中間已因精度不足丟掉的數字。先界定輸入範圍,再選足夠的計算精度。

五. 半值遇到誰?把正負數一起測
#

下面都保留兩位小數:

原值 ROUND_HALF_EVEN ROUND_HALF_UP
1.245 1.24 1.25
1.255 1.26 1.26
-1.245 -1.24 -1.25

HALF_EVEN 在剛好一半時選末位為偶數的鄰值;HALF_UP 在剛好一半時往遠離零的方向。

不要把 UP 想成「永遠往正無限大」。ROUND_UP 是遠離零,ROUND_DOWN 是朝零,ROUND_CEILING 才是朝正無限大。

from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP

cent = Decimal("0.01")
assert Decimal("1.245").quantize(cent, rounding=ROUND_HALF_EVEN) == Decimal("1.24")
assert Decimal("-1.245").quantize(cent, rounding=ROUND_HALF_UP) == Decimal("-1.25")

本文範例選 HALF_UP,是為了展示一份明確政策,不代表所有應用都該用它。要跟外部系統對帳,先確認雙方規則。

六. quantize(0.05) 不等於捨入到五分倍數
#

quantize() 對齊的是目標的指數,不是目標數值的整數倍。

from decimal import Decimal, ROUND_HALF_UP, localcontext

value = Decimal("1.03")
assert value.quantize(Decimal("0.05")) == Decimal("1.03")

with localcontext() as ctx:
    ctx.prec = 28
    step = Decimal("0.05")
    rounded = (value / step).quantize(Decimal("1"), rounding=ROUND_HALF_UP) * step
    assert rounded == Decimal("1.05")

要對齊固定級距,就先換算成「幾個級距」,取整數,再乘回去;這段示範的數值範圍很小,因此指定的精度足夠。

如果級距、數值範圍或運算步驟更複雜,精度預算也必須重新驗證,不能把這條式子當成任意精度保證。

七. 分清 Rounded 與 Inexact
#

有時我們不想自動修正輸入,而是想拒絕真正超過兩位小數的值。

兩個 signal 的差別非常重要:丟掉位數會觸發 Rounded;丟掉非零資訊才會觸發 Inexact。

from decimal import Decimal, Inexact, Rounded, localcontext

with localcontext() as ctx:
    ctx.clear_flags()
    Decimal("1.2300").quantize(Decimal("0.01"))
    assert ctx.flags[Rounded]
    assert not ctx.flags[Inexact]

    ctx.clear_flags()
    Decimal("1.234").quantize(Decimal("0.01"))
    assert ctx.flags[Inexact]

Flags 會保留到清除為止,不能把上一次運算留下的旗標當成這次結果。

若把 ctx.traps[Inexact] = True,丟失非零資訊時就直接拋出例外;尾端只是多了零的 1.2300 則仍可接受。

這是「最多兩位有效小數」政策,不是「字串最多只能寫兩位小數」政策。若連多餘零都不准,請另做語法限制。

八. 實作一份有界、可預測的計算契約
#

現在把完整程式存成 pypy_decimal.py:

# file: pypy_decimal.py
import re
from decimal import (
    Context, Decimal, DivisionByZero, Inexact,
    InvalidOperation, Overflow, ROUND_HALF_UP, localcontext,
)

CENT = Decimal("0.01")
RATE = Decimal("0.10")
POLICY = Context(
    prec=32, rounding=ROUND_HALF_UP,
    traps=[InvalidOperation, DivisionByZero, Overflow],
)
AMOUNT = re.compile(r"(?:0|[1-9][0-9]{0,11})(?:\.[0-9]{1,4})?")


def parse_amount(raw: str) -> Decimal:
    if not isinstance(raw, str):
        raise TypeError("amount must be a decimal string")
    if len(raw) > 17 or AMOUNT.fullmatch(raw) is None:
        raise ValueError("invalid amount syntax or range")
    with localcontext(POLICY) as ctx:
        ctx.traps[Inexact] = True
        try:
            value = Decimal(raw).quantize(CENT)
        except Inexact as error:
            raise ValueError("amount has nonzero digits beyond cents") from error
    return value


def quote(amounts: list[str]) -> dict[str, str]:
    if not isinstance(amounts, list):
        raise TypeError("amounts must be a list")
    if not 1 <= len(amounts) <= 1000:
        raise ValueError("expected 1..1000 amounts")
    with localcontext(POLICY):
        subtotal = sum((parse_amount(x) for x in amounts), Decimal("0.00"))
        fee = (subtotal * RATE).quantize(CENT)
        total = subtotal + fee
        return {
            "subtotal": format(subtotal, ".2f"),
            "fee": format(fee, ".2f"),
            "total": format(total, ".2f"),
        }

這個入口刻意不接受 float、負值、科學記號、空白、千分位、前導零或非 ASCII 數字。

NaN 與 Infinity 本來可以是 Decimal 值,但不屬於這份金額語法。不是換個型別就能省略領域驗證。

範例最多 12 位整數、4 位輸入小數、1000 筆,解析後一律兩位小數;32 位有效數字足以涵蓋這些加總與固定比例乘法。

我們明確建立 Context,再用 localcontext(POLICY) 複製,因此不會繼承呼叫者意外設下的低精度或 Inexact trap。

POLICY 仍是可變物件,請當成模組內部常數使用;有多種版本政策時,建立不同 context,不要執行中修改共用設定。

九. 同樣是 HALF_UP,放的位置仍會改答案
#

把以下程式另存為 compare_rounding.py:

from decimal import Decimal, localcontext
from pypy_decimal import CENT, POLICY, RATE, quote

amounts = ["0.05", "0.05", "0.05"]
with localcontext(POLICY):
    per_line = sum(
        ((Decimal(x) * RATE).quantize(CENT) for x in amounts),
        Decimal("0.00"),
    )
assert per_line == Decimal("0.03")
assert quote(amounts) == {"subtotal": "0.15", "fee": "0.02", "total": "0.17"}

逐筆加工費是 0.005 → 0.01,三筆合起來是 0.03;總額加工費是 0.015 → 0.02。

這不是浮點誤差,也不是 Decimal 壞掉。兩段程式計算的政策不同。

本文 quote() 選的是「加總後收一次加工費」。如果產品要求逐筆收費,就必須更改實作、範例和測試,不能只換畫面上的格式。

輸出改成字串是為了保留兩位小數的交換契約,不要最後又 float(total)。序列化策略請接著看前面的 JSON 文章。

十. 測試規則,而不是只測一張正常單據
#

建立 test_decimal_policy.py:

# file: test_decimal_policy.py
from decimal import Decimal, Inexact, ROUND_DOWN, localcontext
import pytest
from pypy_decimal import parse_amount, quote


@pytest.mark.parametrize("raw, expected", [
    ("0", "0.00"), ("1.2", "1.20"), ("1.2300", "1.23"),
    ("999999999999.99", "999999999999.99"),
])
def test_valid_input(raw, expected):
    result = parse_amount(raw)
    assert str(result) == expected
    assert result.as_tuple().exponent == -2


@pytest.mark.parametrize("raw", [
    "1.234", "NaN", "Infinity", "-0.01", "1e2", " 1.20",
    "01.20", "1_000", "1.20", "1000000000000", "1.23456",
])
def test_rejected_input(raw):
    with pytest.raises(ValueError):
        parse_amount(raw)


@pytest.mark.parametrize("raw", [0.1, 1, True, None])
def test_no_implicit_coercion(raw):
    with pytest.raises(TypeError):
        parse_amount(raw)


def test_round_once_after_sum():
    assert quote(["0.05"] * 3) == {
        "subtotal": "0.15", "fee": "0.02", "total": "0.17",
    }


def test_caller_context_cannot_change_quote():
    with localcontext() as ctx:
        ctx.prec = 2
        ctx.rounding = ROUND_DOWN
        ctx.traps[Inexact] = True
        result = quote(["999999999999.99"] * 1000)
        assert result["total"] == "1099999999999989.00"
        assert ctx.prec == 2 and ctx.rounding == ROUND_DOWN
        assert ctx.traps[Inexact]


@pytest.mark.parametrize("amounts", [[], ["0"] * 1001])
def test_batch_bounds(amounts):
    with pytest.raises(ValueError):
        quote(amounts)
uv run --no-project python -m pytest -q

這組測試同時防守語法、數值範圍、不可偷轉型、尾端零、捨入位置與外部 context 污染。

特別是最大值案例:如果未來有人把 prec 誤改成 2,不會等到大額資料進來才發現。

若之後新增折扣、負數退款或不同幣別,請先補政策案例。不要把「目前測試綠了」解讀成所有商業情境都已涵蓋。

結語:精確型別,需要精確規則配合
#

拍拍君會把 Decimal 程式碼審查拆成五個問題:輸入從哪裡來、允許多大、計算精度多少、在哪裡捨入、半值往哪邊走?

先把這五件事寫成契約,再用邊界案例固定下來。型別負責十進位運算,測試負責提醒大家:原來我們約定的是這個答案。

下次再差一分,先別急著加 epsilon。看看兩邊是不是根本在算不同的規則!

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 142: 本文

相關文章

Python warnings 實戰:棄用通知、篩選規則與 CI 防線
·8 分鐘· loading · loading
Python Warnings DeprecationWarning Pytest Ci API Migration Developer-Tools
Python contextvars 實戰:Async Context、結構化 Logging 與隔離測試
·8 分鐘· loading · loading
Python Contextvars Asyncio Logging Concurrency Testing Standard-Library
Python hashlib 實戰:檔案完整性、串流雜湊與安全比對
·8 分鐘· loading · loading
Python Hashlib SHA-256 Checksum Security Standard-Library
Python struct 實戰:二進位資料打包、解析與檔案格式入門
·7 分鐘· loading · loading
Python Struct Binary Standard-Library Developer-Tools
Python JSON 實戰:解析、序列化、自訂型別與大型資料
·7 分鐘· loading · loading
Python Json Serialization Standard-Library JSON Lines Data-Engineering Developer-Tools
Python heapq 實戰:優先佇列、Top-K 與任務排序
·9 分鐘· loading · loading
Python Heapq Priority-Queue Algorithm Standard-Library