これは Ruby Advent Calendar 2022 の25日目の記事です。
書籍 『RubyでつくるRuby ゼロから学びなおすプログラミング言語入門』(以下、 『RubyでつくるRuby』 )で扱われているミニ言語 MinRuby のパーサを書いてみました。
『RubyでつくるRuby』は、Ruby を使った基本的なプログラミングの入門から始まり、後半ではミニ言語 MinRuby(Ruby のサブセット)のインタプリタを作って最終的に MinRuby言語で MinRuby インタプリタを書くところまでやってしまう、という内容の入門書です。
……と自分なりに内容紹介してみましたが、以下もあわせて参考にしていただければと思います。
RubyでつくるRuby ゼロから学びなおすプログラミング言語入門 – 技術書出版と販売のラムダノート
プログラミングを始めるなら、プログラミング言語を自分でつくってみるのがいちばん! 最低限の機能なら、こんなに簡単にインタプリタを作れます。よくわからなかったプログラミングも、裏側の仕組みから分かってしまえば怖くない!
2016年9月から2017年1月にかけてアスキーjpの「プログラミング+」コーナーで連載された大好評のWebコンテンツ『Rubyで学ぶRuby』を、さらにわかりやすく紙版の書籍として編纂しなおして発売するものです。豊富なイラストもカラーで完全採録。
たしか発売された頃に読んで「せっかくだからパーサも自作したい」と思った記憶があります。当時はパーサの作り方についての知識が足りずどうすればよいか分からなかったのですが、その後あれこれあって簡単なものなら作れるようになりました。
https://github.com/sonota88/cookpad-hackarade-minruby/tree/recursive-descent-parser
$ wc -l rcl_*.rb my_minruby_parser.rb
33 rcl_common.rb
65 rcl_lexer.rb
496 rcl_parser.rb
17 my_minruby_parser.rb
611 合計
一応動いてはいます。advent calendar には間に合った……リファクタリングなどは冬休み以降の宿題にします。
(2023-01-22 追記) interp.rb がパースできるようになりました。
$ cat test3-4.rb
n = 1
while n < 100
if n % 3 == 0
if n % 5 == 0
p("FizzBuzz")
else
p("Fizz")
end
else
if n % 5 == 0
p("Buzz")
else
p(n)
end
end
n = n + 1
end
$ ruby my_minruby_parser.rb test3-4.rb
[:stmts, [:var_assign, "n", [:lit, 1]],
[:while,
[:<, [:var_ref, "n"], [:lit, 100]],
[:stmts,
[:if,
[:==, [:%, [:var_ref, "n"], [:lit, 3]], [:lit, 0]],
[:if,
[:==, [:%, [:var_ref, "n"], [:lit, 5]], [:lit, 0]],
[:func_call, "p", [:lit, "FizzBuzz"]],
[:func_call, "p", [:lit, "Fizz"]]],
[:if,
[:==, [:%, [:var_ref, "n"], [:lit, 5]], [:lit, 0]],
[:func_call, "p", [:lit, "Buzz"]],
[:func_call, "p", [:var_ref, "n"]]]],
[:var_assign, "n", [:+, [:var_ref, "n"], [:lit, 1]]]]]]
まずは先行事例を軽く調べました。
Hackarade #04: Create Your Own Interpreter - クックパッド開発者ブログ
完全セルフホスト:MinRubyでパーサを書き、minruby gemに依存せずにinterp.rb単体でセルフホストするようにした
さすがのクックパッドさん。今回私はここまではやっていません。パーサを MinRuby で書くのもおもしろそう……。
同記事により、 mame/cookpad-hackarade-minruby というリポジトリでテストケースが用意されていることを知りました。ありがたく有効活用させていただきましょう。
mame/cookpad-hackarade-minruby: material for Cookpad's Hackarade #4
今回はこのテストケースを使い、自作パーサと minruby gem に同じ入力を与えて同じ結果になればヨシ! ということにしました。
パーサ入門でよくある再帰下降パーサにしました。
専用のパーサライブラリを使うのに比べるとデメリットもあり本格的な言語実装ではあまり採用されない手法だと思いますが、入門者目線だと「全部自分で書いたぞ感」「全部把握できてる感覚」が得られるという、とても良い良さがあります。
(ちなみに、Rust製 Ruby 実装 ruruby のパーサは手書きだそうです。すごい……。)
ここで選択肢が2つあり、
ちょっとだけ (1) を試した後、時間(というか計画性)がないこともあって (2) に切り替えました。
さて、Ruccola というのがスッと出てきましたね。これはなんでしょうか。
Ruccola は私が自分の勉強のために作っている素朴な自作プログラミング言語です。
https://github.com/sonota88/ruccola
最新の知見を盛り込んだキラリと光る言語……とかでは全然なく、現代の水準からするとかなり原始的なものです。自分の勉強用・入門用なので素朴でよいと割り切っています。
2021年にセルフホストできた(詳しくは↓の記事を参照)後も趣味の盆栽プログラミング的にちびちびと機能追加やリファクタリングを続けています。
素朴な自作言語Ruccolaのコンパイラをセルフホストした - Qiita
ざっくり書くとこんな感じです。このうち「見た目は Ruby っぽい」というのがポイントで、Ruccola のパーサ( 202-12-25 時点のソース )にいくつか手を加えれば MinRuby のパーサとして使えそうでした。
コード例です。以下は Ruccola言語で書いた cat コマンド。
def main()
var EOF = -1;
var c;
while (true)
c = getchar();
if (c == EOF)
break;
end
write(c, 1);
end
end
だいたい Ruby ですね。中身は原始的なのに見た目は Ruby 風なので、書いているとちょっと不思議な気分になります。
作者の目にはだいたい Ruby と同じに見えますが、Ruby に慣れている方には次のような点が目に付くのではないでしょうか。
;
(必須)(
)
(必須)他にも関数呼び出しの (
)
、関数定義の仮引数を囲む (
)
、エントリポイントとなる main 関数も必須です。
自分が言語実装ビギナーなので、難しいことをしなくていいように Ruby の文法にいろいろ制限を加えてこうなっています。Ruby と完全にコンパチではありませんが、構文ハイライトやエディタのインデント支援は Ruby 向けのものがだいたいそのまま使えています。
ちなみに、Ruccola よりももっとコンパクトな mini-ruccola(元は vm2gol-v2 という名前だった)もあります。VM〜コンパイラを割と素朴に書いて1500行以下というもの。自分が欲しい(難しすぎず、簡単すぎず、読み書きに慣れている言語向けの)教材が見つけられず、じゃあ自分で作ってしまえとなって作ったもの。
https://github.com/sonota88/vm2gol-v2
以前週刊Railsウォッチで紹介していただきました。
作った順番としては mini-ruccola の方が先で、最初にこっちで簡単なコンパイラ実装になんとか入門し、その後セルフホストに必要な機能を追加していく形で Ruccola を作っていきました。
Ruccola の AST は現時点では MinRuby と同じく入れ子の配列(のようなもの)で木構造を表す方式です。そこは同じ。ただ細かいところが違うので合わせていきます。
簡単なところでいえば、たとえば以下は変数への代入です。
- [:set, var_name, expr]
+ [:var_assign, var_name, expr]
ものによってはこの程度の変更でOK。
Ruccola では自分でも手に負えるようにいろいろと単純化しています。演算子の優先順位もそのひとつで、たとえば 1 + 2 * 3
という式は (1 + 2) * 3
として扱われます(単純に左結合とする)。
えっそれでいいの? と思いましたか? 思いますよね。
言語処理系の解説を見ると必ずといっていいほど演算子の優先順位の話題が出てきて、自作言語を作るには避けて通れないトピックであるかのように思われます。私もある時までそう思い込んでいたのですが、実はそんなことはありません……と思うんですよね(微妙な自信のなさ)。
Ruccola では 1 + (2 * 3)
と解釈させたい場合は明示的に括弧で優先順位を指定して 1 + (2 * 3)
と書けばよい、ということにしています。たまに自分でも忘れていて素でびっくりします。まあ自分の勉強用の言語だからいいんですよこれで。最初から実装しなくてもよいので後回しにしています。Ruccola言語の記述力がまだ低いため、今入れてもコード量が膨れるデメリット(セルフホストしているのでばかにならず、下手に機能追加すると自分が困る)の方が大きそうで嫌かなという考えもあり。
ちなみに、似たような方針を採っている言語として VTL (Very Tiny Language) という言語があると後から知りました。仲間がいた……。
VTLでは,演算子の間に優先順位は存在せず,左から順番に演算が行われます.演算の順序を変えたい場合は括弧"()"をつけます.例えば,"2+3*4"の結果は"20"となり,"2+(3*4)"の結果は"14"となります.
あ、ていうか優先順位を明示する言語といえば Lisp があるじゃないか、とこの記事を書いていて今思い至りました。Lisp、心強いですね。
さて、Ruccola ではそれでいいとして、演算子の優先順位に対応しないと cookpad-hackarade-minruby のテストが通りません。なんとかしなければ。
これについては自分にとって今回初めてという訳ではなく、以前単体で履修していたのでした。
これを組み込めばいけるはず。
test1-4.rb と test1-5.rb の間の飛躍が大きかったので小さなテストを追加しながら進めました。
1 == 2
から<
を右側に加えた 1 == 2 < 3
1 == 2 < 3 + 4 * 5
が (1 == (2 < (3 + (4 * 5))))
と解釈できればOKという流れで進めることで意外とすんなり書き換え完了しました。
参考: 演算子式 (Ruby 3.1 リファレンスマニュアル)
優先順位まわりについては書籍『Rubyで作る奇妙なプログラミング言語』もおすすめです。私はこの本の写経で入門しました。
Ruccola では if 文は case 文のシンタックスシュガーという扱いになっています。「case文が動けば if文はそのサブセット扱いにできるよね」という、大は小を兼ねる的な素朴な発想によるものです。あと Ruccola の前身の mini-ruccola は最初はパーサがなくて構文木を手書きするところからスタートしたため、その都合もあります。
MinRuby では逆になっていて、 case 式は内部的に入れ子の if 式としてパースされます(『RubyでつくるRuby』 p85)。ここは今回『RubyでつくるRuby』を読み返してなるほどと思ったところでした。
Ruccola では現時点では if文の条件式全体を囲む括弧は省略不可です(case文、while文も同様)。また、代入や関数呼び出しなどの文の末尾にはセミコロンが必須です。改行はスペースと同じように字句解析の段階で捨てています。
if (i == 10)
p(1);
end
一方 MinRuby では条件式を囲む括弧や関数呼び出しなどの末尾のセミコロンは省略可能です。改行の考慮が必要そう。
if i == 10
p(1)
end
改行を考慮したパースはこれまで経験がなく不確実な部分でしたが、結論からいえば特別な対応は不要で、適当に改行を読み飛ばすだけで cookpad-hackarade-minruby のテストは通りました(というか試しにやってみたら字句解析の段階で全部捨てても大丈夫だった)。
今の実装では、式とみなせるまとまりの次に二項演算子が来たら「まだ式の続きがあるな」と判断し、そうでなければ「いったんそこで式が終わっているな」と判断する、という動作になっています。
たとえば上のコード例だと i == 10
の次に p
というトークンが来ています。これは二項演算子ではありませんから、ここで式の区切りと判断され、i == 10
と p
以降は別のまとまりだということになります。
i == 10 + ...
^ 二項演算子なのでまだ式が続いていると判断
i == 10 p ...
^ 二項演算子ではないので p からは別のまとまり
あくまで cookpad-hackarade-minruby のテストのカバー範囲と今回作ったパーサの実装の組み合わせでは問題なかったというだけで、Ruby に近づけていこうとするとどこかでちゃんとした対応が必要になってくるでしょうね。
Ruccola では配列リテラルや配列の要素にアクセスするための専用の構文は(まだ)ありません。ハッシュはサポート予定なし。
なので配列・ハッシュまわりは新たに書きました。ここはすんなり書けてしまって特に書くことがありません。
(2023-01-22 追記) 上記2つ完了しました。「これで完璧!」というものではありませんが、とりあえず interp.rb はパースできています。
以上のように自作言語 Ruccola のパーサにいくつかの変更を加えることで MinRuby のパーサを作ることができました(ただし cookpad-hackarade-minruby のテストを通すところまで)。
以前から MinRuby のパーサ作れないかなーとボンヤリ考えていたのでこの機会に実現できてよかったです。
以下、おまけ的な雑多な話題・メモなどです。
再帰下降パーサについては書籍やウェブに解説がたくさんありますので「再帰下降 パーサ」「再帰下降 構文解析」などで調べてください。英語だと recursive descent parser です。
今回作ったものはパーサだけで 500行くらいありますし、再帰下降パーサの一番最初の入門用としてはちょっと大きいかもしれません。そういう場合は分解してちょっとずつ手を付けるのがおすすめです。
数
のあとに , 数
をくりかえし1, 2, 3
数
のあとに 演算子 数
のくりかえし1 + 2 + 3
[[]]
, [[[]]]
のような入れ子の構造上の方でも書いたように、演算子の優先順位は後回しにできますから、先にそれ以外の部分を作ってから後付けで追加するのもよいと思います。
「MinRuby のパーサを書いてみたいけどいきなり全部書くのは無理そう……また今度にしよう」と思った方は、まず次のような簡単なものから手を付けてみるのはどうでしょう。どちらも50行くらいです。
((), (()))
のような入れ子の括弧だけをパースできるパーサ。コード例があった方がよさそうと思って簡単なものを昨日急いで書きました。今回調べていて見つけたもの。
https://speakerdeck.com/m_seki/extend-your-own-programming-language-rubykaigi-2018
MinRuby に末尾呼び出し最適化を実装する話や Rinda + MinRuby の話。
https://matsubara0507.github.io/posts/2019-05-16-minruby-with-patternmatch.html
パーサは「木を組み立てる」処理がメインなのでパターンマッチの出番はそんなにありませんが、評価器は「木を使う」処理がメインなのでパターンマッチを使うといい感じに書けますね。
『RubyでつくるRuby』もそうですが、どの本も古びていない、これからも通用する、使い回しのきく内容だと思います。Ruby に慣れていて言語処理系に興味があるという人におすすめです。
Lisp に興味のある方には mal(Make a Lisp)もおすすめです。ちょうど『RubyでつくるRuby』の Lisp 版といった感じで、簡単な Lisp インタプリタを作ってセルフホストするまでが11ステップに分かれており、テストを通しながら作っていきます。入門者向けのシンプルな実装ですが、マクロや例外機構、末尾呼び出しの最適化まで含まれています。すでに Ruby 版の実装(主な部分は500行程度)もリポジトリに含まれていますから、そのまま写経してもいいですし、自力で書いて詰まったときに参考にすることもできます。
ちなみに私は一度 Ruby で写経した後 LibreOffice Basic で書くということをやりました。
LibreOffice BasicでLispインタプリタ(mal)を書いた - Qiita
入門者向けに作られているものではありませんが、Ruby 関連の小さめの処理系というと PicoRuby がありますね。
https://github.com/picoruby/mruby-pico-compiler
この記事の前日(2022-12-24)に開発者募集されていました。
【お誘い】PicoRubyの開発者を募集しています · picoruby/picoruby Wiki
Ruby よりは小さくて難しくなさそう?? 読めそうだったら参考のためにソース読んでみようかな……と思いつつまだ何もできていません。
Lemon というパーサジェネレータを使ってるんですね。Lemon 知らなかった。SQLite が使っているものだそうです。
mini-ruccola のパーサを Racc と Parslet を使って書いたもの。
自作言語のコンパイラにオレオレアセンブリではなくx86_64アセンブリを生成させる(関数呼び出しと足し算だけ) - Qiita
コンパイラ作ったらやはりこれもやっておきたい、ということで(ちょっとだけ)やったみたもの。RISC-V 版や WebAssembly 版もやってみたい。
Ruby+DXOpalでリレー式論理回路シミュレータを自作して1bit CPUまで動かした - Qiita
言語処理系をやるとさらに下のレイヤーも気になってきますよね? ということでついでに紹介。これも楽しかったです。Ruby(と DXOpal)で作れます!
せっかくのアドベントカレンダーなので今年書いたものも並べてみます。
Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。
また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!
こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?
コメント