0x19f (Shinya Kato) の日報

主にプログラミング関連の話をします

Cコンパイラ自作チャレンジ(その2・プリプロセッサ編)

字句解析に続いてプリプロセッサ

前回は字句解析器を作るところまでの話でした。

0x19f.hatenablog.com

今回はプリプロセッサのお話です。

先に言い訳をしておくと、WG14/N1570 Committee Draftを読みながらザクザクと実装を進めてそれっぽいマクロ展開などはできるようになってきましたが、まだ完全に作りきれてはいないです。 加えてだんだん自分でも何を書いているのかよくわからなくなってきたので、少し整理しながら書き直したいと思っています。 というわけで、まだ具体的な実装はGitHubリポジトリにpushしてないです。

そんな感じなので、一度プリプロセッサの挙動を書き起こして整理したいと思います。 僕の備忘録的な色が強いです。

プリプロセッサの概要

プリプロセッサというと、#include#defineなどがパッと思い浮かぶんじゃないかと思います。 これらはプリプロセッシング・ディレクティブと呼ばれます。

ディレクティブとマクロの展開は字句解析で得られたトークン列に対して行われます。 プリプロセッシングによって得られたトークン列は型の特定や文字列の結合などの処理が行われて構文解析されていきます。

ディレクティブの一覧は以下の通りです。

  • Conditional inclusion: #if, #ifdef, #ifndef, #elif, #else, #endif
  • Source file inclusion: #include
  • Macro replacement: #define, #undef
  • Line control: #line
  • Error directive: #error
  • Pragma directive: #pragma

#line__LINE__マクロとか__FILE__マクロとかを制御するためのものっぽい。 #errorはこのディレクティブを読むと後ろに指定されたエラーメッセージを出してくれるようです。 #pragmaコンパイラ固有・環境固有の機能とかを追加するのに使われるディレクティブという認識だけど、よくわからない。

規格に書かれているディレクティブはこれで全てなので、この他は各コンパイラ拡張機能ということになるんですかね。

ここからはプリプロセッシングの中心的な機能となるMacro replacementとSource file inclusion, Conditional inclusionについて書いていこうと思います。

Macro replacement

マクロの定義

#defineディレクティブでマクロを定義できます。

マクロの定義には2種類あって、オブジェクト型のマクロ(object-like macro)と関数型のマクロ(function-like macro)のようなものがあります。 関数型のマクロは可変長引数みたいなものを受け取ることもできて、その場合は#define function(a, b, ...)のような宣言の仕方になります。

#define identifier replacement-list
#define identifier( identifier-list ) replacement-list
#define identifier( ... ) replacement-list
#define identifier( identifier-list , ... ) replacement-list

なお、混乱を避けるために以下ではパラメータと引数を明確に区別して使います。 パラメータと言った場合は、マクロ宣言の()の間に含まれる識別子のことを指します。 引数と言った場合には、マクロ呼び出しの()の間に含まれるトークン列のことを指します。 要するにパラメータは宣言側のこと、引数は呼び出し側のことと思ってください。

マクロの置換

テキスト中にオブジェクト型のマクロの名前が出てきた場合には、対応するreplacement-listでそれを置き換えます。 置き換えが終わったら、置き換えられた部分を再度スキャンしてマクロを再帰的に展開していきます。

テキスト中に後ろに(が続く関数型のマクロの名前が出てきた場合には、対応する括弧を除いた次の)までを以下の手順で置き換えます。

  1. 1番外側の()の内側のトークン列を,で区切って関数型マクロの引数とする
    ただし、対応する括弧の中にある,を除く
  2. replacement-list中に現れるパラメータを引数で置換します
    ただし、パラメータの前に#トークンがある場合と前後に##トークンがある場合を除きます
    また、置き換えを行う前に引数に含まれるすべてのマクロを展開します
  3. #トークンの後ろに続くパラメータは文字列化されます
    ##トークンの前後のトークンは連結されます
  4. 置き換えられた部分を再スキャンしてマクロを再帰的に展開していきます

パラメータに...を持つ関数型マクロの呼び出しの際はパラメータの数以上の引数を渡すことができます。 その場合、余分な引数の部分は,を含めて__VA_ARGS__というパラメータに対応付けられたように振舞います。

# 演算子について

#は後ろに続くパラメータを引数を文字列化したもので置き換えるという演算子です。

引数の先頭と末尾の空白文字は取り除かれます。 それ以外は元のトークン列のスペリングが維持されたままひとつの文字列に変換されます。 例外として、トークン列中に現れる"\のみエスケープされます。

## 演算子について

##は前後のトークン列を連結するという演算子です。 関数型マクロだけでなくオブジェクト型のマクロでも使うことができます。

引数が空のトークン列である場合は、placemarkerという特殊なトークンで置き換えます。 トークンを連結する際、片方がplacemarkerである場合は、その結果はもう片方のトークンになります。 両方がplacemarkerの場合は、結果もplacemarkerとなります。 placemarkerは再スキャンの前に全て取り除かれます。

マクロの再帰的な展開

一通り上記の手順でマクロを展開し終えたら、置換された部分を再度スキャンして再帰的にマクロを展開します。 再帰的な展開の途中ですでに展開済みのマクロ名に出会った場合は、展開を行いません。

実装する上では、マクロ展開を終えた後に展開したマクロにフラグを立てておいて、フラグが立っているマクロを無視しながら再スキャンを行い、再スキャンが終わったらフラグを折るという感じで問題ないと思います。

この再帰的な展開を図にしてみたんですが、あんまりわかりやすくはなさそうですね...

f:id:mizu0x19f:20180528072317p:plain

Source file inclusion

#includeディレクティブでソースファイルのインクルードができます。

#include <h-char-sequence>
#include "q-char-sequence"

厄介なのが#includeの後ろに続くのが上記の2つ以外のトークン列の場合です。 この場合、トークン列を通常のテキストと同様にマクロ展開し、ひとつのヘッダー名に変換し、それが上記の2パターンに当てはまればインクルードを行います。

Conditional inclusion

#ifディレクティブなどから次の#endif#elif#elseまでが出てくるまでのことをグループ(group)と規格では呼んでいたので、以下でもそれに習ってグループという言葉を使っていきます。

#ifdef, #ifndefはマクロの定義状態によってどのグループを読むかを決めるディレクティブです。 これはおそらく実装するのも難しくないです。

#if, #elsifがやばいです。 なぜやばいのかというと、実は#ifの後に続く条件は整数定数式として評価されるからです。 この時点で結構重めの構文解析が必要です。

さらにただの整数定数式ではなく、defined演算子という後に続く識別子がマクロとして定義されているかいないかによって1/0に置き換えられる演算子があったり、マクロの展開も行われます。 なので、defined(macro) + 3 >= 4のようなトークン列を処理する必要が出てきます。

具体的には、#if#elifの後ろに続くトークン列は以下のようにして真か偽か評価されます。

  1. defined演算子がついている識別子を除いて、マクロを展開する
  2. defined演算子を0/1に置き換える
  3. 残っている識別子を全てpp-numberの0で置き換え、トークン列をtoken(pp-tokenではなく)に変換する
  4. 整数定数式として評価する
  5. 得られた結果が0でなければ真、0なら偽

else, endifはあんまりちゃんと読んでないけれど、見たとおりだと思います。

一応、読み込まれなかったグループはマクロの展開とかディレクティブの評価とかをせずに読み飛ばすっぽい?

次こそプリプロセッサ

だいたいの挙動は理解できたし実装のイメージもつかめたので、もう少しいい感じにプリプロセッサを作り直します。 空白を読み飛ばす処理が面倒でつらいなぁという気持ちになったので、そこはうまくやりたいなぁと。