decimal_gda 教程

本教程教你使用 decimal_gda 进行计算。该包实现了 Cowlishaw 的通用十进制算术(GDA):保留尾随零的十进制数,确定精度、舍入和指数界限的上下文,记录所发生情况的粘滞状态,以及在终止计算的同时仍把确定结果交给你的陷阱。学完之后,你将能够在计算中传递上下文、对金额舍入、处理陷阱、上溢和下溢、调用初等函数,并选择合适的比较方式。每条规则背后的数学见设计页面;所有公开名称都在 API 页面上。

快速入门

添加模块并导入该包:

moon add Luna-Flow/floating
import {
  "Luna-Flow/floating/decimal_gda",
}

每个 GDA 运算都接收操作数和一个 GdaContext,并返回一个 GdaOutcome:结果、下一步要使用的上下文,以及该运算引发的情形。

///|
test "quick start: one third in nine digits" {
  let ctx = @decimal_gda.context(precision=9)
  let one = @decimal_gda.parse("1", ctx)
  let three = @decimal_gda.Decimal::from_string("3").unwrap()
  let third = @decimal_gda.divide(one.value(), three, one.next_context())
  inspect(third.value(), content="0.333333333")
  inspect(third.raised().contains(Inexact), content="true")
  inspect(third.next_context().status().contains(Rounded), content="true")
}

raised() 描述的是这一个运算。next_context().status() 是粘滞的:它收集自上下文创建或清除以来的所有情形。

日常任务

在计算中传递上下文

只有把每个结果的 next_context() 传给下一个运算,状态才会累积。你最初使用的上下文永远不会改变,因为上下文是一个值,而不是可变对象。

///|
test "sticky status follows the threaded context" {
  let start = @decimal_gda.context(precision=5)
  let two = @decimal_gda.Decimal::from_string("2").unwrap()
  let three = @decimal_gda.Decimal::from_string("3").unwrap()
  let q = @decimal_gda.divide(two, three, start) // inexact
  let tiny = @decimal_gda.Decimal::from_string("0.00001").unwrap()
  let s = @decimal_gda.add(q.value(), tiny, q.next_context()) // exact
  inspect(s.value(), content="0.66668")
  inspect(s.raised().contains(Inexact), content="false")
  inspect(s.next_context().status().contains(Inexact), content="true")
  inspect(start.status().contains(Inexact), content="false")
  // Start a new observation window and keep the trap settings.
  let fresh = s.next_context().clear_status()
  inspect(fresh.status() == @decimal_gda.GdaFlags::none(), content="true")
}

保留并设定量子

GDA 数由系数和指数组成,因此 2.50 和 2.5 是相等的数,但指数不同(量子不同)。算术保留精确结果所要求的指数,而 quantize 显式地设定指数。这就是把金额舍入到分的方法:

///|
test "round to cents with quantize" {
  let even = @decimal_gda.GdaContext::decimal64() // HalfEven
  let half_up = @decimal_gda.context(precision=16, rounding=HalfUp)
  let price = @decimal_gda.Decimal::from_string("2.50").unwrap()
  let qty = @decimal_gda.Decimal::from_string("3").unwrap()
  inspect(@decimal_gda.multiply(price, qty, even).value(), content="7.50")
  let cents = @decimal_gda.Decimal::from_string("0.01").unwrap()
  let x = @decimal_gda.Decimal::from_string("2.345").unwrap()
  let banker = @decimal_gda.quantize(x, cents, even)
  let school = @decimal_gda.quantize(x, cents, half_up)
  inspect(banker.value(), content="2.34")
  inspect(school.value(), content="2.35")
  inspect(banker.raised().contains(Inexact), content="true")
  // A quantum that needs more digits than the precision is invalid.
  let tiny = @decimal_gda.Decimal::from_string("1E-20").unwrap()
  let bad = @decimal_gda.quantize(x, tiny, even)
  inspect(bad.value(), content="nan")
  inspect(bad.raised().contains(InvalidOperation), content="true")
}

reduce 的作用正相反:它去除尾随零,因此 reduce(7.50) 是 7.5。只有在确实需要同值类中最短的成员时才使用它。

处理陷阱而不丢失结果

用 trap 派生出一个新上下文即可启用陷阱。被陷阱捕获的运算返回 Trapped(signal, value, next_context, raised):GDA 定义的结果仍然在其中,粘滞状态也照样更新。

///|
test "a trapped division keeps its defined result" {
  let ctx = @decimal_gda.GdaContext::decimal64().trap(DivisionByZero)
  let one = @decimal_gda.Decimal::one()
  let zero = @decimal_gda.Decimal::zero()
  match @decimal_gda.divide(one, zero, ctx) {
    @decimal_gda.GdaOutcome::Trapped(signal, value, next, raised) => {
      inspect(signal == DivisionByZero, content="true")
      inspect(value, content="inf")
      inspect(raised.contains(DivisionByZero), content="true")
      inspect(next.status().contains(DivisionByZero), content="true")
    }
    @decimal_gda.GdaOutcome::Completed(_, _, _) => fail("expected a trap")
  }
}

InvalidOperation 陷阱也会捕获四种细分的无效情形(ConversionSyntax、DivisionImpossible、DivisionUndefined、InvalidContext)。basic 上下文启用了它,因此格式错误的字面量会触发陷阱:

///|
test "the InvalidOperation trap covers conversion syntax" {
  let basic = @decimal_gda.GdaContext::basic()
  let out = @decimal_gda.parse("1.2.3", basic)
  match out {
    @decimal_gda.GdaOutcome::Trapped(signal, value, _, raised) => {
      inspect(signal == InvalidOperation, content="true")
      inspect(value, content="nan")
      inspect(raised.conversion_syntax, content="true")
    }
    @decimal_gda.GdaOutcome::Completed(_, _, _) => fail("expected a trap")
  }
}

当一个运算引发多个被陷阱捕获的情形时,本包按固定的优先级(列于 API 页面)报告其中之一,因此你无需自行规定顺序。

遵守指数范围

上下文把调整后的指数限制在 [e_min, e_max] 之内。超出 e_max 时结果上溢;上溢成什么取决于舍入模式。低于 e_min 时结果成为次正规数并丢失数字:

///|
test "overflow and underflow in decimal32" {
  let d32 = @decimal_gda.GdaContext::decimal32() // p=7, e_max=96, HalfEven
  let down = @decimal_gda.context(
    precision=7,
    rounding=Down,
    e_min=-95,
    e_max=96,
    clamp=true,
  )
  let big = @decimal_gda.Decimal::from_string("9E+96").unwrap()
  let ten = @decimal_gda.Decimal::from_string("10").unwrap()
  inspect(@decimal_gda.multiply(big, ten, d32).value(), content="inf")
  inspect(@decimal_gda.multiply(big, ten, down).value(), content="9.999999E+96")
  let small = @decimal_gda.Decimal::from_string("1.234567E-95").unwrap()
  let thousand = @decimal_gda.Decimal::from_string("1000").unwrap()
  let sub = @decimal_gda.divide(small, thousand, d32)
  inspect(sub.value(), content="1.235E-98")
  inspect(sub.raised().contains(Subnormal), content="true")
  inspect(sub.raised().contains(Underflow), content="true")
}

调用初等函数

sqrt、exp、ln 和 log10 是正确舍入的,并且无论上下文的舍入模式如何,总是采用半偶舍入。非整数指数的 power 同样是正确舍入的,但使用上下文自身的舍入模式。按照 GDA 规范的要求,exp、ln、log10 和非整数 power 只接受精度、e_max 和 -e_min 都不超过 999,999 的上下文;decimal32/64/128 预设满足这一条件,而 GdaContext::new 和 context 的默认值(指数范围 ±999,999,999)不满足:

///|
test "elementary functions" {
  let ctx = @decimal_gda.GdaContext::decimal64()
  let one = @decimal_gda.Decimal::one()
  let two = @decimal_gda.Decimal::from_string("2").unwrap()
  let ten = @decimal_gda.Decimal::from_string("10").unwrap()
  inspect(@decimal_gda.exp(one, ctx).value(), content="2.718281828459045")
  inspect(@decimal_gda.ln(ten, ctx).value(), content="2.302585092994046")
  inspect(@decimal_gda.log10(two, ctx).value(), content="0.3010299956639812")
  inspect(@decimal_gda.sqrt(two, ctx).value(), content="1.414213562373095")
  // exp, ln, log10 and non-integer power need |e_min|, e_max <= 999999.
  let floor3 = @decimal_gda.context(
    precision=3,
    rounding=Floor,
    e_min=-999_999,
    e_max=999_999,
  )
  let three_halves = @decimal_gda.Decimal::from_string("1.5").unwrap()
  inspect(@decimal_gda.exp(one, floor3).value(), content="2.72") // still half-even
  inspect(@decimal_gda.power(two, three_halves, floor3).value(), content="2.82") // floor
}

比较与打印

compare 按数值比较并返回一个十进制数(-1、0、1,或在某个操作数为 NaN 时返回 NaN)。compare_total 对表示进行排序,因此能区分 2.50 和 2.5。打印一个值会得到其科学记数法字符串;class_name 给出其类别名称:

///|
test "comparisons and text" {
  let ctx = @decimal_gda.GdaContext::decimal64()
  let a = @decimal_gda.Decimal::from_string("2.50").unwrap()
  let b = @decimal_gda.Decimal::from_string("2.5").unwrap()
  inspect(@decimal_gda.compare(a, b, ctx).value(), content="0")
  inspect(@decimal_gda.compare_total(a, b, ctx).value(), content="-1")
  let nan = @decimal_gda.Decimal::nan()
  inspect(@decimal_gda.compare(a, nan, ctx).value(), content="nan")
  inspect(
    @decimal_gda.compare_signal(a, nan, ctx).raised().contains(InvalidOperation),
    content="true",
  )
  let big = @decimal_gda.Decimal::from_string("123E+5").unwrap()
  inspect(big, content="1.23E+7")
  inspect(@decimal_gda.class_name(big, ctx).value(), content="+Normal")
  let (eng, _) = @decimal_gda.Decimal::to_eng_string(
    "123E+5",
    @decimal_gda.DecimalContext::new(),
  )
  inspect(eng, content="12.3E+6")
}

深入了解

用 decimal_gda_checked 构建长链

手动传递上下文在边界处最为清晰。对于较长的线性流水线,decimal_gda_checked 只保留一个结果,替你传递粘滞上下文,并在第一个陷阱之后停止:

///|
test "a checked GDA pipeline" {
  let ctx = @decimal_gda.GdaContext::decimal64()
  let checked = @decimal_gda_checked.GdaDecimalChecked::parse("2", ctx)
    .sqrt()
    .multiply(@decimal_gda.Decimal::from_string("10").unwrap())
  inspect(checked.value(), content="14.14213562373095")
  inspect(checked.is_trapped(), content="false")
  inspect(checked.status().contains(Inexact), content="true")
}

通过 Luna-Flow/arithmetic 编写泛型代码

Decimal 实现了 Luna-Flow/arithmetic 的 contextual trait,因此针对 AddContextual、DivContextual、SqrtContextual 等编写的代码可以在其上运行。这些适配器使用 ArithmeticContext 的五种 IEEE 风格舍入模式,以 Err 报告错误,并且对陷阱一无所知:

///|
fn[T : @lf_arith.AddContextual] sum3(
  a : T,
  b : T,
  c : T,
  ctx : @lf_arith.ArithmeticContext,
) -> Result[T, @lf_arith.ArithmeticError] {
  match @lf_arith.AddContextual::add_contextual(a, b, ctx) {
    Ok(ab) =>
      @lf_arith.AddContextual::add_contextual(ab.value, c, ctx).map(o => o.value)
    Err(error) => Err(error)
  }
}

///|
test "generic contextual addition" {
  let ctx = @lf_arith.ArithmeticContext::new(3)
  let x = @decimal_gda.Decimal::from_string("1.25").unwrap()
  inspect(sum3(x, x, x, ctx).unwrap(), content="3.75")
}

子集算术与丢失的数字

GdaContext::basic() 是 GDA 的基本默认上下文:精度 9、HalfUp、extended=false,并启用 DivisionByZero、InvalidOperation、Overflow、Underflow 和 Clamped 陷阱。非扩展上下文会在使用之前对长于精度的操作数进行舍入,并在因此丢失信息时报告 LostDigits:

///|
test "subset arithmetic rounds long operands" {
  let basic = @decimal_gda.GdaContext::basic()
  let long = @decimal_gda.Decimal::from_string("1234567891").unwrap()
  let zero = @decimal_gda.Decimal::zero()
  let out = @decimal_gda.add(long, zero, basic)
  inspect(out.value(), content="1.23456789E+9")
  inspect(out.raised().contains(LostDigits), content="true")
}

交换编码

GdaInterchange 以密集压缩十进制(DPD)编码保存 decimal32、decimal64 或 decimal128 位模式,写作 # 加十六进制数字:

///|
test "decimal64 interchange round trip" {
  let x = @decimal_gda.Decimal::from_string("-7.50").unwrap()
  let (bits, _) = @decimal_gda.GdaInterchange::from_decimal(x, Decimal64)
  inspect(bits.to_hex(), content="#A2300000000003D0")
  inspect(bits.to_decimal(), content="-7.50")
}

常见陷阱

  • 重复使用原始上下文。 只有把 next_context() 传递下去,状态才会累积。你在某个上下文上设置的陷阱集合或状态,也会随从它派生的每个上下文一起传递。
  • 把 Trapped 当作“没有值”。 陷阱改变的是变体,而不是结果;在决定停止之前,请用 value() 读取值。
  • 指望构造器保留零。 Decimal::from_int(100) 和 Decimal::make 会去除尾随零,因此 from_int(100) 打印为 1E+2。当量子重要时,请使用 Decimal::from_string("100") 或 parse。
  • 用运算符做 GDA 计算。 +、-、* 和 / 不接收上下文:它们以半偶舍入舍入到较大的操作数精度(* 是精确的),从不发出信号,并且 + 和 / 返回同值类中最短的成员。只要 GDA 的结果、标志或陷阱有意义,就请使用包中的函数。
  • 相等性与 NaN。 == 和 compare 把每个 NaN 视为与其他所有 NaN 相等,并大于每个数(这是一个全预序,因此排序从不中止)。当 NaN 必须保持无序时,请使用 compare(包中的函数)、compare_signal 或 Decimal::compare_checked。
  • 混用两个十进制包。 @decimal_gda.Decimal 和 @decimal.Decimal 是具有不同契约的不同类型;在它们之间转换请通过字符串或交换位模式。
  • 指望 exp、ln、log10 或 sqrt 遵循上下文的舍入。 它们总是采用半偶舍入;只有 power 遵循上下文。
  • 用默认上下文调用 exp、ln、log10 或非整数 power。 GdaContext::new() 和 context() 允许指数达到 ±999,999,999,超出了这些函数有定义的范围,因此它们返回 NaN 并引发 InvalidContext(一种 InvalidOperation)。请使用预设,或传入 e_min=-999_999, e_max=999_999。

后续步骤

  • 设计:算术模型、理想指数、舍入函数、信号与陷阱状态机,以及初等函数如何认证。
  • API 参考:每个类型、函数和方法。
  • 符合性和性能:固定版本测试套件的结果以及如何测量速度。
  • decimal_gda_checked 教程:流水线中的陷阱短路与恢复。
  • decimal 教程:带逐运算标志的 IEEE 754 十进制模型。