这一篇会把 number.py
的代码一段段贴出来读。代码是 beancount
的原文;代码里的中文注释是本文加的。
每段代码后面跟一段讲解。
01 篇讲的是"钱必须带币种",这一篇讲的是更底下的一层:那个数本身该怎么存。整个模块只有 184 行,但它挡住的是记账软件最经典的一类事故。
(01 篇开头解释过的五个词——类、函数、不可变、断言、异常——这里不再重复。本篇会新用到"常量"和"哨兵",用到时会说。)
先看一个可以自己动手验证的现象。打开任何一台电脑上的 Python,输入:
>>> 0.1 + 0.2
0.30000000000000004
这不是 Python 的 bug,几乎所有编程语言都这样,Excel 和 JavaScript
也一样。原因是计算机内部用二进制存小数,而 0.1
在二进制里是个无限循环小数——就像十进制写不出精确的 1/3
一样,二进制也写不出精确的 0.1。存进去的其实是一个非常接近 0.1 但不等于
0.1 的数。
平时这没什么关系,误差在小数点后十几位。但记账不一样:
这类数字有个名字叫浮点数(floating-point)。number.py
的 docstring
里对它的态度是一句不留余地的话(number.py:46):
never use floating-point in an accounting system (记账系统里绝对不要用浮点数)
Python 标准库里有个类型叫
Decimal,它不用二进制存小数,而是老老实实记下"这个数由哪几个十进制数字组成、小数点在哪"。
number.py 的第 19 行就是整个模块的地基:
from decimal import Decimal
# 从标准库把 Decimal 这个类型取过来。
# beancount 里所有的钱,底层都是它,没有例外
代价是慢一点,好处是你写多少就是多少:
>>> Decimal("0.1") + Decimal("0.2")
Decimal('0.3')
这里有个细节要留心,后面好几篇都会用到:Decimal
会记住你写了几位小数。Decimal("0.10") 和
Decimal("0.1")
在数值上相等,但它们不是同一个东西——前者记得自己有两位小数。
这个"多余"的信息在 beancount 里是有用的原料:你在账本里写
10.50 而不是
10.5,系统会理解成"这笔账你是按分记的",并据此推断出对账时该用多大的容差(07
篇)、报表里该保留几位小数(06
篇)。你写了几位小数,被当成一种意图来读。
也正因为如此,beancount 从不对存下来的数调用
normalize()(一个把 100.00 压成
1E+2 的函数)——那会把这份意图抹掉。
数字进入系统的形态五花八门:可能是字符串
"1,234.50"(带千分位逗号)、可能是空的、可能已经是
Decimal 了。D() 把这些情况在一个地方收口。
number.py:41-71:
def D(strord: Decimal | str | None = None) -> Decimal:
# 名字只有一个字母,因为它被调用的次数太多了
# 参数可以是 Decimal、字符串或者什么都不传
try:
# 下面整段被 try 包住:无论出什么错,都在最后统一处理
if strord is None or strord == "":
return Decimal()
# 什么都没传,或者传了个空字符串 → 返回 0
# 这是为导入对账单这类场景准备的:表格里的空格子就当 0
elif isinstance(strord, str):
return Decimal(_CLEAN_NUMBER_RE.sub("", strord))
# 是字符串:先把里面所有的逗号和空格删掉,再交给 Decimal
# 所以 "1,234.50" 变成 "1234.50","- 122.34" 变成 "-122.34"
elif isinstance(strord, Decimal):
return strord
# 已经是 Decimal 了:原样还回去,不做多余的事
elif isinstance(strord, (int, float)):
return Decimal(strord)
# 是整数或浮点数:直接转
# 注意这一行——上一节刚说过绝不能用浮点数,这里却接受它
else:
assert strord is None, "Invalid value to convert: {}".format(strord)
# 其它类型:崩溃
except Exception as exc:
raise ValueError(
"Impossible to create Decimal instance from {!s}: {}".format(strord, exc)
) from exc
# 上面任何一步出错,都统一包装成一个 ValueError 抛出去,
# 报错信息里带上原始输入,方便定位是哪个数据有问题
删逗号那一行用到的工具在 number.py:38:
_CLEAN_NUMBER_RE = re.compile("[, ]")
# 一条规则:匹配"逗号或空格"。上面用它把这两种字符全删掉
这个函数值得注意的地方有三处。
它很宽容,而且是故意的。 docstring
说明了它的用途:给"从文件里导入金额"用(number.py:44)。你从银行下载的对账单可能把空金额留成空格、把一千写成
1,000——D()
一律接住。宽容的边界也写清楚了:逗号一律当千分位删掉,所以法国式的写法(用逗号当小数点)在这里会被理解错,D("1 000,5")
得到的是 10005 而不是 1000.5。docstring
直说了不支持这种写法。
它对浮点数的态度是矛盾的。 上面第 64-65 行接受
float,而同一个函数的 docstring
却写着"绝不要用浮点数"。传进去会发生什么:
>>> D(3.30)
Decimal('3.29999999999999982236431605997495353221893310546875')
那串尾巴就是第一节讲的二进制误差,它被原封不动地记进了账本。更麻烦的是打印出来看不见——'{:.2f}'
格式化之后它显示为
3.30,一切正常,直到某天对账差了一分钱。测试里也没有任何一个
float 的用例。
最后那个 assert 有点怪。 它写的是
assert strord is None,但能走到这一行的前提恰恰是
strord 不是 None(第一个分支已经把
None
拦掉了)。所以这个断言必定失败——它其实是被当成"到这里就该崩"用的。副作用是:Python
有个优化开关(-O)会把所有断言删掉,开着它跑的话,这个分支什么都不返回,等于悄悄返回了一个"空",反而比崩溃更糟。
这是本篇最容易被误会的一点:D()
看着像"所有数字的总入口",但你写在 .beancount
文件里的金额不经过它。
账本文件由一套 C 语言写的解析器处理(08 篇细讲),数字在 C
那一层就直接构造成 Decimal
了。于是同一个字符串,从这两条路进来,结果可能完全不同:
| 你写的东西 | 从 D() 进(导入工具) |
从账本文件进(C 解析器) |
|---|---|---|
1,234.50 |
接受 | 接受 |
12,34(逗号分组错了) |
接受,得到 1234 |
拒绝 |
1e3(科学计数法) |
接受,得到 1000 |
拒绝 |
1_000(下划线分隔) |
接受 | 拒绝 |
NaN、inf(非数字、无穷大) |
接受 | 拒绝 |
123(全角数字) |
接受 | 拒绝 |
""(空) |
返回 0 | 不适用 |
左边那列是给程序员写的导入脚本用的,宽松是为了好用;右边那列是用户手写的账本,严格是为了不让垃圾数据混进来。
两条路的严格程度差这么多,是因为它们防的不是同一件事。 账本是你亲手写的、要长期保存的原始数据,一个字符都不能含糊;导入脚本处理的是别人(银行、券商)给的格式,你只能尽量适应。
账本允许你故意不写某个数字,让系统自己推算。比如一笔转账,你写了转出 500,转入那行的金额可以留空——它必然也是 500。
系统读到这种留白时,需要一个东西来占位。这个占位符叫
MISSING(number.py:28-32):
# A constant used to make incomplete data, e.g. missing numbers in the cost spec
# to be filled in automatically. We define this as a class so that it appears in
# errors that would occur from attempts to access incomplete data.
#
# 译:一个用来标记"数据不完整"的常量,比如成本说明里等着自动填的数字。
# 我们把它定义成一个类,这样在误用这类不完整数据而出错时,
# 它的名字会出现在报错信息里。
class MISSING:
pass
# pass 的意思是"这个类里什么都没有"
# 它不需要有任何内容,存在本身就是它的全部作用
这七行是本篇最值得琢磨的设计。
为什么不用 None? None 是
Python 里表示"空"的通用值。但在 beancount
里,"空"有两种完全不同的含义:
None = 这个成分根本不存在。
比如你没写价格,那就是没有价格,不需要推算什么。MISSING =
这里应该有个数,但用户留给系统算。这两件事如果都用 None
表示,后面的补全环节就分不清"不用管"和"该我算了"。所以 beancount
让它们各占一个值。
为什么定义成一个类,而不是一个普通的值?
上面那段注释自己回答了:为了让它在报错时显出名字来。如果
MISSING
是个普通对象,误把它当数字用时,报错信息只会说"某个类型不支持相加";而它是个类的话,01
篇里那些断言(assert isinstance(number, Decimal), repr(number))打印出来就带着
MISSING 这个词,一眼看出是哪个半成品被误用了。
这个占位符的寿命很短。
它只活在"解析完成"到"补全完成"这一小段里。解析器的说明文件写死了一条规矩(beancount/parser/parser.py:25-28):加载完成的交易里绝不允许还残留
MISSING。
所以整个系统里有三种"零和空",各管各的:
| 值 | 意思 | 举例 |
|---|---|---|
MISSING |
用户留空,等系统推算 | 转账第二行不写金额 |
None |
这个成分根本不存在 | 这笔交易没有价格信息 |
ZERO |
明确的数字 0 | 手续费就是 0 元 |
number.py:22-25:
ZERO = Decimal() # 0
HALF = Decimal("0.5") # 0.5
ONE = Decimal("1") # 1
TEN = Decimal("10") # 10
"常量"就是给一个固定的值起个名字,各处直接用这个名字,不必每次重新造。
有意思的是它们的实际使用情况很不平均:
ZERO
到处都是——判断"这笔钱是不是零"、给累加器起个头。ONE 有两个用途:算容差时用
ONE.scaleb(小数位) 造出"最小误差单位"(07
篇),以及算汇率的倒数(ONE / 汇率,18 篇)。TEN 只在本模块内部用了一次。HALF
在所有非测试代码里一次都没被用过,只有定义它的那一行。留着一个没人用的常量不算错,但这类"定义了就忘了"的痕迹,在一个十年的项目里很常见。
number.py:74-95,两个都只有一行:
def round_to(number: Decimal, increment: Decimal) -> Decimal:
"""Round a number *down* to a particular increment."""
# docstring 说:把数字向下取整到某个增量
return int(number / increment) * increment
# 除一下、取整、再乘回去。
# 比如把 135.12345 按 0.01 取整:除得 13512.345,取整得 13512,乘回 135.12
def same_sign(number1: Decimal, number2: Decimal) -> bool:
# 判断两个数是不是同号
return (number1 >= 0) == (number2 >= 0)
# 两边各自问一句"你是不是非负数",两个答案一样就算同号
# 注意 0 被归进"非负"这一边
round_to
这里有个文档和代码对不上的地方:docstring
说的是"向下取整"(round down),但代码用的 int()
是向零截断。正数没区别,负数就分道扬镳了:-135.12345
向下取整应该是 -135.13(更小),而实际得到的是
-135.12(更接近零)。测试把后者钉死了,所以真实行为是截断,文档那句话不准。
same_sign 的用处在 04
篇:往一个账户里记一笔钱时,得先判断这是加仓还是减仓——看新来的数和已有的数是不是同号就知道了。
模块里还有另外三个函数(auto_quantize
一族),作用是给那些从浮点数转来、带一串垃圾尾数的数字反推出"本来应该是几位小数"。它们在所有非测试代码里零调用,只有测试文件在用。细节在文末技术版。
Decimal。10.50
还是
10.5,系统当成"你想记到分还是记到角"来读,并据此推断容差和显示精度。所以存下来的数从不做
normalize()。D(),宽容得多——因为它们防的不是同一件事。MISSING(该有个数,等推算)和
None(根本没这回事)不能混。分不清,补全环节就没法工作。MISSING
做成一个类,就是为了误用时报错信息里能看见它的名字。这些和 01 篇合起来,就是 beancount 对"一笔钱"的完整定义:一个精确的十进制数,带着币种,带着你写下的小数位数,而且如果暂时还不知道是多少,它有一个会自报家门的占位符。
以下是本篇的技术版记录,对照源码查阅用。行号均以 commit
97472138 为准。
| 名称 | 位置 | 作用 |
|---|---|---|
| 模块 docstring | number.py:1-11 |
两条使用约定:从 beancount.core.amount 取
Decimal(已脱节);优先用 D() |
ZERO / HALF / ONE /
TEN |
:22-25 |
常量;ONE.scaleb(expo)
是容差算子,TEN**exponent 是量化算子 |
class MISSING |
:28-32 |
哨兵,定义成类而不是实例或 None |
NUMBER_RE |
:36 |
r"[+-]?\s*[0-9,]*(?:\.[0-9]*)?",供别处拼正则用,D()
自己不用它 |
_CLEAN_NUMBER_RE |
:38 |
re.compile("[, ]"),D()
用它删掉所有逗号和空格 |
D |
:41-71 |
统一构造入口,任何失败包装成 ValueError |
round_to |
:74-83 |
按增量取整,实现是向零截断 |
same_sign |
:86-95 |
两数是否同号,0 归为非负 |
auto_quantized_exponent |
:98-120 |
按阈值反推小数位 |
auto_quantize |
:123-148 |
用上面反推的指数做 quantize +
normalize |
num_fractional_digits |
:151-159 |
返回 -exponent |
infer_quantum_from_list |
:162-184 |
一组数上取最大小数位,返回 -exponent |
类型注解(:41)未列出
int/float,但 :64-65
接受它们(bool 作为 int
子类一并放行)。其它类型走 :66-67 的
assert strord is None,AssertionError 连同
InvalidOperation 被 :68-71 包成
ValueError——这个"只捕一种异常"的契约在
python -O 下失效(assert 被剥离,静默返回 None)。测试
number_test.py:33-41 (test_D) 覆盖 Decimal
透传、逗号、"- 122.34"(负号后带空格,靠删空格正则通过)、空串、None,没有
float 用例,也没有非法字符串用例。
账本文件里的金额/价格/成本不经过
D()(option 指令里的数字仍走
D()):beancount/parser/lexer.l:282 的 NUMBER
正则先决定哪些字符串能成为数字
token,beancount/parser/tokens.c:55-67 (pydecimal_from_cstring)
再用
validate_decimal_number(:9-53)校验千分位分组,最后经
beancount/parser/decimal.h:26 (PyDec_FromCString) 调用
Decimal(str)。正负号是独立
token(lexer.l:220-221),由
grammar.y:338-346 (number_expr) 作一元运算处理,所以账本里
- 122.34 合法而 1e3 不合法。
| 输入形态 | 例 | beancount D() |
beancount 账本(lexer + tokens.c) |
|---|---|---|---|
| 纯数字、前导零 | 007、1234567 |
接受 | 接受 |
| 符号紧贴数字 | +5、-0.5 |
接受 | 接受(符号是独立 token,lexer.l:220-221) |
| 符号与数字间有空格 | - 122.34 |
接受(number_test.py:39,靠删空格) |
接受(一元运算,grammar.y:338-346) |
| 首尾空白 | " 5 " |
接受 | 不适用 |
| 尾随换行 | "5\n" |
接受(Decimal() 自身剥离首尾空白) |
不适用 |
| 千分位逗号:首段 1-3 位,其后每段恰 3 位,小数部分无逗号 | 1,234.50、1,234,567.891 |
接受(删逗号) | 接受(tokens.c:19-26,33-39,46-48) |
| 逗号分组错误 | 12,34、1,00.5 |
接受(得
1234、100.5) |
拒绝(-EINVAL) |
| 小数点一侧无数字 | .5、5. |
接受 | .5 拒绝、5.
接受((\.[0-9]*)?) |
| 下划线、全角/Unicode 数字 | 1_000、123 |
接受 | 拒绝(不匹配 NUMBER) |
| 指数、NaN、Infinity | 1e3、NaN、inf |
接受 | 拒绝(不匹配 NUMBER) |
| 空串 | "" |
返回 0 | 不适用 |
tokens.c 不是一个可以照抄的校验器::28-31
只有 isdigit 才把字符拷进输出缓冲,没有 else
分支,未知字符被静默跳过——单独拿它校验
"1e3" 会得到 "13"、"-1.5" 会得到
"1.5"。它能工作是因为 lexer 正则已经在前面把关。
ZERO
在非测试代码里有几十处引用(判零、初始化累加器);ONE
有两类消费者——ONE.scaleb(expo) * multiplier
生成容差(beancount/core/interpolate.py:188、beancount/ops/balance.py:41),以及取汇率倒数
ONE / rate(beancount/core/prices.py:112,131,240,:333,372
用 ONE 作同币种汇率);TEN**exponent 只用于
auto_quantize(number.py:144);HALF
在非测试代码里零调用。
MISSING 是类对象,isinstance(MISSING, type)
为 True,因此能通过 beancount/core/amount.py:49 的
valid_types_number = (Decimal, type, type(None))。产生点在
C
语法层:beancount/parser/grammar.y:446-450(INDENT optflag account eol,无金额
posting 的 units 直接传
MISSING_OBJ)、:636-642 (maybe_number) 与
:644-650 (maybe_currency) 的 %empty
分支;Python 侧 beancount/parser/grammar.py:518-519 在
cost_comp_list 为空时直接返回
CostSpec(MISSING, None, MISSING, None, None, False),这是裸
{} 的路径;:583-587 处理的是
cost_comp_list 非空但没有 CompoundAmount
的成本说明(只写了日期或标签),同样填入 MISSING
数字与币种。
给由 float 转来、带垃圾尾数的 Decimal(如
20.899999618530273)反推小数位并量化:number.py:98-120 (auto_quantized_exponent)、:123-148 (auto_quantize)、:162-184 (infer_quantum_from_list)。非测试代码零调用,只有
number_test.py:68-156 在用;docstring :174-175
说接受 float,实际传裸 float 在 :107 的
number.normalize() 处抛 AttributeError。
num_fractional_digits(:151-159)返回
-number.as_tuple().exponent;display_context.py
没有调用它,而是内联同一算式(display_context.py:204-205)。
| 决策 | 理由 | 没选的替代方案 | 证据 |
|---|---|---|---|
单一入口 D(),但只是 "Prefer" |
输入形态(None/空串/逗号)在一处收口 | 各调用点自行 Decimal(x) |
number.py:8-9(模块
docstring);:44-49 (D);核心代码仍有裸调
display_context.py:319、inventory.py:381 |
MISSING 是类,不是 None 或实例 |
误当数字运算立刻 TypeError;None
会静默传递 |
None、object() 单例 |
number.py:28-32;beancount/parser/parser.py:3-8 |
账本数字由 C 层直接 Decimal(str),不走
D() |
词法器与语法器本身是 flex/bison 生成的 C 代码,数字在 C
层就地构造;千分位与字符集校验也在 C 层完成,比 D() 严 |
词法层产字符串,Python 侧 D() |
lexer.l:282;tokens.c:9-53,55-67;decimal.h:26 |
| 插值结果只在"容差像用户手写"时才量化 | 自动推断的容差位数不可信,量化会伪造精度 | 一律量化 / 一律不量化 | interpolate.py:46-58 (is_tolerance_user_specified);:392-393 |
显示量化在 localcontext 内把 prec 抬到
小数位 + 12 |
修 issue #584:推断出的极端精度 + 大整数导致 quantize
超出默认 28 位 |
全局改 getcontext().prec |
commit 4c1e87bb(2020-11-25,+9);commit
c9132bba(2025-12-21,改 +12 并加
NOTE);display_context.py:321-331 |
| 从不设置舍入模式 | 无记录;代码里没有任何显式设置,沿用 decimal 默认 |
显式 ROUND_HALF_UP 等 |
非测试代码 grep ROUND_|rounding= 无命中 |
| 现象 | 后果 | beancount 的处理 | 证据 |
|---|---|---|---|
D(float) 是裸
Decimal(float);D(3.30) =
3.29999999999999982236431605997495353221893310546875 |
二进制展开进账本;D(1 - 0.01) 得
0.98999999999999999111… 作为容差下界 |
无;docstring 说禁 float 但代码接受;测试无 float 用例 | number.py:64-65;beancount/plugins/check_average_cost.py:35,58-59;number_test.py:33-41 |
D(True) = Decimal(1) |
bool 经 int 分支混入金额(bool 是
int 子类) |
无 | number.py:64-65 |
python -O 下 D(object()) 返回
None |
类型错不再抛 ValueError,None 流入下游 |
无 | number.py:66-67(assert 被 -O 剥离) |
| 全仓库从未设置舍入模式 | quantize、'{:.2f}'、除法均沿用调用时的
decimal context;未被调用方修改时结果是
ROUND_HALF_EVEN(2.345→2.34、0.125→0.12),但这不是这些操作自身的保证 |
无 | interpolate.py:393;display_context.py:331,372,405,451;grammar.py:918-919 |
| 除法结果带满 prec 位小数 | 10.00 / 3 → 27 位小数,exponent
-27,进入容差推断/显示统计即为极端精度 |
display_context.quantize 抬 prec 兜底 |
grammar.py:918-919;position.py:368;display_context.py:321-331 |
D() 删除所有空格和逗号 |
D("1 000,5") =
10005;欧式小数逗号被当千分位 |
docstring 明说不支持法式逗号;C 层另有校验 | number.py:38,46-48,61;tokens.c:19-26 |
D("NaN")、D("inf")、D("1e3")
均被接受 |
非有限值或科学计数法进入金额 | 无(账本文件靠 lexer 正则拦住) | number.py:60-61;lexer.l:282 |
tokens.c 对未知字符静默跳过 |
把它当校验器照抄,"1e3" 会得
"13"、"-1.5" 得 "1.5" |
依赖 lexer 正则在前面把关 | tokens.c:28-31 |
| 非有限值不只来自字符串 | 除法在 trap 关闭时也能产出 NaN/Infinity;核心代码里还有裸写的
Decimal("Infinity") |
无 | inventory.py:381 (Inventory.average) |
round_to docstring "round down",实现
int() 向零截断 |
负数方向与 floor 不同:-135.12345 → -135.12,不是
-135.13 |
测试锁定截断行为 | number.py:75,83;number_test.py:46-47 |
same_sign 把 0 归为非负 |
same_sign(正, 0) True,same_sign(负, 0)
False |
测试锁定 | number.py:95;number_test.py:60-65 |
num_fractional_digits(Decimal("1E+2")) =
-2 |
位数为负 | 无 | number.py:159 |
quantize_with_tolerance 判断的是
quantum = (tolerance * 2).normalize() 是否"像手写",不是
tolerance 本身;guard 只限有效数字个数 |
MAX_TOLERANCE_DIGITS = 5 实际允许最多 4
位有效数字;quantum = 1E-40 能通过
guard,quantize 照样抛 InvalidOperation |
作者 TODO 承认魔数 2 应改为 tolerance_multiplier
倒数 |
interpolate.py:35,58,377-381,392 |
负零:-1 * 0.00、(-0.001).quantize(0.01)
得 -0.00 |
str() 输出 -0.00;== 0 为
True 拦不住 |
无(非测试代码 grep 无
is_signed/copy_abs) |
实测;见下方 (i) |
str(Decimal("0.00000001")) = '1E-8' |
adjusted exponent < -6 时 str()
自动切科学计数法 |
有推断小数位时格式串是 f 型,无小数位时
:403 退回通用格式、同样输出 1E-8 |
display_context.py:372,403,405,451 |
【文档漂移】(1) number.py:5-6 要求从
beancount.core.amount 导入 Decimal,但
amount.py:16 只是顺带
from decimal import Decimal,number.py:19
自己也直接从 decimal 导入;(2) parser.py:3-8
说缺失值是 'NA',:25-28 才说是
MISSING;(3) display_context.py:322-323 注释
"1 billion"(对应 +9)而代码是 +12;(4) MISSING
注释称"会出现在错误里",裸算术 MISSING + 1 的
TypeError 只显示 'type',名字只在带
repr() 的断言里可见;(5)
infer_quantum_from_list docstring 说接受 float 实则抛
AttributeError |
按注释找代码会找不到 | 无 | number.py:5-6,19,28-30,107,174-175;amount.py:16,59;parser.py:3-8,25-28;display_context.py:322-330 |
验证环境 Python 3.14.6,默认
getcontext().prec == 28。
(a) 构造精度只由输入决定,context.prec
只影响运算。
>>> with localcontext() as c:
... c.prec = 2
... print(Decimal("0.1122334455"), Decimal("0.1122334455") + 0)
...
0.1122334455 0.11
beancount 测试 number_test.py:16-26 (test_formatting)
引用了官方文档同一句话。
(b) 0.10 == 0.1 为 True,但 exponent
不同;normalize() 会丢掉它。
>>> Decimal("0.10") == Decimal("0.1"), Decimal("0.10").as_tuple().exponent, Decimal("0.1").as_tuple().exponent
(True, -2, -1)
>>> Decimal("100.00").normalize()
Decimal('1E+2')
exponent 的消费点:interpolate.py:185-188(交易容差 =
ONE.scaleb(expo) * tolerance_multiplier,multiplier 默认
0.5,:130-131)、ops/balance.py:34-41(Balance
容差)、display_context.py:204-205(显示精度众数统计)。
(c) quantize 超出 prec 抛
InvalidOperation,不是截断。
>>> with localcontext() as c:
... c.prec = 4
... Decimal("12345.678").quantize(Decimal("0.01"))
...
decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]
beancount 唯一的防御是 display_context.py:321-331 的
localcontext;interpolate.py:383-391
只在注释里引用了这条规则,其 guard 防不住。
(d) 默认舍入 ROUND_HALF_EVEN;beancount
从未改动;str.format 不接受 rounding 参数。
>>> getcontext().rounding, Decimal("2.345").quantize(Decimal("0.01")), Decimal("0.125").quantize(Decimal("0.01")), "{:.2f}".format(Decimal("2.345"))
('ROUND_HALF_EVEN', Decimal('2.34'), Decimal('0.12'), '2.34')
>>> Decimal("2.345").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Decimal('2.35')
>>> "{:.2f}".format(Decimal("2.345"), rounding=ROUND_HALF_UP) # 关键字被静默忽略
'2.34'
>>> with localcontext(rounding=ROUND_HALF_UP): "{:.2f}".format(Decimal("2.345"))
'2.35'
(e) Decimal(float) 原样保留二进制展开,且
'{:.2f}' 会把它藏起来。
>>> Decimal(3.30)
Decimal('3.29999999999999982236431605997495353221893310546875')
>>> Decimal(3.30).as_tuple().exponent, Decimal(3.30) == Decimal("3.30"), "{:.2f}".format(Decimal(3.30))
(-50, False, '3.30')
>>> Decimal(str(3.30)), Decimal(True)
(Decimal('3.3'), Decimal('1'))
exponent 为 -50 意味着按 (b) 推断出的容差是
ONE.scaleb(-50) * 0.5 = 5E-51,等价于无容差。
(f) D("") 在 beancount 返回新对象,与
ZERO 相等但不同一。
>>> D("") == ZERO, D("") is ZERO
(True, False)
(g) Decimal 与 float
可以比较(按精确值),但不能做算术。
>>> Decimal("0.1") < 0.1, Decimal("0.5") == 0.5, Decimal("0.1") == 0.1
(True, True, False)
>>> Decimal("0.1") + 0.1
TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'
beancount
依赖前者:number.py:111-114 (auto_quantized_exponent)
直接拿 float 阈值与 Decimal 比较。
(h) 除法吃满 prec,并受舍入模式影响。
>>> Decimal("10.00") / Decimal("3"), (Decimal("10.00") / Decimal("3")).as_tuple().exponent
(Decimal('3.333333333333333333333333333'), -27)
对应 grammar.py:918-919(@@
总价折单价)、position.py:368。
(i) 负零:乘负数与舍入都会产生,加零可消除,==
看不出。
>>> Decimal("-1") * Decimal("0.00"), Decimal("-0.001").quantize(Decimal("0.01")), -Decimal("0.00")
(Decimal('-0.00'), Decimal('-0.00'), Decimal('0.00'))
>>> Decimal("-0.00") == 0, Decimal("-0.00").is_signed(), Decimal("-0.00").copy_abs(), Decimal("-0.00") + 0
(True, True, Decimal('0.00'), Decimal('0.00'))
(j) str() 在 adjusted exponent < -6
时切科学计数法,format(…, "f") 不会。
>>> str(Decimal("0.00000001")), format(Decimal("0.00000001"), "f"), str(Decimal("0E-8"))
('1E-8', '0.00000001', '0E-8')
(k) Python Decimal() 默认接受的写法,以及
\d 的全角陷阱。
>>> Decimal("1_000"), Decimal("123.45"), Decimal(".5"), Decimal("5."), Decimal(" 5 ")
(Decimal('1000'), Decimal('123.45'), Decimal('0.5'), Decimal('5'), Decimal('5'))
>>> bool(re.match(r"\d", "1")), bool(re.match(r"[0-9]", "1"))
(True, False)
| 值 | 语义 | 典型来源 | 在哪里被区分 |
|---|---|---|---|
MISSING(类对象) |
用户留空、要求系统推算 | grammar.y:446-450(无金额 posting → units 为
MISSING_OBJ);:636-642 (maybe_number)、:644-650 (maybe_currency)(@
后空 → Amount(MISSING, MISSING),{100 # USD} →
number_total=MISSING);grammar.py:518-519(裸
{} →
CostSpec(MISSING, None, MISSING, None, None, False))与
:583-587;docstring 例子
parser.py:32-33,37-38,50-51,73-74 |
booking_full.py:830-837,852-853 (interpolate_group) 用
is MISSING
收集待插值项;:730 (compute_cost_number)
if MISSING in (number_per, number_total): return None;:348-354,366 (categorize_by_currency)
同时判
is not MISSING and is not None;grammar.py:177 (_dcupdate)
跳过 MISSING 币种 |
None |
该成分在输入里根本不存在,不需要推算 | parser.py:44-45(无 @ →
price = None);:78-79({100 USD}
→ number_total=None) |
booking_full.py:509(units is MISSING or units is None
才补币种);amount.py:49 允许 type(None) |
ZERO |
明确的数值 0 | position.py:342-364 (from_string)(工具解析器,非账本语法:成本表达式里
per_number/total_number 匹配为空串时置
ZERO) |
position.py:117-123 (is_total_cost_spec) 用
number_per == ZERO and isinstance(number_total, Decimal)
判"纯总价写法";position_test.py:231-239 对照
CostSpec(ZERO, …) 打印 {{202.46 USD}} 与
CostSpec(MISSING, …) 打印 {# 202.46 USD} |
parser.py:1-108 的 docstring 共 12 组 INPUT 例子,第
27-28 行明确 "MISSING should never appear in completed, loaded
transaction postings"。两条不变量支撑这套三态:完成态不得残留
MISSING,以及判缺失只用
is MISSING、不用真值判断或 == None(因为
None 在同一个字段上有另一个含义,MISSING
又不是假值)。
number_test.py 160 行、3 个类共 11 个测试,其中 6 个属于
TestInferQuantization:
| 测试 | 行号 | 锁定的行为 |
|---|---|---|
TestDecimalPrecision.test_formatting |
17-26 | prec=2 的 localcontext 里
D("0.1122334455") 的 str() 仍是全部位数 |
TestToDecimal.test_ZERO |
30-31 | ZERO == Decimal("0") |
test_D |
33-41 | Decimal 透传、纯数字串、千分位逗号、"- 122.34"
靠删空格通过、D("") 与 D(None) 都等于
Decimal() |
test_round_to |
43-52 | 增量 0.01 与
10、正负各两例;-135.12345 与
-135.12987 都得 -135.12,把"向零截断"钉死 |
test_same_sign |
54-65 | 四种符号组合;ZERO 与正数同号、与 ZERO
同号,与负数不同号 |
test_infer_quantization_none |
74-96 | 10 个由 float 直接转来的 Decimal 推不出量化,期望 exponent
-21 |
test_infer_quantization_one |
98-101 | 整数加千分之一量级扰动的一组数,期望 0 |
test_infer_quantization_normal |
103-107 | exp 取 2/3/5,TEN**exp 分别得
0.01/0.001/0.00001 |
test_infer_quantization_under |
109-129 | 11 个 30 位小数的价格串,结果是
1e-29——算法在这组输入上没能收敛 |
test_infer_quantization_under3 |
131-139 | 只调用不断言,防崩溃的回归用例 |
test_auto_quantize |
141-156 | 1135.109998 在阈值 0.01 与 0.02 下都得
1135.11;1135.102399 在 0.01 下得
1135.1024、在 0.001 下原样返回 |
setUp(:69-72)用
random.uniform 造 100 个随机数,所以
test_infer_quantization_one/_normal
每次跑的输入都不同。
没有测试覆盖的行为:D(float)、D(True)、D(object())(含
python -O 下静默返回
None)、任何非法字符串、D(int);常量只测了
ZERO;MISSING、NUMBER_RE、num_fractional_digits、auto_quantized_exponent
在本测试文件里完全没有出现。
| 日期 | 提交 / 记录 | 变化 |
|---|---|---|
| 2015-07-16 | b6b132ae |
加入 NUMBER_RE 常量;_CLEAN_NUMBER_RE
允许符号与数字之间有空格,D("- 122.34") 因此合法 |
| 2015-12-12 | 90a31c76 |
检测到 Python 自带的 decimal 不是 C 实现时输出警告 |
| 2016-01-10 | b3246233;CHANGES:2726-2740 |
修 issue #96:内置 decimal 是纯 Python 实现时,改用显式安装的
cdecimal |
| 2016-10-30 | CHANGES:1829-1831 |
删掉 amount.py 里的旧兼容代码,记载 D()
此前已从 amount 迁到 number |
| 2019-04-27 | 8f53d9ee |
澄清 D() 的注释 |
| 2020-06-10 | efd7339d |
删除 cdecimal 回退:最低支持版本升到 Python 3.5 |
| 2020-06-10 | 092a099d |
全仓库改为直接
from decimal import Decimal;提交信息说此前的间接层"并没有按预期工作" |
| 2020-12-28 | 7ddfde03 |
新增 infer_quantum_from_list |
| 2020-12-28 | 47bfa2fa |
加固并修正 auto_quantize 系列 |
| 2024-11-09 | a9fd82de |
补类型注解并加 py.typed 标记 |
| 2024-06-16 | 48a311a0、2a455c79 |
全仓库 ruff 格式化 |
模块 docstring 里"从 beancount.core.amount 导入
Decimal"这条建议是 D()
迁移前的遗留,092a099d 之后已无实际约束力。
re 与
decimal(number.py:18-19)。D()
的非测试调用点:amount.py:151 (Amount.from_string)、position.py:342,363-364 (from_string)、data.py:557,589,591、interpolate.py:30、parser/options.py:70,89,538,553、plugins/check_average_cost.py:58-59、projects/export.py、scripts/example.py。集中在"工具代码
+ option 值 + 测试用迷你解析器",不在账本主路径上。D() 直接构造 Decimal
的地方:账本数字走 C 层(lexer.l:282 →
tokens.c:55-67 →
decimal.h:26);核心代码里还有裸调
display_context.py:319、inventory.py:381(后者写的是
Decimal("Infinity"))。MISSING 的流转链:产生于
grammar.y:446-450,636-650 与
grammar.py:519,587 → 经 amount.py:49,59
的宽松构造断言携带 → 在
booking.py:73-75,210-216、booking_full.py:348-366,509-521,730,830-852、convert.py:100-101
被 is MISSING 判定;实际回填发生在
booking_full.py:798-853 与
:1012-1020,币种缺口由
replace_currencies (booking_full.py:489-538) 处理 →
完成态不得残留(parser.py:25-28)。interpolate.py:185-188
与 ops/balance.py:34-41
拿它算容差,display_context.py:204-205
拿它统计显示精度。beancount 从不对存储值做
normalize(),原因就在这里。commit:基线
97472138(2026-08-22);4c1e87bb(2020-11-25,Fixed
#584);c9132bba(2025-12-21,改为 +12 并加 NOTE)。
beancount/core/number.py:1-11 模块 docstring;5-6
漂移句;8-9 Prefer D();19
from decimal import Decimal;22-25 常量;28-32 MISSING;36
NUMBER_RE;38 _CLEAN_NUMBER_RE;41-71 D();44 importer
用途;46 "never use floating-point";58-65 各分支;66-67 assert
分支;68-71 ValueError 包装;74-83 round_to;86-95 same_sign;98-120
auto_quantized_exponent;107 normalize();111-114 float
阈值与 Decimal 比较;123-148 auto_quantize;144
TEN**exponent;151-159 num_fractional_digits;162-184
infer_quantum_from_list;174-175 float 声明。
beancount/core/number_test.py:16-26 test_formatting;30-31 test_ZERO;33-41 test_D;43-52 test_round_to;54-65 test_same_sign;68-156 TestInferQuantization。
beancount/core/amount.py:16 import;40
Optional[Decimal];49 valid_types_number;59 构造
assert;71-76 to_string;135-151 from_string。
beancount/core/interpolate.py:30 MAXIMUM_TOLERANCE;35 MAX_TOLERANCE_DIGITS;46-58 is_tolerance_user_specified;97 infer_tolerances;130-131 multiplier 默认 0.5;185-188 exponent → 容差;364-394 quantize_with_tolerance。
beancount/core/display_context.py:201-205
小数位统计;298-331 quantize;319 裸 Decimal(1);321-331
localcontext;330 prec + 12;372、403、405、451
格式串。
beancount/parser/lexer.l:220-221 PLUS/MINUS;282 NUMBER 正则。tokens.c:9-53 validate_decimal_number;19-26 逗号规则;28-31 仅 isdigit 拷贝;55-67 pydecimal_from_cstring。decimal.h:26 PyDec_FromCString。grammar.y:338-346 number_expr;446-450 无金额 posting;636-642 maybe_number;644-650 maybe_currency。
beancount/parser/parser.py:1-108 docstring;3-8
'NA' 漂移;25-28 MISSING
不变量。grammar.py:177
_dcupdate;519、587
CostSpec(MISSING, …);918-919
总价折单价除法。options.py:70、89、538、553。
其它:convert.py:100-101;prices.py:112,131,240,333,372;position.py:117-123,342-364,368;ops/balance.py:20-41;plugins/check_average_cost.py:35,58-59;inventory.py:186-198,381,402-437;scripts/example.py:836,842,1039,1584;data.py:557,589,591;projects/export.py:162。