この記事について
SATySFi には多段階計算という機構があり,文書のプリプロセスに利用することができる.
ただ,現状まとまった資料がないので使ってみようと思っても敷居が少し敷居が高い. この記事は多段階計算について,
- 何のためにあるのか
- どうやって書くのか
といった部分の基本を入門向けにまとめる試みである.
SATySFiのバージョンは0.0.7
に基づいている.
推測に基づいて書いている部分もあるので,間違っている箇所があったら遠慮なく指摘してほしい.
なお,前提としてSATySFiの通常のコマンド定義等は知っているものとしている.
多段階計算とは
多段階計算は,プログラムの評価を多段階に分けて処理する機構である.
それぞれの段階をステージと呼び,stage 0
-> stage 1
-> ... -> stage (n-1)
の順で評価を行う.
stage 0
を評価するとstage 1
のプログラムが出力され,stage 1
を評価するとstage 2
のプログラムが出力され,... といった具合で処理が進む.
つまり,あるステージは次のステージのためのプリプロセス的な役割を行うことができる.
SATySFi の多段階計算の動機
SATySFi は組版処理に特化した言語であり,型検査による組版処理開始前のエラー報告が大きな強みである.
一方,組版処理においては文書を生成するという役割上,比較的複雑な処理でも簡潔に記述できることが望まれる. このような要求に対処する方法として,文字列を解釈するような言語内DSL(domain specific language)を設計して使うという方法がある.
ここで,通常DSLが解釈されるのは文書の組版処理と同じタイミングであるため,DSLの入力にエラーがあった場合にこのエラーが発見されるのは組版処理中である. したがって,例えば500ページの文書を組版するのにエラー箇所が490ページ目であればそこまでの490ページ分の組版処理が終わってから初めてエラーが発見されて処理が中止され,そこまでの組版結果は無駄になってしまう.
そこで,多段階計算を導入してプリプロセスの段階でDSLの解釈を行うことで全体の文書の組版を開始する前にエラー報告を行うことができるようにしたいという動機がある.
SATySFi では2段階の計算をサポートしており,stage 0
とstage 1
が存在する.
stage 0
でstage 1
のプリプロセスを行う間にエラーを発見することで,迅速なエラー報告を行うことができるようになる.
文法と評価規則
肝となる文法は次の2つである.
stage 1
のプログラム中に,~(...)
という記法によってstage 0
のコードを埋め込むことができる.stage 0
のプログラム中に,&(...)
という記法によってstage 1
のコードを埋め込むことができる.
SATySFiの文書ファイルはstage 1
のプログラムとして解釈する.
stage 1
のプログラム中に~(...)
という式が現れたら,stage 1
の評価を行う前に~(...)
の中身をstage 0
のプログラムとして評価する.
stage 0
の評価の結果が&(exp)
(exp
はstage 1
の式)という形になればstage 0
の評価は終了し,~(&(exp))
全体をexp
で置き換える.
非常に単純な例として,1 + ~(&2)
というstage 1
のコードを考える.
stage 1
の評価を始める前に,~(&2)
の中身&2
をstage 0
のプログラムとして評価する.
2
は整数を表すstage 1
の式なので,&2
はすでに&(stage 1 の式)
という形になっている.
したがってstage 0
の評価は直ちに終了して~(&2)
-> 2
という置き換えが行われ,元のプログラムは1 + 2
というstage 1
のコードになる.
上の例は実質的にstage 0
で何もしていないのであまり意味がないが,もちろんstage 0
の上でいろいろな計算を行うことができる.
後から,もう少し複雑な例についても考えていく.
stage の指定
SATySFi では,ファイル冒頭にステージの指定@stage: ...
を書くことでそのファイルがどのステージに関するコードかを記述する.
指定できるのは,次の3つである.
@stage: 0
:stage 0
のコード@stage: 1
:stage 1
のコード@stage: persistent
:stage 0
,stage 1
どちらでも使えるコード(後述)
プリプロセス用の関数を定義したいなら,基本的に@stage: 0
のファイル内で書くことになる.
指定を省略した場合,デフォルトで@stage: 1
と等価になる.
文書ファイルに対するステージの指定は@stage: 1
でなければならない.
@stage: persistent
は少し特殊で,stage 0
とstage 1
両方で使えるコードを記述する.
ただし,~(...)
や&(...)
のステージをまたぐ文法を使うことはできない.
例えば,標準ライブラリのlist.satyg
などはpersistent
である.
ファイル間の依存関係において,次の制約を満たす必要がある.
@stage: 0
のファイルは@stage: 1
のファイルに依存してはいけない.@stage: persistent
のファイルは@stage: 0
,@stage: 1
のファイルに依存してはいけない.
型システム
SATySFi の多段階計算では,int
,string
,int -> int
といった普通の基本型や関数型に加えて,&(...)
で表される "1つ上のステージのコードを表す型" を導入している.
例えば,stage 0
での &int
型というのは,"stage 1
でint
として評価されるコードの型" である.
ステージ間に関する型の規則として次の2つが要求される.
stage 1
でτ
型の値が要求される部分にstage 0
のコードの埋め込み~(exp)
がある場合,exp
はstage 0
で&τ
型が付かなければならない.stage 0
で&τ
型の値が要求される部分にstage 1
のコードの埋め込み&(exp)
がある場合,exp
はstage 1
でτ
型が付かなければならない.
このような型システムによって,stage 0
のプリプロセスによって生成されたstage 1
のコードが正しく型付けされたコードになっていることが保証される.
少し複雑な例
次のようなプログラムを考える.
% stage 1 let n = 100 in ~( let double x = &(~x + ~x) in double &n )
それぞれの変数のステージと型をまとめると,次のようになっている.
n
:stage 1
のint
x
:stage 0
のint&
double
:stage 0
の&int -> &int
基本として,stage 0
の変数はstage 0
のコード中でしか使えないし,stage 1
の変数はstage 1
のコード中でしか使えない.
特に,stage 0
中の&τ
型の変数と,stage 1
中のτ
型の変数は別物であるという点に注意しておくべきだろう.
中身について少し詳しく見てみる. まずは関数定義を見てみる.
% stage 0 let double x = &(~x + ~x)
&(...)
に囲まれた部分はstage 1
のコードであるから,~x + ~x
をstage 1
の式として解釈する.
+
は左右にint
をとり,int
型の値を返す二項演算子であるから,~x
や~x + ~x
はstage 1
でint
型が付かなければならない.
従って上に述べた型付け規則から,x
はstage 0
で&int
型が付かなければならない.
&(~x + ~x)
はstage 0
で&int
型の値であるから,double
の型は&int -> &int
である.
次に関数適用に注目する.
% stage 0
double &n
n
はstage 1
でint
型の変数として宣言されているので,&n
はstage 0
で&int
型の値である.
double
の定義を思い出すと,stage 0
は次のように評価される.
~(double &n) -> ~(&(~(&n) + ~(&n))) -> ~(&(n + n)) -> (n + n)
つまり,元のプログラムはstage 0
のプリプロセスが終わると,全体let n = 100 in (n + n)
というstage 1
のプラグラムになっていることがわかる.
もう少し実用的な例: 累乗関数の生成
プリプロセスの目的として,エラー報告の迅速化以外だけでなく,事前計算による高速化がある.
下の関数はpower 3
のように書くことで(fun x -> x * x * x * 1)
のようなコードを生成し,実行時の再帰をなくすことで処理が高速化できることが期待される.
@stage: 0 let power n = let-rec aux n x = if n <= 0 then &1 else &(~x * ~(aux (n - 1) x)) in &(fun x -> ~(aux n &x))
power 3
の評価の様子を下に示す.
power 3 -> &(fun x -> ~(aux 3 &x)) -> &(fun x -> ~(if 3 <= 0 then &1 else &(~(&x) * ~(aux (3 - 1) &x)))) -> &(fun x -> ~(&(x * ~(aux 2 &x)))) -> &(fun x -> ~(&(x * ~(if 2 <= 0 then &1 else &(~(&x) * ~(aux (2 - 1) &x)))))) -> &(fun x -> ~(&(x * ~(&(x * ~(aux 1 &x)))))) -> &(fun x -> ~(&(x * ~(&(x * ~(if 1 <= 0 then &1 else &(~(&x) * ~(aux (1 - 1) &x)))))))) -> &(fun x -> ~(&(x * ~(&(x * ~(&(x * ~(aux 0 &x)))))))) -> &(fun x -> ~(&(x * ~(&(x * ~(&(x * ~(if 0 <= 0 then &1 else ~(aux (0 - 1) &x))))))))) -> &(fun x -> ~(&(x * ~(&(x * ~(&(x * ~(&1)))))))) -> &(fun x -> ~(&(x * ~(&(x * ~(&(x * 1))))))) -> &(fun x -> ~(&(x * ~(&(x * (x * 1)))))) -> &(fun x -> ~(&(x * (x * (x * 1))))) -> &(fun x -> (x * (x * (x * 1)))) ~(power 3) -> ~(&(fun x -> (x * (x * (x * 1))))) -> (fun x -> (x * (x * (x * 1))))
stage 0 から stage 1 への値の移動
stage 0
で何らかの計算を行ってその結果をstage 1
のコード中で使いたいとする.
例えば,stage 0
で足し算を行うなら~(1 + 2)
のように記述すれば良いが,結果として出てくる3
はstage 0
のint
であってstage 1
のint
ではないのでそのままではstage 1
のコード中に埋め込めない.
このようなとき,lift-string : int -> &int
というプリミティブを使うと,stage 0
における計算結果をstage 1
に埋め込めるようにすることができる.
上の例であれば ~(lift-int (1 + 2))
とすればlift-int (1 + 2) = lift-int 3
が評価され,"3
を表すstage 1
のコード" を埋め込むことができる.
現状は,lift-int
,lift-float
,lift-string
,lift-length
の4つのプリミティブが提供されているが,将来的には任意の型に使えるlift : 'a -> &'a
に置き換わる可能性がある.
汎用のlift
が無い場合でも,たとえばlist型のlift関数は次のようにして実装できる.
@stage: 0 @require: list let lift-list liftf lst = let codeacc = lst |> List.fold-left (fun codeacc x -> &(~(liftf x) :: ~codeacc)) &[] in &(List.reverse ~codeacc)
lift-list : ('a -> &'a) -> 'a list -> &('a list)
は,リストの各要素をliftする関数とリストを受け取って,リスト全体をliftしたものを返す.
参考記事
本記事を執筆するにあたって参考にした記事・スライドをいくつか挙げる.
多段階計算導入前の gfngfn 氏 (SATySFi開発者) の記事. モチベーションやおおまかなアイデアが描かれている.
同じく gfngfn 氏のスライド. 多段階計算の型システムに関する解説. 直観的な図を使って説明していてわかりやすい.
zr_tex8r 氏の記事. 文字列の16進数を解釈するコマンドを題材にした多段階計算の利用.