ラベル shift/reset の投稿を表示しています。 すべての投稿を表示
ラベル shift/reset の投稿を表示しています。 すべての投稿を表示

2014/08/08

shift/reset(限定継続)をSchemeのメタ循環インタプリタに実装

 Schemeのメタ循環インタプリタに, shift/reset(限定継続)の機能を付け加えよう. という話です.
最初からすべてを実装するのは大変なので, 3つのversionを作ります.Schemeの処理系はGaucheを想定しています.
  • interp1 : 普通のSchemeで作成した(Schemeのsubsetを解釈する)メタ循環インタプリタ
  • interp2 : カリー化されたSchemeのメタ循環インタプリタ
  • interp3 : Abstrcting Control(論文)のSemanticsに基づいたインタプリタ

shift/resetの評価器

 次の一連の式は, shift/resetを実行可能な評価関数の定義です. 論文(Abstracting Control)のセクション1, Extended Continuation-Passing Styleの後半部分に登場する評価器(evaluator)です.
 一番先頭の行は, 評価器(インタプリタ)の型で, 式と環境, 継続とメタ継続(限定継続)を引数にとり, 計算結果を返すものです. 上から順に変数, 組み込み関数(オペレータ)の適用, 関数適用, ラムダ抽象, if(条件分岐), call/cc, shift, resetを表します.
 基本的に, ラムダ計算にcall/cc, shift/reset, ifと定数/プリミティブ関数を拡張したプログラムになります.

Eval :: Exp -> Env -> Cont -> MCont -> Ans
Eval[x]ρκγ      = κ (ρ[x]) γ
Eval[π E]ρκγ    = Eval[E] ρ (λνγ'. κ (π ν) γ') γ
Eval[E1 E2]ρκγ   = Eval[E1] ρ (λfγ'. Eval[E2] ρ (λaγ". f a κ γ") γ') γ
Eval[λx. E]ρκγ  = κ (λνκ'γ'. Eval[E] ρ[x := ν] κ' γ') γ
Eval[E1 → E2 | E3]ρκγ
                = Eval[E1] ρ (λbγ'. b → Eval[E2]ρκγ' | Eval[E3]ρκγ') γ
Eval[εk. E]ρκγ  = Eval[E] ρ[k := (λνκ'γ'. κ ν γ')] κ γ
Eval[ξk. E]ρκγ  = Eval[E] ρ[k := (λνκ'γ'. κ ν (λw. κ' w γ'))] (λxγ". γ" x) γ
Eval[{E}]ρκγ    = Eval[E] ρ (λxγ'. γ' x) (λν.κνγ)
 ρ[x] は, 環境ρから変数xに相当する値を返す処理を意味しています.
 E1→E2|E3は, Schemeにおける (if E1 E2 E3)を表します.
 ρ[x:=ν]は, 環境ρ中のxをνに置き換えることの表現です.

 Eval[x]ρκγ = ...は例えば, 評価関数が式x(変数)と, 環境ρ, 継続κ, メタ継続γを引数として受け取った時, 右側の式を返すという風に読みます.

 全体的に限定継続付きの継続渡しスタイルになっていて, ある評価ステップで計算しきれなかった計算(=残りの計算)は, 次以降のステップで継続として記述されます. 定数/変数項やラムダ抽象のステップでは, これ以上計算できないので, 継続にその式(定数やラムダ抽象など)を関数適用します. それ以外のステップは, 基本的に残りの計算を継続で表現して, 式(プログラム)の解釈を進めます.

 最終的な目標

 次のような実装を実現することです.
gosh> (interp3 '(reset (+ 1 (shift (c) (c 10)))))
11
gosh> (interp3 '(reset (+ 1 (shift (c) (c (c 10))))))
12
gosh> (interp3 '(reset (+ 1 (shift (c) 10))))
10
 一番目の実行では, 限定継続が1回実行されて11を返しますが, 二回目は, 2回実行されて12を返します. 最後の実行では, 残りの計算が切り捨てられて10を返しています.


interp1(メタ循環インタプリタの実装)

 interp1では, 簡単なラムダ抽象とその関数適用, ifによる分岐, プリミティブの演算の実行が可能な処理系を作成します.

 文法は以下のようなものを受け付けます.
exp := const | var | (exp exp ...) | (lambda (var ...) exp) | (if exp exp exp)

interp1の実装は次のようになります.

 環境(変数を表すシンボルとその値のペアからなるリスト)から変数名を探し, それに該当する値を返すのがlookup関数, extend*は, 環境を拡張する関数でインタプリタでは共に定番の補助関数となっています. initial-envは, プリミティブの演算(関数)が含まれている環境. eval1がインタプリタ. interp1は例外の機構を付け加えたインタプリタです(interp2以降では, もう少し複雑な使われ方をします).
 サンプルプログラムとして, 階乗を計算するプログラムとフィボナッチ数を計算するプログラムを作成しました. ループは不動点コンビネータで記述します.
 評価関数は, schemeのmatchマクロの都合上, 最初に(lambda, ifなどの)スペシャルフォームを持ってきて, 次に関数適用, 定数/変数といった順に並べる必要がありました.
 ラムダ抽象を評価した結果の値は, レキシカルクロージャ(ラムダ抽象と環境のペア)です.
 エラーが発生するポイントとしては, 探している変数が見つからないlookup関数(8行目), 間違った関数適用の形式(46行目), 想定されていないプログラムのフォーマット(54行目)などがあります. このようなケースでは, continuationを使って, 脱出します.

 実行結果は, こんな感じです.
gosh> (interp1 factorial-p)
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000
gosh> (interp1 fib-p)
144
gosh> (interp1 '((lambda (x y) (+ x (* y y))) 7 12))
151

interp2(カリー化)

 基本的に動作(計算結果, 機能など)については, interp1とほぼ同じですが, 内部的に式をカリー化したインタプリタを作成します. interp2では, プログラムとなる式を受け取ると, 内部的にカリー化して, カリー化された式を受け付けるインタプリタによりプログラムを計算します.
 また, これに伴い, プリミティブの関数もカリー化します.
 カリー化が必要なのは, 冒頭の説明にあるshift/resetの評価器がカリー化されたλ式をターゲットプログラムとしているからです.

 interp2の動作とinterp1の動作の違いは, 例えば, 次のプログラムを解釈できるか否かだと言えます.

gosh> (interp2 '((lambda (x) (x 2)) (+ 4)))
6
gosh> (interp1 '((lambda (x) (x 2)) (+ 4)))
4"# error :: malformed lambda"

 interp1では, +関数のカリー化に失敗しています(ラムダ抽象で包み込む必要があります)が, interp2では, その必要がありません (Haskell likeなスタイルになっているようにも見えます).

interp2の実装は以下のとおり.

 実行結果は,前述のケース以外では, interp1と同じです.
 プリミティブ関数をカリー化するcurryマクロとそれに関連するcurry*, curry-primitives-2などのマクロが登場しています. これは, プリミティブの関数fを, (lambda (x) (lambda (y) (f x y)))と書き直すことで, カリー化するマクロです. カリー化されたことで, eval2インタプリタでは, 関数適用が必ず関数と引数のペアになりました(今までは, 関数と引数の割合は一対多でした).
 curry-s-expは, 普通のS式を引数にとって, カリー化されたS式を返します.


interp3(素朴なshift/resetの実装)

 interp2まで完成すると, いよいよ, shift/resetが実装できるようになります. shift/resetの評価器は, 継続渡しスタイルで記述されます.
 この素朴な実装のメリットは, 上記の評価器をそのまま書けば, shift/resetの実装として正しく動いてくれるところです. ただし, matchマクロにあうように, スペシャルフォームから順に実装する必要があります.

ターゲット言語の文法は, 機能を追加したので, 次のようになります.
exp := const | var | (exp exp ...) | (lambda (var ...) exp) | (if exp exp exp)
             | (call/cc (var) exp) | (shift (var) exp) | (reset exp)

interp3の実装はこのようになります.


 normal2->cps2は, 2引数をとるプリミティブの関数を限定継続付きの継続渡しスタイルへ変換するマクロです. add-transform-cps-2で, その処理をすべてのプリミティブ関数に実行します.
 eval3では, インタプリタ上の例外処理をきれいに扱うため, factory関数を作り, eval3をその関数が生成するクロージャとすることで, 評価器の定義をそのまま記述できるようにしました. 基本的に書き方は, 冒頭で書いた定義と(順番が違う以外は, )ほぼ同じです.
 カリー化するプログラム(curry-s-exp)も, 文法の拡張部分について, 修正をほどこします.
 eval3へ最初に渡す継続は, (λx m. x)で, xに式全体の計算結果が渡されるので, それを返すようにします.
 サンプルプログラムとして, factorial-pでcall/ccとshift/resetを使ったものを作成しました.

 interp3の実行結果は次のようになるはずです.
gosh> (interp3 normal-p)
3628801
gosh> (interp3 shift-exception-p)
101
gosh> (interp3 call/cc-exception-p)
"call/cc exception"
 normal-pでは, 例外を返さない範囲内の引数が与えられたので, 普通にfactorialを計算します. shift-exception-pでは, factorial関数に50より上の値が渡されたので限定継続を使って, resetの外側へ脱出しています. 負の値を渡した場合は, 継続によってcall/ccの外へジャンプします.

 紛らわしいのですが, call/ccは, 継続を束縛した変数cを呼び出すことで例外のように式の外側へジャンプしますが, shift/resetは, 限定継続を束縛した変数cを呼び出さないことで式(reset)の外側へ脱出します.

 以上で, メタ循環インタプリタに限定継続(と継続)の機能を実装することができるようになりました.

 この投稿では, 素朴な(お試しの)実装について書きましたが, 本格的な言語に実用的な実装するには, Stackなどによる実装にする必要があります(遅いです).

2013/04/30

shift/reset

 限定継続という概念(制御構文)があって、これを使うと、バックトラックとかが簡単に実装できるのですが、その動作がやたらややこしい、という話のメモです。

限定継続では、shiftというオペレータとresetというオペレータを用いて、継続を扱います。shiftというオペレータは、継続の呼び出しです(Schemeのcall/ccのようなものです)。それに対して、resetは、その範囲を限定します。
例えば、以下のように使えます。

(+ 1 (reset (+ 10 (shift c (c 100)))))
:;==> 111

ここでは、resetが継続の取得する範囲を区切って、shiftのcに渡します。ここで渡される継続は、(lambda (x) (+ 10 x))の処理です。このラムダ式は、resetが用意してくれるようなイメージです。shiftは、resetで渡された限定継続(= 10を足すという処理)を受け取り、この継続をcにバインドします。call/ccで、継続をバインドするのと同じです。次にバインドされたcに対して、引数100を与えて評価するので、このshiftは、110を返します。その後、resetの外側にジャンプし、最後に1が足されて111と評価されます。
次に別の例です。

(+ 1 (reset (+ 10 (shift c 100))))
;;==> 101
ここでは、resetが継続の取得する範囲を区切って、shiftのcに渡します。 ここまでは、前の動作と同じです。今回も同じように、cに継続をバインドしているのですが、今回は、cを呼び出していないので、このcが切り捨てられます。つまり、10を足すという残りの処理がなくなります。shiftにより、resetのところまで、ジャンプし、結果、shiftが返す値100に1がたされて101と評価されます。
shift/resetが一組の場合は難しくないかと思われますが、shiftが2つ出てきたりすると一気にややこしくなります。
このような例です。

(+ 1 (reset (+ 10 (shift c (c 100)) (shift c 1000))))
;;==> 1001
(+ 1 (reset (+ 10 (shift c (c 100)) (shift c (c 1000)))))
;;==> 1111

上記の例の上の式では、(shift c 1000)が、ばっさりそれまでの式を切り捨てた上で1000と評価されるので、1001が帰ってきます。問題は、下の式ですが、継続渡しスタイルみたいになっているのが面白いですね。

こういうのって、手続き型言語風に書くとわかりやすかったりしますよね。なので、beginを使って表現してみました。Wikipediaの記事と似ていますが、そちらよりは若干原始的です。それぞれの動作とスコープが途切れる前後に数字を割り振っています。
1を出力 → reset → 2を出力 → shift → 3を出力 → 継続呼び出し → 4を出力 → shift終わり → 5を出力 → reset終わり → 6を出力というコードです。

(begin
    (display "1. before reset\n")
    (reset
     (begin
       (display "2. before shift\n")
       (shift cont
              (begin
                (display "3. before call-cont\n")
                (cont 0)
                (display "4. after call-cont\n")))
       (display "5. after shift\n")))
     (display "6. after reset\n"))

上記に対して、以下のような結果を出力します。

1. before reset
2. before shift
3. before call-cont
5. after shift
4. after call-cont
6. after reset

4と5が入れ替わっている以外は、順番どおりに実行されていることが確認できるかと思います。では、なぜ入れ替わったのかという点ですが、上記のプログラムにおいて継続が呼ばれたポイントに原因があります。3と4の間に継続が呼ばれています。なので、5が3と4の間に来ます。ただし、限定継続なので、resetで取得された範囲外にある6は、継続として呼ばれません。

ところで、限定継続は、ネイティブに固有の言語に実装されていません。shift/resetを文法として実装している言語なんてあるんでしょうか? じゃあ使われてないじゃん、って突っ込まれたのですが、まあ、おそらくその通りかもしれません。ただし、SchemeでマクロとCall/cc(プリミティブの継続)を使えば、shift/resetを実装できるみたいです。『Final Shift for Call/cc: Direct Implementation of Shift and Reset』って、論文にその方法が書いてあります。
こんな感じです。

(define-syntax reset
  (syntax-rules ()
    ((reset ?e) (*reset (lambda () ?e)))))

(define-syntax shift
  (syntax-rules ()
    ((shift ?k ?e) (*shift (lambda (?k) ?e)))))

(define (*meta-continuation* v)
  (error "You forgot the top-level reset..."))

(define (*abort thunk)
  (let ((v (thunk)))
    (*meta-continuation* v)))

(define (*reset thunk)
  (let ((mc *meta-continuation*))
    (call-with-current-continuation
     (lambda (k)
       (begin
         (set! *meta-continuation*
               (lambda (v)
                 (set! *meta-continuation* mc)
                 (k v)))
         (*abort thunk))))))

(define (*shift f)
  (call-with-current-continuation
   (lambda (k)
     (*abort (lambda ()
               (f (lambda (v)
                    (reset (k v)))))))))

以上のコードを定義すると、Racket上でshift/resetの構文が使えるようになります。

上記のコードは、call/ccが多用されてて、読みにくいですが、resetとshiftの実際の使用例に照らし合わせて考えると読みやすくなります。例えば、shiftとresetのマクロですが、それぞれ、引数として渡されている処理をlambda式として囲い、関数形式の*shiftと*resetに渡しているだけです。

まず、*resetは、はじめに(普通の)継続が呼ばれています。ここで取得される継続はresetで囲った処理以降の計算で、それをkで表しています。そして、広域変数*meta-continuation*にresetの範囲が終わった後の処理を記述をset!しています。その処理とは、resetが再帰的に呼ばれることを想定した*meta-continuation*の値のリセットと、残りの計算を実行するようなlambda式です。set!の処理が終わった後、引数として与えられた限定継続の部分を実行します。

実際に実行するのが*abortの役割です。引数なし関数thunkを実行し、結果の値を*meta-continuation*関数に渡します。*meta-continuation*は、resetの後始末をしています。(ここで、let文が使われていて、一瞬、(*meta-continuation* (thunk))でもいいような気がしたのですが、気のせいでした。)

最後に*shiftですが、この*shiftの引数fは、(shift c 100)でいうところの、(lambda (c) 100)です。限定継続をバインドしたlambda式になります。つまり、(f (lambda (v) (reset (k v))))で与えられている、(lambda (v) (reset (k v)))が、限定継続だということです。そして(k v)の呼び出しにより、現在の継続を捨てるようです。この時、(k v)をresetで囲うことでこの時点の継続を*meta-continuation*の確保している継続に対して、上書きします。

数十行程度のコードだったのに、やけに重たかったのは気のせいでしょうか。これを使って実際にどんなバックトラックのコードが書けるのかは、また別の記事に書きます。