PHPコードのコンパイルの流れメモ

久しぶりに PHP を触り処理系の動きがどんな感じだったのか忘れたのでおさらいメモ.ざっくりと大枠.

PHP では実行時にソースコードコンパイルされます.で,最終的にオペコードと呼ばれる中間コードに変換され,実行マシンによって逐次的に実行されます.コンパイルはいくつかのフェーズに分かれていて,今回はその各フェーズについて概要を書きます.なお,たまたま PHP を題材にしていますが,大まか流れはどの言語も似たような感じだと思います.

レキシング(字句解析)

ソースコードはまず,コンパイル時にレキサ(レキシカルアナライザ,字句解析器)によって字句解析されます.字句解析とは,ソースコード中の文字列を意味のある単位で分割し,それぞれを要素(トークン)として識別することです.

自然言語は名詞や動詞,接続詞などの様々な品詞に分けられますが,それと同様にソースコードも意味のある単位に分けられます.例えば,「<?php」 は T_OPEN_TAG,「$hoge」という変数は T_VARIABLE,スペースは T_WHITESPACE というトークンとして識別されます.以下は PHPトークンの一覧で,眺めるとなんとなく雰囲気が掴めると思います.

PHP: パーサトークンの一覧 - Manual

で,コード上の文字列をそれぞれ何のトークンとして識別するのかを決めているのが,以下のような定義ファイルです.ここには,どういう状態の時に,どういう文字列が検出されたら,どういうトークンとして識別する,というような決めごとが書いてあったります.逆に言えば,この定義ファイルを変えれば言語の動作を変えることもできるということです.

php-src/zend_language_scanner.l at master · php/php-src · GitHub

こいつを re2c というレキサジェネレータに突っ込んでレキサを作り,それを使ってソースコードの字句解析をします.(re2c 自体は PHP プロジェクト外で開発されたものですが,このようなサードパーティ製のジェネレータが使われるのは車輪の再発明をしないためでしょう.最適化などが熟考された汎用的なジェネレータソフトが既にあるので,定義ファイルだけ作れば高性能なレキサが手に入ります.)

このようにして識別・生成されたトークン群は次のステップの主役であるパーサに出力されます.つまりこのフェーズでは,ソースコードをレキサへの入力とし,結果としてトークン列をパーサに出力します.

パース(構文解析

レキサがコードをトークン列に変換すると,次にパーサがそれを構文解析(パース)します.構文解析では,書かれたソースコードの構造(文法)を解釈し,その正しさを確認します.

これも自然言語で考えることができます.自然言語には,例えば「動詞→名詞」はOKとか,「名詞→副詞」はNGとか,様々な文法の規則が存在します.プログラミング言語にも同じように文法があり,そのルールが守られているかどうかをパーサが検証するわけです.具体的には,トークンの順序やトークン間の関係性を検証し,文法的に認められていないコードを検出したらエラーとします.

パーサも,レキサと同様にジェネレータを使って生成されます.で,文法は以下に定義されています.これをパーサジェネレータへの入力とし,パーサを作るわけです.

https://github.com/php/php-src/blob/master/Zend/zend_language_parser.y

自然言語の文はこのように木構造で表すことができます.ソースコードも同様に,文を木構造で表現できます.これは構文木と呼ばれます.また,この構文木からオペコードの生成に不要な要素を取り除いて簡略化したものを抽象構文木(AST)と呼びます.以下はこちらで説明されている構文木と抽象構文木の違いです.両方とも表現しようとしているソースコードは同じです.

f:id:norikone:20180611002035p:plain:w260       f:id:norikone:20180611002043p:plain:w260

パーサが生成した AST は,次のステップの主役であるコンパイラへの入力となります.したがって,構文解析フェーズの役割は,トークン列を入力としてそれを基に AST を生成することです.

ちなみに PHP7 までのパーサは AST を生成せずに,トークン列を読みながら直接オペコードを生成していたらしいです.AST を生成するようになったのは,パーサとコンパイラを分離することで実装の保守性を高めたり,これまで実現が難しかった構文を導入したりできるようになると期待されたためです(詳細).

トークン列を読みながら直接オペコードを生成する方法だと,トークンを読んだ時点で出力するオペコードを決定しなければなりません.AST を構築する方法では一回全体を把握した後にオペコードを生成するため,読んだトークン以降のトークンを考慮して構文を解釈できるようになり,幅が広がるというイメージでしょうか.

コンパイル

ここまで来たら,あとはソースコードの中間表現である AST を辿りながら,実行マシンが理解できるオペコードに変換します.実際にそれを実行しているのは以下のファイルです.

https://github.com/php/php-src/blob/master/Zend/zend_compile.c

こいつが AST を読み,オペコードを出力していきます.AST が出来上がった段階でどのようなオペコードを出力すればいいかは決まっているので,この変換作業は割と単純なはずです.逆に言えば,パースでは演算の優先度などを考慮して AST を構築するため,変換作業が複雑になります.

ちなみにコンパイル時には,様々な最適化やアクセラレータによるオペコードのキャッシュなども行われます.キャッシングによってコードを毎回コンパイルし直す必要がなくなるので,実行の高速化が期待できます.代表的なアクセラレータとして,APC や OPcache などが挙げられます.

コンパイルが完了したら実行マシンが逐次的にオペコードを実行していきます.

おわり

ということで PHPコンパイル(広義)は,レキシング→パース→コンパイル(狭義)という順で行われます.これだけでは詳細まで理解できませんが,なんとなく大枠の雰囲気は掴めたかなと思います.