型推論は間に合わなかったので違うお話を書きます。まあ今年のカレンダーでも10日目、12日目、16日目、20日目と型推論の情報はいっぱいあるのでいいよね?(?
というわけでちょっと変わった構文解析を実装した話です。
現在、シェーダ(GPUプログラム)をHaskellライクな構文でほぼ純粋に書けるようにする「PureShader(仮名)」を製作しているので、そこの実装を交えての記事となります。名前はまんまPureScript1に乗っかった感じです。
レイアウト規則について
Haskellの構文における特徴的なものとして**「インデントを重視する(レイアウト規則)」**というものがあります。これはHaskellに限ったものではなくて、Pythonなんかもインデントを構文のルールの一つとして取り入れているわけですが、Haskellの場合はPythonのそれに加えて式を複数行に分けることが可能なので少しだけ複雑になります。今回は後者を実装しました。
例
main =
if f 1 == True then do
putStrLn $ "2" -- *1
++ show 3 -- *2
++ show 4 -- *2
putStrLn "---" -- *1
else putStrLn "false" -- *2
do
以降の2つのputStrLn
(*1の行)はインデントが揃っています。ここはPythonと同様です。
重要なのは式のインデント規則(*2の行)で、ここも二行目以降は揃っている必要があるかと思えば、if
よりelse
のほうがインデントが浅かったり++ show 3
より++ show 4
のほうがインデントが浅かったりしています。両者に共通しているのは式の先頭(if``else
はmain =
、++ show 3``++ show 4
はputStrLn $ "2"
)のインデントよりはいずれも深いということです。
実装
Pctg-x8/shading_descriptor:src/parserが実装したコードなのでそっちを見てもらったほうが早いのですが、コメントが少なくてあまりにも読みにくいので簡単な解説をします。
基本方針は「あるインデントレベルより深ければ(つまり右側にあれば)連接するトークンとして消費する」です。
※検査の起点はトークンの左端です
putStr "hello, " | "hello, "はputStrより右側なのでつながる
putStrLn $ "wo"
++ "rl"
+ "d" | すべてputStrLnより右側にあるのでつながる
putStrLnはputStrより右側にないので繋がらない
再帰下降パーサの一種として実装しているため式や各種構文ルールをそれぞれ関数化しています。ルール内でレイアウトについて検査するとき「ルールの先頭だけ同一インデントを含めたり含めなかったりして、以降は含めない」という場合2があるため、基準インデントレベルとして単純にusize
を渡すだけでは役不足です。
ここでenum Leftmost
を導入します。見てもらえばわかるとおり
-
Nothing
(インデントなし、もしくは明示的なブロック3に入っている状態) -
Inclusive(usize)
(インデント状態で、判定するときに同じインデントのトークンを含める。暗黙的なブロックの開始で使われることが多い(do
の後ろとか)) -
Exclusive(usize)
(インデント状態で、判定するときに同じインデントのトークンは含めない)
という3状態を取るものです。トークンを検査する際にLeftmost::satisfy
を使って対象トークンが要求されたインデント規則を満たしているかを検査します。
ステップバイステップで、式f 2 + f 3
を例に解析してみます。
-
まず
infix_expr
をLeftmost::Nothing
で呼び出したとします。最初はそのままprefix_expr
に制御を渡し、prefix_expr
もまたterm_expr
に、term_expr
もfactor_expr
に制御をそのまま渡します。ここまでで以下のようになっています。
-
まず
leftmost.satisfy(stream.current(), true)
でチェックを行います。現在のトークンが要求インデントを満たさない場合に解析結果として「消費しなかった(NotConsumed
)」を返します。第二引数のtrue
はLeftmost::Nothing
の場合に返却する値です。今回はLeftmost::Nothing
なのでここのチェックは通過します。 -
factor_expr
は式の中で最も基本的なものを解析するので、今回は一番目のf
が消費されてシンボルとして返ります。 -
term_expr
に戻って、次はlet leftmost = leftmost.into_nothing_as(Leftmost::Exclusive(lhs.position().column)).into_exclusive();
となります。
Leftmost::into_nothing_as
はLeftmost::Nothing
を指定のものに変換するものです。
Leftmost::into_exclusive
はLeftmost::Inclusive
をLeftmost::Exclusive
に変換するものです(それ以外は通過)。例のセクションで示したとおり、先頭が同じインデントの場合は別の式として認識されてほしいので以降の解析では先頭以降のトークンのみ含めるようにしています。
-
以降、構文規則に沿って繰り返しです
最後に
別にこんなこと考えなくてもHaskellの構文はレイアウト規則も入れてすべて公開されている4のでそれをそのまま実装すればできます。そこにあるレイアウト規則(トークン挿入規則)5もそこまで難しくはないですが、ブロックを自動で閉じる際などで構文に依存する部分があってちょっとだけ面倒です。あとうちではトークナイザをIterator
として実装していた時期があって、挿入操作が面倒すぎてとりやめたというのもあります。
-
HaskellライクなAltJSのひとつ http://www.purescript.org/ ↩
-
たとえば
infix_expr
はほぼ先頭を含むけど、factor_expr
は「a + b
のような場合、a
の解析では先頭を含むがb
の解析では含まない」など呼び出し元に依存する ↩ -
Haskellでは
let { a = 1; b = 2 } in a + b
のように{}や;を使ってブロックを明示することができる ↩ -
https://www.haskell.org/onlinereport/haskell2010/haskellch10.html ↩
-
パーサにインデント規則を盛り込む今回の方法と違って、事前にインデントによって{}や;を挿入するモジュールを通す方法をとる ↩