RustでC言語コンパイラ実装の途中経過メモ

2025年、学ぶプログラミング言語としてRustにしました。このあたりの話は過去記事に書きました。なんとなくの静的型検査が実装できたところで、一旦中断!(ちょっと飽きてきたのもある)

将来、再開した時の自分宛にメモ書きを残しておく、という日記です。

構文解析までは「Go言語でつくるインタプリタ」を参考にしながら順調に進みましたが、意味解析でASTを辿りながらツリー構造を構築するところでRustの洗礼を浴びました。。

過去記事でインプットは以下の1冊、アウトプットはC言語コンパイラと書いていましたが、

他にも色々参考にさせていただきました。参考資料&書籍は以下のとおりです。

処理の流れ

Rustのファイルの置き方、というか mod の使い方に最初は戸惑った。今でもあんまり分かってない気がする。「プログラミングRust」を参考に、最終的に以下の構成に落ち着いた。

src
├── bin
│   └── foo.rs
├── lexer
│   └── token.rs
├── lexer.rs
├── lib.rs
├── parser
│   ├── ast.rs
│   ├── parser_for_expressions.rs
│   ├── parser_for_external_items.rs
│   └── parser_for_statements.rs
├── parser.rs
├── sema
│   ├── env.rs
│   ├── scope_checker.rs
│   ├── semantic_checker.rs
│   └── type_checker.rs
└── sema.rs
// lib.rs
mod lexer;
mod parser;
mod sema;

pub fn compile(input: &str) {
    let mut parser = create_parser(input);
    let ast = parser.parse_program();
    sema::semantic_analyze(ast)
    // TODO:
}

コンパイラのフェーズごとにモジュールを作り、データを流していく。

  • lexer
    • 入力となるC言語ソースコード文字列 → トークン列
  • parser (構文解析)
    • トークン列 → AST
  • sema (意味解析)
    • scope_checker
      • AST → スコープツリー
    • semantic_checker
      • AST → 文や式の妥当性チェック(トップレベルに break がないか、 return がグローバルにないか、などなど)
    • type_checker
      • AST + スコープツリー → 静的型検査

構文解析

lexer, parserあたりの実装は、「Go言語でつくるインタプリタ」を参考にした。parserの処理はGoと比べてRustだから有利/不利、というのは特に感じなかった。

Rustのenum

ASTの表現はRustの方がしやすいと感じた。enumで代数的データ型を表現できるのがとても強力だった。パターンマッチで網羅性をコンパイル時に担保できる、かつ、Destructureで内部の値を各変数に分配できる。ソースコードがとても簡潔に書けるし読みやすい。

Rust最高!という気分。

Rustで再帰的なデータ構造

「Go言語でつくるインタプリタ」ではASTをExpressionを再帰的に参照するツリー構造で表現している。

type Node interface {
  TokenLiteral() string
}

type Expression interface {
    Node
}

...

type InfixExpression struct {
  Token token.Token
  Left Expression
  Operator string
  Right Expression
}
func (oe *InfixExpression) TokenLiteral() string { return oe.Token.Literal }

...

Rustではデータはスタックに置かれる。そのため、コンパイル時にメモリサイズが分かる必要がある。再帰的なデータ構造だと、どこまで再帰が続くか分からないためサイズが決まらないためコンパイルエラーになる。

pub enum Expression {
  ...
  Infix {
        operator: String,
        left: Expression,
        right: Expression,
  },
  ...
}

left , right をポインタにして、実体はスタックではなくヒープに置くようにすればいい。Rustでは Box<T> を使ってこれを実現できる。実体のサイズは不確定だが、ポインタのサイズは決まっているためスタックに置ける。

pub enum Expression {
  ...
  Infix {
        operator: String,
        left: Box<Expression>, // Expressionそのものではなく、ヒープを指すポインタ
        right: Box<Expression>,
  },
  ...
}

スタックに確保された Box<T> が解放されるタイミングで、ヒープに置かれた実体のメモリも解放される。

意味解析で壁にぶつかる

構文解析までは「Go言語でつくるインタプリタ」と大きな違いはなかったけど、この先が大きく違う。

「Go言語でつくるインタプリタ」ではASTを辿りながら式の評価を行う。ore-c-rustでは、ASTを辿りながら意味解析をする。意味解析では大きく2つのステップに分けてる。

  1. ASTを辿ってスコープツリーを構築し、変数名と型定義のマッピングをする
  2. ASTを辿って型解析をする

これをRustで実装するのが簡単ではなく、Rustコンパイラにたくさん怒られた。所有権と借用チェッカと仲良くならないといけない。

スコープツリーの構築

スコープとASTノード

「Go言語でつくるインタプリタ」では、ソースコード上に登場する変数や関数を格納しておくために Environment というstructを実装してる。 store map[string]Object で変数や関数を管理している。 outer で親スコープを持つ。

type Environment struct {
  store map[string]Object
  outer *Environment
}

func (e *Environment) Set(name string, val Object) Object {
  e.store[name] = val
  return val
}

func (e *Environment) Get(name string) (Object, bool) {
  obj, ok := e.store[name]
  if !ok && e.outer != nil {
    obj, ok = e.outer.Get(name)
  }
}

ASTの各ノードを辿りながら、新たな変数が登場したら Environment に追加していく。変数を取得する際は、まず自分自身から取得し、存在しなければ親スコープから取得することを試みる。

さらに、ASTを評価した際に Function というstructのオブジェクトを作り、このstructに関数スコープの Environment を持たせることで、関数本体を表すASTノードとスコープの関連づけをしている。直感的・シンプルで分かりやすい。

type Function struct {
  Parameters []*ast.Identifier
  Body       *ast.BlockStatement
  Env        *Environment
}

ここまででオブジェクトの関連は以下のようになる。

ある Environment が、 Function と子の Environment と2つのオブジェクトから参照されている。 Function 経由で参照される場合は変数が追加される時である可能性があるため書き込まれる。子の Environment から参照される場合は変数を探す時であるため書き込みはなく読み取りのみとなる。

これをRustでは簡単に表現できない!

借用チェッカ

Rustでは参照は借用と呼ばれ、いつか借り元へ返さないといけない。借用チェッカによっていくつかルールを守っているかどうかコンパイル時にチェックされる。

ルールの1つ:

  • アイテムの所有者に加えて以下のどちらかだけが存在できる
    • そのアイテムへの任意の数の不変参照
    • そのアイテムへの1つの可変参照
  • ただし、両方を持つことはできない

このルールによって所有者の操作も制限される。参照が存在する場合は、所有者であってもアイテムの更新はできない。所有者による更新とは言っても、一時的に可変参照を使って更新すると考えると納得できる(可変参照は1つしか取得できないため)。

上記のことから、先ほどの Environment の表現は子の Environment からの参照が存在する状態で、”関数”からも参照させて Environment に新たな変数を追加したいわけだが、追加するということは可変参照が必要なのだけど、一方で子から親への不変参照も必要。これはRustでは上記のルールによってコンパイルエラーになる。”関数”を Environment の所有者にしたとしても、これもコンパイルエラーで実現できない。

struct Environment<'a> {
  parent: &'a Environment // 不変参照
}
struct Function {
  environment: Environment // 所有
}

Goのように気軽にポインタを繋ぐ設計は破綻。だけど、Rustでもスマートポインタ( RcRefCell )を使うと近いことを実現できる。今回はRust学習のため、使わないことにした。

解決策:ID管理による疑似ポインタ

scope_checker.rsに実装。今回の実装では、 Environment 相当のstructを Scope と命名した。

では、スマートポインタを使わずにASTノードとスコープの関連づけをどうするか?

それは全ての Scope の所有権を持つ管理者( Env )を作り、各 Scope はID(数値)で管理するというアプローチにした。(Arenaパターンと呼ばれるパターンに近いような、そうでもないような)

Scope にIDを用意して、各 Scope では親 Scope のIDを持たせる。そして、 Env というstructを用意して全ての Scope の所有者にする。ASTを辿るときは Env の可変参照を持ち回る。

pub struct Scope {
  pub id: usize,

  // 親Scopeのid
  parent: Option<usize>,

  /// シンボル名と型名を対応表
  entities: HashMap<String, TypeRef>,
}

struct Env {
  scope_table: HashMap<usize, Scope>,
}

impl Env {
  fn find(&self, scope: &Scope, name: &str) -> Option<TypeRef> {
     // 引数の `scope` から、 `name` で指定された変数を探す
    let mut result = scope.find(name);
    ...
    let parent_id = scope.parent; // 見つからなければ `scope` から親ScopeのIDを取得する
    ...
    result = self.scope_table.get(parent_id).and_then(|p| p.find(name)); // `scope_table` から親Scopeを取得して、変数を探す
    // 繰り返し...
  }
}

さらに、ASTノードと Scope の関連づけも Env に持たせる。

struct Env {
  scope_table: HashMap<usize, Scope>,
  node_scope: HashMap<Node, usize>,
}

これでASTを辿りながら Scope を構築できる。同じ要領で、型名と型定義の解決もする。

※ 現状、型のテーブルを Env で管理してしまっているけど Scope で管理するように直すのが正しいはず。そうしないと、Cソースコードで関数内でtypedefを定義するとグルーバルスコープで同名のものが定義されていた場合にコンフリクトしてしまう。。

GoをベースにRustでの実装し直すというは、単なるロジックの移植ではなくメモリ管理とデータの所有権の再設計だった。GCなしでメモリを安全に扱うためにRustコンパイラが助けてくれるというのが少し分かった気がする。

型検査

型検査は「型システムのしくみ」という本で学んだことを実装した → 前回のブログ記事

担当しているのはtype_checker.rs。ASTを辿りながら各式が返す型を評価する。ここで上述のscope_checker.rsで構築したスコープツリーを利用する。ASTノードから Scope を取得 → Scope から変数定義を取得 → 変数定義から型定義を取得 → 取得した型定義を使って型検査する、という処理の流れ。

例えば int a = f(x); というCソースコードの文があったら、左辺の型定義と右辺の型定義を手に入れて、同じ型かどうかチェックする。

本を読むまではとてもハードルが高く感じていたのだけど、「型システムのしくみ」がとても良い本で勉強になったのでありがたい。

おしまい

再開した時の自分宛にメモはここまで!再開して次にやりたいことは、アセンブリを生成したい。

けど、その前にやった方が良さそうなことがあるなぁ。。

  • 型テーブルを Env じゃなくて Scope に持たせる
  • 再起的な関数定義いける?
  • プリプロセッサの対応(特にinclude)
  • printfとか(暫定的に組み込み関数にする?)
  • 中間表現(IR)はスキップで良いかな..

「型システムのしくみ」という本で静的型検査を書くことができた

「型システムのしくみ - TypeScriptで実装しながら学ぶ型とプログラミング言語」という本のおかげで、はじめて静的方検査を実装することができた、という日記です。

型システムのしくみ ― TypeScriptで実装しながら学ぶ型とプログラミング言語www.lambdanote.com

新卒で中小SIerに入社し、そこからプログラミングをやり出しました(かなりの月日が流れ気がつけば私も小学生の父親に...)。色々なことを学習してきた中でコンパイラの実装というものに憧れてきました。

Parserやインタプリタは実装したことはあるけれど、「型検査」というものの実装が自分にとってはハードルが高く、なかなか手をつけられず。

話は変わって2025年になりまして。今年はRustを学んでみたいなー、インプットのための良い本はたくさんありそうだ、アウトプットは何にしよう→「そうだ、RustでC言語コンパイラ実装すれば静的型検査のモヤモヤも払拭できて一石二鳥だ!」という気持ちに。

そんなこんなで2025年4月、ラムダノートから「型システムのしくみ - TypeScriptで実装しながら学ぶ型とプログラミング言語」が出版され即購入!

めちゃくちゃ良かった!!この本がなかったら多分挫折してました。出会えて良かった、著者の方、出版社の方、ありがとうございます!

(実装中のRustでCコンパイラはまだまだ実装できてないことがたくさんあるのだけど...)

どんな感じの本か?

ソースコードである文字列を入力し、型検査の結果がOKかNGかを出力する型検査器をTypeScriptで実装していくスタイルです。実際に手を動かしながらちょっとずつ作っていけるのがとても好みです。

例えば、以下の入力

1 + 1

に対する出力はOKだけど、

1 + true

に対する出力はNGとなります。number型とboolean型を加算しているためNGです。

入力されたソースコード構文解析してASTを構築するところまでの実装は書籍から提供されているため、読者は型検査に集中できるようになってます。

書籍ページの目次を見てもらうと分かりますが、関数型、オブジェクト型、ジェネリクス型など段々と複雑な型検査を実装していくようになっています。

おしまい

型検査に興味あるけどとっかかりがないんだよなぁと感じてる方がいたら、ぜひ読んでみてはどうでしょうか。オススメです。

Rustで手書きC言語パーサー

達人プログラマーにこう書かれています。

毎年少なくとも言語を1つ学習する。 言語が異なると、同じ問題でも違った解決方法が採用されます。いくつかの異なったアプローチを学習すれば、思考に幅が生まれ、ぬかるみにはまる自体を避けられるようになります。

達人プログラマー P.20 より

今年の学習言語はRust!

インプットの軸として何が良いのかなと検討したところ、オライリーの『プログラミングRust 第2版』を読むのが良さそう。読みつつ何か作りながら学ぼうと思いました。 id:t-wada さんもオススメしていたし!

そして、「そういえば以前からC言語コンパイラを作ってみたかった...」ということで、RustでC言語コンパイラを作りにチャレンジしてみることにしました。という日記です。

Parserが動いた

Rustを書いていて面白かったのは、所有権とmoveの概念。move後の変数を使おうとするとコンパイルエラーになるのは面白い!本を読んでいるときは難しそうに感じたけど、実際にコードを書くとそこまで困らず、むしろ良いコードを書くのをRustコンパイラが助けてくれる感覚があって心強い。

さて、C言語コンパイラ実装の道のりはまだまだあるけど、なんとなく動くParserはできました。

コードはこちら↓

GitHub - kariyayo/ore-c-rust

勉強用なのでパーサージェネレータは使わずにパーサーを手書きしました。「Go言語でつくるインタプリタ」という本で紹介されているPratt構文解析の実装を参考にしています。

例えば以下のお試しCコード(上記リポジトリのsample.c)。

int main() {
    int s = 1 + 2;
    return 0;
}

実行( $ cargo run --package ore_c_rust --bin parse )すると、以下のASTになる。

Program {
  external_items: [
    FunctionDecl {
      return_type_dec: Named(
          "int",
      ),
      name: "main",
      parameters: [],
      body: Some(
        Block(
          [
            VarDecl(
              [
                (
                  Named(
                    "int",
                  ),
                  Declarator {
                    name: "s",
                    value: Some(
                      InfixExpression {
                        operator: "+",
                        left: Int(
                          1,
                        ),
                        right: Int(
                          2,
                        ),
                      },
                    ),
                  },
                ),
              ],
            ),
            Return(
              Some(
                Int(
                  0,
                ),
              ),
            ),
          ],
        ),
      ),
    },
  ],
}

悩んだところ

C言語Switch文のfallthrough

そこまで困らずと書いたものの、困ったところもあった。C言語のswitch文。

switch (x) {
    case 1:
        printf("One\n");
    case 2:
        printf("Two\n");
        break;
    case 3:
        printf("Three\n");
        break;
}

最初、ASTの構造体は以下のようにしていた。 enum Statement が文を表すASTのノード。

pub enum Statement {
    Return(Option<Expression>),
    If { condition: Expression, consequence: Box<Statement>, alternative: Option<Box<Statement>> },
    ...
    Switch { condition: Expression, arms: Vec<SwitchArm> },
    ...
}

pub enum SwitchArm {
    Case(Vec<Expression>, Vec<Statement>),
    Default(Vec<Statement>),
}

Statement::Switchcondition がCコードの switch (x)x を表して、 arms がCコードの各 case ... 部分を表す。

ところが、以下のようなfallthroughに対応しようとして困った。

switch (x) {
    case 1:
    case 2:
        printf("One or Two\n");
    case 3:
        printf("Three\n");
        break;
}

この場合、 printf("Three\n"); break; は、 case 1 , case 2 , case 3 いずれでも実行される。

つまり、 printf("Three\n"); を表す Statement は、複数の SwitchArm から参照されることになる。ここで出てくるのがRustの所有権。

私が普段使ってる他の言語だったら、Statementオブジェクトへの参照を複数のSwitchArmで持つ実装をしたと思う。そして、 Statementの参照カウントがゼロになるとGCによりメモリからStatementが回収される。

Rustではmoveがあり所有者は1つ。Rustでも Rc 型 で参照カウント方式を使えるが、なんとなくRustらしくないのかなと思って避ける方法を考えた。

解決策

考えた結果、以下のような表現にした。fallthroughする場合でも Statement の所有者は SwitchBlock のみであり、 SwitchBlock はswitchブロック内の Statement を全て持つ。また、 SwitchLabelEntry でcaseごとに「どのインデックスから実行するか」を管理する。

pub enum Statement {
    ...
    Switch { condition: Expression, switch_block: SwitchBlock },
    ...
}

pub struct SwitchBlock {
    pub label_entries: Vec<SwitchLabelEntry>,
    pub body: Vec<Statement>, // case節共通の文リスト
}

pub struct SwitchLabelEntry {
    pub labels: Vec<SwitchLabel>, // case 1: case 2: や case 3: を表す
    pub start_index: i32,         // body内のどこから実行を始めるか
}

pub enum SwitchLabel {
    Case(Expression),
    Default,
}

さきほどのfallthroughするコードをparseすると、以下のようになる。

Switch {
  condition: Identifier(
    "x",
  ),
  switch_block: SwitchBlock {
    label_entries: [
      SwitchLabelEntry {
        labels: [
          Case(
            Int(
              1,
            ),
          ),
          Case(
            Int(
              2,
            ),
          ),
        ],
        start_index: 0,
      },
      SwitchLabelEntry {
        labels: [
          Case(
            Int(
              3,
            ),
          ),
        ],
        start_index: 1,
      },
    ],
    body: [
      ExpressionStatement(
        FunctionCallExpression {
          function_name: "printf",
          arguments: [
            StringLiteral(
              "One or Two\\n",
            ),
          ],
        },
      ),
      ExpressionStatement(
        FunctionCallExpression {
          function_name: "printf",
          arguments: [
            StringLiteral(
              "Three\\n",
            ),
          ],
        },
      ),
      Break,
    ],
  },
},

Rc 型を使わずにfallthroughに対応できた!これが良い設計かどうかは正直まだ自信がない……誰か教えてください 🙏

おしまい

まだ対応できてないことがありつつも、Parserがなんとなく動くところまでできました!

さて、次は意味解析。...なのだけど、Parserを手書きした時点で『プログラミングRust』を読む目的はそこそこ満たされてしまった感がある。

実装がこの先進むかどうか、少し不安。。

(まだ読んでない19章「並列性」、20章「非同期プログラミング」も楽しみ)