decimal チュートリアル

このチュートリアルでは、人が書くとおりに振る舞う十進数を使った計算方法を示します。0.1 + 0.2 は厳密に 0.3 であり、12.30 は小数点以下 2 桁であることを覚えており、すべての丸めはあなたが選び、その結果があなたに報告されます。値をパースして書式化し、コンテキストの下で計算してそのフラグを読み取り、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 は同じコホートの二つの元です。数値としては等しく、異なる指数で書かれています。normalized() は最短の元を選びます。小数点以下の桁数が意味を持つ金額に対しては呼ばず、標準的なキーが欲しい場合に呼んでください。

整数は約分された形で構築されます。Decimal::from_int(1000) は 1×1031 \times 10^{3} として格納され、1E+3 と出力されます。4 桁が欲しい場合は "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() は偽のままです。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 へ送り、half-up はゼロから遠ざかる方へ送ります。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 は小数点以下 2 桁のまま戻ってきます。自分で生成していないビット列は非標準形かもしれません。それらは 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 なしで返ります。

さらに進んで

代数トレイト上のジェネリックなコード

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 トレイト

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 は、スティッキーなステータスとトラップを備えた General Decimal Arithmetic のモデルを実装しています。その値は別の型です。IEEE の演算ごとのフラグではなく .decTest の振る舞いが必要な場合に使ってください。
  • 十進値を二進の区間で包含するには、to_bin_float(mode=TowardNegative) と to_bin_float(mode=TowardPositive) で 2 回変換し、その二つの上下界から 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 を渡してください。
  • 演算子はコンテキスト演算ではない。 * は決して丸めないので、積を繰り返すと際限なく大きくなります。/ はオペランドの精度に丸め、まれに最後の桁で 1 単位ずれることがあります。結果を有界にしたり正しく丸めたりする必要がある場合は 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.1 ではなく、厳密な二進値 0.1000000000000000055511151231257827(34 桁に丸めたもの)です。代わりにテキストをパースしてください。from_bin_float は −0-0 を +0+0 に変えます。

次のステップ