読者です 読者をやめる 読者になる 読者になる

tailRecM とパーサコンビネータ

TL; DR

tailRecM は, Alternative あるいは MonadPlus でもあるパーサ (ParsecT) において, 再帰的なパーサの定義をする際にも有効.

パーサコンビネータは便利

例として文字列リテラルのパーサを書いてみます.

BNF で書くと (character はいい感じにやるとして),

stringHead ::= '"'
stringTail ::= '"' | character stringTail
stringLiteral ::= stringHead stringTail

Parsec (Haskell) なら, このような文字列リテラルの簡易的なパーサを, 以下のように BNF の定義に近い形で簡潔に書けます.

import Text.Parsec
import Text.Parsec.String

stringHead :: Parser ()
stringHead = char '"' >> return ()

stringTail :: [Char] -> Parser [Char]
stringTail xs =
        (char '"' >> return (reverse xs))
    <|> (noneOf "\"\r\n" >>= \x -> stringTail (x : xs))

stringLiteral :: Parser [Char] 
stringLiteral = stringHead >> stringTail ""

実行は (例えば GHCi で) こんな感じに.

λ> parse stringLiteral "" "\"foobar\""
Right "foobar"

さて, 同じことを loquat (JavaScript) でもやってみましょう. 以下は見た目が少しごちゃついていますが, 大体 Parsec のものと同じことをやっています.

const lq = require("loquat")();

const stringHead = lq.char("\"").void();
const stringTail = xs =>
    lq.char("\"").return(xs)
    .or(lq.noneOf("\"\r\n").bind(x => stringTail(xs + x)));
const stringLiteral = stringHead.and(stringTail(""));

実行はこんな感じ.

console.log(lq.parse(stringLiteral, "", "\"foobar\""));
// { success: true, value: "foobar" }

うまく動きました. 便利ですね〜.

JavaScript での問題点

さて, 次のような場合はどうでしょう.

const veryLongString = "\"" + "A".repeat(10000) + "\"";
console.log(lq.parse(stringLiteral, "", veryLongString));
// RangeError: Maximum call stack size exceeded

はい. コールスタックが溢れて死にました.

一方 Parsec はどれだけ文字列を長くしても (たぶん) うまく動きます (出力が長くなるので結果は捨てている).

λ> let veryLongString = "\"" ++ replicate 10000 'A' ++ "\""
λ> parse stringLiteral "" veryLongString >> return "ok"
Right "ok"

なぜ JavaScript では死んでしまうのかというと, stringTailstringTail を使って再帰的に定義にされているためです.

stringTail xs =
        (char '"' >> return (reverse xs))
    <|> (noneOf "\"\r\n" >>= \x -> stringTail (x : xs))
const stringTail = xs =>
    lq.char("\"").return(xs)
    .or(lq.noneOf("\"\r\n").bind(x => stringTail(xs + x)));

Haskell では末尾呼び出しの除去が行われて, かつ Parsec はそれがうまく行われるようパーサを定義しているので, コールスタックが溢れずに済むのですが, JavaScript は末尾呼び出しが除去されず *1, しかも loquat ではそれがうまく動くようにも定義していない*2ので, コールスタックが溢れてしまいます.

問題を整理するために以下のように書き換えます (stringLiteralf).

e x = char '"' >> return (reverse x)
s x = noneOf "\"\r\n" >>= \y -> y : x
f x = e x <|> (s x >>= f)

このような f の形は, 文字列リテラルだけでなく, 他にも頻繁に登場するので, 一般的な話をするためにこの形の f について考えることにします.

問題は, このような再帰的定義を JavaScript で,

const f = x => e(x).or(s(x).bind(f));

と愚直に書くと, 繰り返しが多すぎた場合, コールスタックが増えすぎると死んでしまう, ということですね.

一応, 手作業で再帰的定義をループに書き換えてやることで解決できることもあり, 実際に many などのコンビネータの定義はそのようにやっています. ただ, この書き換えにはパーサコンビネータの内部構造に関する知識が必要になる上に, なによりとても面倒なので, 内部で使うのであればともかく, 簡単にパーサを書くためのライブラリのはずなのに簡単に書けない, ということになってしまっていて大変不便です.

tailRecM

というわけで何か解決方法はないかと適当な単語で検索していたら, 以下のページを見つけました.

これは Scala のライブラリ Cats の話だけれど, 元は PureScript *3 で, コールスタックが溢れないように Free モナドを実装するにはどうしたら良いか, という話らしい.

以下では論文に倣って, 「コールスタックが溢れない (スタック消費が定数となる)」ことをスタック安全と呼ぶことにします.

あまり詳しくは読んでいないので適当なことを言っているかもしれませんが, 内容をかいつまんで紹介すると, まず tailRecM を以下のように定義します.

tailRecM :: Monad m => (a -> m (Either a b)) -> a -> m b
tailRecM f a = f a >>= go
  where
    go (Left a)  = f a >>= go
    go (Right b) = return b

繰り返し処理 (再帰的定義) はこれを使うことで (おそらくある範囲内では) 実現できそうです.

この定義は一般の Monadインスタンスに対するものですが, このままではスタック安全ではありません. これを各 Monadインスタンスに対してスタック安全な形で定義しなおしてやれば, 繰り返しや再帰tailRecM を使うことでスタック安全に書くことができるようになります.

要するに, 上で書いた面倒な書き換えを tailRecM についてさえ行っておけば, あとはそれを使って様々なことが出来るだろう, という話です. (もっとも, PureScript や Scala は自己再帰であればコンパイラが最適化してくれるので, 手作業でループに書き直すほど面倒ではなさそう. あなたはコンパイラですか?)

tailRecM を使ってみる

実際に tailRecM を使って, 上で見たような再帰的定義されたパーサを書き換えられるのかを考えてみます.

まずは元の定義はこれ.

f x = e x <|> (s x >>= f)

対称性が悪いのでまた定義を書き換えます.

e' x = Right <$> e x
s' x = Left <$> s x
f' (Left a)  = (e' a >>= f') <|> (s' a >>= f')
f' (Right b) = return b
f x = f' (Left x)

さらに書き換えるために, Haskell/Alternative and MonadPlus - Wikibooks, open books for an open world にあるような分配法則が成り立つかどうかを考える必要があります.

(m >>= k) <|> (n >>= k)  =  (m <|> n) >>= k

これは MonadPlus 一般について成り立つわけではなく, さらに運の悪いことに, 今考えているパーサに対しては成り立ちません. ところがほとんどの場合にはこれを使って書き換えることができます.

どういうことかというと, まず, パーサを実行すると得られる結果は 4 通り (入力を消費したか, パースが成功したかで 2 × 2 = 4) あります. ここでは Parsec での関数名に従って cok, cerr, eok, eerr と呼ぶことにしましょう. これらの結果の各組み合わせについて分配法則が成り立つかを確認してみると, 実は「meok になり得て, かつ k が返すパーサが eerr になり得る」場合のみ成り立ちません *4. 今考えているのは m = e' a, k = f' なので, 分配法則が成り立たないのは e' aeokLeft 値を返し得る場合のみです. 今の場合では e' a は常に Right 値を返すので問題ありませんし, 仮に e' aLeft 値を返しうる一般の場合についても, (よほど特殊な場合でない限りは) パース処理が無限ループとなってしまうはずなので, そもそも元のパーサの定義が正しくありません (無限にパースし続けたいのであれば別だけれど). したがって, 正しく定義されているパーサについては, 分配法則が成り立つと言って良いでしょう.

というわけで分配法則を使って書き換えます.

f' (Left a)  = (e' a <|> s' a) >>= f'
f' (Right b) = return b

かなり tailRecM の定義にある go に近い形になりました. 実際, f af(\x -> e' x <|> s' x) を代入すると go = f' となることがわかります.

tailRecM f a = f a >>= go
  where
    go (Left a)  = f a >>= go
    go (Right b) = return b

さて, 元の f はどうなるでしょうか.

f x = f' (Left x)

f' を代入して適用してみます.

f x = (e' x <|> s' x) >>= f'

おや, これは,

f a = (\x -> e' x <|> s' x) a >>= f'

ですね. もう一度 tailRecM の定義を見れば明らかなように, 結局 ftailRecM を用いて以下のように書けます.

f = tailRecM (\x -> e' x <|> s' x)

めでたしめでたし.

JavaScript の話をしましょう

JavaScript の話をしているつもりだったんですけどねぇ……

というわけで loquat にも tailRecM を実装してみました.

Scalaz / Cats の tailRecM と同じ引数の順番の tailRecM(val, func) と, カリー化されていて PureScript の tailRecM と同じ ftailRecM(func)(val) があります. (なんでこんな命名になっているのかというと, JS っぽい引数順で定義した map(parser, func) と, オリジナルの fmap(func)(parser) が混在しているのに対応するようにしたため.)

const lq = require("loquat")();

const stringHead = lq.char("\"").void();
const stringTail = lq.ftailRecM(xs =>
    lq.char("\"").return(xs).map(x => ({ done: true, value: x }))
    .or(lq.noneOf("\"\r\n").map(x => xs + x).map(x => ({ done: false, value: x })))
);
const stringLiteral = stringHead.and(stringTail(""));

console.log(lq.parse(stringLiteral, "", "\"" + "A".repeat(10000) + "\""));
// { success: true, value: "AAA..." }

これだと少々煩雑なので以下のようなメソッド .done(), .cont() を定義しようかしらん.

const stringTail = lq.ftailRecM(xs =>
    lq.char("\"").return(xs).done()
    .or(lq.noneOf("\"\r\n").map(x => xs + x).cont())
);

npm には今週末にでも publish します.

まとめ

というわけで JS でもいい感じに再帰的にパーサを書けるようになりました.

どんどんいい感じにしていっているので, 急に DSL が作りたくなったりした場合は是非お試しください. github.com

*1:ES6 の仕様では末尾呼び出しの除去を要求しているが, まだ実装している処理系は少ない

*2:旧バージョンではうまく動きそうだけれど, 式年遷宮するときにパフォーマンス上の理由とかでやめてしまった (後で気がついた (アホ) ).

*3:JavaScriptコンパイルターゲットとする Haskell 風で正格評価の関数型言語 http://www.purescript.org

*4:あまり厳密に確認していないのでひょっとしたら間違っているかも