Qiita に書きました。
日本語プログラミング言語「なでしこ」 Advent Calendar 2021 の 13日目です。
main ブランチにはマージせずにブランチで個別に試しています。
true
, false
func
を def
にして、 { ... }
をやめて end
で閉じるようにしたものf1(f(2))
のように関数呼び出しを入れ子にしたり、 while (f()) { ... }
のように while文や case文の条件式で関数呼び出しを使えるようにしたものfn_arg_names
, lvar_names
を愚直に渡していたところを context オブジェクトとしてまとめて渡す形に書き直してみたもの。いくつか機能を追加するとセルフホストできるようになります。
Qiita に書きました。
Qiita に書きました。
変更前はこうでした。
case { (current_val == 0) { case { (count == 3) { set next_val = 1; } } } (0 == 0) { case { (count == 2) { set next_val = 1; } (count == 3) { set next_val = 1; } } } }
case { ... }
の波括弧はなくてもよさそうですね。なくてもパースできそう。外してみましょう。
case (current_val == 0) { case (count == 3) { set next_val = 1; } } (0 == 0) { case (count == 2) { set next_val = 1; } (count == 3) { set next_val = 1; } }
ネストが減り、 }
がなくなることで行数も減ってすっきりします。
パースの都合としてはこれでも問題ないのですが、各分岐の先頭にキーワードを置いた方が見た目的に落ち着きがいいかも?
when
を書くようにしてみます。
case when (current_val == 0) { case when (count == 3) { set next_val = 1; } } when (0 == 0) { case when (count == 2) { set next_val = 1; } when (count == 3) { set next_val = 1; } }
ふむ、良いのでは。ということで今回このように変更しました。
ちなみに、なぜ最初からこの形にしていなかったのかというと、
多少冗長でも case { ... }
のように波括弧があった方が case 文の範囲が分かりやすくて良いのではないか(パース処理で面倒なことを考える必要が減るのではないか)という、初心者の素朴な直観みたいなものがあったためです。
実用を目指しているわけではなく(自分の)教育用を想定した言語なので、そういう初心者目線の感覚は大事にしたい……という気持ちもあってちょっと悩んだのですが、実装も少しだけシンプルになりますし、変えてしまいました。
パーサの変更はこれだけ:
--- a/vgparser.rb +++ b/vgparser.rb @@ -258,6 +258,7 @@ def parse_while end def _parse_when_clause + consume "when" consume "(" expr = parse_expr() consume ")" @@ -272,16 +273,12 @@ end def parse_case consume "case" - consume "{" - when_clauses = [] - while peek().value != "}" + while peek().value == "when" when_clauses << _parse_when_clause() end - consume "}" - [:case, *when_clauses] end
RuboCop を導入したはいいものの、微妙に放置気味でしたし、 この程度の規模のプロジェクトには大げさな感じがして、Standard (standardrb) に変えました。
『コーディングを支える技術――成り立ちから学ぶプログラミング作法』によれば、C++ の try { ... } catch { ... }
の try は飾りなのだそうです(p68)。
C++設計者の Bjarne Stroustrup によれば、try はわかりやすくするためのただの飾りだそうです。
詳しくは『C++の設計と進化』を読むと良いようです。
前々から気にはなっていたけど後回しにしていたものを片付けるシリーズです。
例として gen_call
を見てみます。
def gen_call(fn_arg_names, lvar_names, stmt_rest) fn_name, *fn_args = stmt_rest # ... end # 呼び出し側 def gen_stmt(fn_arg_names, lvar_names, stmt) stmt_head, *stmt_rest = stmt case stmt_head when "call" gen_call(fn_arg_names, lvar_names, stmt_rest) # ...
stmt
(文に対応するデータ)の先頭の要素は文の種類を表しているわけですが、
「これは○○文だな」と判断するためにその先頭要素を使っているのは呼び出し側であって、
呼び出した先では不要です。
不要なものは渡さなくていいんじゃないか、という(ぼんやりした)理由により
このような作りになっていました。
しかし、だんだん不都合の方が気になってきました。
stmt_rest
ってなんやねん問題
というわけで、先頭を除いた残りだけ渡すのをやめ、 文のデータ全体をそのまま渡すようにしました。 渡された側では単純に先頭の要素を無視することに。
def gen_call(fn_arg_names, lvar_names, stmt) _, *funcall = stmt # ... end def gen_stmt(fn_arg_names, lvar_names, stmt) case stmt[0] when "call" gen_call(fn_arg_names, lvar_names, stmt) # ...
○○文を処理するメソッドに○○文のデータをそのまま渡すようになりました。やっぱりこの方が単純で分かりやすいように思います。
トップレベルで _cmt
を使えるようになっていましたが、そうする必要がなかったので廃止。
パース時にそれぞれ eq
, neq
に変換していましたが、
あらためて考えると不要な変換と思えたので
+
, *
と同様に ==
, !=
とするように。
さらにリファクタリングすれば parse_expr_right
をコンパクトにできる状態になっていますが、今回の差分が分かりにくくなるので次回に先送りします。
Token#type
から Token#kind
に変更しました。
他の処理系や解説などを見ていると、これは kind という名前にしているものが多いようなんですよね。 いわゆる型と混同しないように type という名前を避けているのか、単に慣習的なものなのか、はたまた英語的に kind の方が適切なのか……みたいな推測をしてますが、はっきりした理由は把握していません。
気になっていた細かい部分をまとめて修正しました。 リファクタリングのみなのでページを分けずに続けて書きます。
変更後の行数はこんな感じ。
$ LANG=C wc -l vg*.rb 66 vgasm.rb 381 vgcodegen.rb 58 vglexer.rb 380 vgparser.rb 447 vgvm.rb 1332 total
step 60 の変更により変換処理が不要になったので case を削除。
修正前:
def parse_expr expr = _parse_expr_factor() while binary_op?(peek()) op = case peek().value when "+" then "+" when "*" then "*" when "==" then "==" when "!=" then "!=" else raise ParseError, "must not happen" end $pos += 1 expr_r = _parse_expr_factor() expr = [op.to_sym, expr, expr_r] end expr end
修正後:
def parse_expr expr = _parse_expr_factor() while binary_op?(peek()) op = peek().value $pos += 1 expr_r = _parse_expr_factor() expr = [op.to_sym, expr, expr_r] end expr end
すっきりしました。
cg
という名前を見て code generator の略だとすぐ分かる人はいなさそう、このように略している例を見ない、ということで、一般的ですぐ分かる名前に変更。
気になっていた箇所をまとめて修正。
while 文と case 文では 条件式の評価結果を 1 と比較して真偽を判定していました。 疑似コードで書くとこう。
# 修正前 if 条件式の評価結果 == 1 真と判定 => while_true ラベルにジャンプ else 偽と判定 => end_while ラベルにジャンプ(ループを抜ける) end label while_true ループの処理本体 label end_while
この場合、たとえば条件式の評価結果が 2 だった場合も偽になってしまいます。 ちょっと違和感がありますね。
というわけで、次のように 0 と比較する形に変更しました。
# 修正後 if 条件式の評価結果 == 0 偽と判定 => end_while ラベルにジャンプ(ループを抜ける) else 真と判定 => while_true ラベルにジャンプ end label while_true ループの処理本体 label end_while
この方が整数から真偽値へのマッピングのルールとして筋が良い気がします。少なくとも C や JavaScript などに近い挙動にはなります。
さらに、この修正により、真だった場合の while_true
ラベルへのジャンプが不要(すぐ次のラベルにジャンプしている)になります。これも消してしまえるので、最終的に次のようになりました。
if 条件式の評価結果 == 0 偽 => end_while ラベルにジャンプ(ループを抜ける) end ループの処理本体 label end_while
コンパイラのコードもコンパイラが生成するコードもちょっとだけコンパクトに。
予約語と識別子を切り出している箇所(修正前)です。
while pos < src.size rest = src[pos .. -1] case rest # ... # 予約語の判定 when /\A(func|set|var|call_set|call|return|case|while|_cmt|_debug)[^a-z_]/ str = $1 tokens << Token.new(:kw, str) pos += str.size # ... # 識別子の判定 when /\A([a-z_][a-z0-9_]*)/ str = $1 tokens << Token.new(:ident, str) pos += str.size else # ... end end
この、予約語用の正規表現の [^a-z_]
の部分がなんか微妙で、なんとかできないかなという気持ちが前からありました。
そこで、次のように修正しました。
--- a/vglexer.rb +++ b/vglexer.rb @@ -1,5 +1,10 @@ require_relative "./common" +KEYWORDS = [ + "func", "set", "var", "call_set", "call", "return", "case", "while", + "_cmt", "_debug" +] + def tokenize(src) tokens = [] @@ -19,10 +24,6 @@ def tokenize(src) str = $1 tokens << Token.new(:str, str) pos += str.size + 2 - when /\A(func|set|var|call_set|call|return|case|while|_cmt|_debug)[^a-z_]/ - str = $1 - tokens << Token.new(:kw, str) - pos += str.size when /\A(-?[0-9]+)/ str = $1 tokens << Token.new(:int, str.to_i) @@ -33,7 +34,8 @@ def tokenize(src) pos += str.size when /\A([a-z_][a-z0-9_]*)/ str = $1 - tokens << Token.new(:ident, str) + type = KEYWORDS.include?(str) ? :kw : :ident + tokens << Token.new(type, str) pos += str.size else p_e rest[0...100]
曖昧さが減り、安定感のあるコードになったと思います。パターンの考慮漏れについての不安が小さいコードになったというか。
これは正規表現が使えない(標準ライブラリ等に含まれていない)言語に移植していたときに思いついた方法で、それを Ruby 版にフィードバックしたものです。
ちなみに、このように書けるのは 予約語のパターンが識別子のパターンに含まれるためですね。 その前提が崩れるとダメですが、まあ、問題ないでしょう。
Token#get_value
に抽出