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 是 1/101/10,而 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) 存储为 1×1031 \times 10^{3},打印为 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 告诉你 100/3⋅3100/3 \cdot 3 没有被精确计算;它不是错误,因此 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。

从两侧界定结果

定向舍入给出有保证的界。把同一个商分别向 −∞-\infty 和向 +∞+\infty 舍入,即可夹住精确值:

///|
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 值,1/71/7 严格位于它们之间。

交换 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。log⁡101000=3\log_{10} 1000 = 3 这类精确结果返回时不带 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() 的指数范围为 ±999 999 999\pm 999\,999\,999,超出了初等函数所接受的范围;它们会返回带 invalid_context 的 NaN。请使用 decimal32()/decimal64()/decimal128(),或传入 ±999 999\pm 999\,999 以内的 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,因此位数超过精度的指数会在求幂之前被舍入。请保持 ∣n∣<10p|n| < 10^{p},或以精确的 Decimal 指数调用 power_ctx。
  • with_rounding 无法选择 HalfUp、HalfDown 或 ZeroFiveUp。 请用 DecimalContext::new(decimal_rounding=...) 构造上下文。
  • 二进制转换会丢失十进制含义。 from_double(0.1) 是精确的二进制值 0.1000000000000000055511151231257827(舍入到 34 位),而不是 0.1;请改为解析文本。from_bin_float 会把 −0-0 变为 +0+0。

后续步骤