実践・C コンパイラ演習

複合代入 — 左辺を1回しか評価しない

今日のゴール

x += 3; p -= 1; x %= 5; のような複合代入 (+= -= *= /= %=)を、字句・構文・コード生成の3段すべてで実装する。

この回の位置づけ

項目 内容
必須の前提 コマ13(グローバル変数)まで。完了条件のうち golden.py は完成した final/mycc.py を土台に fixed17 を回すので、やり切るにはコマ15 も要る
推奨の前提 —
改変しない mycc.py、scaffold/(lexer.py・parser.py を含む)、ラッパー langcc.py(配布済み・完成品)
編集する compound.py
完了条件 check.py の全 Step が PASS になり、golden.py が複合代入のテストの通過と fixed17 全通を報告する
コマ数 1
仕様との関係 仕様の外側。language_spec.md は演算子節の「除外」に複合代入を挙げている。追加する構文と意味論は下記の「L2 が追加する仕様」節が定義する
備考 言語機能発展シリーズ。複合代入は language_spec.md の外側にある機能で、追加する構文と意味論は資料の「L2 が追加する仕様」節が定義する

複合代入は language_spec.md の外側にある機能である (仕様の「除外機能の一覧」に載っている)。 この回は言語そのものを 1 つ広げる回で、追加する構文と意味論はこの資料が定義する(次節)。 コマ5 で作った前置 ++/-- の一般化にあたる。 仕様も「前置 ++/-- の左辺は 1 回だけ評価される」と書いており、 複合代入はその規則を 5 つの二項演算へ広げたものになる。 F2 の発展課題2「複合代入を追加する」を構文で止めた人は、その続きになる。

L2 が追加する仕様

language_spec.md は複合代入を持たない。L2 はそこへ次の 5 つの演算子を足す。 以下がこの回で実装すべき言語仕様の追加分であり、check.py / golden.py の 判定はこの定義に従う。

構文

language_spec.md の EBNF の assign_expr を次で置き換える。

assign_expr ::= unary_expr assign_op assign_expr   /* 右結合 */
              | cond_expr
assign_op   ::= '=' | '+=' | '-=' | '*=' | '/=' | '%='

優先順位・結合の向きは = と同じ(最も低い段、右結合)。 左辺は unary_expr 由来の lvalue でなければならない。 字句では += -= *= /= %= を 2 文字の 1 トークンとして読む。

意味論

e1 op= e2(op は + - * / % のいずれか)について、次を定める。

  1. 左辺は 1 回だけ評価される。 e1 の lvalue(アドレス)を求める評価は ちょうど 1 回起こる。e1 が副作用を持つ式(*bump()、a[f()] など)でも、 その副作用は 1 回しか起こらない。 したがって e1 op= e2 は e1 = e1 op e2 への書き換えでは実装できない。
  2. 評価順序は「e1 の lvalue 決定 → e2 の評価 → e1 の現在値の読み出し → op の適用 → e1 への格納」とする。
  3. 式の値と型は代入と同じで、格納後の左辺の値・左辺の型である (y += x += 3 のように連ねて書ける)。
  4. 型の規則は対応する二項演算と代入の規則をそのまま使う。
    • int op= int、char op= int は = と同じ縮小規則(下位 8 ビット)に従う。
    • p += n / p -= n(p はポインタ、n は int)は「ポインタ ± int」であり、 n は指し先型のサイズで尺度化される。*= /= %= にポインタは使えない。
    • %= の両辺は int/char に限る。
  5. 未定義動作は language_spec.md の該当規則をそのまま引き継ぐ (/= %= の 0 除算、符号付きオーバーフローなど)。
  6. 複合代入は && || には無い(&&= のような演算子は追加しない)。

標準トラックのコンパイラはこの 5 つのトークンを知らないままである。 L2 を実装しても language_spec.md は変わらない — 変わるのはあなたのコンパイラが 受理する言語のほうである。

x = x + 3 に書き換えればいい、ではない

複合代入を実装する一番簡単そうな方法は、構文解析の時点で x += 3 を x = x + 3 に展開してしまうことである(脱糖、desugaring)。 実際 x がただの変数なら、これで正しい答えになる。

しかし左辺が副作用を持つとき、この方法は壊れる。

*bump() += 10;          // bump() は呼ばれるたびにカウンタを増やす

これを *bump() = *bump() + 10; に展開すると、bump() が2回呼ばれる。 前節で定めたとおりである。

左辺は 1 回だけ評価される。e1 が副作用を持つ式でも、その副作用は 1 回しか起こらない。

つまり脱糖版はこの回の仕様に違反する。 「見た目が同じ式に書き換えられる」ことと「意味が同じ」ことは違う。 そのため、この回では CompoundAssign という専用のノードを作り、 コード生成で左辺のアドレスを1回だけ求める。

補足

なぜ C にはこの規則があるのか。 a[f()] += 1 や *p++ += 1 のように、左辺そのものが副作用を持つ式は 実際のコードによく出てくる。もし「左辺を2回評価してよい」規則だったら、 複合代入は「短く書ける記法」ではなく「意味の違う別物」になってしまう。 複合代入が単なる略記であり続けるために、 左辺の評価は1回という規則が必要なのである。

3段すべてに手を入れる

いまのコンパイラに x += 3; を含むソース(tests/basic.c)を食わせると、 字句解析の時点で話が終わっている。

構文解析エラー: [line 6] 式が期待されましたが '=' がありました

+ と = が別々のトークンとして読まれ、x + (=) 3 を読もうとして失敗している。 += というトークンがそもそも存在しないからである。

段階 追加すること 担当
字句 += -= *= /= %= を2文字トークンとして読む Step 1
構文 代入の段で読み、CompoundAssign の木にする langcc.py
コード生成 アドレス1回・ロード・演算・ストア Step 2・3

字句 — 2文字演算子を足す

スキャフォールド の lexer.py は、1文字ずつ切り出す前に 2文字演算子の表 TWO_CHAR_PUNCTS を先に試すという順序になっている。 表に '+=' を足すだけで += が1つのトークンになる。

TWO_CHAR_PUNCTS = ['==', '!=', '<=', '>=', '&&', '||', '->', '++', '--']

++ と += が取り違えられないのは、2文字まるごとを比較しているからである。 順序を気にする必要があるのは「片方がもう片方の先頭になっている」場合 (< と <= のような1文字と2文字の関係)だけで、 2文字どうしにその関係はない。

補足

標準トラックのテストでは、この5つのトークンは「無い」ままである。 workbook/final/tests(fixed17)も、OCaml 版パーサとの S 式一致テストも、 複合代入を含まないソースだけで書かれている。 この回の tests/*.c は標準トラックの字句を通らないので、 web の golden テストでは skip として数えられる。これは失敗ではなく、 複合代入が language_spec.md の外側にあるという線引きがそのまま出ている状態である。

構文 — 代入と同じ段、右結合

複合代入は代入と同じ優先順位・同じ結合の向きで読む (「L2 が追加する仕様」で置き換えた assign_expr は右結合)。

assign_expr ::= unary_expr assign_op assign_expr
              | cond_expr
assign_op   ::= '=' | '+=' | '-=' | '*=' | '/=' | '%='

_parse_assign は「まず cond_expr として読み、直後が代入演算子かを見る」形なので、 '=' の判定を5つの演算子にも広げるだけでよい(F2 の発展課題2 と同じ手である)。 右辺で自分自身を呼ぶので、a += b += c は a += (b += c) と読まれる。

kind='CompoundAssign'、sval='+='、lhs=Var(a)、rhs=CompoundAssign('+=', Var(b), Var(c))

この部分はラッパーが用意する。演算子を sval に入れて種類を1ノードで持つのは、 Add / Sub / … と5種類のノードを作るより、コード生成で表を引くほうが素直だからである。

コード生成 — アドレスは1回、値は往復

x += 3 がやることを分解すると、次の4つになる。

1. 左辺のアドレスを求める      ← ここを1回だけにするのが主題
2. そのアドレスから値を読む
3. 右辺と演算する
4. 同じアドレスへ書き戻す

アドレスは 1 で求めたものを 4 で使い回す。 その間に右辺の評価が挟まる(右辺も a0 を使う)ので、 アドレスと途中の値はスタックへ退避する。

;; x += 3   (x は int、s0-8 にある)
  # 左辺のアドレス          ← codegen_lval は1回だけ
  addi a0, s0, -8
  addi sp, sp, -8
  sd a0, 0(sp)          ;; アドレスを退避
  lw a0, 0(a0)          ;; いまの値
  addi sp, sp, -8
  sd a0, 0(sp)          ;; いまの値も退避
  li a0, 3              ;; 右辺
  ld a1, 0(sp)          ;; a1 = いまの値
  addi sp, sp, 8
  add a0, a1, a0        ;; 演算(演算子ごとに命令が変わる)
  ld a1, 0(sp)          ;; a1 = 左辺のアドレス
  addi sp, sp, 8
  sw a0, 0(a1)          ;; 書き戻す

コマ5 の codegen_PreInc と見比べてほしい。 addi a0, a0, 1 が「右辺を評価して演算する」に置き換わっただけで、 骨格は同じである。++e を e += 1 の特別な場合とみなせるのは、 L2 がそう定義したからであり、コードの上でもそのとおりになっている。

演算子 命令
+= add
-= sub
*= mul
/= div
%= rem

書き戻したあと a0 には結果が残るので、y += x += 3 のように 式としての値も自然に手に入る。

補足

x /= 0 はどうなるか。 0 による除算・剰余は仕様の「未定義動作」に挙がっている。 コンパイル時に止める義務はなく、実行時に何が起きても仕様違反ではない (RV64 の div は例外を出さず、決められた値を返す)。 検査を足したければ Q1(型検査パス)の側の仕事になる。

ポインタへの += は要素サイズぶん進む

p += 2 は「2バイト進める」ではなく「2要素ぶん進める」である。 コマ9 の p + 2 と同じスケーリングを、複合代入でも行う必要がある。

;; p += 2   (p は int*)
  li a1, 4
  mul a0, a0, a1        ;; 右辺 2 → 8 バイト

スケーリングは右辺を評価した直後に入れる。 このとき「いまの値」はスタックの上にいるので、a1 を作業に使ってよい。

なお、ポインタに使えるのは += と -= だけである (p *= 2 は仕様の「ポインタ演算はポインタ ± int のみ」に反する)。

実装

Step 実装対象 内容
1 extend_puncts 2文字演算子の表に5つを足す
2 gen_compound_assign アドレス1回 → ロード → 演算 → ストア
3 scale_rhs_for_ptr ポインタなら右辺を要素サイズ倍する

Step 2 の間はポインタを忘れて int だけ動かし、Step 3 で足すとよい。

python3 langcc.py tests/basic.c | grep -A8 "addi a0, s0"   # 往復の形を見る
python3 langcc.py tests/once.c | grep -c "call bump"       # 2 のはず(実行時に2回呼ぶ)
python3 langcc.py tests/ptr.c | grep -B1 "mul a0, a0, a1"  # スケーリング

テスト

python3 check.py     # Step ごとの単体テスト(未実装は SKIP)
python3 golden.py    # 複合代入のテスト + fixed17
テスト 確認内容
basic.c 5つの演算子と、y += x += 3(右結合・式の値)
once.c *bump() += 10 で bump() が1回しか呼ばれない
ptr.c int* の += -= が要素単位で進む / char* は1バイト

once.c が本質である。脱糖で実装すると、答えは合っているのに カウンタが2になって落ちる。答えが合うことと、意味が正しいことは別だと テストで示している。

発展課題

  1. ++e を作り直す: コマ5 の PreInc を消し、パーサで ++e を e += 1 の CompoundAssign に変換する。 language_spec.md 自体はこの同値性を定めていない (複合代入は L2 が追加した機能である)。L2 が定めた意味論のもとで ++e と e += 1 を同値なものとして実装できるか (ポインタのスケーリングが両方で効くことを確かめる)
  2. スケーリングの確認: p += 2 の生成コードを p = p + 2 と見比べる。 同じ命令列になっているか。違うなら、どちらが仕様に沿っているか
  3. &&= が無いのはなぜか(S1 完了者向け): C には += はあるのに &&= はない。短絡評価の && に代入を混ぜると何が困るのかを、 S1 で作ったジャンプの形から考える
  4. 型検査と組み合わせる(Q1 完了者向け): p *= 2 や 構造体への += をコンパイル時に弾く。どの情報があれば判定できるか
  5. 後置 ++: Core プロファイルは後置 ++ を除外している。 x++ を「増やす前の値を式の値にする」形で実装しようとすると、 前置と比べて何が増えるか
補足

コラム: 略記のための規則。 x += 3 は x = x + 3 の略記である——教科書はそう説明するし、 たいていの場合それで困らない。 しかし言語仕様は、その「たいてい」を許さない。 a[i++] += 1 のような式でも略記でありつづけるために、 「左辺は1回だけ評価する」という規則をわざわざ書き足している。 仕様書の細かい一文は、ほとんどがこういう例外を潰すためにある。 コンパイラを書くと、その一文がコードのどの行に対応するのかが見えるようになる。

この資料の原稿を見る