javascript/C言語/アセンブラを用い、 字句解析、構文解析、インタプリタ、コンパイラのプログラムをスクラッチから作りながら、 「プログラミング言語の作り方」を解説する。
プログラミング言語を作るにはどうすれば良いのか。
自作のプログラミング言語を作るのは、どれくらい難しいのだろうか?
プログラミング言語の作り方の解説本やwebサイトなどでは、 遠まわしで難しい説明がなされていることが多いように思う。
飛行機を作るのに、最初から戦闘機や巨大な旅客機を作る人はいない。
航空力学が分からなくても、エンジンが設計できなくても、紙飛行機は子供でも作れる。
紙飛行機だって飛行機だ。ちゃんと飛ぶ。飛べば飛行機なのだ。
そりゃ、ボーイングやエアバス社で働いている専門家なら紙飛行機ではダメだ。
自作プログラミング言語を作りたいと思う人のほどんどは、 プログラミング言語を開発しているマイクロソフト、アップル、google、oracleなどに、 勤めているプログラミング言語の専門家ではない。
例え、紙飛行機でもすぐに飛べるようなプログラミング言語を、さくっと簡単につくろう。
では、具体的に、独自のプログラミング言語を作るには、どうしたら良いだろうか?
自作プログラミング言語で書かれたソースコードは、 機械語や制御コードが入ったバイナリデータではなく、英数字記号でできた「単なる文字列」だ。
しかも、実生活で使われる文章のような自由度の高い曖昧なものではなく、 「限られた単語しかない、とても簡単なルールに合致した、機械的な文字列」だ。
例えば、人工知能を作る場合、人間の問いかけが多少ルールに合致していなくても、 複雑な理論とロジックで何とか意味を解釈して、 人間の意図通り動かさないといけないので、とてもハードルが高い。
しかし、プログラミング言語の場合は、ソースコードがルールにちょっとでも合致していなければ、 問答無用でエラー吐いて終了させてしまえばよく、気楽に割り切れる分、とてもハードルが低い。
つまり、「プログラミング言語を作る」とは、 「ルールに合致した機械的な文字列だけを扱う、割り切ったプログラムを作る」ことだ。
こういう割り切ったプログラムを作れば良いので、これから説明する処理も簡単なものになる。
詳細は後述するが、「インタプリタ」と「コンパイラ」というのは聞いたことがあるだろう。
これら2つに共通する処理として、「字句解析」と「構文解析」がある。
小難しい言い方だが、呼び名がないと説明しづらいので、この4つの用語は覚えよう。
プログラミング言語で書かれたソースコードは、1つの長い文字列だ。
字句解析というと、とっつきにくいが、要は、1つの長い文字列をそのまま扱うより、 最初に、単語など、意味のある単位(トークン)に、区切っておく方が 便利だよね、というだけの話だ。
具体的に言えば、以下のような自作プログラミング言語のソースコードがあったとき、
hensuu=13-5*2; print(hensuu);
ソースコードのファイルの中身を1つの長い文字列として、sourceという変数に読み込んだ状態が以下だ。
source = '(@hensuu=13-5*2; print(hensuu);@)';
結局、字句解析とは、source変数をトークンで区切り、tokens配列に入れておくという簡単な文字列処理だ。
tokens = [ (@'hensuu'@), (@'='@), (@'13'@), (@'-'@), (@'5'@), (@'*'@), (@'2'@), (@';'@), (@'print'@), (@'('@), (@'hensuu'@), (@')@)', (@';'@) ];
具体的な実装は、後述するが、 文字列分割するsplitのような機能や、正規表現などは、最近の言語には備わっている。
そんな機能がないC言語で、ベタに文字列分割機能を実装するにしても、 source変数を、先頭から一文字ずつ、アルファベットか数字か記号か チェックしていくだけだ。
なので、字句解析は、簡単な文字列分割機能だけで十分なのは明確だ。 複雑な理論に基づいた処理は不要だ。
tokens配列を人間が見ると、何の苦労もなく、以下のようにイメージすることができる。
tokens配列を、この順番になるように変換する作業が構文解析だ。
構文解析の実装は後述するが、ポイントは、「hensuu = X」というところや、 「13 - X」というところで、「おあずけ」を食らっているところだ。
いったん処理を保留し、別の処理を先にやり、再度再開するような、おあずけを実現する手段が必要だ。
しかし、何のことはない、関数の途中呼び出しで、おあずけを実現できる。
代入を解析する関数の途中で、引き算を解析する関数を呼び、そのまた途中で、 掛け算を解析する関数を呼ぶだけだ。
つまり、掛け算の解析が終わると、10がかえってきて、引き算の解析の途中から再開し、 13-10が処理され、3が返ってきて、 代入を解析する関数が途中から再開されて、hensuuに3が代入される、といった具合だ。
ここで、この解析途中にその過程を、以下のようなtree構造のデータにしておくと、 どこを先に処理するべきかを、人間が見ても、プログラムが見ても扱いやすい。
{ left:{ left: 'hensuu', op: '=', right: { left:'13', op:'-', right:{ left:'5', op:'*', right:'2' } } }, op:';' right:{ left:'print', op:'()', right:'hensuu' } }
名前がないと説明しづらいので、このtree構造を、抽象構文木(AST)と呼ぶ。 treeの細かい構造をどうするかは、自分の好きなように表現すれば良い。
構文解析とは、tokens配列を、astのtree構造に、変換しているだけだ。 関数で「おあずけ」を表現でき、難しい理論は不要で、淡々と実装していけばよいことが分かる。
インタプリタでは、字句解析、構文解析の後、astをそのままの流れで実行する。
astを作らずに、構文解析の途中で即実行しながらやっても良い。
しかし、扱う処理が多くなると、わけわからなくなるので、astに一度出力して置いた方が便利だ。
結局、インタプリタは、astをそのまま実行するだけだ。
コンパイラでは、字句解析、構文解析の後、astをもとに、アセンブラを出力するだけだ。 実行はしないし、できない。
出力されたアセンブラは単なるテキストファイルだ。
アセンブラのテキストファイルは、asコマンドで、機械語(バイナリ)に変換する。
この時、コンパイラは、あたかも自分がやっているかのように、as外部コマンドを叩くだけだ。 このアウトプットは、オブジェクトファイルと呼ばれる中間形式で、実行ファイルではない。
その後、リンカと呼ばれるldコマンドで、(分割コンパイルされた)複数のオブジェクトファイルを、 連結して1つの実行ファイルにする。 またしても、コンパイラは、あたかも自分がやったように、このld外部コマンドを叩くだけだ。
コンパイラとしては、実行ファイルが作られると、もう何もすることがないし、実行にかかわることもできない。
ちなみに、いざ、実行ファイルを、コマンドラインから実行すると、execシステムコールが呼ばれる。
OSは、バイナリ中で指定された、ローダーと呼ばれる外部プログラムを起動する。
ローダーは、実行ファイルや動的ライブラリなどを、メモリ上に配置して、 指定されたエントリポイントからCPUに制御を渡す。つまり実行される。
寄り道したが、結局、コンパイラは、astからアセンブラに変換するだけだ。