打开网易新闻 查看精彩图片

0.1加0.2等于多少?小学生脱口而出0.3。但Python会告诉你:0.30000000000000004。

这个多出来的0.00000000000000004,在脚本里是个冷笑话。放到日处理1亿笔交易的支付系统里,它就是一台印钞机——只不过印的是负债。印度支付巨头PhonePe的工程师曾做过测算:若用标准浮点数收取每笔0.1美元的手续费,1亿笔交易后账面会凭空蒸发0.19美元。听起来不多?乘以365天,再乘以汇率波动,足够让CFO在董事会上摔杯子。

为什么CPU连小学算术都算不对

为什么CPU连小学算术都算不对

人类用十进制思考。0.1就是1/10,干净、直观、符合直觉。

CPU用二进制思考。它只能用2的幂次方拼凑分数:1/2、1/4、1/8、1/16……

问题就在这里:你永远无法用有限个1/2、1/4、1/8……精确凑出1/10。就像你永远无法用乐高积木的1×2砖块,无缝拼出一个完美的圆形。0.1的二进制表示是0.0001100110011……无限循环。但IEEE 754标准只给浮点数64位存储空间,CPU只能咔嚓一刀,把尾巴砍掉。

被截断后的0.1,实际存储值是0.100000000000000005551115123125。单次误差小于尘埃,但累加就是雪崩。

Python的decimal模块正是为此而生。它绕过CPU的浮点运算单元(FPU),在软件层用纯二进制编码的十进制(BCD)算术重造了一个计算器。代价是速度——比原生float慢10到100倍。但金融系统从不为速度牺牲精度,它们为精度牺牲一切。

decimal模块:把计算器搬进内存

decimal模块:把计算器搬进内存

decimal的核心设计只有两个字:可控。它让你像操作纸质账本一样操作数字——指定小数位、定义舍入规则、上下文隔离。

来看一段对比代码。同样的1亿次0.1累加,float和decimal的差异:

float版本:

total = 0.0

for _ in range(100_000_000):

total += 0.1

print(total) # 9999999.999999981

decimal版本:

from decimal import Decimal, getcontext

getcontext().prec = 28 # 精度28位

total = Decimal('0')

fee = Decimal('0.1') # 字符串初始化,绕过二进制转换

for _ in range(100_000_000):

total += fee

print(total) # 10000000.0

差距一目了然。但decimal的陷阱藏在细节里:必须用字符串初始化Decimal('0.1'),而不是Decimal(0.1)。后者会把已经被污染的float二进制值原封不动搬进来,精度修复沦为行为艺术。

上下文(context)是decimal的另一把锁。你可以为不同业务模块设置不同的精度策略——外汇交易用28位,内部核算用8位,税务申报用2位并强制四舍五入。这种隔离在float时代不可想象,全局精度意味着全局风险。

IEEE 754:工程师的浮点原罪

IEEE 754:工程师的浮点原罪

1985年制定的IEEE 754标准,是计算机浮点运算的通用语法。它定义了32位单精度、64位双精度、特殊值(NaN、Infinity)以及五种舍入模式。Python的float就是C语言的double,即64位IEEE 754。

标准的设计初衷是科学计算——物理模拟、图形渲染、信号处理。这些场景容忍误差,追求速度和范围。但金融计算是另一物种:1分钱必须等于1分钱,不能是0.9999999999分钱。

IEEE 754的致命盲区在于:它无法精确表示绝大多数十进制小数。不只是0.1,0.01、0.001、甚至0.123456789,全部带有二进制尾迹。表格对比更直观:

十进制值 | IEEE 754存储值(最接近)

0.1 | 0.1000000000000000055511151231257827021181583404541015625

0.2 | 0.200000000000000011102230246251565404236316680908203125

0.3 | 0.299999999999999988897769753748434595763683319091796875

所以0.1 + 0.2 ≠ 0.3,不是Python的bug,是二进制与十进制的结构性冲突。所有主流语言——JavaScript、Java、C++、Ruby——全部中招。这不是实现差异,是数学层面的不可能。

Python的statistics模块对此有清醒认知。它提供的mean()、variance()、stdev()等函数,内部使用decimal或特殊算法(如Welford算法)来抵消浮点误差。但文档明确警告:「对于需要绝对精度的财务计算,请使用decimal。」

statistics模块:在浮点废墟上造桥

statistics模块:在浮点废墟上造桥

标准库statistics的设计哲学是「尽力而为」。它知道你在用float,它知道float有毒,所以它用算法给你解毒。

最典型的是方差计算。教科书公式是先算均值,再求各点与均值差的平方和。这个「两步法」在float里会灾难性失真:当数据量级差异巨大时,大数吃小数,小数被淹没。

statistics.variance()改用Welford在线算法,单遍扫描、增量更新,数值稳定性提升数个量级。测试数据:10亿加上0.001,重复1000次。两步法方差为0(小数全部被吞),Welford法能保留真实波动。

但statistics从不承诺绝对精确。它的返回值仍是float,只是「更干净的float」。对于审计级精度,你必须自己换decimal。

模块还提供几何平均、调和平均、中位数、分位数等工具。中位数算法尤其考究:偶数个元素时,传统实现取中间两数平均,再次触发浮点加法。statistics用特殊处理确保中间值选择的无偏性。

真实世界的代价:从PayPal到火星探测器

真实世界的代价:从PayPal到火星探测器

浮点错误的代价从不停留在理论。

1991年海湾战争,爱国者导弹拦截系统因浮点时钟累积误差,导致定位偏差0.34秒。导弹误判来袭飞毛腿弹道,28名美军士兵死亡。根源:系统内部用0.1秒为单位计时,0.1无法被二进制精确表示,72小时运行后误差累积至致命阈值。

1996年阿丽亚娜5号火箭首飞爆炸,5亿美元化为火球。惯性导航系统试图将64位浮点数转换为16位整数,溢出未捕获。软件复用自阿丽亚娜4号,但新火箭速度更快,数值超出旧系统的安全范围。

金融领域同样惨烈。2012年Knight Capital交易算法故障,45分钟亏损4.6亿美元。代码部署错误导致旧逻辑与新参数混用,买卖方向反转。虽然直接原因不是浮点精度,但高频交易系统的每一微秒、每一分位,都在浮点钢丝上行走。

更近的案例:2023年某加密货币交易所因浮点舍入错误,用户提现时多获得0.00000001 BTC。金额微小,但自动化脚本批量执行,数小时内流失资产超千万美元。

Python生态对此的防御是分层的。Django的DecimalField强制使用decimal模块;Pandas的Decimal类型支持有限但可用;SQLAlchemy映射时可选Numeric(precision, scale)。每一层都是前人用真金白银买的教训。

架构师的决策树:什么时候用什么

架构师的决策树:什么时候用什么

没有银弹,只有权衡。选择工具前,先回答三个问题:

第一,误差是否可累积?传感器读数、图形坐标、机器学习权重——单次误差随机分布,正负抵消,float足够。货币金额、账户余额、税率计算——误差单向累积,必须用decimal。

第二,性能是否瓶颈?decimal比float慢10到100倍,1亿次运算从0.3秒变成30秒。多数Web请求无需在意,但高频交易、实时风控需要混合策略:核心账本用decimal,缓存预估用float。

第三,是否需要跨语言兼容?JSON没有decimal类型,JavaScript没有decimal原生支持。Python后端用decimal计算,序列化为字符串传给前端,前端再用库如decimal.js还原。链路越长,转换成本越高。