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

2014/12/19

GaucheのPeg(Parser Expression Grammar)で漢数字の構文解析

※注意(2017/02/28) :本文中の表記には本来ドルマークが表示されるべき箇所で、ドルマークが表示されていません。また、不適切なイタリック体になっている箇所があります。いずれ修正しますが、とりあえず、今の所は修正していませんのでご注意ください。申し訳ありません。m(_ _)m

 構文解析は, HaskellのParsecや, trampoline, OpenNPLを今まで使ってきましたが, 今回はGaucheのPegを使って構文解析してみようと思います.

 Pegとは, Parser Expression Grammarの略で, 従来のパーサジェネレータ等に対して, 近年(Wikipediaには2002年の論文へのリンクが貼ってありますが...)登場した新しいタイプの形式言語で構文解析などに用いられます. 従来のCFG(文脈自由言語)に対して, 曖昧さが無く, 文法をそのまま再帰的下向き構文解析として用いることができるそうです. 理由は, 強欲な(Greedy)先読みを行うためで, 読み込めるまで優先順位の高い規則を使って読み込み, 失敗したらバックトラックにより戻ります. また, Greedyなために左再帰を用いた記述はできません.

 GaucheでのPegは, パーサコンビネータとして実装されています. 使い方は次のように使えます.
gosh> (use parser.peg)
#<undef>
gosh> (peg-parse-string ($or ($s "1")  ($s "2")) "2")
"2"
gosh> (peg-parse-string ($seq ($s "L") ($or ($s "L(1)")  ($s "R"))) "LR")
"R"
gosh> (peg-parse-string ($seq ($s "L") ($or ($s "L(1)")  ($s "R"))) "Peg")
*** PARSE-ERROR: expecting L at 0, but got #\P
Stack Trace:
_______________________________________
  0  (eval expr env)
        At line 179 of "c:\\Program Files (x86)\\Gauche\\share\\gauche-0.9\\0.9.4\\lib/gauche/interactive.scm"
 高階関数の組み合わせにより文法を定義し, peg-parse-stringへ文法を定義した高階関数とターゲットとなる文字列を渡すと, 構文解析結果(最後にパースされた文字列?)が帰ってきます. エラー処理を何もしなかった場合, 上記のようにreplのトップにエラーが出力されました.
 $sとは引数に与えた文字列をパースせよという意味です. それに対して, $orは, 引数に与えられた各要素について, 先頭から順に調べて行って, どれか一つにマッチしたらOKという意味でしょう.

もう少し, 返される文字列を工夫したいという場合は, $doと$returnが使えます.
gosh> (peg-parse-string ($do [word ($or ($s "a") ($s "b"))] ($return word)) "b")
"b"
もう少し, 返される文字列を加工したいという場合は, rope->stringが使えます.
gosh> (peg-parse-string ($do [word ($or ($s "a") ($s "b"))] ($return (string-append "the answer is " (rope->string word)))) "b")
"the answer is b"
 $do構文中では, 変数wordに解析結果(呼び出した関数から返される値)をバインドし, $returnでその文法が返す値を決めることができますが, 変数wordから文字列を取り出すには, rope->stringという関数に一回通す必要があります.

 見た感じHaskellのParsecによく似ていますが, Comparision of parser generators - Wikipeida によれば, Parsecは, Deterministic CFGのリストにあり, アルゴリズムは, LL+Backtrackingと書かれているため, Pegとはまた別物のようです.

 さて, 本題ですが, 漢数字の表記("一億千七百六十五万四千二百二十一"みたいなの)をパースして, 読みやすいアラビア数字の表記に変換します.

 漢数字の表記で特徴的なのは, 千の位まででひと塊になること, 一万単位で新しい位に名前が付くことで, 一万, 一億, 一兆, ...etcといった感じになることです.

10未満を表す漢数字は当然, 次のようになります.

A → "一" | "二" | ... | "九"

 0は入りません. 次に100未満を表す文法は, 以下のように記述できます.

A' → "二" | ... | "九"
B10 → A' "十" A | A' "十" | "十" A | A

 十の前後に漢数字が入ってくるケース, 九十九などか, 1の位が記述されない九十, 単体の十, 十の位が記されない九など. 基本的にこれ以降の位でも同様のことが起きます. 得に紛らわしいのは, "二十"と書いても, "一十"とは書かないことで, 2~9までの漢数字を表すA'が別途必要になります. 100と1000の位は以下.

B100  → A' "百" B10 | A' "百" | "百" B10 | B10
B1000 → A' "千" B100 | A' "千" | "千" B100 | B100

 こんな感じで, ただし, 百と千の後ろはそれぞれ, 百未満の数字, 千未満の数字を表す非終端記号が入ってきます. 前述したように, 一万を超えると, 千の位まででひと塊で, 一千万など位を表す漢数字の前に1~9999までの漢数字が入ってきます.

Cm → B1000 "万" B1000 | B1000 "万" | B1000
Co → B1000 "億" Cm | B1000 "億" | Cm

 億以降は同じことの繰り返しになるので, 省略します.

 似たような構造が続くので, もう少し簡潔に書けそうな気がしますが, 一つ間違えれば, 九十九十九みたいな文字を認識してしまうので, 各位の判定には注意が必要になります. 必ず億の次に万が来るように.

 ただし, 細かい話をすれば, 一千万と言っても, 千万とは言わないとか, 億千万とはいうけど, この時の億千万とは, 多分, 一億一千万のことではないとか...

 というわけで以上のCFGをまとめると以下の様になります.

Top   → Cm  | "零"
Cm    → B1000 "万" B1000 | B1000 "万" | B1000
Co    → B1000 "億" Cm | B1000 "億" | Cm
B1000  → A' "千" B100 | A' "千" | "千" B100 | B100
B100  → A' "百" B10 | A' "百" | "百" B10 | B10
B10   → A' "十" A | A' "十" | "十" A | A
A'   → "二" | ... | "九"
A    → "一" | "二" | ... | "九"

 似たようなタイプ別に, A, B, C,とその添字でまとめています. このくくりは実装で関数としてまとめる単位別にA, B, Cとなっています.

 おそらく, この漢数字による数値表現は, CFGを使わなくても, 正規表現(有限状態オートマトン)で十分認識可能だとは思われますが, 今回は一応, Schemeの数値型へ変換するということで, Pegを使っています.

 以下がそのコードです. 実際に構文解析のスタート地点となるのは, parse-cc-numで, それ以降のコードはすべてテスト用のコードです. parse-Adは, 上記のCFGのA'に相当します. parse-B関数は, それぞれパラメータを与えて, B1000, B100, B10を解析する関数へ変化します. parse-C関数も同じ. コード中のnnは, Natural Numberの略で, ccはChinease Characterの略です
(use parser.peg)           ;; for parser
(use gauche.generator)     ;; for test
(use srfi-1)
(use data.random)          ;; for test

;; target characters :: 零一二三四五六七八九十百千万億
(define cc-0-9
  (string->list "零一二三四五六七八九"))

;; from chinease character number format to natural number (integer type)

(define cc-num->nn
  (let ((corresp (apply hash-table (cons 'eq? (zip cc-0-9 (iota 10))))))
    (lambda (cc-num)
      (car (ref corresp cc-num)))))

(define parse-Ad
  ($do [c ($or ($s "二") ($s "三") ($s "四") ($s "五")
               ($s "六") ($s "七") ($s "八") ($s "九"))]
       ($return (cc-num->nn (car (string->list (rope->string c)))))))

(define parse-A
  ($do ($or ($do [v ($c #\一)] ($return 1))
            parse-Ad)))

(define parse-B
  (lambda (head middle tail value)
    ($or
     ($try ($do [v10 head] [temp middle] [v1 tail]  ($return (+ (* v10 value) v1))))
     ($try ($do            [temp middle] [v1 tail]  ($return (+ value v1))))
     ($try ($do [v10 head] [temp middle]            ($return (* v10 value))))
     ($try ($do                          [v1 tail]  ($return v1)))
     ($try ($do            [temp middle]            ($return value))))))

(define parse-B10
  (parse-B parse-Ad ($s "十") parse-A 10))

(define parse-B100
  (parse-B parse-Ad ($s "百") parse-B10 100))

(define parse-B1000
  (parse-B parse-A ($s "千") parse-B100 1000))

(define parse-C
  (lambda (head middle tail value)
    ($or
     ($try ($do [v10 head] [temp middle] [v1 tail] ($return (+ (* v10 value) v1))))
     ($try ($do [v10 head] [temp middle]           ($return (* v10 value))))
     ($try ($do                          [v1 tail] ($return v1))))))

(define parse-Cm
  (parse-C parse-B1000 ($s "万") parse-B1000 10000))

(define parse-Co
  (parse-C parse-B1000 ($s "億") parse-Cm 100000000))

(define parse-top
  ($or ($do [temp ($s "零")] ($return 0))
       ($try parse-Co)
       ($return "faild to parse")))

(define (parse-cc-num cc-num)
  (peg-parse-string parse-top cc-num))

;; from natural number (integer type) to chinease character number format

(define nn-num->4unit-list
  (let ()

    (define char->number
      (let ((zero (char->integer #\0)))
        (lambda (c)
          (- (char->integer c) zero))))

    (define num->cc-num
      (let ((corresp (apply hash-table (cons 'eq? (zip (iota 10) cc-0-9)))))
        (lambda (num)
          (string (car (ref corresp num))))))

    (define (each-unit value unit)
      (cond
       ((= value 0) "")
       ((and (= value 1) (or (equal? unit "百") (equal? unit "十"))) unit)
       (else (string-append (num->cc-num value) unit))))

    (define zeros (circular-list 0))

    (define lim 12) ;; 0 ~ 9999,9999,9999 (maximum length of string is 12)

    (define unit '("千" "百" "十" ""))

    (define 3units (append unit unit unit)) ;; (千 ~) 億 (千 ~) 万 (千 ~) 一

    ;; nn-num->4unit-list
    (lambda (nn-num) ;; natural number (supposed to be integer type)
      (let* ((nn-nlist (map char->number (string->list (number->string nn-num))))
             (rest (- lim (length nn-nlist))))
        (if (< rest 0)
            (map each-unit (take zeros lim) 3units)
            (map each-unit (append (take zeros rest) nn-nlist) 3units))))))

(define 4unit-list->cc-num
  (let ()

    (define (take&concat ls index)
      (apply string-append (take-right (take ls (* index 4)) 4)))

    (define (wipe-out-blanks unit-str cc-unit-name)
      (if (equal? unit-str "") ""
          (string-append unit-str cc-unit-name)))

    ;; 4unit-list->cc-num
    (lambda (4unit-list)
      (string-append
       (wipe-out-blanks (take&concat 4unit-list 1) "億")  ;; oku
       (wipe-out-blanks (take&concat 4unit-list 2) "万")  ;; man
       (wipe-out-blanks (take&concat 4unit-list 3) "")))));; ichi

(define (nn->cc-num n)
  (4unit-list->cc-num (nn-num->4unit-list n)))

;; test

;; int -> int
(define id
  (lambda (x)
    (parse-cc-num (nn->cc-num x))))

(define (do-test n) ;; n = the number of repeat
  (call/cc
   (lambda (cc)
     (map (lambda (value)
            (if (not (= value (id value)))
                (cc (format "fail to parse ~D" value))))
          (generator->list (integers-between$ 0 1000000000000) n))
     (display "all tests passed"))))
parse-B, parse-C関数にある$tryの部分は, 強欲な読み込みの裏返しで, 読み込みが失敗した時に, $tryの位置まで戻ってくることを指しているようで, Javaのtry-catchのようなものでしょうか.
 nn->cc-numは, ちょうど解析器の(おおよその)逆関数になっていて, 数値を入力として受け取り, 漢数字を返します. 若干不十分なところはありますが(例えば, 入力1000について, 千ではなく, 一千と返してしまいます).

gosh> (nn->cc-num 1000)
"一千"
gosh> (nn->cc-num 1290123139)
"十二億九千十二万三千百三十九"
gosh> (nn->cc-num 1290000009)
"十二億九千万九"
というわけでParserを実際に動かしてみると, こんな感じに認識してくれることがわかります.
gosh> (parse-cc-num "十二億九千十二万三千百三十九")
1290123139
gosh> (parse-cc-num "十二億九千万九")
1290000009
gosh> (do-test 1000)
all tests passed#

2014/08/18

syntax-rulesの書き方のコツのようなもの(Scheme)

 Scheme(R5RS)のマクロであるsyntax-rulesは, Lispの伝統的なマクロとは書き方や雰囲気も全く違います. 伝統的なマクロがないSchemeは, どこか言語として(Lispとして), 物足りない感じがしました.  しかし, そんな私も最近は, 折にふれて, ちょっとした作業でsyntax-rulesマクロを使うようにしたところ, Hygenicマクロへの抵抗感が薄れてきました. 使い慣れたついでの, Hygenicマクロの書き方のコツのようなもののメモです.

再帰的な関数の書き方

 どこで読んだのか覚えていないのですが, 関数型言語でプログラムを書くコツは,if(cond)式について, 特殊なケースから順に書いていくことだそうです.
 例えば,  フィボナッチ数を計算する関数は, 再帰呼び出しを用いて書く場合,
(define (fib n)
    ... ...
という形で最初に関数名と引数の数とその値の型について考えます. ここでは, nは整数(integer)です.
 次に, 特殊なケースについて, n = 0の時と, n = 1について考えます.
(define (fib n)
  (cond
    ((= n 0) 1)
    ((= n 1) 1)
    ... ...
それぞれ以上のように書けることは, 明らかです. 関数型言語を覚えたての頃は, 任意のnのケースについて考えるのが大変なのですが, フィボナッチ数はその性質から, fib(n - 1)とfib(n - 2)の和だということを考えると, 残りのプログラムが書けて, 
(define (fib n)
  (cond
    ((= n 0) 1)
    ((= n 1) 1)
    (else (fib (- n 1)) (fib (- n 2)))))
という感じで, プログラムを書いていきます.
 これだと, nに負の値を与えられた場合が問題になるので, throw-exceptionに継続をバインドして, 次のように書いてもいいかもしれません.
(define (fib n)
  (cond
    ((< 0 n) (throw-exception "wrong argument"))
    ((= n 0) 1)
    ((= n 1) 1)
    (else (fib (- n 1)) (fib (- n 2)))))
 これは, 普段プログラミングを行うときに自然にやっていることですが, 関数型言語風の書き方を始めたばかりの頃は, 再帰呼び出しでの書き方が分からず, 混乱した時に, このアドバイスに従い, 整理して, 書いていました. この方法論は, かなり(私には)有用で, 大抵のケースで, このアドバイスが応用できました.
 上記は数値の例ですが, 例えば, リスト処理や文字列の処理でも同じような考え方でコードを書いたのを覚えています.

syntax-rulesマクロの書き方

 この方法論は, syntax-rulesマクロを書くときにも応用できます. マクロの引数が1つ与えられている場合について考えるのです.
 次のようなフォームを反転させるマクロを実装してみます. 例えば, (1 2 3 4 5 6)が与えられるとそれを反転した(6 5 4 3 2 1)が, S式として展開されるようなマクロです. 実用性はさておき, syntax-rulesの柔軟性を調べるためには十分だと思います.
 実行結果は次のようになります.
gosh> (macro-list-reverse (1 2 3 4 5 6))
*** ERROR: invalid application: (6 5 4 3 2 1)
Stack Trace:
_______________________________________
gosh> (macro-list-reverse (1 2 3 4 5 +))
15
 これは, Schemeにおけるreverse関数をパターンマッチにより実装するのとさほど変わりません. ところで, 一般に, reverse関数は, cons関数と再帰呼び出しのために, 2つ引数をとります.  (reverse (1 2 3) ()) → (reverse (2 3) (1)) → (reverse (3) (2 1)) → (reverse () (3 2 1)) というふうに末尾再帰を繰り返すので, 2つの引数を想定します.
(define-syntax macro-list-reverse
  (syntax-rules ()
    ((_ () ())
     ... ...
 次に特殊なケースについて考えます. 1つ目の引数が空リストの場合です. 2つ目の引数には, 反転されたリストが入っているので, そのリストを返せばよく,
(define-syntax macro-list-reverse
  (syntax-rules ()
    ((_ () ())
     ())
    ((_ () (xs ...))
     (xs ...))
     ... ...
と書けます. (xs ...)は, 何らかのリストを意味しています. これがそのまま, 展開した結果として返されます.
最後に, 一般的なケースについて考えると, 左の先頭を右の先頭へ移す処理なので, (x xs ...)と分解して, 左の(ys ...)に(x ys ...)と加えて, 次のように書けます.
(define-syntax macro-list-reverse
  (syntax-rules ()
    ((_ () ())
     ())
    ((_ () (xs ...))
     (xs ...))
    ((_ (x) (xs ...))
     (x xs ...))
    ((_ (x xs ...) (ys ...))
     (macro-list-reverse (xs ...) (x ys ...)))))
 これで, 与えられた要素が反転するマクロができますが, 初期値の空リストが冗長なので, パターンマッチでそのケースについて追加します.
(define-syntax macro-list-reverse
  (syntax-rules ()
    ((_ (xs ...))
     (macro-list-reverse (xs ...) ()))
    ((_ () ())
     ())
    ((_ () (xs ...))
     (xs ...))
    ((_ (x) (xs ...))
     (x xs ...))
    ((_ (x xs ...) (ys ...))
     (macro-list-reverse (xs ...) (x ys ...)))))
 というわけで, 最初のmacro-list-reverseが完成します.

 パターンマッチが分岐の役割を果たし, 再帰的展開が普通の関数における再帰呼び出しに相当するため, Schemeにおける関数と遜色ない程度の計算力を持ったマクロがかけるようです.

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*の確保している継続に対して、上書きします。

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