7 / 3 得到 2,-7 / 3 得到 -3;0.1 + 0.2 不等于 0.3;'oops'.to_i 得到 0。这些行为各有用途,但放进同一输入管道,就可能把错误数据变成合法业务值。本篇分别检查表示、运算、舍入与转换。

文章卡

项目 内容
先修 第 01 篇;除法、字符串与错误处理的概念
实验 bundle exec ruby labs/02/run.rb
核心问题 精度在哪里丢失,输入何时必须失败?
验收 负整数除法、浮点反例、两种精确表示、舍入与非法转换
边界 CRuby 3.4.11;BigDecimal 的加载版本由实验输出记录

整数不只是更小的浮点数

Ruby 的 Integer 表示整数。大整数仍保持整数计算,不因为超过常见机器字大小就自动变成 Float。lab 检查 (10**100 + 1) - 10**100 == 1,这项断言关注保留低位,并不承诺任意大的数字没有内存和计算成本。Integer

两个整数相除得到向下取整的商。负数最能区分向下取整与朝零截断:

1
2
3
raise unless 7 / 3 == 2
raise unless -7 / 3 == -3
raise unless -7 == (-7 / 3) * 3 + (-7 % 3)

商是 -3 时,余数是 2,满足“被除数 = 商 × 除数 + 余数”。如果从其他语言迁移一个分桶公式,假定 -7 除以 3 是 -2,会在负数区间得到不同桶号。不要只测试正样本。

希望得到浮点商,可以显式使用浮点操作数或 fdiv。希望保留精确分数,可以使用 Rational(7, 3)。这三个需求的返回类型不同,调用者也应有不同预期。优先级和任务 ID 在 Taskbook 中是整数契约;不需要为了方便转换把 3.7 截成 3。

Float 的误差来自表示与运算

这段 lab 故意验证“不相等”:

1
2
raise if 0.1 + 0.2 == 0.3
puts 0.1 + 0.2

本次输出为 0.30000000000000004。有限的二进制浮点位数无法精确表示许多有限十进制小数,输入表示和运算都会舍入。输出格式减少小数位可以改变显示,却不会改变存储值。Float

测量值的比较需要与领域有关的容差。绝对误差适合固定量纲的小范围,相对误差适合尺度变化较大的量;接近零时又需要绝对误差补充。不能把 Float::EPSILON 当成所有业务的统一阈值,也不能把一个任务优先级当成近似数比较。

外部输入即使成功转换为 Float,也需要检查范围和有限性。NaN、Infinity 和极大值可以破坏排序、序列化和统计;“是 Float”与“业务可接受”是两层判断。第 26、38 篇会将这些边界与结构、体积一起考虑。

精确分数与十进制计算选择不同表示

Rational 可以精确保存分子与分母:

1
2
3
sum = Rational('0.1') + Rational('0.2')
raise unless sum == Rational(3, 10)
raise if Rational(0.1) == Rational('0.1')

第二项是重要反例。传入 Float 时,已经存在的二进制近似值被转成分数;它不会恢复字符串 '0.1' 所代表的十进制意图。需要精确输入时,从原始字符串或整数构造,并明确输入允许的格式。Rational

BigDecimal 适合需要十进制表示和十进制舍入的计算:

1
2
3
4
5
require 'bigdecimal'
sum = BigDecimal('0.1') + BigDecimal('0.2')
raise unless sum == BigDecimal('0.3')
puts BigDecimal::VERSION
puts sum.to_s('F')

本篇工程记录实际加载的 BigDecimal 版本,不将“发行包附带”理解成永远不需要依赖说明。后续打包若公开 API 依赖它,就应在 gemspec 声明。这里从字符串构造,是为了避免在构造之前先经过 Float。BigDecimal 官方仓库

BigDecimal 也不等于所有计算都无误差:除不尽的十进制结果需要精度和舍入策略;与其他数值类型混算还需要检查转换规则。对金额一类数据,是否在每次步骤还是最终结果舍入、使用哪种半值规则、保留几位,都属于领域契约。选择表示只解决其中一部分。

把舍入规则写进代码

整数化存在 floor、ceil、truncate 与 round 等不同需求。-2.5 的 floor 是 -3,朝零截断是 -2;round 在中点还要选择规则。lab 用二进制可以精确表示的 2.5 和 3.5,单独测试舍入策略:

1
2
3
4
raise unless 2.5.round(half: :even) == 2
raise unless 3.5.round(half: :even) == 4
raise unless 2.5.round(half: :up) == 3
raise unless (-2.5).round(half: :up) == -3

使用这些输入可以避免把十进制表示误差与中点策略混在同一个实验中。:even 在正好中点时选择偶数,:up 的这个实验得到远离零的结果。政策要求十进制舍入时,还应在对应十进制类型上验证,不把 Float 的输出直接当成十进制规则的证据。

不要依靠打印格式反向猜测舍入后的值。格式化可能得到 '2.50',这只是字符串;它不能替代数值比较或领域对象的验收。Taskbook 的优先级要求整数,因此更直接的方案是拒绝小数,而不是先选择一种舍入方式。

宽松转换适合提取,不适合输入验收

String#to_i 可以读取开头的数字,也可以在没有数字时返回 0。本篇验证:

1
2
3
raise unless '17oops'.to_i == 17
raise unless 'oops'.to_i == 0
raise unless Integer('017', 10) == 17

输入优先级若写成 '3oops',to_i 会产生处于合法范围的 3。范围检查因此通过,格式错误已经被掩盖。Integer(input, 10) 用显式十进制解析,并对这些非法字符串抛 ArgumentError;lab 捕获这两次错误,未发生错误时则主动失败。Kernel 数值转换

严格转换仍需要契约。如果业务只允许 ASCII 数字、禁止正负号和外围空白,需要先检查字符串语法;Integer 接受的格式比某些业务表单宽。接着检查取值范围,最后才产生领域数据。解析器认可、类型正确、数值合法是连续的三个检查,不是同一个判断。

实验与练习

labs/02/run.rb 同时记录整数商、Float 结果、Rational 结果与 BigDecimal 版本。断言通过时最后输出 PASS 02。数值类型选择的结论来自这些固定输入和文档定义,不来自一个未运行的速度对比。

练习是实现只接受十进制优先级字符串的解析函数:输入只能由一个数字 1 到 5 组成,返回 Integer。用 '3'、'03'、'3oops'、' 3 ' 和 '6' 检查规则。再把输入改成允许前导零,明确修改哪层规则,而不是顺便扩大所有转换行为。

参考资料

系列导航

导读 · 上一篇:01:表达式与控制流 · 下一篇:03:String、Symbol 与编码 · 完整源码包