为什么 4.99 保存为 4.98?

iga*_*nev -3 rust

我可能在矩阵中发现了一个故障。一位客户报告了一个错误:他们通过后端设置了产品价格4.99,并说保存为4.98。

我怀疑他们输入了错误的值,所以我自己测试了它,确实该值出现了,而4.98 不是4.99。所有其他产品价格似乎不受影响。一堆 0.99s、1.99s 和 19.99s。我认为我们最近可能引入了这个错误,因为这是客户第一次也是唯一一次报告看到这样的问题。

为了给您提供更多背景信息,它是一个 React UI,它使用 apollo graphql 将数据推送到 Rust (actix) 后端。然后,后端将需要保存的任何内容保存在数据库中,并且产品价格通过其 API 发送到 stripe。4.98 是条纹的。Stripe API 接受价格的方式是单位金额,即美分,而不是美元。所以4.99应该以条纹着陆499。根据我们后端发送的他们的仪表板498。啥?!

几分钟后

我无法缩小代码中任何明显错误的范围。graphql 突变将其作为浮点值发送,它再次4.99保存在我们的数据库中,但价格最终以条带形式出现。FLOAT(4,2)4.994.98

我们为 stripe API 准备值的方式是:

let unit_amount = (product_price_row.price * 100f32) as i64;
Run Code Online (Sandbox Code Playgroud)

product_price_rowSeaORM 行所在的price位置是4.99

在这种情况unit_amount下最终为498.

似乎没有其他值会出现这样的故障。

我意识到这是一些奇怪的浮点错误簇,但它引起了我的注意。我写了一个小的 Rust 沙箱来说明发生了什么。要亲自测试它,请检查此 存储库

基本上可以归结为以下几点:

let i = 4.99; // any other .99 number works fine

println!("i = {}", i); // 4.99
println!("i.2 = {:.2}", i); // 4.99
println!("i.10 = {:.10}", i); // 4.9900000000

println!();

let f = format!("{:.10}", i).parse::<f32>().unwrap(); // lets introduce some errors
println!("f = {}", f); // 4.99
println!("f.2 = {:.2}", f); // 4.99
println!("f.10 = {:.10}", f); // 4.9899997711 <- here it goes

println!();

let d = f * 100f32;
println!("d = {}", d); // 498.99997 <- blyatiful
println!("d.2 = {:.2}", d); // 499.00
println!("d.10 = {:.10}", d); // 498.9999694824

println!();

let a = d as i64; // how the code used to work
println!("a = {}", a); // 498

let b = format!("{:.2}", d).parse::<f32>().unwrap() as i64; // workaround
println!("b = {}", b); // 499
Run Code Online (Sandbox Code Playgroud)

在上面的代码中,a代表客户端报告的值,并b代表我修复它的解决方法。

替换i为任何 0.99 数字的行为都不同。为了证明这4.99是一个异常值,我制作了另一个小二进制文件(在同一个存储库中)。跑去cargo run --bin itr亲自测试一下。它从 0 开始,直到爆炸或您停止它 (CTRL+C) 并显示未通过测试的“有缺陷”数字a == b

我得到这个结果的唯一数字是4.99

对于如何在 Rust 中更正确地使用浮动有什么建议吗?

Sha*_*ger 5

执行浮点数学后进行转换,而不进行第一次舍入,是错误的。当你这样做时:

let unit_amount = (product_price_row.price * 100f32) as i64;
Run Code Online (Sandbox Code Playgroud)

由于浮点精度限制,数学中的product_price_row.priceas显然会产生一个更像或类似的值(我懒得去找到确切的值),并且转换只是删除小数点后的数据,而不是四舍五入,给你留下的不是你所期望的。4.99f324.99f32 * 100f32f32498.99999999998498499

相反,做:

// Casting to f64 before doing math removes risk of data loss for higher prices
let unit_amount = (product_price_row.price as f64 * 100f64).round() as i64;
Run Code Online (Sandbox Code Playgroud)

或(在小数点可能超出小数点后第二位的情况下),使用libmath

let unit_amount = math::round::half_to_even(product_price_row.price as f64 * 100f64) as i64;
Run Code Online (Sandbox Code Playgroud)

它使用“银行家舍入”来确保半值的舍入不会始终以一种方式或另一种方式进行,从而最大限度地减少总体舍入偏差的风险。

无论哪种方式,.999999998您都会正确舍入,然后转换现在的安全值,而不是被丢弃。

  • @iganev:这不是任何不深入了解 IEEE 754 二进制浮点的人可以预测的(除了只有能被 2 的幂整除的十进制值才能完美表示的简单规则之外,其他一切都是不精确的)。哪些值存在问题在“f32”和“f64”数学之间有所不同,并且只有当乘法碰巧低于“真实”目标时您才会注意到该问题;它一直在“结束”,你只是没有注意到,因为你把它扔掉了。我保证,如果您更彻底地探索价格空间,您会发现这个问题的很多实例。 (2认同)