decimal 教程
本教程展示如何使用行为与人们书写方式一致的十进制数进行计算:0.1 + 0.2 恰好等于 0.3,12.30 记得自己有两位小数,每一次舍入都由你选择并向你报告。你将解析和格式化值,在上下文下计算并读取其标志,用 quantize 对金额舍入,交换 decimal64 位模式,并调用正确舍入的初等函数。每一步背后的数学见 decimal 设计;每个函数都在 decimal API 中有规定。
快速入门
把 floating 加入你的模块并导入该包:
moon add Luna-Flow/floating
import {
"Luna-Flow/floating/decimal",
}
最小的实用程序把两个 Double 无法表示的十进制小数相加:
///|
test "decimal quick start" {
let a = @decimal.Decimal::from_string("0.1").unwrap()
let b = @decimal.Decimal::from_string("0.2").unwrap()
inspect(a + b, content="0.3")
inspect(0.1 + 0.2 == 0.3, content="false")
}
0.1 是 ,而 2 的幂都不能被 5 整除,因此二进制浮点只能近似它;十进制浮点则能精确存储它。
日常任务
解析金额并保留其小数位数
解析会保留文本中的指数。两个值可以相等却携带不同的信息:
///|
test "decimal parsing keeps the quantum" {
let price = @decimal.Decimal::from_string("12.30").unwrap()
let same = @decimal.Decimal::from_string("12.3").unwrap()
inspect(price == same, content="true")
inspect(price.quantum(), content="-2")
inspect(same.quantum(), content="-1")
inspect(price.same_quantum(same), content="false")
inspect(price.normalized(), content="12.3")
}
12.30 和 12.3 是同一个同值类(cohort)的两个成员:数值相等,但以不同的指数书写。normalized() 选取最短的成员。不要对小数位数有意义的金额调用它;需要规范键时才调用它。
整数以约简形式构造:Decimal::from_int(1000) 存储为 ,打印为 1E+3。需要四位数字时请解析 "1000"。
在上下文下计算并保留标志
DecimalContext 确定精度、舍入模式和指数范围。每个 *_ctx 运算都返回结果及其引发的标志。边计算边合并标志:
///|
test "decimal64 pipeline with flags" {
let ctx = @decimal.DecimalContext::decimal64()
let d = fn(s : String) { @decimal.Decimal::from_string(s).unwrap() }
let (total, f1) = d("100").div_ctx(d("3"), ctx)
let (scaled, f2) = total.mul_ctx(d("3"), ctx)
let flags = f1.combine(f2)
inspect(total, content="33.33333333333333")
inspect(scaled, content="99.99999999999999")
inspect(flags.inexact, content="true")
inspect(flags.has_error(), content="false")
}
inexact 告诉你 没有被精确计算;它不是错误,因此 has_error() 仍为 false。has_error() 报告无效运算、除以零、不可能的除法和无效上下文。当你的应用关心 overflow、underflow、inexact 这些标志时,请逐个检查。
用 quantize 对金额舍入
quantize 使一个值具有模板值的指数。在上下文中选择舍入模式;商业舍入是 HalfUp,共享的 RoundingMode 枚举中没有它,因此请传入 decimal_rounding:
///|
test "decimal round to cents" {
let d = fn(s : String) { @decimal.Decimal::from_string(s).unwrap() }
let cents = d("0.01")
let bankers = @decimal.DecimalContext::decimal64()
let commercial = @decimal.DecimalContext::new(
precision=16,
e_min=-383,
e_max=384,
decimal_rounding=@decimal.DecimalRoundingMode::HalfUp,
)
inspect(d("2.345").quantize(cents, bankers).0, content="2.34")
inspect(d("2.345").quantize(cents, commercial).0, content="2.35")
inspect(d("7").quantize(cents, bankers).0, content="7.00")
}
半偶(“银行家”)舍入把平局 2.345 送到末位为偶数的 4;半上舍入则把它送向远离零的方向。quantize 从不悄悄选择另一个指数:如果结果所需的位数超过精度,你会得到带 invalid_operation 的 NaN。
从两侧界定结果
定向舍入给出有保证的界。把同一个商分别向 和向 舍入,即可夹住精确值:
///|
test "decimal directed rounding brackets the exact quotient" {
let ctx = @decimal.DecimalContext::decimal32()
let one = @decimal.Decimal::one()
let seven = @decimal.Decimal::from_int(7)
let down = ctx.with_rounding(@def.RoundingMode::TowardNegative)
let up = ctx.with_rounding(@def.RoundingMode::TowardPositive)
inspect(one.div_ctx(seven, down).0, content="0.1428571")
inspect(one.div_ctx(seven, up).0, content="0.1428572")
}
两个结果是相邻的 decimal32 值, 严格位于它们之间。
交换 decimal64 位模式
交换格式是数据库、文件和其他语言之间交换数据所用的格式。请用对方期望的编码进行编码,并用同一编码解码:
///|
test "decimal64 DPD and BID round trip" {
let fmt = @decimal.DecimalInterchangeFormat::Decimal64
let price = @decimal.Decimal::from_string("19.99").unwrap()
let (bits, flags) = @decimal.DecimalInterchange::from_decimal_with_encoding(
price,
fmt,
@decimal.DecimalInterchangeEncoding::BID,
)
inspect(bits.to_hex(), content="#31800000000007CF")
inspect(flags.has_error(), content="false")
inspect(bits.to_decimal(), content="19.99")
let (dpd, _) = price.to_interchange_hex(fmt)
inspect(dpd, content="#22300000000004FF")
}
两种编码都保留指数,因此 19.99 读回时仍有两位小数。不是你自己产生的位模式可能是非规范的;把它们保存在 DecimalInterchange 中,并在比较位模式之前调用 canonical()。
调用初等函数
对数、指数、幂和三角函数在每种舍入模式下都是正确舍入的。它们需要一个指数范围有界的上下文,例如某个格式预设:
///|
test "decimal certified logarithm" {
let ctx = @decimal.DecimalContext::decimal64()
let two = @decimal.Decimal::from_int(2)
match two.try_ln_ctx(ctx) {
Ok((value, flags)) => {
inspect(value, content="0.6931471805599453")
inspect(flags.inexact, content="true")
}
Err(e) => fail("not certified: \{e.is_certification_failure()}")
}
inspect(@decimal.Decimal::from_int(1000).log10_ctx(ctx).0, content="3")
}
try_ln_ctx 只在结果无法在细化预算内认证时返回 Err;ln_ctx 把这种情形变为带 invalid_operation 的 NaN。 这类精确结果返回时不带 inexact。
深入了解
基于代数 trait 的泛型代码
Decimal 通过其普通运算符实现了 luna-generic 的 Ring,因此泛型代码无需修改即可在其上运行:
///|
fn[T : @lf_alg.Ring] dot(xs : Array[T], ys : Array[T]) -> T {
let mut acc : T = @lf_alg.Zero::zero()
for i in 0..<xs.length() {
acc = acc + xs[i] * ys[i]
}
acc
}
///|
test "decimal in generic ring code" {
let d = fn(s : String) { @decimal.Decimal::from_string(s).unwrap() }
inspect(dot([d("1.5"), d("2.25")], [d("4"), d("0.2")]), content="6.45")
}
普通运算符没有上下文:* 是精确的,+ 舍入到较大的操作数精度(默认 34 位)并返回同值类中最短的成员。需要精度、指数范围或标志的代码应接收一个 DecimalContext 并调用 *_ctx 运算。
共享的 contextual trait
针对 Luna-Flow/arithmetic 编写的代码使用 ArithmeticContext,并得到带诊断信息的 ArithmeticOutcome:
///|
test "decimal through the contextual traits" {
let ctx = @lf_arith.ArithmeticContext::new(5)
let x = @decimal.Decimal::from_int(2)
match x.div_contextual(@decimal.Decimal::from_int(3), ctx) {
Ok(outcome) => {
inspect(outcome.value, content="0.66667")
inspect(outcome.diagnostics.inexact, content="true")
}
Err(_) => fail("unexpected error")
}
inspect(x.div_contextual(@decimal.Decimal::zero(), ctx) is Err(_), content="true")
}
错误(invalid_operation、division_by_zero……)变为 Err;不精确和范围事件变为诊断信息。
流水线、GDA 状态与区间
decimal_checked包装一个值、它的上下文及其累积的标志,因此长流水线无需显式调用combine。decimal_gda实现了带粘滞状态和陷阱的通用十进制算术模型。它的值是一个独立的类型;当你需要.decTest的行为而不是 IEEE 的逐运算标志时,请使用它。- 要把十进制值包络在二进制区间中,可分别用
to_bin_float(mode=TowardNegative)和to_bin_float(mode=TowardPositive)转换两次,再以这两个界构造一个ball_float球。
用于存储的确定性顺序
compare_total 对每种表示(包括同值类、带符号零和 NaN)都给出顺序,因此它是对存储值排序或对逐位相同的记录去重的合适键:
///|
test "decimal total order separates cohorts" {
let d = fn(s : String) { @decimal.Decimal::from_string(s).unwrap() }
let xs = [d("1.0"), d("-0"), d("1.00"), d("NaN"), d("0")]
xs.sort_by(fn(a, b) { a.compare_total(b) })
inspect(xs.map(fn(x) { x.to_string() }).join(" "), content="-0 0 1.00 1.0 nan")
}
常见陷阱
- 初等函数需要有界上下文。
DecimalContext::new()的指数范围为 ,超出了初等函数所接受的范围;它们会返回带invalid_context的 NaN。请使用decimal32()/decimal64()/decimal128(),或传入 以内的e_min/e_max。 - 运算符不是上下文运算。
*从不舍入,因此反复相乘会无限增长;/舍入到操作数精度,在极少数情况下末位可能相差一个单位。当结果必须有界或正确舍入时,请使用mul_ctx和div_ctx。 ==不是 IEEE 相等。Eq和compare把每个 NaN 视为与每个 NaN 相等且大于每个数,这样排序才能正常工作。当 NaN 必须无序时,请使用compare_checked或is_nan。has_error()的范围很窄。 它忽略inexact、overflow、underflow和conversion_syntax。在from_string_ctx之后,请检查conversion_syntax或is_nan()来发现错误的文本。- 整数指数按上下文精度转换。
pown_ctx、pow_int_checked和pow_nat_checked会把整数指数转换为具有上下文精度的Decimal,因此位数超过精度的指数会在求幂之前被舍入。请保持 ,或以精确的Decimal指数调用power_ctx。 with_rounding无法选择HalfUp、HalfDown或ZeroFiveUp。 请用DecimalContext::new(decimal_rounding=...)构造上下文。- 二进制转换会丢失十进制含义。
from_double(0.1)是精确的二进制值0.1000000000000000055511151231257827(舍入到 34 位),而不是0.1;请改为解析文本。from_bin_float会把 变为 。
后续步骤
- decimal API:每个类型和函数及其精确语义。
- decimal 设计:格式、同值类、编码、舍入、误差界与认证。
- decimal 符合性和 decimal 性能:有限的证据与测量方法。
- 关于流水线与 GDA 状态,见
decimal_checked教程和decimal_gda教程。