実践・C コンパイラ演習

sizeof 単項式 — 答えは合っているのに、間違っている

今日のゴール

sizeof x sizeof *p sizeof (a + 1) のような式形式の sizeof を実装する。 コマ9 で作った sizeof(型名) 形式はそのまま残し、1トークンの先読みで見分ける。

この回の位置づけ

項目 内容
必須の前提 コマ12(構造体)まで。完了条件のうち golden.py は完成した final/mycc.py を土台に fixed17 を回すので、やり切るにはコマ15 も要る
推奨の前提 —
改変しない mycc.py、scaffold/(lexer.py・parser.py を含む)、ラッパー langcc.py(配布済み・完成品)
編集する sizeofexpr.py
完了条件 check.py の全 Step が PASS になり、golden.py が sizeof 式のテストの通過と fixed17 全通を報告する
コマ数 1
仕様との関係 仕様の外側。language_spec.md は sizeof を「型名形式のみ」と定め、除外機能の一覧に sizeof 式を挙げている。追加する構文と意味論は下記の「L4 が追加する仕様」節が定義する
備考 言語機能発展シリーズ。L 系列で最も軽い入口で、字句の変更は無く、コード生成も1命令で終わる

sizeof 式は language_spec.md の外側にある機能である (仕様の「除外機能の一覧」に載っており、代替表は sizeof(型名) で書けと指示している)。 コマ9 の sizeof(型名) を一般化する回で、追加する構文と意味論はこの資料が定義する(次節)。

L 系列の中ではいちばん軽い。字句は 1 文字も変えない(sizeof はコマ9 の時点で すでにキーワードとして読めている)。構文は分岐が 1 つ増えるだけで、 コード生成にいたっては li a0, N の 1 命令で終わる。 それでも、この回でしか出会えない考え方が 1 つある——評価しないという意味論である。

L4 が追加する仕様

language_spec.md の sizeof は型名形式だけである。L4 はそこへ式形式を足す。 以下がこの回で実装すべき言語仕様の追加分であり、check.py / golden.py の 判定はこの定義に従う。

構文

language_spec.md の EBNF の unary_expr に、次の 1 行を足す。

unary_expr ::= …
             | 'sizeof' '(' type_name ')'     /* 既存 */
             | 'sizeof' unary_expr            /* L4 が追加 */

オペランドは unary_expr である(cast_expr の無いこの言語では ISO C の sizeof unary-expression と同じ位置になる)。したがって sizeof は単項演算子で、 sizeof x + 1 は (sizeof x) + 1 と読まれる。

2 つの形式は 'sizeof' の次の 1 トークンだけで見分けられる。

sizeof の次 読み方
'(' + 型キーワード(int char void struct) 型名形式
それ以外 式形式

language_spec.md が「type_name は型キーワードで始まるため構文衝突はない」と 書いているのはこのことである。仕様は衝突が無いことを式形式を禁じる根拠に使ったが、 同じ事実は両方を共存させる根拠にもなる。1 トークンの先読みで足りる。

意味論

sizeof e について、次を定める。

  1. 結果は e の静的な型のサイズ(バイト数)である。実行時の値には一切依存しない。
  2. e は評価されない。 e に含まれる関数呼出し・代入・++/-- などの副作用は 1 つも起こらない。e は型を決めるためだけに使われる。
  3. 結果の型は int(language_spec.md の例外 E1 をそのまま引き継ぐ。 本仕様に size_t は無い)。
  4. 不完全型・void への sizeof はコンパイルエラーとする。 本体の見えていない struct(前方宣言のみ)と void がこれにあたる (language_spec.md の「未定義動作/エラー」の項目 8 と sizeof(void) の禁止に対応)。 ポインタは指し先が不完全でもサイズ 8 で決まるので、これは通す。
  5. 型名形式 sizeof(型名) の意味は変わらない。

L4 を実装しても language_spec.md は変わらない —— 変わるのはあなたのコンパイラが 受理する言語のほうである。

中心にある規則: 評価しない

sizeof は「大きさを測る」演算子だから、まず値を作ってから測るのだと思いたくなる。 しかし C の sizeof は逆である。型だけを見て、値は作らない。

int calls;
int bump() { calls = calls + 1; return 7; }

calls = 0;
n = sizeof bump();      // n は 4。calls は 0 のまま

もしオペランドをコード生成してしまうと、どうなるか。

  call bump          ← 呼ばれてしまう(calls が 1 になる)
  li a0, 4           ← 答えは 4 で「合っている」

n は 4 になる。テストの戻り値だけを見ていると通ってしまう。 それでも仕様違反である。前節で定めたとおり e の副作用は 1 つも起こってはならない。

e は評価されない。 e に含まれる副作用は 1 つも起こらない。

L2 の「複合代入の左辺は 1 回だけ評価される」と同じ形の罠である。 答えが数値として合うことと、意味論として正しいことは別であり、 この回のテスト tests/noeval.c は calls を表示して後者を確かめる。

補足

なぜ C にはこの規則があるのか。 sizeof はコンパイル時定数である。malloc(sizeof(struct Node)) は実行時に struct Node を 1 つ作ってから測っているわけではない——測る対象が無くても測れる。 さらに sizeof *p は、p が NULL でも安全でなければならない (p が指す先の型は p の宣言だけで決まる)。 「評価しない」は例外規定ではなく、sizeof が型の演算子であることの言い換えである。

いまのコンパイラに食わせるとどうなるか

tests/expr.c を標準トラックのコンパイラに渡すと、構文解析で止まる。

構文解析エラー: [line 19] sizeof は sizeof(型名) 形式のみ使えます

スキャフォールドの _parse_sizeof が、'(' + 型キーワードでなければ 明示的にエラーにしている。この回はここに「そうでなければ式形式」を足す。

段階 追加すること 担当
字句 なし(sizeof はすでにキーワード) —
構文 1 トークン先読みで SizeofExpr を作る langcc.py
意味 オペランドの静的な型を求める Step 1
コード生成 型のバイト数を 1 命令で置く Step 2
エラー 不完全型・void を弾く Step 3

構文 —— 1 トークンの先読み

ラッパーが用意する部分は次のとおりである。

def _parse_sizeof(self):
    line = self.cur.line
    self.pos += 1                       # 'sizeof'
    if self.cur.sval == '(' and self._peek_is_type():
        ...                             # 従来どおり SizeofType(ty_str=…)
    return Node('SizeofExpr', operand=self._parse_unary(), line=line)

_peek_is_type() は '(' の次のトークンが型キーワードかを見る。 sizeof (a) は a が識別子なので式形式、sizeof (int) は型形式になる。 オペランドを _parse_unary() で読むので、sizeof x + 1 は (sizeof x) + 1 と読まれる(+ は _parse_unary の外側の段にある)。

sizeof c + 1     → Add(SizeofExpr(Var c), Num 1)     = 1 + 1 = 2
sizeof (c + 1)   → SizeofExpr(Add(Var c, Num 1))     = 4

char を使うとこの差が見える(int どうしだと両方 4 になって区別がつかない)。 tests/prec.c はこの形で優先順位を確かめている。

型を辿る —— 評価しない型付け

sizeof に必要なのはオペランドの静的な型である。 土台の mycc.py はすでに _type_of_expr という同じ規則を持っている。 違うのは、こちらは型を知るためだけに木を辿り、命令を 1 行も出さないという点だけである。

ノード 型
Var / Deref / Index / Member cg.lval_type(node) に任せる(左辺値の型)
Str char*
Addr(&e) e の型 + *
Assign 左辺の型
PreInc / PreDec 左辺の型
Cond(a ? b : c) b 側の型
Add どちらかがポインタならそのポインタ型、でなければ int
Sub 左辺がポインタならそのポインタ型、でなければ int
その他(Num Neg Not Mul Div Mod 比較 && || Call SizeofType SizeofExpr) int

Add でポインタを左右どちらでも見るのは、p + 1 と 1 + p の両方が ポインタ型になるからである(コマ9 のスケーリングと同じ非対称性)。 Sub は左だけ見ればよい —— ポインタ同士の引き算は仕様の外にある(発展課題 L1 の題材)。

補足

なぜ Call は無条件に int でよいのか。 language_spec.md の関数はすべて int を返すか void である。 void 関数の呼出しを sizeof に渡す(sizeof f())のは本来エラーだが、 土台のコード生成器は式の型として int を返す。ここでは仕様の外の細部を 追わず、_type_of_expr と同じ規則にそろえておく。 戻り値型まで見て弾きたい人は発展課題 4 へ。

コード生成 —— 1 命令

型が決まれば、あとは大きさを置くだけである。

cg.emit(f'  li a0, {cg.type_size(ty)}')
;; sizeof *p   (p は int*)
  li a0, 4

codegen はどこにも現れない。この「無い」ことがこの回の実装である。 コード生成の関数が 1 行しかないのに、その 1 行に到達するまでに 型を辿る仕事が要る——sizeof が「実行時ではなく翻訳時の演算子」であることが、 生成コードの形にそのまま出ている。

コマ9 の codegen_SizeofType と比べてほしい。出力は同じ li a0, N で、 違いは N の求め方だけである。型名形式は文字列から直接、式形式は木を辿って求める。

不完全型を弾く

大きさが決まらない型が 2 つある。

型 なぜ決まらないか
void 大きさを持たない。仕様も sizeof(void) を禁じている
本体の無い struct(前方宣言のみ) メンバが見えていない。lib.h の struct FILE がこれ

土台の size_of_ty_str は知らない型に既定値を返してしまうので、 放っておくと黙って嘘の数が出る。コンパイルエラーで止めるほうが正しい。

[line 12] 不完全型に sizeof は使えません: 'struct FILE'(構造体の本体が見えていない)

ポインタは通す。struct FILE *fp の sizeof fp は 8 で確定する—— 指し先が不完全でも、アドレスの大きさは型に関係なく決まっているからである。 この線引きは C の規則そのままである。

実装

Step 実装対象 内容
1 static_type_of オペランドの静的な型を、命令を出さずに求める
2 gen_sizeof_expr 型のバイト数を li a0, N で置く
3 reject_incomplete void・不完全型をコンパイルエラーにする

langcc.py はこの 3 つを次の順に呼ぶ。

ty = static_type_of(cg, node.operand)
reject_incomplete(cg, ty, node.line)
gen_sizeof_expr(cg, ty)

Step 1 は場合分けが多いが、1 つずつ check.py を回しながら足していけばよい。 Step 2 は 1 行、Step 3 は 3 行で終わる。

python3 langcc.py tests/noeval.c | grep -c "call bump"   # 1 のはず(sizeof の外の1回だけ)
python3 langcc.py tests/prec.c | grep "li a0, 1"         # sizeof c が 1 で出ている

テスト

python3 check.py     # Step ごとの単体テスト(未実装は SKIP)
python3 golden.py    # sizeof 式のテスト + fixed17
テスト 確認内容
expr.c 変数・*p・p[i]・.・->・&x・文字列リテラル・括弧つきの式が型名形式と一致する
noeval.c sizeof bump() で bump() が呼ばれない。sizeof (calls = 99) でも calls が動かない。sizeof *p が p == 0 でも落ちない
prec.c sizeof c + 1 が 2((sizeof c) + 1)になる
typename.c 従来の sizeof(型名) 形式が壊れていない

noeval.c が本質である。check.py の Step 2 も同じことを単体で確かめており、 偽のコード生成器で codegen の呼出し回数が 0 回であることを直接見ている。 golden.py の最後で fixed17 を回すのは、型名形式を壊していないことの確認である。

発展課題

  1. sizeof の結果を unsigned にしたくなったら: C の sizeof の型は size_t (符号なし)である。本仕様は例外 E1 で int と定めた。もし size_t を入れるなら、 a - b < 0 のような比較がどう変わるかを考える
  2. 配列があったら: この言語に配列は無いが、もし int a[10]; を足したら sizeof a は 40 で sizeof &a[0] は 8 になる。「配列はポインタである」という よくある説明が、なぜ sizeof の前で崩れるのかを説明する
  3. sizeof の中の宣言: sizeof (struct Point){1, 2} のような複合リテラルは ISO C にはあるが本仕様には無い。_peek_is_type() の先読み 1 トークンで 足りなくなるのはどんな構文か
  4. 型検査と組み合わせる(Q1 完了者向け): sizeof f() で f が void を 返すときにエラーにする。どの情報を Step 1 まで運べばよいか
  5. L2 と並べる(L2 完了者向け): 「左辺は 1 回だけ評価される」と 「オペランドは評価されない」を、コード生成のどこで守っているかで比べる。 片方は codegen_lval の呼び方で、もう片方は codegen を呼ばないことで守っている
補足

コラム: 書いていないコードが仕様を守る。 バグは普通「書いたコードの間違い」として現れる。 しかしこの回のバグは、cg.codegen(node.operand) という もっともらしい 1 行を足してしまうことで生まれる。 足しても答えの数値は変わらないので、値だけを見るテストでは捕まらない。 仕様の一文(「オペランドは評価されない」)を読んでいなければ、 そもそもバグだと気づけない。 コンパイラを書いていると、こういう「書かないことが実装である」場面に何度か出会う。 短絡評価(S1)の「右辺を飛ばす」も、末尾呼出し(B3)の「フレームを積まない」も同じ形をしている。

この資料の原稿を見る