numeric_expr チュートリアル

このチュートリアルでは、計算を numeric_expr の木として一度だけ記述し、リテラルを読むコールバックと演算を実行するコールバックの 2 つを与えることで、任意の数値型に対して実行する方法を説明します。最後には、Int 上や IEEE フラグ付きの BinFloat 上で式を評価し、失敗をそのソース行で報告できるようになります。これは、適合性フロントエンド(frontend/gda_expr など)がテストコーパスの実行に用いている仕組みです。

クイックスタート

moon.pkg にパッケージを追加します。

import {
  "Luna-Flow/floating/numeric_expr",
}

1 + 2 を構築し、Int 上で評価します。

///|
test "quick start" {
  let one_plus_two = @numeric_expr.Expr::invoke(
    @numeric_expr.Operation::new("add"),
    [
      @numeric_expr.Expr::literal(@numeric_expr.Literal::new("1")),
      @numeric_expr.Expr::literal(@numeric_expr.Literal::new("2")),
    ],
  )
  let result : Result[Int, @numeric_expr.EvalError[String]] = @numeric_expr.evaluate(
    one_plus_two,
    literal => if literal.raw() == "1" { Ok(1) } else { Ok(2) },
    (_, arguments) => Ok(arguments[0] + arguments[1]),
  )
  inspect(result is Ok(3), content="true")
}

evaluate は両方のリテラルをデコードし、デコードされた引数 [1, 2] で演算コールバックを呼び出します。

日常的なタスク

小さなインタプリタを書く

多くの呼び出し側は、演算名と引数配列に対してパターンマッチします。配列パターンのマッチはアリティの検査も兼ねますが、numeric_expr 自身はこれを行いません。

///|
fn int_literal(literal : @numeric_expr.Literal) -> Result[Int, String] {
  match literal.raw() {
    "0" => Ok(0)
    "1" => Ok(1)
    "2" => Ok(2)
    "3" => Ok(3)
    "10" => Ok(10)
    other => Err("not a small integer: " + other)
  }
}

///|
fn int_operation(
  operation : @numeric_expr.Operation,
  arguments : Array[Int],
) -> Result[Int, String] {
  match (operation.name(), arguments) {
    ("add", [a, b]) => Ok(a + b)
    ("mul", [a, b]) => Ok(a * b)
    ("neg", [a]) => Ok(-a)
    ("sum", values) => Ok(values.fold(init=0, (acc, x) => acc + x))
    (name, values) =>
      Err(name + " does not take " + values.length().to_string() + " arguments")
  }
}

///|
fn lit(raw : String) -> @numeric_expr.Expr {
  @numeric_expr.Expr::literal(@numeric_expr.Literal::new(raw))
}

///|
fn call(name : String, arguments : Array[@numeric_expr.Expr]) -> @numeric_expr.Expr {
  @numeric_expr.Expr::invoke(@numeric_expr.Operation::new(name), arguments)
}

///|
test "small interpreter" {
  // -(2 * 3) + sum(1, 2, 3, 10)
  let expression = call("add", [
    call("neg", [call("mul", [lit("2"), lit("3")])]),
    call("sum", [lit("1"), lit("2"), lit("3"), lit("10")]),
  ])
  let result = @numeric_expr.evaluate(expression, int_literal, int_operation)
  inspect(result is Ok(10), content="true")
}

sum は、演算が任意個の引数を取りうることを示しています。それを決めるのはコールバックです。

BinFloat 上で評価し、IEEE フラグを収集する

値の型 V は数値以上のものを保持できます。ここでは各値が BinFloat とそれまでに発生した IEEE フラグの組であり、各演算は引数のフラグと自身のフラグを統合します。

///|
fn binary_literal(
  literal : @numeric_expr.Literal,
) -> Result[(@bin_float.BinFloat, @bin_float.BinaryFlags), String] {
  match
    @bin_float.BinFloat::from_string_ctx(
      literal.raw(),
      @bin_float.BinaryContext::binary64(),
    ) {
    Ok(pair) => Ok(pair)
    Err(_) => Err("not a number: " + literal.raw())
  }
}

///|
fn binary_operation(
  operation : @numeric_expr.Operation,
  arguments : Array[(@bin_float.BinFloat, @bin_float.BinaryFlags)],
) -> Result[(@bin_float.BinFloat, @bin_float.BinaryFlags), String] {
  let context = @bin_float.BinaryContext::binary64()
  match (operation.name(), arguments) {
    ("add", [(a, fa), (b, fb)]) => {
      let (value, flags) = a.add_ctx(b, context)
      Ok((value, fa.combine(fb).combine(flags)))
    }
    ("div", [(a, fa), (b, fb)]) => {
      let (value, flags) = a.div_ctx(b, context)
      Ok((value, fa.combine(fb).combine(flags)))
    }
    _ => Err("unsupported " + operation.name())
  }
}

///|
test "binary64 evaluation with flags" {
  // 1/3 + 1/3 in binary64
  let third = call("div", [lit("1"), lit("3")])
  let result = @numeric_expr.evaluate(
    call("add", [third, third]),
    binary_literal,
    binary_operation,
  )
  match result {
    Ok((value, flags)) => {
      inspect(value.to_hex(), content="0x15555555555555p-53")
      inspect(flags.inexact(), content="true")
    }
    Err(_) => fail("expected a value")
  }
}

2 つのコールバックを差し替えるだけで、同じ木を @decimal_gda.Decimal 上で評価することもできます。式の中に数値型に依存する部分は何もありません。

エラーをそのソース位置で報告する

各ノードに、それが由来するテキストのスパンを与えます。コールバックが失敗すると、EvalError は失敗したノードを返すので、スパンによってエラーの位置を特定できます。

///|
test "locate a bad operand" {
  let span = @numeric_expr.SourceSpan::new("rows.txt", line=42, column=9)
  let expression = @numeric_expr.Expr::invoke(
    @numeric_expr.Operation::new("add", span~),
    [
      @numeric_expr.Expr::literal(@numeric_expr.Literal::new("2", span~)),
      @numeric_expr.Expr::literal(@numeric_expr.Literal::new("two", span~)),
    ],
  )
  let message = match
    @numeric_expr.evaluate(expression, int_literal, int_operation) {
    Ok(_) => "ok"
    Err(@numeric_expr.LiteralFailure(literal, error)) =>
      literal.span().source() +
      ":" +
      literal.span().line().to_string() +
      ": " +
      error
    Err(@numeric_expr.OperationFailure(operation, error)) =>
      operation.name() + ": " + error
    Err(@numeric_expr.UnsupportedExpression(_)) => "unsupported"
  }
  inspect(message, content="rows.txt:42: not a small integer: two")
}

評価順序を確かめる

コールバックは左から右への後行順で実行され、評価は最初の失敗で停止します。トレースを取るとこれがわかります。

///|
test "post-order with early stop" {
  let trace : Array[String] = []
  let expression = call("add", [
    call("mul", [lit("2"), lit("3")]),
    call("add", [lit("bad"), lit("1")]),
  ])
  let _ = @numeric_expr.evaluate(
    expression,
    literal => {
      trace.push(literal.raw())
      int_literal(literal)
    },
    (operation, arguments) => {
      trace.push(operation.name())
      int_operation(operation, arguments)
    },
  )
  inspect(trace.join(" "), content="2 3 mul bad")
}

bad が先に失敗するため、リテラル 1 と 2 つの add ノードはいずれも訪問されません。

さらに進んで

  • コーパスのフロントエンド。 frontend/gda_expr は、.decTest の各行 id operation operands -> expected conditions を Expr::invoke(Operation::new(op), operands.map(Expr::literal)) に変換し、@decimal_gda 上のコールバックで評価します。このパターンの完全な例については gda_expr を参照してください。
  • より豊かな値。 演算が異なる種類の結果(数値、真偽値、文字列)を返す場合、V を列挙型にできます。gda_expr は compare、class、isnan についてそうしています。
  • 型付きのエラー。 E は String の代わりに独自の列挙型にできます。EvalError[E] はそれをそのまま保持します。
  • checked なパイプライン。 ドメインエラーで評価を止めずに値として残したい場合は、V を @bin_float_checked.BinFloatResult のような checked 型にし、コールバックが常に Ok を返すようにします。

よくある落とし穴

  • アリティ検査はありません。 Expr::invoke は任意個の引数を受け付けます。invoke でアリティを検査し(配列パターンで検査できます)、一致しなければ Err を返してください。
  • 早期停止。 最初の失敗の後、それ以上コールバックは実行されません。失敗したノードより右にあるノードのコールバックの副作用に依存しないでください。
  • 深い木。 評価は再帰的で、スタックの深さは木の高さに等しくなります。非常に深い左偏りの木(例えば入れ子の add で構築した 100 万項の和)はスタックを使い果たす可能性があります。代わりに平坦な sum ノードを構築してください。
  • 既定のスパン。 明示的なスパンがない場合、ノードは SourceSpan::new("") を報告し、その行と列は 0 です。
  • ここではリテラルを解析しません。 Literal は生のテキストを保持します。変換の丸め、精度、フラグはすべて decode コールバックが決定します。

次のステップ