vm2gol v2 追加機能など

追加機能など

main ブランチにはマージせずにブランチで個別に試しています。

リファクタリング

セルフホスト

いくつか機能を追加するとセルフホストできるようになります。

パーサ

他のターゲット向けに出力

vm2gol v2 (62) case 文の構文を変更 / standardrb に変更



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;
      }
    }
  }
}

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

standardrb に変更

RuboCop を導入したはいいものの、微妙に放置気味でしたし、 この程度の規模のプロジェクトには大げさな感じがして、Standard (standardrb) に変えました。

メモ 2024-01-08 / C++ の try

コーディングを支える技術――成り立ちから学ぶプログラミング作法』によれば、C++try { ... } catch { ... } の try は飾りなのだそうです(p68)。

C++設計者の Bjarne Stroustrup によれば、try はわかりやすくするためのただの飾りだそうです。

詳しくは『C++の設計と進化』を読むと良いようです。



vm2gol v2 (60) stmt をそのまま渡す / (61) リファクタリング



step 60

stmt_rest ではなく stmt を渡す

前々から気にはなっていたけど後回しにしていたものを片付けるシリーズです。

例として 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)
  # ...

○○文を処理するメソッドに○○文のデータをそのまま渡すようになりました。やっぱりこの方が単純で分かりやすいように思います。

トップレベルでのVMコメントの廃止

トップレベルで _cmt を使えるようになっていましたが、そうする必要がなかったので廃止。

==, != をそのまま使う

パース時にそれぞれ eq, neq に変換していましたが、 あらためて考えると不要な変換と思えたので +, * と同様に ==, != とするように。

さらにリファクタリングすれば parse_expr_right をコンパクトにできる状態になっていますが、今回の差分が分かりにくくなるので次回に先送りします。

Token#kind にリネーム

Token#type から Token#kind に変更しました。

他の処理系や解説などを見ていると、これは kind という名前にしているものが多いようなんですよね。 いわゆる型と混同しないように type という名前を避けているのか、単に慣習的なものなのか、はたまた英語的に kind の方が適切なのか……みたいな推測をしてますが、はっきりした理由は把握していません。

step 61

気になっていた細かい部分をまとめて修正しました。 リファクタリングのみなのでページを分けずに続けて書きます。

step 61 の差分はこちら


変更後の行数はこんな感じ。

$ 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

すっきりしました。

vgcodegen.rb にリネーム

cg という名前を見て code generator の略だとすぐ分かる人はいなさそう、このように略している例を見ない、ということで、一般的ですぐ分かる名前に変更。

変数名・メソッド名などのリネーム、コメントの整理

気になっていた箇所をまとめて修正。



vm2gol v2 (59) while, case の真偽判定の変更 / 予約語の判定の改良



真偽判定の変更(コード生成)

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 版にフィードバックしたものです。

ちなみに、このように書けるのは 予約語のパターンが識別子のパターンに含まれるためですね。 その前提が崩れるとダメですが、まあ、問題ないでしょう。

その他

  • パーサ: loop を while で書き換え
  • トークンの値を取り出す処理を Token#get_value に抽出