sankantsuのブログ

技術メモ・競プロなど

SATySFi のマクロ入門

この記事について

SATySFi のマクロは,インラインコマンドやブロックコマンドの形で多段階計算によるプリプロセス機構を利用できるようにするものである. SATySFi上でDSLを実装してパッケージなどとして提供するような場合には,最終的にマクロによるインターフェースとして提供するのが基本になるだろう.

この記事では,マクロの定義方法やエラー位置の取得方法について説明する.

前回の記事で,SATySFiの多段階計算について基本的な内容をまとめた.多段階計算でわからない部分があれば合わせて読んでみてほしい.

sankantsu.hatenablog.com

大半はSATySFi本体のソースコードや手元での実験に基づいて書いている. 間違いがあれば遠慮なく指摘してほしい.

バージョンは0.0.7に基づいている.

マクロの定義

マクロの定義は,次の文法によって行う.

let-inline \macro-name@ param... = ...
let-block +macro-name@ param... = ...

macro-nameはマクロの識別子であり,通常の変数名と同じである. 識別子の最後に@文字をつけることに注意する.

param...は,~param-nameまたはparam-nameの並びである. 各パラメータは,ステージ0の変数として扱われる.また,

  • ~param-name (early macro parameter) は 'a型の変数
  • param-name (late macro paramter) は &'a型の変数

として扱われる.

マクロの定義本体はステージ1のプログラムとして記述する. 返り値の型はインラインコマンドであればinline-text,ブロックコマンドであればblock-textになる必要がある.

通常のインラインコマンドなどと違い,コンテキストの第0引数を受け取る形の定義

let-inline ctx \macro-name@ param... = ...

はサポートされていないことに注意しておく.

また,moduleの内部にマクロを定義することも現状できない.

マクロの適用

マクロの適用は,次の文法によって行う.

\macro-name@ arg...;
+macro-name@ arg...;

通常のインラインコマンド,ブロックコマンドと同様,\macro-name@はインラインテキスト中に,+macro-name@はブロックテキスト中に書く必要がある.

arg...~(exp)または(exp)の並びである.括弧は省略できない. expには通常の関数に渡す引数のとほとんど同じ形式の式を書ける.

パラメータの種類ごとに引数の渡し方に違いがある.

  • ~param-nameの形のパラメータ (early macro parameter)

    • ~(exp)の形で引数を渡す.
    • param-nameにステージ0の式expが束縛される.
  • param-nameの形のパラメータ (late macro parameter)

    • (exp)の形で引数を渡す.
    • param-nameにステージ0で&(exp)と書いた場合に相当する値が束縛される.

マクロの適用は文書全体の評価よりも先に行われ,評価結果のインラインテキストに置き換えられる. マクロの評価の詳細については後で再び説明する.

簡単な例

マクロの使い方の確認のため,次のような例を考える.

@require: stdjareport

let-inline \foo@ ~a b =
  let x = ~(lift-int a) + 1 in
  let y = ~b + 1 in
  let it = embed-string ((arabic x) ^ #` `# ^ (arabic y)) in
    it

in

document (|
  title = {Test of macro definition};
  author = {sankantsu};
|) '<
   +p {
     \foo@ ~(100) (200);
   }
>

例ではaint型,b&int型の変数になっている. 動作自体は受け取った引数それぞれに1を足して出力するだけの簡単なものであるが,~をつけて宣言するかどうかで変数の使い方が違うのがわかる.

マクロの評価

先程の例で用いたマクロは,マクロを使わずに次のように書くのとほぼ等価である. ただし,関数の定義部分は@stage: 0の別ファイルに分けた.

% header0.satyh
@stage: 0

let foo a b = 
  &(let x = ~(lift-int a) + 1 in
    let y = ~b + 1 in
    let it = embed-string ((arabic x) ^ #` `# ^ (arabic y)) in
      it
  )
@require: stdjareport
@import: header0

let-inline \id it = it

in

document (|
  title = {Test of macro definition};
  author = {sankantsu};
|) '<
   +p {
     \id(~(foo 100 &200));
   }
>

次の点に注意しておく.

  • マクロの定義はステージ0の関数定義になっている.また,定義全体を&(...)で囲んでいる.
  • マクロの適用はステージ0の関数適用になっている.
    • 引数の受け渡しについて,~(exp)のタイプは単にexpに,(exp)のタイプは&(exp)に置き換わっている.

マクロの動作がわかりにくいという場合は上のような置き換えを考えてみると良いかもしれない.

エラー位置の取得

マクロの導入のモチベーションは,DSLのパースをプリプロセス中に行うことで迅速なエラー報告を可能にするというものであった. これまで説明した内容だけでも,DSLの実装やエラー報告自体は可能になるが,ソースコード上のエラー位置を報告することができない. DSLである程度複雑な文法を扱うようにするとなると,入力箇所のどこにエラーがあるかわかったほうが便利だろう.

この問題を解決するために導入されたのが,@`...`という形で記述する positioned literal というものである. この形式で書かれた文字列は,通常の文字列stringに加えてコード中の位置を表すinput-position型が付加されたinput-position * string型として扱われる.

input-position型の値は,

val get-input-position : input-position -> string * int * int

というプリミティブを用いることで文字列先頭位置の(ファイル名,列番号,行番号)を取り出すことができる.

文字列位置の取得を行う簡単な例を示す (report-position.satyという名前で保存されているとする.).

% report-position.saty
@require: stdjareport

let-inline \report-position@ ~posmsg =
  ~(
    let (ipos,msg) = posmsg in
    let (fname,line,col) = get-input-position ipos in
    &(
      let it-msg = embed-string ~(lift-string msg) in
      let it-fname = embed-string ~(lift-string fname) in
      let it-line = embed-string (arabic ~(lift-int line)) in
      let it-col = embed-string (arabic ~(lift-int col)) in
        {#it-msg; (at #it-fname;:#it-line;:#it-col;)}
    )
  )

in

document (|
  title = {Test of reporting position};
  author = {sankantsu};
|) '<
   +p {
     \report-position@ ~(@`Hello!`);
   }
>

コンパイルすると,本文としてHello! (at report-position.saty:24:27)という文字列が得られ,文字列の先頭位置が取得できていることがわかる.

文字列の途中の位置を取得したい場合には,文字列をパースしていくときに同時に文字列中での現在位置を追っていく必要がある. 自分自身はまだ詳しく見れていないが,satysfi-base/parserなどで一応このあたりの基本的な機能は提供されているようである.

使用例: 2進文字列の10進変換

単純な例であるが,文字列で表現された2進数を10進変換して表示するマクロを作成した.

2進数表現の文字列を受け取り10進表示するマクロ\decode@を提供する. 例えば,\decode@ ~(@`001011`);とすれば,11が表示できる. \decode@ ~(@`0010a1`);のように,0,1以外の文字を含めた場合には次のような表示をして停止する.

...
preprocessing 'decode-binary.satyh' ...
preprocessing 'stdjareport.satyh' ...
preprocessing 'test.saty' ...
! [Error during Evaluation] test.saty:21:31: error of decode-binary: invalid character 'a'

出力されるログから,プリプロセス中にエラー検出がなされていることが確認できる.

実装と使用例を次に示す.

% decode-binary0.satyh
@stage: 0
@require: list
@require: base/char
@require: base/string

module DecodeBinary0 : sig

  val decode : input-position * string -> int

end = struct

  type digit-char =
  | Digit of int
  | UnknownCharacter of Char.t

  type result-type =
  | DecodeVal of int
  | ErrorPos of int * Char.t

  let decode-char ch =
    let char-0 = Char.make `0` in
    let char-1 = Char.make `1` in
      if Char.equal ch char-0 then
        Digit(0)
      else if Char.equal ch char-1 then
        Digit(1)
      else
        UnknownCharacter(ch)

  let decode-str str =
    let char-lst = String.to-list str in
    let-rec aux (acc,pos) lst =
      match lst with
      | [] -> DecodeVal(acc)
      | ch :: tail -> (
          match decode-char ch with
          | Digit(b) ->
              let accnew = 2*acc + b in
                aux (accnew, pos + 1) tail
          | UnknownCharacter(ch) -> ErrorPos(pos,ch))
    in
    aux (0,0) char-lst

  let decode (ipos,str) =
    let (fname,line,col) = get-input-position ipos in
    let res = decode-str str in
    match res with
    | DecodeVal(value) -> value
    | ErrorPos(pos,ch) ->
        let error-msg =
          fname ^ `:` ^ (arabic line) ^ `:` ^ (arabic (col + pos + 1)) ^ `: `#
          ^ `error of decode-binary: invalid character`
          ^ #` '` ^ (Char.to-string ch) ^ `'`
        in
        abort-with-message error-msg

end
% decode-binary.satyh
@stage: 1
@import: decode-binary0

let-inline \decode@ ~posstr =
  let num-str = arabic (~(lift-int (DecodeBinary0.decode posstr))) in
    embed-string num-str
% test.saty
@require: stdjareport
@import: decode-binary

let num-iter = 3000
let text = {The quick brown fox jumps over the lazy dog.}
let-rec repeat n x = if n <= 0 then [] else (x :: (repeat (n - 1) x))
let long-text =
  repeat num-iter text
    |> List.fold-left (fun lhs rhs -> {#lhs; #rhs;}) {}

in

document (|
  title = {Decode binary digits};
  author = {sankantsu};
|) '<
   +p {
     #long-text;
   }
   +p {
     001011: \decode@ ~(@`0010a1`);
   }
>

実装に関してはそれほど凝ったことはしていないので,詳しくは説明しない. 基本的には文字列を前から読んで2進数の値を計算していき,0,1以外の文字が来たらエラーを出力して停止するだけである.

ソースコードはレポジトリにもまとめてある.

github.com

参考資料

マクロに関する資料は現状非常に少ない.

マクロでコード中の位置を取得・使用する機能 · gfngfn/SATySFi Wiki · GitHub

gfngfn 氏のエラー位置報告機能に関する紹介.

コード中の位置を取得してエラー報告できるDSLのパッケージを試しに創ってみた · gfngfn/SATySFi Wiki · GitHub

同じく gfngfn 氏によるやや複雑なDSLによるデモ. 本記事より複雑な例を見たい場合は実装を読んで見ると良いと思う.