ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

VB.NET中Decimal与Double的区别:金额计算为什么必须用Decimal?

VB.NET中Decimal与Double的区别:金额计算为什么必须用Decimal? “上个月一个同事拿着 Excel 对账表来找我系统导出的两笔金额数据库里存的是 59.97 和 4.80合计应该是 64.77偏偏导出来变成了 64.77000000000001。查了两个小时最后发现是有人把字段类型写成了 Double。这类问题我见过太多次了尤其是刚接触 VB.NET 的人往往只记得‘小数用 Double’却不知道金额、账目这类业务和科学计算用的‘小数’根本不是同一种语义。这篇文章就把 VB.NET 里 Decimal 和 Double 的底层差异、选型标准、代码坑和性能表现一次说清楚不管是正在学 VB.NET 的新手还是维护老项目的同学应该都能直接拿去用。”1. 一宗没对上账的悬案Decimal 和 Double 差在哪1.1 先从“十进制人”和“二进制机器”的矛盾说起人类从小到大记账、算钱都是十进制的0.1 就是 0.11.23 就是 1.23这是一个非常自然的概念。但计算机底层不是这么想的它的内存里只有 0 和 1所有数字最终都要转换成二进制来存。二进制能精确表示整数比如 1、2、3、1024 都没问题。但表示小数就有意思了二进制小数每一位相当于 1/2、1/4、1/8 这种二的负次幂。像 0.5、0.25、0.125 都能精确表示因为它们在二进制里刚好是 0.1、0.01、0.001。问题是 0.1 这种数字在二进制里是无限循环小数就像十进制里 1/3 永远写不完一样。一个看起来很简单的 0.1在二进制世界里的真实面目是0.00011001100110011001100110011001100110011...这个循环会一直持续下去。Double 只有 52 位尾数加一个隐含位总共 53 位有效二进制位装不下无限循环只能截断。截断之后当你再把它转换成十进制显示出来的其实是 0.1000000000000000055511151231257827021181583404541015625 这样一个非常接近但又不完全等于 0.1 的数。这就是问题的根源。不是 VB.NET 的 bug不是某个库有问题而是所有遵循 IEEE 754 浮点标准的语言都这样。你在 C#、Java、Python、JavaScript 里做 0.1 0.2都会看到类似的现象。只不过有些语言会故意把显示结果修整得好看一点让你平时察觉不到但内部运算和比较一直都带着这种“尾巴”。1.2 Double 和 Decimal 的底层有什么不同先说结论Double 是二进制浮点数Decimal 是十进制浮点数或者说它是一种“用整数加小数点位置来模拟十进制数字”的类型。Double 在 .NET 里是 64 位的 IEEE 754 双精度浮点数由 1 位符号位、11 位指数位和 52 位尾数位组成。它的优势是范围极大、计算极快劣势是很多十进制小数无法精确保存而且有效数字只有 15 到 16 位。Decimal 在 .NET 里是 128 位的结构体内部由 96 位的整数尾部、1 位符号位和 5 位小数点缩放因子组成。它本质上是把一个十进制数字拆成“整数部分加小数点的位置”来存。比如 123.45 在内部可以理解为整数 12345 配合一个 scale2 的缩放因子表示小数点往左移两位。因为每一个十进制位都是直接保存的所以它能精确表示所有十进制值只要在它的有效位数和范围之内。DEcimal 的有效位数是 28 到 29 位十进制位这个精度对于绝大多数商业计算绰绰有余。代价是它的范围比 Double 小很多最大也只有大约 7.9 × 10^28而 Double 可以到 1.8 × 10^308。用一句话概括两者差别Double 是高速的科学计算器Decimal 是精确的商业账本。1.3 一个反直觉的实测0.1R 0.2R 不等于 0.3我直接写一段 VB.NET 代码来演示这段代码我建议每个项目组都留存一份当有人质疑“为啥要用 Decimal”的时候跑给他看Dim a As Double 0.1R Dim b As Double 0.2R Dim c As Double a b Console.WriteLine(c.ToString(R)) 输出0.30000000000000004注意代码里我写的是0.1R而不是0.1。这是 VB.NET 里最容易踩的语法坑之一R后缀表示 DoubleD后缀表示 DecimalF后缀表示 Single。VB.NET 的规则和 C# 不一样C# 里你写0.1默认就是 double但 VB.NET 里不带后缀的小数字面量默认是 Decimal而不是 Double。如果这里写0.1 0.2再赋给 Double 变量你会得到一个 Decimal 相加的精确结果表面上看起来没问题反而掩盖了 Double 在背后会产生的误差。如果你把同样两个值用 Decimal 保存Dim m1 As Decimal 0.1D Dim m2 As Decimal 0.2D Dim m3 As Decimal m1 m2 Console.WriteLine(m3.ToString()) 输出0.3输出干净利落就是 0.3。这就是 Decimal 作为十进制浮点数的核心价值它在人可读的十进制世界里不会产生额外误差。2. 选型判断什么时候咬死 Decimal什么时候只用 Double2.1 金钱、税率、单据从源头就把类型锁死为 Decimal如果你在写任何和金额、账目、价格、税率、折扣、余额有关的代码我的建议非常简单粗暴一律用 Decimal不要在任何一个环节引入 Double。举个例子一个简单的下单计算Dim unitPrice As Decimal 19.99D Dim quantity As Integer 3 Dim subtotal As Decimal unitPrice * quantity 59.97精确 Dim taxRate As Decimal 0.08D Dim tax As Decimal subtotal * taxRate 4.7976也精确 Dim total As Decimal Math.Round(subtotal tax, 2) Console.WriteLine(total) 输出 64.78这个计算换成 Double 会怎样19.99 本身就不是一个能用二进制精确表示的数字它在 Double 内部会变成 19.98999999999999843 个相加之后尾巴会累积。单笔订单还好几万笔订单汇总对账时误差就会一分一分地冒出来。这里还要特别注意 Math.Round 的默认舍入规则。.NET 里 Math.Round 默认使用“银行家舍入”也就是当小数点后第 3 位正好是 5 时会舍入到偶数而不是直接进位。比如 Math.Round(1.25, 1) 得到 1.2Math.Round(1.35, 1) 得到 1.4。很多财务系统要求的是“四舍五入”不是“四舍六入五成双”所以最好显式写出舍入模式Dim total As Decimal Math.Round(subtotal tax, 2, MidpointRounding.AwayFromZero)这一行代码能避免很多财务对账时“差一分钱”的千古奇案。2.2 科学计算、绘图、算法Double 才是默认选择反过来如果你的计算是物理模拟、几何坐标、波形处理、统计采样、机器学习特征值这类场景那 Decimal 就完全不合适了。第一Decimal 的计算速度比 Double 慢一个数量级后面我会说实测数据。第二Math 库里大部分数学函数比如 Math.Sin、Math.Cos、Math.Sqrt、Math.Log、Math.Pow提供的都是 Double 版本。你手头拿着一个 Decimal 的值想开平方就得先 CDbl 转成 Double算完再转回来这一来一回的转换又可能引入精度损失最终结果也不见得比全程用 Double 更准。举一个例子计算平抛运动的落地距离Dim g As Double 9.8R Dim height As Double 20.0R Dim t As Double Math.Sqrt(2 * height / g) Dim distance As Double 32.0R * t Console.WriteLine(distance.ToString(F4))这种计算里面 9.8、20.0 这些物理常数本身也是近似值根本不需要 Decimal 那种 28 位精度保留到 15 位有效数字已经绰绰有余。硬要用 Decimal 反而画蛇添足还会让你的代码变得又慢又别扭。关键判断标准很简单这个数字代表的是“实际观察到的量”还是“必须分毫不差的账目值”。物理世界的量永远有测量误差用二进制浮点带来的微小误差完全淹没在测量误差里。而账目是人为规定的精确值一分钱就是一分钱不允许计算过程把它变成 0.999999999。2.3 数据库字段和 ORM 的映射细节数据库选型也是一个大坑。如果你用的是 SQL Server金额字段请用 decimal(18, 4) 或者 decimal(18, 2).NET 端的 SqlDataReader.GetDecimal 可以直接对应到 .NET 的 Decimal全程无转换误差。同样MySQL / MariaDB 里的 DECIMAL 类型也能干净地映射到 .NET Decimal。但 SQLite 就麻烦一点。SQLite 的类型是动态的你建表时写 DECIMAL它并不会强制校验和存储为十进制数实际插入数据时如果驱动把它当 REAL 处理读出来的就是 Double。用 System.Data.SQLite 时会遇到一个经典场景某列声明为 DECIMAL但执行查询后 reader.GetValue 返回的是 double直接 CDec 转换后你会看到一条很长的尾巴。这不是 Decimal 的问题而是数据在入库时就已经被转成二进制浮点了。数据源头错了后面用什么都救不回来。所以我的经验是数据库到代码的传输路径上尽量使用类型化读取方法比如 GetDecimal、GetDouble 要分清楚不要偷懒统一用 GetValue 再转换。拿到手上的类型到底是什么你要非常清楚。2.4 SingleFloat在这里的地位能不碰就不碰很多做 VB.NET 的人是从 VB6 时代过来的老代码里喜欢用 Single也就是 VB6 里的 Float 单精度浮点。Single 只有 32 位有效数字大约只有 7 位比 Double 误差更大。如果你现在还在新代码里用 Single 存金额或者做复杂累计我建议尽早替换成 Decimal 或 Double。Single 适合的场景极其有限一般也就是移动端图形坐标、音频采样这种对内存和带宽极度敏感、而且精度要求不高的地方。普通业务代码里用 Single基本就是给自己埋雷。三者的定位区分可以看这个表类型占用大小有效十进制位数值范围主要用途Single(Float)4 字节约 7 位±3.4 × 10^38图形、音频、低精度中间量Double8 字节约 15-16 位±1.8 × 10^308科学计算、物理模拟、统计Decimal16 字节28-29 位±7.9 × 10^28金额、税率、财务、计量3. 落到代码里的关键细节后缀、比较、转换和数学函数坑3.1 字面量后缀 D/R/FVB.NET 最容易引起误会的语法前面已经提过一次这里专门展开。VB.NET 里小数默认是 Decimal这大概是老 VB6 程序员留下的传统。如果你写过 C#跨到 VB.NET 时会特别不适应。同样的字面量在不同语言里默认类型完全不同Dim x 0.1 默认是 Decimal Dim y 0.1R Double Dim z 0.1F Single Dim w 0.1D Decimal如果项目里开了 Option Strict On你写Dim d As Double 0.1甚至会直接编译报错因为 Decimal 转 Double 在 VB 眼里属于收缩转换需要显式 CDbl。这个行为吓到过不少人我也曾被它坑过。所以凡是写小数我都习惯手动带后缀尤其是写 Double 计算的代码时一定写0.1R以免默认 Decimal 掩盖问题。3.2 比较相等Double 要靠误差Decimal 可以严格因为 Double 存在表示误差直接用等号比较两个 Double 通常是危险的。最典型的例子Dim d1 As Double 0.3R Dim d2 As Double 0.1R 0.2R If d1 d2 Then Console.WriteLine(相等) Else Console.WriteLine(不相等) End If 输出不相等d1 和 d2 在数值上“应该”相等但 d1 存的是 Double 对 0.3 的最佳近似d2 是两次加法累积出来的近似值两个近似值不是同一个二进制位模式结果就是不相等。比较浮点的标准做法是设定一个容差范围即 epsilonDim epsilon As Double 0.0000001R If Math.Abs(d1 - d2) epsilon Then Console.WriteLine(可以认为相等) End If但这里也有一个隐藏问题epsilon 的选取要和数值量级匹配。如果你比较的是几百万级别的数字1e-7 的相对误差可能太小仍然会误判如果你比较的是 1e-10 级别的微小量1e-7 又太宽松。比较稳健的做法是用相对误差或者干脆对这个场景改用 Decimal。如果换成分数有理数或商业金额Decimal 就是完全不同的体验。在 Decimal 中1.0D 1.00D是 True末尾的零不影响数值相等因为 Decimal 比较的是实际数值而不是内部表示。这也是为什么财务代码里用 Decimal 做相等判断没有任何心理负担。3.3 类型转换CDbl、CDec 不是万能解药很多人以为“反正我最后用 CDec 转一下就行”没这么简单。Double 转 Decimal 会把你以为已经“抹平”的尾巴重新带回来。一个很典型的现场Dim raw As Double 0.1R Dim converted As Decimal CDec(raw) Console.WriteLine(converted.ToString())在某些 .NET 版本和某些运行时环境下这个输出可能会带一长串数字尾巴比如 0.1000000000000000055511151231257。正是因为 Double 里的 0.1 并不是真的 0.1CDec 把二进制背后的精确值“原封不动”地解码成了十进制。你以为自己在处理 0.1实际上 Decimal 接收的是一个已经变形的 Double。所以这里有一条经验金额计算要保证源头是 Decimal不要在中间环节引入 Double 再转回来。一旦数据经过 Double 的“加工”误差就成了既定事实转换成 Decimal 只是给误差拍了一张更清晰的照片不是修正。如果实在没办法只能拿到一个 Double那在转 Decimal 之前先用 Math.Round 把尾巴截到业务需要的精度。比如业务只要求小数点后 4 位Dim safeValue As Decimal CDec(Math.Round(rawDouble, 4))这样可以减少 Double 尾巴进入 Decimal 的概率。虽然这本质上还是“修修补补”但至少不会把 17 位的脏尾巴带到下游。另外强烈建议项目开启 Option Strict On这样 CType、CDbl、CDec 这些转换必须在代码里显式写出来很多潜在问题在编译时就被拦住而不是运行时悄悄丢精度。3.4 Decimal 与数学函数、除零、NaN 的不兼容Decimal 好用但不是没有脾气。它有以下几个硬伤写代码时一定要知道第一Math 库对 Decimal 的支持非常有限。Math.Round、Math.Abs、Math.Sign 有 Decimal 重载但 Math.Sqrt、Math.Sin、Math.Log、Math.Pow、Math.Exp 都没有。想对 Decimal 开平方必须转 Double 算完再转回 Decimal。第二Decimal 无法表示 NaN 和 Infinity。Double 里0.0R / 0.0R得到 NaN1.0R / 0.0R得到 Infinity这不会直接崩溃但会污染后续计算。Decimal 完全没有这个概念除以零直接抛出 DivideByZeroException。第三Decimal 的范围有限。最大只有约 7.9 × 10^28而 Double 可以到 10^308。如果做涉及天文数字的运算Decimal 很容易溢出。比如某些规模极大的统计汇总中间过程的乘积可能瞬间突破 Decimal 上限。第四Decimal 没有“非规范数”的概念。Double 在指数极小的时候会退化为 subnormal 表示精度逐渐丧失但至少不直接归零。Decimal 的极小值受制于小数点缩放因子最多 28 位小数再小就只能靠科学计数或者改变单位。这些差异表明 Decimal 不是 Double 的简单替代品而是一种“语义不同”的数值类型。选型时必须根据业务语义坚定地二选一不要因为“精度不够心里慌”就无脑全部换成 Decimal。4. 性能实测Decimal 究竟慢了多少钱4.1 一份最简单的基准测试代码理论说完了来看实测。有人在论坛上问“Decimal 是不是特别慢”也有人觉得“都 2025 年了硬件这么强那点差距无所谓”。我建议别靠感觉跑一段最朴素的代码感受下Dim sw As Stopwatch Stopwatch.StartNew() Dim d As Double 0.0R For i As Integer 0 To 9999999 d 0.1R Next sw.Stop() Console.WriteLine($Double 耗时: {sw.ElapsedMilliseconds} ms) Console.WriteLine($Double 结果: {d.ToString(R)}) Dim sw2 As Stopwatch Stopwatch.StartNew() Dim m As Decimal 0.0D For i As Integer 0 To 9999999 m 0.1D Next sw2.Stop() Console.WriteLine($Decimal 耗时: {sw2.ElapsedMilliseconds} ms) Console.WriteLine($Decimal 结果: {m.ToString()})这只是一个纯累加的基准不涉及复杂的业务逻辑用来量级性地感受差距已经足够。Release 模式、x64、关闭调试器附加跑出来会更有参考价值。4.2 结果分析与“十几倍慢”的代价换算我本机连续跑了多次大致趋势是同样一千万次加法Double 耗时大约几十毫秒Decimal 耗时在几百毫秒量级实测下来确实有大约 10 到 20 倍的差距。不同 CPU、不同 .NET 版本会有浮动但“慢一个数量级”这个结论非常稳定。为什么这么慢因为 Float 和 Double 是 CPU 硬件直接支持的寄存器运算底层可以用 SSE、AVX 指令一把梭。而 Decimal 不是硬件原生类型.NET 运行时必须通过软件方式把 96 位整数乘法、加法、缩放因子对齐、舍入处理这些步骤一步步算出来每一步都涉及多个基础整数指令自然就慢。更有意思的是Double 一千万次累加 0.1R 之后结果已经不再是整数。而 Decimal 一千万次累加 0.1D结果依然精确。同样的循环一边在高速地积累误差一边在低速地保持精准。选哪个取决于你到底把“正确性”看得多重。4.3 什么时候损失性能值得什么时候是过度设计如果一个系统一天只有几千笔订单每笔订单做几十次四则运算Decimal 比 Double 慢的那几百毫秒可以忽略不计为了安全我毫不犹豫选 Decimal。反过来如果你在做一个实时物理引擎每帧要对几十万个粒子做坐标运算Decimal 这个速度差距会直接让画面卡成 PPT这种场景就必须 Double而且越多越好。本质上这是一个“正确性”和“绝对的原始速度”之间的权衡。业务系统里 99% 的“慢”是慢在数据库、网络、磁盘、序列化上CPU 里那点 Decimal 差距完全不是瓶颈。与其花心思优化掉这点毫秒不如把类型选对省掉后续排查对账差异的时间。顺着这个思路很多人会问列表容器用 Decimal 会不会内存翻倍确实Decimal 是 16 字节Double 是 8 字节一个 100 万个元素的 Decimal 数组比 Double 数组多占 8 个字节 × 100 万也就是约 8MB 内存。在大多数现代应用里这并不是灾难但如果数组规模上百亿这个差距就必须纳入考虑了。5. 我踩过的坑和现在写进项目规范的四条铁律5.1 用 Double 当数据库主键和排序字段的后果这是一个非常隐蔽的坑。有人建表时会随手把标识字段或者排序字段定义成 float理由是“存小数方便范围又大”。结果就是索引失效、查询结果对不齐甚至精确匹配主键时出现“明明这条记录存在但 WHERE id 某个值 查不到”的诡异现象。原因很简单Double 的等值判断本身就不可靠而数据库索引、HASH 关联、主键匹配都依赖严格的等值语义。只要存储值在入库和查询时发生任何一位二进制位级别的偏差匹配就会失败。对于标识、序号、排序字段要么用整数要么用字符串要么用十进制精度可控的 Decimal但绝对不要用 Double。这个教训是我从一次生产事故里换来的当时系统里有一批单据编号用了浮点字段后来全部重建索引才恢复。5.2 SQLite 读取金额的经典翻车现场前面提到 SQLite 时简单说了一下这里把我的完整案例讲出来。某次做一个小工具SQLite 表里一个字段类型写的 DECIMAL(18,2)插入代码用的参数类型是 DbType.Decimal看起来天衣无缝。但实际读取时某些条件下 System.Data.SQLite 返回的却是 double因为 ADO.NET 参数映射在底层把值转成了实数存储。当我拿着这个 double 值再计算并显示金额时出现了 0.30000000000000004 类似的尾巴。因为在使用 SQLite 时光写 DECIMAL 是不够的你必须在读取阶段做类型收敛。我的处理方式是封装一个读取方法遇到 double 就先用 Math.Round(value, 2) 再转 Decimal。类似的坑在 Excel 导入、CSV 解析、文本配置解析里也常出现。凡是解析外部数据都可能拿到 Double。解析层就要收敛精度不要把这个脏值一路传递到业务层。5.3 旧系统升级从 VB6 的 Currency 到 Decimal 的迁移如果你和老项目打过交道应该知道 VB6 时代有一个专门的 Currency 类型用于金额计算。Currency 本质上是带 4 位小数的定点数可以精确表示小额金额但范围非常有限。VB.NET 里不再有 Currency而是用 Decimal 取代它。迁移老代码时最大的坑不是类型本身而是历史数据里已经用 Currency 或 Double 存储的金额可能早就带上了精度误差。直接CDec(旧值)会把旧的误差带进新系统。我的做法是迁移前先做一轮数据清洗对企业务精度重新四舍五入再写入新类型的字段否则上线后你会发现“旧数据和新系统永远对不平”。这种“源头有误差后面再怎么精确都于事无补”的问题它在任何语言、任何框架里都会出现但只要理解了 Decimal 和 Double 的本质排查方向就会非常清晰。5.4 我在项目里固定下来的四条硬性约定经历了这次“对不上账”的排查以及多年在 VB.NET 项目里踩过的大小坑之后我现在写项目代码时会强制自己遵守这几条约定已经写进团队开发规范所有金额、税率、折扣、余额、单价、总计字段无论数据库、DTO、JSON 还是内存变量一律使用 Decimal禁止用 Double 或 Single。Double 只允许出现在科学计算、数值分析、几何图形、物理模拟等明确不涉及货币语义的模块中并且要把类型转换封闭在模块内部不允许 Double 值通过公共接口流到财务相关功能里。比较 Double 时不允许用等号必须通过 Math.Abs 加相对误差判断。判断这一点可以用 Decimal 语义或业务精度要求来确定容差范围。高精度数据在数据访问层就完成类型收敛禁止把 Double 的脏尾巴传播到上层业务逻辑。这几条约定不复杂但能挡住一大部分“看起来没问题、一到对账就翻车”的隐蔽错误。想让一个系统稳定很多时候不是靠多高深的技术而是靠把最基本的数据类型选对、用对然后在关键路径上堵住已知的漏洞。我现在遇到金额问题第一反应就是 Decimal整个数据链路从字段定义到读取转换都锁死不再给 Double 任何介入的机会。这是连续踩坑之后形成的本能也是这篇文章里最想分享给你的一点。
返回列表