コンパイラを作っているうちに、コンパイラの各ステージにおけるエラーの扱いについて考えが変わった。
当初の考え
はじめは次のように、各ステージが失敗しうるという考えを持っていた。
つまり各ステージを S -> Result<T, E>
のかたちの関数とみなして、これを合成する (>>=
) ものとして定義していた。
- 字句解析
- 成功: トークン列
- 失敗: 字句エラー
- 構文解析
- 成功: 構文木
- 失敗: 構文エラー
- 型検査
- 成功: 型情報
- 失敗: 型エラー
- (同様に) 他のステージ
- 成功: 変形後のデータ
- 失敗: 変換中のエラー
考えが変わったところ
次のような考えの変化があった。
- 意味解析までのステージは、エラーがあっても結果を返せる
- 型検査が成功した後はエラーが発生しない
- 型エラーがあるコードをコード生成以降のパスに持っていくとややこしい
- エラーの表現は出どころとなるステージによって異なる (かもしれない)
以下に具体的にみていく。
字句解析
字句解析と構文解析はどちらも、エラーがあっても処理を止めず、結果を生成できる。
- 字句解析: ソースコード → トークン列 * エラーリスト
- エラーがあってもトークン列を返す。
字句解析におけるエラーは次のようなものがある。
- 数値の構造がおかしい (
0x
とか42Q
とか) - エスケープシーケンスがおかしい (
\a
とか) - 引用符が閉じていない (
"
を閉じずに改行が出てきたとか) - 不正な文字が出現した (文字列やコメントの外に使えない記号が出てきたとか)
- ソースコードが文字列でない (
\0
が出てきたとか)
いずれもエラー回復はできる。 前の3つはエラーがあっても数値トークンや文字列トークンを作れるし、後の2つは余計な部分を無視すればいい。
字句解析のエラーの表現
字句解析のエラー位置は、元になったソースファイルと、エラーの発生位置を文字単位で指定することになる。 つまり範囲の開始・終了位置の行番号・列番号を記録しておけばいい。
余談: 行番号と列番号の計算
トークンの行番号・列番号は字句解析と分離できる。 (複数行文字列リテラルとかを処理するときに行番号・列番号を逐一更新する必要はない。) 行番号は「改行の個数」、列番号は「最後の改行の後にある文字列の長さ」といいかえられる。 トークンの開始位置と終了位置を調べた後に、その間にある改行の個数と最後の改行の後の長さを調べて、開始位置における行番号・列番号に加算することで、終了位置の行番号・列番号を求められる。 (参考: text-position-rs)
構文解析まで
構文解析も、エラーがあっても常に完了するようにできる。
- 構文解析: トークン列 → 構文木 * エラーリスト
- エラーがあっても構文木を作る。エラーがあった部分は空欄にしておく。
構文木が完全でなくてもよいことにすれば、トークンの一部が足りなくても構文木を作れる。 不要なトークンは捨てればいい。
構文解析のエラー位置は、元になったソースファイルと、エラーの発生箇所付近のトークン (あるいはトークンの直前・直後) を指定することになる。 もちろん文字単位の範囲で持ってもいいが、構文エラーがトークンの一部や、コメント・空白の部分を指すことはないはず。
余談: エラー回復の利点
エラー発生時に処理を中断しないことのメリットは少なくない。 構文解析のエラーから復帰できると、部分的に構文が壊れた状態でも定義の参照やホバーなどの入力支援を受けることができる。 コードがどういう状態になっているのかのフィードバックを受けられるので、エラーを直すときのヒントになる。 記述中に一時的に構文が壊れたときに、入力支援に影響が出にくい。
余談: 構文木からの構文エラーの復元
構文解析が完了した後に、構文木を走査してトークンが足りていない部分を探し、構文エラーを生成するという方法もある。 構文解析の処理とエラー報告を分離できるという利点がある。 前にやってみたが、めんどくさいので、あまりおすすめではない。
意味解析まで
名前解決や型検査などのいわゆる意味解析ステージも、エラーがあっても処理を止めず、結果を生成できる。
意味解析は通常、複数のソースファイルにまたがる。 意味解析でのエラーの発生位置は構文木のノードを1つ指定することになる。
コード生成以降
意味解析まででエラーが出なかったら、入力されたプログラムは妥当だとみなせる。 コード生成はプログラムの意味を変えずに変換する工程なので、ここで新たにエラーが出ることはないはず。
余談: 壊れたプログラムのコード生成
しばしば静的言語で型エラーがあるプログラムでも適当に abort
を埋めることでコード生成できるはずで、できたほうが便利だという意見をみる。
前に実際にやってみたが、開発上の問題があってやめた。
未報告のコンパイルエラーをかかえた状態で意図しない状態に陥って assert
違反を踏むケースがよくあった。
その際にコンパイルエラーがあることに気づかずに処理系の不具合だと思ってしまうと無駄にデバッグするはめになった。
逆にコンパイラの不変条件が破れているのに、コンパイラがクラッシュせずに単に abort
を生成してしまうということもしばしばあった。
終わりに
全体としての構造を振り返ると次のようになる。
- 意味解析まで:
_ -> 解析結果 * エラーリスト
- 字句解析:
_ -> _ * エラーリスト
- 構文解析:
_ -> _ * エラーリスト
- 意味解析:
_ -> _ * エラーリスト
- 字句解析:
- コード生成:
解析結果 -> 生成コード
意味解析が終わった時点でエラーがあったら、コード生成に入らずにコンパイルを終わる。