decimal_gda チュートリアル

このチュートリアルでは、Cowlishaw の General Decimal Arithmetic (GDA) を実装するパッケージ decimal_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)も捕捉します。基本コンテキストはこれを有効にしているので、不正なリテラルはトラップされます。

///|
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 トレイトを実装しているので、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 は、densely packed decimal (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 を渡してください。

次のステップ