decimal_checked 设计

设计目标

decimal_checked 把一系列 IEEE 754 十进制运算变为一个值,它在最后回答两个问题:结果是什么,以及途中发生了什么。IEEE 十进制算术把异常条件作为状态标志,与一个有定义的结果一同报告;单个运算返回 (value, flags),但多步计算需要每一步的标志。DecimalChecked 让同一个上下文贯穿各步并累积标志,同时保持 IEEE 的规则:异常结果仍是值。API 页面列出了各项运算;教程展示了审计式的流水线。

数学背景

标志幺半群

DecimalFlags 有十三个布尔字段(inexact、rounded、overflow、…),因此一个标志集合是 F={0,1}13\mathbb{F} = \{0, 1\}^{13} 的元素,或等价地,是十三种信号构成的集合 Σ\Sigma 的子集。DecimalFlags::combine 是逐字段的 OR:

(a∨b)i=ai∨bi(i=1,…,13),0=DecimalFlags::new().(a \lor b)_i = a_i \lor b_i \qquad (i = 1, \dots, 13), \qquad \mathbf{0} = \texttt{DecimalFlags::new()}.

由于布尔 OR 满足结合律、交换律和幂等律,单位元为 00,且运算逐字段进行,

(a∨b)∨c=a∨(b∨c),a∨0=0∨a=a,a∨b=b∨a,a∨a=a.\begin{aligned} (a \lor b) \lor c &= a \lor (b \lor c), & a \lor \mathbf{0} &= \mathbf{0} \lor a = a,\\ a \lor b &= b \lor a, & a \lor a &= a . \end{aligned}

因此 (F,∨,0)(\mathbb{F}, \lor, \mathbf{0}) 是交换幂等幺半群,即有界并半格;按子集解释,∨\lor 就是并集。DecimalFlags::contains(s) 读取坐标 ss,它是到 ({0,1},∨,0)(\{0,1\}, \lor, 0) 的幺半群同态:contains(a∨b,s)=contains(a,s)∨contains(b,s)\texttt{contains}(a \lor b, s) = \texttt{contains}(a, s) \lor \texttt{contains}(b, s)。

运算作为 writer 箭头

固定上下文 cc 下的 IEEE 运算是十进制值 DD 上的函数 opc:D→D×F\mathrm{op}_c : D \to D \times \mathbb{F},返回舍入后的结果及其引发的标志(Decimal::add_ctx、div_ctx、sqrt_ctx、…)。这样的函数是 F\mathbb{F} 上 writer 单子的 Kleisli 箭头:

η(v)=(v,0),(v,F)> ⁣ ⁣> ⁣ ⁣=f=(v′,F∨r)where (v′,r)=f(v).\begin{aligned} \eta(v) &= (v, \mathbf{0}),\\ (v, F) \mathbin{>\!\!>\!\!=} f &= (v', F \lor r) \quad\text{where } (v', r) = f(v). \end{aligned}

单子律归结为幺半群律。左单位律:η(v)> ⁣ ⁣> ⁣ ⁣=f=(v′,0∨r)=f(v)\eta(v) \mathbin{>\!\!>\!\!=} f = (v', \mathbf{0} \lor r) = f(v)。右单位律:(v,F)> ⁣ ⁣> ⁣ ⁣=η=(v,F∨0)=(v,F)(v, F) \mathbin{>\!\!>\!\!=} \eta = (v, F \lor \mathbf{0}) = (v, F)。结合律:取 f(v)=(v′,r)f(v) = (v', r) 和 g(v′)=(v′′,s)g(v') = (v'', s),((v,F)> ⁣ ⁣> ⁣ ⁣=f)> ⁣ ⁣> ⁣ ⁣=g((v, F) \mathbin{>\!\!>\!\!=} f) \mathbin{>\!\!>\!\!=} g 与 (v,F)> ⁣ ⁣> ⁣ ⁣=(x↦f(x)> ⁣ ⁣> ⁣ ⁣=g)(v, F) \mathbin{>\!\!>\!\!=} (x \mapsto f(x) \mathbin{>\!\!>\!\!=} g) 都等于 (v′′,(F∨r)∨s)=(v′′,F∨(r∨s))(v'', (F \lor r) \lor s) = (v'', F \lor (r \lor s))。11 P. Wadler, “Monads for functional programming”, 1995(输出单子);任何幺半群都给出一个 writer 单子,其单子律恰好就是幺半群律。

流水线状态

DecimalChecked 存储 σ=(v,c,r,F,ε)\sigma = (v, c, r, F, \varepsilon):值、上下文、最近一步的标志、累积的标志,以及一个可选的错误。私有的 record 步骤就是 writer 绑定,扩展了最近标志,并受错误守护:

record⁡(σ,(v′,r′))={σε≠none,(v′,c,r′,F∨r′,none)ε=none.\operatorname{record}(\sigma, (v', r')) = \begin{cases} \sigma & \varepsilon \ne \text{none},\\ (v', c, r', F \lor r', \text{none}) & \varepsilon = \text{none}. \end{cases}

record_result 处理可能失败的运算 opc:D→(D×F)+E\mathrm{op}_c : D \to (D \times \mathbb{F}) + E:在 Ok((v′,r′))\mathrm{Ok}((v', r')) 上它就是 record,在 Err(e)\mathrm{Err}(e) 上它返回 (v,c,r,F,Some(e))(v, c, r, F, \mathrm{Some}(e))。因此完整的步骤是叠加在异常单子之上的 writer 单子,错误具有吸收性。

设计决策

标志被累积,异常结果仍是值

问题。 一个长的十进制计算必须报告是否有任何一步发生了舍入、上溢或除以零,而 IEEE 754 为每个这样的步骤都定义了结果。

方案。 (a) 把异常条件变为错误,就像 Luna-Flow/arithmetic 的上下文 trait 对除以零和无效运算所做的那样。(b) 只返回最后一步的标志。(c) 保留 IEEE 值并累积标志。

选择:(c)。 IEEE 的模型是:计算以有定义的结果继续进行,状态标志说明发生了什么;审计在最后检查这些标志。22 IEEE 754-2019,第 7 条(默认异常处理)和第 8 条(替代异常处理):状态标志一经引发便保持引发状态,直到被显式清除。 方案 (a) 会在 1/01/0 处停止,尽管 IEEE 定义了 +∞+\infty;方案 (b) 会丢失较早的条件。错误只保留给本实现中不存在有定义 IEEE 结果的唯一情形:经认证的初等函数无法认证其舍入。同时保留 rr(raised)和 FF(flags),调用者既能看到最近一步,也能看到全部历史。

每条流水线一个上下文,操作数为普通值

问题。 二元运算本可以把另一条流水线作为操作数。

选择。 操作数是普通的 Decimal 值。两条流水线携带两个上下文和两段标志历史;合并它们需要规定哪个上下文胜出,而历史的并集会掩盖是哪一方引发了标志。使用普通操作数时,流水线是同一上下文下的单一线性历史,上下文只能通过 with_context 改变,它会记录把当前值舍入到新上下文时的标志。出于同样的原因,该类型不实现任何运算符。

映射 Luna-Flow/arithmetic 上下文

流水线接受一个 DecimalContext。持有 ArithmeticContext 的调用者用 DecimalContext::from_arithmetic_context 将其转换,映射如下

ArithmeticContextDecimalContext
precisionprecision
roundingrounding,以及 decimal_rounding = DecimalRoundingMode::from_arithmetic(rounding)
e_min, e_max相同;缺省时为 ∓999 999 999\mp 999\,999\,999
clampclamp

并采用 extended = true 以及舍入后的微小性检测。因此预定义的 ArithmeticContext::decimal64() 恰好映射为 DecimalContext::decimal64()。各构造函数让上下文经过 DecimalContext::ieee754(),这是选择 IEEE 配置的钩子;在当前分支上它原样返回上下文。

数学函数像 General Decimal Arithmetic 规范对 exp、ln、log10 和 power 所做的那样限制其上下文:精度和两个指数界的绝对值都必须在 999 999999\,999 以内,否则结果为带 invalid_context 的 NaN(src/decimal 中的私有检查 math_context_is_restricted)。33 M. Cowlishaw, General Decimal Arithmetic Specification, version 1.70, “Arithmetic operations: exp, ln, log10, power”(对上下文的限制)。 因此默认的无界指数范围会禁用它们,API 页面和教程都指出了这一点。

从标志到算术诊断

Luna-Flow/arithmetic 中 Decimal 的上下文 trait 实现报告 ArithmeticDiagnostics,即由 ArithmeticDiagnostics::combine 合并的六个布尔值,同样是逐字段 OR。src/decimal/traits.mbt 中的私有辅助函数 contextual_diagnostics 通过保留六个共有坐标把标志映射为诊断;记此投影为 π:F→{0,1}6\pi : \mathbb{F} \to \{0,1\}^{6}。坐标投影与逐字段 OR 可交换:

π(a∨b)j=(a∨b)ij=aij∨bij=π(a)j∨π(b)j,π(0)=0,\pi(a \lor b)_j = (a \lor b)_{i_j} = a_{i_j} \lor b_{i_j} = \pi(a)_j \lor \pi(b)_j , \qquad \pi(\mathbf{0}) = \mathbf{0},

因此 π\pi 是幺半群同态,由归纳可得

π(⋁i=0nri)=⋁i=0nπ(ri).\pi\Bigl(\bigvee_{i=0}^{n} r_i\Bigr) = \bigvee_{i=0}^{n} \pi(r_i).

在最后对流水线的累积标志转换一次,与逐步转换再合并得到的诊断相同。trait 实现转换为 ArithmeticError 的那些条件(除以零以及由 DecimalFlags::has_error 检测的无效运算族)不在 π\pi 保留的坐标之中;流水线改为把它们作为标志保留。

正确性 / 不变式

累积标志是各步标志的并

定理。 设一条流水线以标志 r0r_0 构造(来自 from_outcome、from_decimal、parse 或数值构造函数),随后经历成功的步骤 1,…,n1, \dots, n,其引发的标志为 r1,…,rnr_1, \dots, r_n(包括 with_context 和 apply),其间没有 clear_flags。则

Fn=r0∨r1∨⋯∨rn,r=rn.F_n = r_0 \lor r_1 \lor \dots \lor r_n, \qquad r = r_n .

证明。 每个构造函数都设置 r=F=r0r = F = r_0,这就是 n=0n = 0 的情形。若 Fk=⋁i≤kriF_{k} = \bigvee_{i \le k} r_i,成功的步骤 k+1k+1 经过 record(或 with_context 中完全相同的更新),由结合律设置 Fk+1=Fk∨rk+1=⋁i≤k+1riF_{k+1} = F_k \lor r_{k+1} = \bigvee_{i \le k+1} r_i,并且 r=rk+1r = r_{k+1}。□\square

由 ∨\lor 的交换律和幂等律,FnF_n 与步骤的顺序以及某信号被引发的次数无关;再由 contains 的同态性质,

contains(Fn,s)  ⟺  ∃ i≤n:contains(ri,s).\texttt{contains}(F_n, s) \iff \exists\, i \le n : \texttt{contains}(r_i, s).

下面的测试在一条三步流水线上检验该定理:累积标志等于各步引发标志的 OR,无论以何种顺序合并。

///|
test "accumulated flags are the union of the raised flags" {
  let ctx = @decimal.DecimalContext::decimal64()
  let s0 = @decimal_checked.DecimalChecked::from_int(1, ctx)
  let s1 = s0.div(@decimal.Decimal::from_int(3))
  let s2 = s1.add(@decimal.Decimal::from_int(10))
  let s3 = s2.div(@decimal.Decimal::zero())
  let forward = s0.raised().combine(s1.raised()).combine(s2.raised()).combine(s3.raised())
  let backward = s3.raised().combine(s2.raised()).combine(s1.raised()).combine(s0.raised())
  inspect(s3.flags() == forward, content="true")
  inspect(forward == backward, content="true")
  inspect(s3.raised().inexact, content="false")
  inspect(s3.flags().inexact && s3.flags().division_by_zero, content="true")
}

在 clear_flags 之后,定理从该点起以 r0=0r_0 = \mathbf{0} 重新成立。FF 沿流水线单调:Fk⊆Fk+1F_k \subseteq F_{k+1}。

错误具有吸收性

定理。 若一个状态有 ε=Some(e)\varepsilon = \mathrm{Some}(e),则除 clear_flags 外的每个运算都原样返回它,而 clear_flags 只改变 rr 和 FF。

证明。 record、record_result 和 with_context 都以守卫 self.error_ is None 开头,否则返回 self;每个算术方法都经过 record 或 record_result。clear_flags 只更新两个标志字段。□\square

因此被报告的是第一个错误,值和标志保持为失败步骤之前的状态,且 result() 返回 Err(e)\mathrm{Err}(e)。在没有错误的流水线上,result() 就是 Ok((v,F))\mathrm{Ok}((v, F)),即 writer 单子的输出。

开销

每一步的开销是被委托的十进制运算,加上一次 13 字段的 OR 和一次常数大小的状态复制。

被否决的替代方案

  • 把 IEEE 异常变为错误。 这与 IEEE 默认异常处理以及本包的目的相矛盾;上下文 trait 已经为单个运算提供了那种模型。
  • 合并流水线。 对两个上下文没有规范的合并规则;见上文。
  • 可变标志。 Luna-Flow 把上下文和状态都保持为值;不可变状态可以分叉和比较。
  • 存储逐步历史。 并集以常数大小回答了审计问题;需要历史的调用者可以自行保留关心的状态。

边界

  • 自身不含算术;每个结果都来自 decimal。
  • 没有陷阱或替代异常处理:IEEE 标志从不中止流水线。由陷阱驱动的控制流属于 decimal_gda_checked 的契约。
  • 没有运算符,没有流水线与流水线之间的运算,也没有隐式的上下文改变。
  • 只有初等函数的认证失败才会变成错误。
  • 上下文是 decimal 的 IEEE 十进制上下文;带粘滞状态的 GDA 上下文属于 decimal_gda。

Footnotes

  1. P. Wadler, “Monads for functional programming”, 1995(输出单子);任何幺半群都给出一个 writer 单子,其单子律恰好就是幺半群律。 ↩

  2. IEEE 754-2019,第 7 条(默认异常处理)和第 8 条(替代异常处理):状态标志一经引发便保持引发状态,直到被显式清除。 ↩

  3. M. Cowlishaw, General Decimal Arithmetic Specification, version 1.70, “Arithmetic operations: exp, ln, log10, power”(对上下文的限制)。 ↩