複合代入 — 左辺を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 回だけ評価される。
e1の lvalue(アドレス)を求める評価は ちょうど 1 回起こる。e1が副作用を持つ式(*bump()、a[f()]など)でも、 その副作用は 1 回しか起こらない。 したがってe1 op= e2はe1 = e1 op e2への書き換えでは実装できない。 - 評価順序は「
e1の lvalue 決定 →e2の評価 →e1の現在値の読み出し →opの適用 →e1への格納」とする。 - 式の値と型は代入と同じで、格納後の左辺の値・左辺の型である
(
y += x += 3のように連ねて書ける)。 - 型の規則は対応する二項演算と代入の規則をそのまま使う。
int op= int、char op= intは=と同じ縮小規則(下位 8 ビット)に従う。p += n/p -= n(pはポインタ、nは int)は「ポインタ ± int」であり、nは指し先型のサイズで尺度化される。*=/=%=にポインタは使えない。%=の両辺は int/char に限る。
- 未定義動作は
language_spec.mdの該当規則をそのまま引き継ぐ (/=%=の 0 除算、符号付きオーバーフローなど)。 - 複合代入は
&&||には無い(&&=のような演算子は追加しない)。
標準トラックのコンパイラはこの 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になって落ちる。答えが合うことと、意味が正しいことは別だと
テストで示している。
発展課題
++eを作り直す: コマ5 のPreIncを消し、パーサで++eをe += 1のCompoundAssignに変換する。language_spec.md自体はこの同値性を定めていない (複合代入は L2 が追加した機能である)。L2 が定めた意味論のもとで++eとe += 1を同値なものとして実装できるか (ポインタのスケーリングが両方で効くことを確かめる)- スケーリングの確認:
p += 2の生成コードをp = p + 2と見比べる。 同じ命令列になっているか。違うなら、どちらが仕様に沿っているか &&=が無いのはなぜか(S1 完了者向け): C には+=はあるのに&&=はない。短絡評価の&&に代入を混ぜると何が困るのかを、 S1 で作ったジャンプの形から考える- 型検査と組み合わせる(Q1 完了者向け):
p *= 2や 構造体への+=をコンパイル時に弾く。どの情報があれば判定できるか - 後置
++: Core プロファイルは後置++を除外している。x++を「増やす前の値を式の値にする」形で実装しようとすると、 前置と比べて何が増えるか
コラム: 略記のための規則。
x += 3 は x = x + 3 の略記である——教科書はそう説明するし、
たいていの場合それで困らない。
しかし言語仕様は、その「たいてい」を許さない。
a[i++] += 1 のような式でも略記でありつづけるために、
「左辺は1回だけ評価する」という規則をわざわざ書き足している。
仕様書の細かい一文は、ほとんどがこういう例外を潰すためにある。
コンパイラを書くと、その一文がコードのどの行に対応するのかが見えるようになる。