世界を動かす技術を、日本語で。

500行のPythonでCコンパイラを書く (2023)

概要

  • Python で500行以内の Cコンパイラ を作成した挑戦記
  • 単一パス で構文解析とコード生成を同時に実施
  • WebAssembly をターゲットとした独自設計
  • いくつかのC言語機能を 省略 しつつ、主要機能を実装
  • 補助クラス や構造についても解説

500行Python製Cコンパイラ開発記

目標と概要

  • Python500行 以内にCコンパイラを実装する試み
  • 省略可能な機能を削り、 シンプル かつ 理解しやすい 設計を目指す
  • ブログ記事内で全コードは紹介できないため、 設計判断削除した機能アーキテクチャ の概要を解説

単一パスコンパイラの選択

  • 単一パス 方式を採用し、構文解析中に即時コード生成
  • 構文木(AST) を生成しない設計
  • 一般的なコンパイラは「構文木生成→コード生成」の 2パス 構成
    • 解析とコード生成を分離しやすい
    • 最適化中間表現 の導入が容易
  • 500行制限のため、 解析と生成を統合
    • 例: ~演算子 のパース時に即時WebAssembly命令を出力
    • 構文木ノード や中間表現は未使用

WebAssemblyをターゲットに選定

  • WebAssembly を出力対象とした理由は興味本位
  • WebAssembly はC言語向きではなく、独自の難しさ
    • goto非対応、代わりに block/break構造 を利用
    • レジスタ非搭載スタックマシン 方式
      • Cの スタック とWASMのスタックは別管理が必要
      • 自前で メモリ上のスタック を用意
  • x86/ARM等の従来ISAより 複雑化 した可能性

エラーハンドリング

  • die関数 による簡易的なエラー処理のみ
  • 異常時は スタックトレース と曖昧なエラーメッセージを出力
  • Rustコンパイラ のような高機能エラー表示は非対応

実装しなかった機能

  • structenumunionプリプロセッサ浮動小数点型long/double
  • 多次元配列前置/後置インクリメントキャスト式標準ライブラリやI/O
  • typedefポインタ 等、C言語実装の肝となる部分は維持

実装済み機能

  • 算術演算子優先順位 の正しい処理
  • int/short/char型文字列定数 (エスケープ対応)
  • 多重ポインタ正しいポインタ演算
  • 一次元配列関数定義・呼び出し
  • typedef 対応(lexer hack活用)

テストケースとサンプルプログラム

  • c-testsuite の34/220テストをパス
  • swap関数フィボナッチ関数 等、実用的なCプログラムも実行可能

補助クラス群の概要

  • Emitter

    • WebAssemblyコードの 整形出力 を担当
    • インデントや no_emit メソッドによる出力制御
  • StringPool

    • 文字列定数を 連続領域 にまとめて管理
    • 既存文字列の再利用や アドレス管理 を実装
    • 最終的に rodataセクション として出力
  • Lexer

    • C言語の トークン分割 を担当
    • peek/next/try_next による柔軟なトークン取得
    • lexer hack でtypedefや型名判定を実現
      • 型情報を動的に Lexer へフィードバックし、型名/変数名の区別を管理
  • CType

    • C言語型( intshort など)の情報を管理するデータクラス
    • ポインタレベル配列サイズ の情報も保持
    • 多次元配列 は非対応
  • FrameVar/StackFrame

    • C言語の スタックフレーム管理 を担当
    • WASMのスタックが使えないため、 独自のメモリスタック を構築
    • 各関数ごとに ローカル変数・引数 のスロット割当と スタックサイズ計算

まとめ

  • 500行制限 の中で、C言語の本質的な機能を Python で実装
  • 単一パスWebAssembly対応 など、制約下での工夫が満載
  • 構文木や最適化を省略しつつ、 実用的なCサブセット をコンパイル可能
  • 補助クラス もシンプルながら必要十分な設計

Hackerたちの意見

500行のCでPythonコンパイラを書いてみて。

バイトコードを処理するPython VMは、そんなに馬鹿げた量のCで作れるかもしれない。500行は無理だと思うけど、管理できる範囲ではあるんじゃないかな?特に古いリリースをターゲットにすれば。

多分できると思うけど、君は気に入らないだろうな。辞書はリンクリストにして、キーを探すのは線形探索になる...(C++をもらえたらstd::mapを使うけど)C標準ライブラリを使うのは許してくれるよね?もしその500行のCでstrlenやmallocを実装しなきゃならないなら、ちょっと無理かも。500行は厳しいけど、IOCCCのおかげで行数を減らすためのトリックがたくさんあるし、言語自体はそんなに大きくない。100%有効なPythonコードが与えられる前提で進めるけど、もしバグやエラーがあったらそれは未定義の動作だからね。Pythonの素晴らしさの大部分は言語そのものじゃなくてライブラリにあると思う。Pythonライブラリの大部分もCで書かれてるはずだから(速度のために)、有用なライブラリがないから、私の500行のPythonコンパイラは役に立たないだろうね。

その手の人になりたくはないけど、Pythonはインタプリタ言語だよね。とはいえ、技術的にはPythonを実行可能なファイルにコンパイルするものを作ることはできるかも?結局、ここはハッカーニュースだし。

たぶん、500行くらいのPythonicでマクロが多めのCかな。マクロのLOCがカウントされないなら、だけど。

以前: 500行のPythonでCコンパイラを書く - https://news.ycombinator.com/item?id=37383913 - 2023年9月(165件のコメント)

この記事はCコンパイラをAVR向けに自分で書けるかもって思わせるくらい分かりやすい。実際にできるかもしれないけど、簡単ではないだろうな。コンパイラの仕組みをちゃんと調べたことはなかったけど、意外と言語学と似てる部分があるね。

それは、チョムスキーが形式文法の理論を発明したときに自然言語と抽象文法の普遍性を研究していたからだよ。コンピュータ科学者たちは後に、プログラミング言語の文法構造を形式化するために同じ理論を使えることに気づいたんだ。¹https://en.wikipedia.org/wiki/Chomsky_hierarchy

「コンパイラは以前から動いていて、言語学と驚くほど似ている/関連している。」コンパイラは明確に定義された文法を持つ言語を変換するから、言語学とのつながりは意外と驚くべきことじゃないかもね。

DNA/ゲノム分析でも似たような経験があるよ。DNA分析の大部分はパーサー理論に基づいてた。この論文がDNA分析とチョムスキー階層の入門だったんだ:https://www.jstor.org/stable/29774782(無料のコピーは見つけられなかったけど)。確か、RNAの擬似ノットは文脈自由文法を必要とするんだよね。

Cのサブセットコンパイラで、たった500行くらいのC4を勉強してみるといいよ。面白いのは、自分自身をコンパイルできるところだね。: https://news.ycombinator.com/item?id=8558822

ぐるっと回ってきたね。

単一パスコンパイラが、従来のレキサー→パーサー→AST→エミッターよりも実装が簡単だなんて、ちょっと驚きだね。(コンパイラの専門家じゃないけど。)ASTを生成するのも同じくらい簡単だと思ってたし、むしろ簡単かもしれないと思ってたよ。それに、ASTを生成すれば、簡単な最適化もやりやすいしね。ASTの一部をパターンマッチして、より効率的なものに置き換えられるから。まあ、考えすぎかもしれないけど。拡張性のあるプログラムデザインが好きなんだよね、プログラムの規模に合ってなくても… でも、この記事は本当にクールだし、プロジェクトもすごいよ。特にStringPoolの技術が気に入った!もしコンパイラを書くことがあったら、覚えておくつもり!

LOCが少ないからって、必ずしも簡単だとは限らないよね!

これは書いている言語によるかもね。歴史的に見ると、Pythonでデータ型を定義するのは、コンパイラを書くために設計された言語に比べてかなり冗長だと思う。これが僕のプロトタイプBicicletaインタープリタからの定義なんだけど、ML、特にOCamlで書いてるよ: type methods = NoDefs (* 名前、ボディ、位置指定 ... *) | Definition of string * bicexpr * bool * methods and bicexpr = Name of string | Call of bicexpr * string | Literal of string option * methods | Derivation of bicexpr * string option * methods | StringConstant of string | Integer of int | Float of float | NativeMethod of (lookup -> bicobj) この10行のコードは、Pythonでは平均1.6属性のクラスが10個になるんだ。データクラスやattrsを使うと、36行のコードになるし、(もしオブジェクト指向的にやるなら)これらのOCaml型の各関数は特定のプロトコルを実装する各クラスのメソッドになるから、引数のシグネチャが各クラスにコピーされることになる。 (namedtupleを使った場合も、コードの行数は変わらないけど、行数は少なくなる。)例えば、このbicexprsの関数は let rec freevars = function Name n -> stringset [n] | Integer _ | StringConstant _ | Float _ -> stringset ["prog"] | NativeMethod _ -> stringset [] | Literal (Some selfname, methods) -> StringSet.diff (freevars_methods methods) (stringset [selfname]) | Literal (None, methods) -> freevars_methods methods | Derivation(object_, self, methods) -> StringSet.union (freevars object_) (freevars (Literal(self, methods))) | Call(object_, ) -> freevars object これが異なるクラスで6〜8個のメソッド定義になるんだ。(定数クラスのために抽象基底クラスを定義すれば、6個に減らせるけど。)それに、Literal.freevarsにはif-then-elseが必要だから、さらに20行のコードが増える。Pythonは今、パターンマッチングをサポートしてるから、こういう関数はMLのバージョンと同じようにプログラムすれば、冗長にはならないかもしれない。Pythonのパターンマッチングは試したことがないから、よくわからないけど。一般的には、Pythonはこの手のことに関してML系言語よりも約2〜4倍冗長だと思うし、それはMLの型チェックが余分なコードなしで正しさに対する信頼を与えてくれる前の話だよ。僕の知る限り、MypyはMLコンパイラが行うようなパターンマッチングの網羅性チェックはしてないんだ。時々、Pythonでこういうコードを書くために通常のタプルを使って「ズル」をしたこともあるけど、うまくいくこともあるけど、デバッグが本当に大変なんだ。 Andy Chuの言葉を引用すると、

「Pythonは言語を[実装する]のに適した言語ではない。」 次のプロジェクトではOCamlを使うつもりだよ。PythonにはGCと動的ディスパッチがあるから、それは大きなポイントだけどね。

これは言語によると思う。Turbo Pascalはかなり速いって聞いたことがあるけど、1) Pascalの言語機能、2) TP 1.0には最適化がないから、らしいよ。

単一パスコンパイラは、最適化をしないから実装が簡単なんだ。アイデアのスケッチを急いで書いてるから、製品利用は気にしないか、70年代や80年代にタイムトラベルして、プロセッサがすごく遅くてメモリがめちゃくちゃ高価だった時代に戻ってきたから、全てのソースファイルを一度にRAMに読み込むことすらできないかもしれないし、その後に中間表現に変換してから機械コードを書くなんてことはできなかったかもしれないね。

Cは単一パスコンパイラでコンパイルされるように設計されてるんだ。

あのグラフィック、めっちゃ好き!いろんな情報が詰まってて、コンパイラの可愛らしい描写だね。

他の言語で何年もプログラミングしてきたけど、やっとCを学んで気づいたことがあるんだ。実はCの仕様を完全に実装しているコンパイラってないんだよね。GCCやClangでもグレーな部分やバグがあるし。前はCはシンプルな言語だと思ってたけど、こういう記事や、ほぼすべての組み込みシステムにCコンパイラがあるっていうよく言われる事実がその考えを支えてたんだ。このポイントは、ブログの一部で「Cヘッダーを実際にパースできない」と書かれていて、強調されてた。ブログはその主張をよくサポートしてるよ。彼らはこんな論文をリンクしてるんだ: > Cをパースできる商業用や学術用のツールはたくさん存在する.... 残念ながら、これらのパーサは古いバージョンの言語(C89など)用に設計されていたり、単に間違っていることが多い。GCCやClangにあるC11パーサはおそらく正しいけど、そのサイズは数万行に及ぶ。実際、リンクされたブログ記事でも、彼らは言語のサブセットしか実装していないって言ってる。もちろん、教育ツールとしての価値はあるけど、これはCについて話したかったちょっとした事実なんだ。[0]: https://faultlore.com/blah/c-isnt-a-language/#you-cant-actua... [1]: https://hal.science/hal-01633123/document

これは、K&RのC本が全てだと思ってる人たちが広める神話だね。ISO CのドラフトPDFを開いたこともないし、POSIXと標準ライブラリの違いも知らない、GCCや最近のClang以外でコードをポータブルにしようともしてないし、コンパイラの拡張章もチェックしてないんだ。

最初のリンクは、若い怒りに満ちてて(ちょっと恥ずかしい部分もあるけど)、Cが抱えている膨大な技術的負債を認識していないね。Cは50年前にPDPで書かれた言語なんだ。例えば、著者は整数のサイズについて怒ってるけど、真剣なCプログラマーは曖昧なサイズの型なんて使わないよ。確かにCには問題がある。たくさん。でも、私たちの技術の驚異の礎なんだ。周りのすべてはCで動いてるし、宇宙のすべてもCで動いてる。今のアプリケーションにはもっと良い選択肢がある?もちろん。Cは消えるの?いや、そんなことはない。

Cは機能が増えすぎてるし、C++はその追加機能がさらに増えたCだね。最初のCコンパイラはPDP-11で動いてて、通常は数キロバイトのRAMしかなかった。文法はそんな限られたリソースでコンパイルするために書かれてるから、ヘッダーやプリミティブ、セミコロン、リンカーが必要なんだ。時間が経つにつれてかなり変わったけど、荷物を減らすんじゃなくて、増やしてる感じだね。

[0]: https://faultlore.com/blah/c-isnt-a-language/#you-cant-actua... このブログ記事は誤解だらけだね。最初に「CはABIを定義している」と主張して、その後「すべてが複雑すぎる」と文句を言ってるけど、実際にはCはABIを定義してないんだ。Cは抽象Cマシンの動作に基づいて定義されてるから、Cに関してはABIは存在しない。Cは言語の意味を規定するだけで、実際にどう実装するかは指定してない。コンパイラは自由にやっていいし、ABIを選ぶこともできる。ABIを定義するのは、マシンとOSから成るプラットフォームだよ。ここでのOSは、少なくともカーネル(またはいくつかのベアメタルプリミティブ)とコンパイラを含む。そして、標準Cライブラリは実際にコンパイラの一部なんだ。だから、GCCとClangやglibcとmuslcの間には常に互換性の問題が出てくる。これらは異なるOSだからね。同じように動作することもできるけど、それは何らかの正式な(POSIXやプラットフォームベンダー)または非公式な(GCCやClang)基準によるものだ。確かに多くのABIはCの構文で定義されているけど、これはCがそのための用語を持っていて、あまり新しくないからだ。好きな言語でこれを指定すれば、同じABIを説明できるよ。そう、intはプラットフォームに依存したサイズを持ってない。でも、仕様がCを構文として使わなければ、「この引数は『プラットフォームのデフォルト整数サイズ』の『付録X定義』に記載されているサイズと同じです」と書くだけになる。つまり「int」と書くのは、正確にこれを短く表現しただけなんだ。

Cヘッダーを実際にパースすることはできない Cヘッダーをパースするのにコンパイラを使う選択が悪いこととして捉えられる理由がわからない。カーネルのwrite(2)に頼るのが悪いことなの?カーネルをバイパスしようとするよりはいいと思うけど。コンパイラがヘッダーの意味を定義してるんだから、結果について聞いてみればいいじゃん。Cプリプロセッサを再実装したくないなら、プリプロセスされたヘッダーをパースすることもできるよ。これは自己完結してるから、インクルードパスを知ってる必要もない。ただし、このアプローチには注意が必要で、ユーザーがCコンパイラをアップデートしたら、あなたの知識は古くなったり間違ったりする可能性がある。CコードをパースするのにCパーサーが必要だというのが変だとされる理由がわからない。これがCパーサーの定義だよ。Cをパースするコードを書いて、なんかCパーサーじゃないってことはできないよ。

176トリプル 最初は視覚的なギャグのために全部含めるつもりだったけど、実際にはそれが多すぎる。いや、176のターゲットトリプル(これはLLVMの用語で、他の用語は「gnuタプル」や「gnuタイプ」)しか、あなたのツールがサポートしてないんだ。明確なリストもないし、プラットフォームの主要なコンポーネントを説明するための構文なんだ。数十年にわたってベンダーが互換性のない方法でプラットフォームを改善してきたから、その説明は当然ごちゃごちゃしてる。例えば、これを見てみて: https://news.ycombinator.com/item?id=43698363 そして、これはGNUタイプのソースのテストデータだ: https://cgit.git.savannah.gnu.org/cgit/config.git/tree/tests... これには1180のタイプが含まれてるけど、もちろんそれも確定的ではない。

pub type intmax_t = i64; 多くのコードはCをループに入れることを完全に諦めて、コアタイプの定義をハードコーディングし始めてる。結局、これらは明らかにプラットフォームのABIの一部だから!彼らは何をするつもりなの、intmax_tのサイズを変えるの!?それは明らかにABIを壊す変更だよ!だからintMAX_tって呼ばれてる理由があるんだ!それには確定的なサイズはなくて、そのプラットフォームでの整数の最大サイズなんだ。確かに、今は硬直化のせいで問題があるけど、それはまさにそのブログの著者のような人たちから来てる。プログラムに安定したABIを持たせたいなら、プラットフォームがより大きな整数型をサポートしても変わらないように、intMAX_tを使わなければいいんだ!

それにしても、x64のint問題がある: それは非常に基本的な型で、長い間そのサイズであったため、無数のアプリケーションがそれについて奇妙で検出不可能な仮定を持っているかもしれない。これが、intがx64で32ビットである理由で、64ビットである「はず」だったのに、intが32ビットであったために、全く新しいアーキテクチャとターゲットトリプルであっても、ソフトウェアを新しいサイズに更新するのは完全に無理だったからだ!これを硬直化と呼ぶ。Cをプログラムする時、サイズを気にする必要はないんだ。プログラムが気にするなら、それは壊れてる/ポータブルじゃない。確かに、これがコンパイラに制限を与えるけど、プログラムが壊れないようにしたいからだ。でも、これは特定のプログラムのバグに配慮するMS Windowsと同じことだよ。これはCの設計ミスではない: > 時には、あまりにもひどい間違いを犯してしまって、元に戻せなくなることがある。

より多くのリアルライフCコンパイラは常に良いことだね。でも忘れないで、Linuxカーネルをビルドするには、膨大な数のGCC拡張が必要になるよ…(私はprintkのスタック変数のアラインメント属性が好きなんだ)。それはさておき、「C」(C99に現代のハードウェアプログラミングに必要なC11の良性なビットを加えたもの)は、コンピュータ言語としての「より悪くない」妥協に過ぎない。構文はすでに豊富すぎる(あなたが考えるように、常識を超えたコンピュータ言語の構文があるから、そう…現在流行しているものでもね)。例えば、Cはループキーワードをloop{}だけにすべきで、実際のハードコンパイル時定数定義を持ち、整数の昇格や暗黙のキャスト(void*やいくつかの一般的な数値リテラルを除く)を持たず、明示的な静的/動的キャストを持つべきだ(でもC++の構文ではない)。サイズ付き型はプリミティブであるべきで、逆ではない(s32,u32,f64…)、typedef/typeof/generic/etcは不要で、現代のハードウェアアーキテクチャプログラミングのためにインラインサポートを含むべきだ、つまりメモリバリアや「アトミック」、明示的なメモリアラインメントアクセスなど。基準は、小さなチームの平均的な開発者が合理的な時間内にリアルライフコンパイラを開発できることだよ。もちろん、多い方が楽しい。

やっとCを学んで、実際にはC仕様をすべて実装しているコンパイラはないことに気づいた。主な理由は、C仕様が異なるCコンパイラの既存の機能をある程度調和させる試みだったからだと思う。つまり、実装が先にあって、その後1、2十年後にC委員会が生き残った機能を標準化しようとするんだ。だから、すべてのCコンパイラベンダーがすぐに参加するわけではない。でも、MSVCは数年の期待を持ってCフロントエンドを現代化しようとした後、また放棄したように見える(C++23やC++26のテーブルでMSVCの赤がたくさんあるのを見ると、MSVC全体が放棄されたのではないかと疑問に思う)。最初は奇妙なアプローチに見えるけど、C++委員会が標準に「アイデア」を受け入れるアプローチよりも、明らかにうまくいってる。なぜなら、C++の良い点は、愚かなアイデアがCに入る前にフィルターとして機能するからだ ;)

すごくクールだね。Wasmはいい命令セットだと思うけど、構造化された制御フローがちょっと変だし、メモリスタックを扱う命令がないのも同意する。でも、x86_64みたいなものよりはずっとクリーンだよ。Cコンパイラの書き方をもっと詳しく学びたいなら、Nora Sandlerの「Writing a C Compiler」って本を超おすすめするよ。[0] これはCコンパイラを書くための超詳細で段階的なガイドなんだ。伝統的なアーキテクチャを使って複数のパスを利用してるし、Tackyっていう独自のIRを使ってる。定数折りたたみやコピー伝播、デッドコード除去、レジスタ割り当てなどの最適化パスも含まれてるよ。この本は配列やポインタ、構造体/共用体、静的変数、浮動小数点、文字列、System V ABIを介してのstdlibへのリンクなど、もっと多くの機能を実装してる。[0] https://norasandler.com/book/