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 について、次を定める。
- 結果は
eの静的な型のサイズ(バイト数)である。実行時の値には一切依存しない。 eは評価されない。eに含まれる関数呼出し・代入・++/--などの副作用は 1 つも起こらない。eは型を決めるためだけに使われる。- 結果の型は
int(language_spec.mdの例外 E1 をそのまま引き継ぐ。 本仕様にsize_tは無い)。 - 不完全型・
voidへのsizeofはコンパイルエラーとする。 本体の見えていないstruct(前方宣言のみ)とvoidがこれにあたる (language_spec.mdの「未定義動作/エラー」の項目 8 とsizeof(void)の禁止に対応)。 ポインタは指し先が不完全でもサイズ 8 で決まるので、これは通す。 - 型名形式
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, 4codegen はどこにも現れない。この「無い」ことがこの回の実装である。
コード生成の関数が 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 を回すのは、型名形式を壊していないことの確認である。
発展課題
sizeofの結果をunsignedにしたくなったら: C のsizeofの型はsize_t(符号なし)である。本仕様は例外 E1 でintと定めた。もしsize_tを入れるなら、a - b < 0のような比較がどう変わるかを考える- 配列があったら: この言語に配列は無いが、もし
int a[10];を足したらsizeof aは 40 でsizeof &a[0]は 8 になる。「配列はポインタである」という よくある説明が、なぜsizeofの前で崩れるのかを説明する sizeofの中の宣言:sizeof (struct Point){1, 2}のような複合リテラルは ISO C にはあるが本仕様には無い。_peek_is_type()の先読み 1 トークンで 足りなくなるのはどんな構文か- 型検査と組み合わせる(Q1 完了者向け):
sizeof f()でfがvoidを 返すときにエラーにする。どの情報を Step 1 まで運べばよいか - L2 と並べる(L2 完了者向け): 「左辺は 1 回だけ評価される」と
「オペランドは評価されない」を、コード生成のどこで守っているかで比べる。
片方は
codegen_lvalの呼び方で、もう片方はcodegenを呼ばないことで守っている
コラム: 書いていないコードが仕様を守る。
バグは普通「書いたコードの間違い」として現れる。
しかしこの回のバグは、cg.codegen(node.operand) という
もっともらしい 1 行を足してしまうことで生まれる。
足しても答えの数値は変わらないので、値だけを見るテストでは捕まらない。
仕様の一文(「オペランドは評価されない」)を読んでいなければ、
そもそもバグだと気づけない。
コンパイラを書いていると、こういう「書かないことが実装である」場面に何度か出会う。
短絡評価(S1)の「右辺を飛ばす」も、末尾呼出し(B3)の「フレームを積まない」も同じ形をしている。