基礎●●○○○

コンパイラ

Compiler ・ コンパイラ

人間が書いたコードを、段階を踏んで機械が実行できる形へ翻訳するプログラム。誤りの検出と最適化も担う。

✦ 30秒で試す — 航海あなたの書いた式は、翻訳の途中で黙って書き換えられている翻訳後、実行するときに残る掛け算は何回?予想してみる

概要

コンパイラは、人間が読み書きするプログラミング言語(ソースコード)を、CPU が直接実行できる機械語や、それに近い中間形式へ翻訳するプログラムです。CPU が理解できるのは2進数で表された単純な命令だけで、if や for、price * 1.1 といった人間向けの書き方をそのまま実行することはできません。この隔たりを埋める翻訳者がコンパイラです。

翻訳は一度に行うのではなく、「文字を単語に区切る」「単語を文法の木に組む」「名前と型の辻褄を確かめる」「機械寄りの形に直して磨く」「CPU の命令を書き出す」という段階の積み重ねです。途中の段は、それぞれ別の種類の誤りを捕まえる検問も兼ねています。ビルドエラーや型システムの型エラーは、どれかの段が「ここから先へは進めない」と止めた結果です。

翻訳のタイミングにも流儀があります。実行前にすべてを機械語にしておく事前コンパイル(C・Rust・Go)、起動時にバイトコードという中間形式へ翻訳して仮想マシンで実行する方式(CPython・Ruby)、そして実行しながらよく使われる部分だけを機械語にする JIT(Just-In-Time)コンパイルです。JavaScript や Java が速く動くのは、この JIT のおかげです。

なぜ生まれたか

コンピュータの黎明期、プログラムは機械語か、それと1対1に対応するアセンブリ言語で書かれていました。数値の羅列に近いコードは書くのも読むのも直すのも苦行で、しかも CPU の種類が変われば全部書き直しです。「人間の考え方に近い記法で書き、機械への翻訳は機械にやらせればよい」という発想を実用にしたのが、1957年に IBM の John Backus らが出荷した FORTRAN コンパイラです。当時は「機械が翻訳したコードなど手書きのアセンブリより遅いに決まっている」と疑われましたが、FORTRAN は最適化によって手書きに迫る性能を示し、高水準言語の時代を開きました。

コンパイラの発明が変えたのは生産性だけではありません。ソースコードと実行形式を分けたことで、同じソースを別の CPU 向けにコンパイルし直せば移植できるようになり、ソフトウェアがハードウェアから独立した資産になりました。そして、翻訳という工程を挟むことで、実行する前にプログラムの誤りを機械的に検査する余地が生まれました。のちに型システムによる静的な型検査として大きく育つ、「失敗を実行時からコンパイル時へ前倒しする」流れの出発点です。

詳細

コンパイラの中身 — 段階のパイプライン

コンパイラの内部は、前の段の出力を次の段が受け取るパイプラインです。x = 2 * 3 + y という1行を例に、順に追ってみます。

1段目の字句解析は、文字の並びを「識別子 x」「記号 =」「数 2」のような意味の最小単位(トークン)に区切ります。言語にない文字(たとえば @)はここで止まります。2段目の構文解析は、トークンの並びを文法に照らして木構造(抽象構文木、AST)に組み上げます。掛け算を足し算より先に結びつけるといった優先順位もここで決まり、括弧の閉じ忘れのような文法違反はシンタックスエラーになります。3段目の意味解析は、木の上で名前と型の辻褄を確かめます。y は宣言されているか、数と文字列を足そうとしていないか。静的型付き言語の型エラーは、この段が出すものです。

ここまでが言語ごとに作るフロントエンドです。続いて木を、機械に近いけれど特定の CPU には縛られない中間表現(IR: Intermediate Representation)に直します。「t1 = 2 * 3、t2 = t1 + y」のように、1命令が1つの演算だけをする形です。最適化はこの IR を、意味を変えずに速く・小さく書き換えます。定数どうしの計算を先に済ませる定数畳み込み(2 * 3 を 6 にする)、使われない計算や到達しないコードの削除、ループの変形、関数のインライン展開などです。最後のコード生成が、IR をターゲットの CPU の命令とレジスタの割り当てに落とします。

フロントエンド(言語ごとに作る)1 字句解析x = 2 * 3 + y2 構文解析x = ((2 * 3) + y)3 意味解析y: int → 式は int✕ 言語にない文字 @✕ 括弧の閉じ忘れ✕ 未定義の名前・型の不一致4 中間表現t1 = 2*3; t2 = t1+y5 最適化t2 = 6 + y6 コード生成mov eax, [y]; add eax, 6意味を変えずに書き換える(2 * 3 を先に計算)ミドルエンド・バックエンド(LLVM などで言語をまたいで共通化できる)
コンパイラのパイプライン — 前の段の出力を次の段が受け取り、段ごとに別の誤りを捕まえる

段を分けておく利点は、組み合わせが利くことです。現代の代表的な基盤である LLVM は、IR から先(最適化とコード生成)を共通の部品にしたもので、Rust も Swift も Clang も、自分の言語のフロントエンドだけを作って LLVM の IR を出力しています。新しい言語は既存の最適化をそのまま使え、新しい CPU は LLVM にバックエンドを1つ足せば、すべての言語がその CPU で動きます。

次の部品で確かめてください。式を書き換えると、トークン列・構文木・型・最適化後の木・命令列が連動して組み変わります。括弧を1つ消す、未定義の変数を使う、数と文字列を足す、といった誤りを入れると、それぞれ別の段で赤く止まり、そこから先の段には何も届きません。

⚡ 体験: 1行の式を翻訳する — 段ごとに組み変わる中間の姿
止めた段
最適化
✓字句解析文字 → トークン✓構文解析トークン → 構文木✓意味解析名前と型の辻褄✓最適化意味を変えずに速く✓コード生成スタックマシンの命令x=(2*3+4)*yx=2*3+4*y✓ y : int(宣言済み)✓ x : int(右辺の型から決まる)x=10*y畳み込み: 2 * 3 → 6、6 + 4 → 10push 10load ymulstore x4 命令(最適化しなければ 8 命令)

この小さな言語で宣言済みの変数は y・n(整数)と s(文字列)。数と文字列は足せません。トークンの札を押すと、その札を消します。

式は5つの段をすべて通りました。まず、トークンの札を押して消したり、下の札で誤りを入れたりして、どの段で止まるかを見てください。

実行方式の比較 — 事前コンパイル・バイトコードと VM・JIT

事前コンパイル — C / Rust / Goソースコード事前に機械語CPU が直接実行バイトコードと VM — CPython / Rubyソースコード起動時にバイトコードVM が1命令ずつ解釈すぐ動くが、命令ごとに解釈の手間がかかるJIT — JavaScript の V8 / Java の JVMソースコード解釈実行で開始呼ばれた回数と型を観察熱い部分機械語にして実行観察した型を前提に最適化
3つの実行方式 — いつ・どこまで翻訳するかが、実行速度と手軽さを分ける

事前コンパイルは実行時が最速ですが、動かすまでにビルドを待つ必要があり、配布物は CPU・OS ごとに作り分けになります。CPython や Ruby は「1行ずつ解釈する」と誤解されがちですが、実際には起動時にソースをまとめてバイトコード(仮想マシン用の命令列)へコンパイルし、それを VM が1命令ずつ解釈しています。ソースを文字どおり1行ずつ読みながら実行するのは、シェルのスクリプトなど一部に限られます。VM 方式は書いたそばから動く手軽さと移植性が魅力ですが、命令ごとに解釈の手間を払うぶん遅くなりがちです。Java はこの2つを組み合わせ、javac が事前にバイトコードを作り、JVM が実行時にそれを解釈・JIT コンパイルします。

JIT の面白さは「実行しながらだからこそできる最適化」にあります。JavaScript の V8 エンジンが好例です。JavaScript は動的型付けで、変数の型が実行してみるまで分かりません。V8 は実行を観察し、「この関数の引数はこれまで常に整数だった」という実測をもとに、整数専用の速い機械語を生成します(投機的最適化)。仮定と違う型、たとえば文字列が渡ってきたら、その機械語を捨てて解釈実行へ戻ります(脱最適化)。静的な情報だけでは不可能な、実行の記録に基づく賭けと巻き戻し — これが「スクリプト言語は遅い」という常識を覆した仕組みです。

バイトコードで開始

何度も呼ばれて熱くなる

型を仮定して機械語を作る

仮定と違う型が来る

機械語を捨てて戻る

解釈実行

最適化コンパイル

最適化コード

脱最適化

脱最適化は正しさを守る安全網ですが、繰り返されると性能の落とし穴になります。同じ関数に数と文字列を交互に渡す、オブジェクトのプロパティを追加する順番がばらばら、といったコードは、最適化と脱最適化を行き来して遅くなります。「同じ場所には同じ形の値を流す」ことが、JIT の恩恵を受けるための実務上のコツです。

コンパイル時に誤りを捕まえる — 静的型検査

翻訳工程を挟むことの大きな恩恵が、実行する前の検査です。静的型付き言語のコンパイラは、意味解析の段で「この関数は数を受け取るはずなのに文字列が渡されている」といった型の矛盾を見つけ、コンパイルエラーとして突き返します。実行してみるまで分からないバグの一群が、書いた直後に機械的に見つかる — テストより手前にある第一の防衛線です。TypeScript の人気は、動的型付けの JavaScript にこのコンパイル時の検査を後付けした点にあります。Rust はさらに型システムの枠組みを広げ、メモリの安全性(ガベージコレクションなしの安全なメモリ管理)までコンパイル時に確かめます。

ただし、検査が強いほど書き手が型を説明する手間とコンパイル時間は増えます。C++ や Rust の大規模なビルドが数十分かかることは珍しくなく、開発の往復を速く保つために、増分コンパイル(変わった部分だけを翻訳し直す)やキャッシュの工夫が欠かせません。

広がるコンパイラの世界 — トランスパイラと WebAssembly

翻訳先は機械語だけではありません。高水準言語から別の高水準言語へ変換するコンパイラはトランスパイラと呼ばれ、TypeScript から JavaScript への変換や、新しい JavaScript の構文を古いブラウザ向けに書き直す Babel が代表です。フロントエンド開発で使うバンドラも、この変換パイプラインの上に成り立っています。また、ブラウザという実行環境に向けた共通のコンパイル先として設計されたのが WebAssembly で、C++ や Rust のコンパイラが Wasm を出力することで、ブラウザ上でネイティブに迫る速度のコードが動くようになりました。「あらゆる言語 → 共通の中間形式 → あらゆる実行環境」という構図は、FORTRAN 以来コンパイラが担ってきた「書く場所と動く場所の分離」の現代版と言えます。

試問

画面は、1行の式を翻訳するコンパイラの5つの段(字句解析 → 構文解析 → 意味解析 → 最適化 → コード生成)です。ソース欄の式は自由に書き換えられ、トークンの札を押すとその札が消えます。この小さな言語で宣言済みの変数は y・n(整数)と s(文字列)です。

✦ 命題挑戦中

ソースを書き換えて、文法としては正しいのにコンパイルが止まる式を作れ

  • 字句解析を通る(すべての文字がトークンに区切れる)
  • 構文解析を通る(構文木ができる)
  • それでも意味解析で止まる
⚡ 体験: 1行の式を翻訳する — 段ごとに組み変わる中間の姿
最適化
✓字句解析文字 → トークン✓構文解析トークン → 構文木✓意味解析名前と型の辻褄✓最適化意味を変えずに速く✓コード生成スタックマシンの命令x=(2*3+4)*yx=2*3+4*y✓ y : int(宣言済み)✓ x : int(右辺の型から決まる)x=10*y畳み込み: 2 * 3 → 6、6 + 4 → 10push 10load ymulstore x4 命令(最適化しなければ 8 命令)

この小さな言語で宣言済みの変数は y・n(整数)と s(文字列)。数と文字列は足せません。トークンの札を押すと、その札を消します。

式は5つの段をすべて通りました。ソースを書き換えたり、トークンの札を押して消したりして、どの段で止まるかを見てください。