見出し画像

カーニハンCを読む ついでにJISXも読む 優先度と評価順序 多分わかったんじゃないか劇場

先日来より、C言語の演算子の優先順位でバタバタしていたんだが、kznさんから「JISを読んでみたら?」とコメントいただいて、JISで「優先」を検索してうんうん唸っていたら、ふと視界が開けた気がする。
だからネットって面白い。

kznさんから教えていただいたJISはこちら。

私のバタバタ記事はこちら。



C言語の「++(後置)」は優先順位が高いのである

C言語のインクリメント演算子である「++(後置)」は実は優先順位が高い。(後置)というのは

「++」の演算を後から実行する

という意味で「後置」という。
だから

最も優先順位が高い

などと言われると面食らう。

後から実行するのに何故優先順位が高いのか
優先順位が高いのであれば、「後から」ではなく「最初に」に計算するのではないか

そう思うのが自然である。

だが。

優先順位は高いが後から実行する

これは正しい(多分)。


コンパイラになって考えてみる

もう、ここはコンパイラになった気持ちで考えてみるしかない。
なので、次のコードをコンパイルしてみる。

a[i] = i++

「a」と「i」はメモリに配置されているものとする。
さらに「i = 3」とする。

a[i] = i++ はどう計算するのだろうか。

(1) メモリから i をレジスタ X1 に取り出す。
 X1 = 3 となる。

(2) さて、これで i の参照は終わった。
 「 = i」が終了したわけである。
 従って、ここで i をインクリメントする。
 i = 4 となる。

(3) 「a[i]」のアドレスを計算する。
 「a」の先頭アドレスに「i」だけ足せばよい。
 ところが「i」は既に「4」になっている。
 従って、a[4] を計算することになる。
 そしてそこに X1 の 3 を代入する。
 結果、次のようになる。

a[4] = 3

(2) のインクリメント処理は後置であるため (1) の後に実行される。
だが、他の演算子(この場合は代入演算子「=」)よりも優先順位は高いため (3) の前に実行される。

(1) のステップはC言語のコード上に明確に見えない。だから、「(1)の後」と言われてもピンとこないところはある。また、コンパイルによっては (1) のステップがないかもしれない。

先にリンクした ISO には次のような記載がある。

6.5.2.4 後置増分及び後置減分演算子
制約 後置増分演算子又は後置減分演算子のオペランドは,実数型又はポインタ型の修飾版又は非修飾版をもたなければならず,変更可能な左辺値でなければならない。
意味規則 後置++演算子の結果は,そのオペランドの値とする。結果を取り出した後,オペランドの値を増分する(すなわち,適切な型の値1をそれに加える。)。制約,型,並びにポインタに対する型変換及び演算の効果については,加減演算子及び複合代入の規定のとおりとする。オペランドに格納されている値を更新する副作用は,直前の副作用完了点から次の副作用完了点までの間に起こらなければならない。
後置--演算子は,オペランドの値を減分する(すなわち,適切な型の値1をそれから引く)点を除いて,後置++演算子と同一とする。

『JISX3010』

太字の「結果を取り出した後」が (1) にあたる。
そうだったのか…(  ̄- ̄)。
結果を取り出した後」でさえあればその後のどこで実行しようが構わないわけだ。「式全体の評価の後」ではない、と。

ちなみに、カーニハンCでは

2.8 インクレメントとデクレメントの演算子
 Cには変数のインクレメントとデクレメントに対して,通常見慣れない二つの演算子がある。インクレメント演算子++はその被演算子に1を加え,デクレメント演算子--は1を引くものである。われわれは次のように変数のインクレメントに++をよく使っている。
 if (c == '\n')
  ++n1;
見慣れない面は,++と--が前置演算子(++n のように前にある)と後置演算子(n++のように後にある)のどちらにでも使えることであろう。両方ともnをインクレメントする効果を持っているが,式++nはnの値を使用する前にインクレメントするのに対し,n++はn の値を使った後にインクレメントするのである。

『プログラミング言語C』

『使用する前』と『使った後』。
『使った後』と言われると「a[i]に代入した後」と思っちゃうのよね。

でも「結果を取り出した後」と言われるとちょっと違う。そこに「a[i]への代入」が含まれている感じがしない。


実際にコンパイルしてみる

とにかく試してみた。
コンパイラはこれ。

~ $ cc -v
clang version 16.0.6
Target: aarch64-unknown-linux-android24
Thread model: posix
InstalledDir: /data/data/com.termux/files/usr/bin
~ $

ソースコードはこれ。

#include <stdio.h>

void test1()
{
        int i = 3;
        char a[10] = {0};
        a[i] = i++;
        printf("a[3] = %d\n", a[3]);
        printf("a[4] = %d\n", a[4]);
        printf("i    = %d\n", i);
}

int main()
{
        test1();
        return 0;
}

コンパイルする。

cc -g -gdbx pri.c -o /data/data/com.termux/files/usr/bin/_local/pri
pri.c:7:10: warning: unsequenced modification and access to 'i' [-Wunsequenced]
        a[i] = i++;
          ~     ^
1 warning generated.

なんと!
警告された。

unsequenced modification and access to 'i' [-Wunsequenced]

順序付けられていない修正と「i」へのアクセス[-WunSequenced]

処理順序が定義されていないことに対する警告だ。
ひぇ~。
昨今のコンパイラはこんなことまで警告してくれるのか。ヘタな静的解析よりよくやってくれるんじゃね? clang には静的解析の機能もあった気がするが。

警告はともかく、error ではなく warning であるので、実行モジュールは作成されているはずである。
なので実行してみる。

~ $ pri
a[3] = 0
a[4] = 3
i    = 4
~ $

面白いくらいに想定通りの動作であった。


ここまで来たらアセンブラを覗こうではないか

って、そう思いません?
なので、コンパイラにアセンブラを出力してもらった。

	.globl	test1
// -- Begin function test1
	.p2align	2
	.type	test1,@function
test1:     // @test1
	.cfi_startproc
// %bb.0:
	// (1) スタックを確保
	// sp[0]-[9]  :a[0]-[10]
	// sp[12]-[15]:i
	// sp[16]-[23]:x29
	// sp[24]-[31]:x30
	// 
	// x29=&sp[16]
	// i は [x29, #-4]
	// a[0]-[9] は [sp, #0]-[sp, #9]
	sub	sp, sp, #32			// sp<-sp-32
	.cfi_def_cfa_offset 32
	
	// (2) x29 と x30 レジスタをスタックに待避しておく。
	stp	x29, x30, [sp, #16]	// push x29, x30
// 16-byte Folded Spill
	add	x29, sp, #16		// x29<-sp+16
	.cfi_def_cfa w29, 16
	.cfi_offset w30, -8
	.cfi_offset w29, -16
	
	// (3) i = 3
	mov		w8, #3			// w8<-3
	stur	w8, [x29, #-4]  // w8->i
	mov		x9, sp
	
	// (4) a[10] = {0}
	str		xzr, [sp]		// 0->a[0]-[7]
	strh	wzr, [sp, #8]	// 0->a[8]-[9]
	
	// (5) w8 に i を取り出す
	ldur	w8, [x29, #-4]	// w8<-i
	
	// (6) i++
	add	w10, w8, #1			// w10<-w8+1
	stur	w10, [x29, #-4]	// w10->i
	
	// (7) a[4] = 3 (a[i++] = i)
	ldursw	x10, [x29, #-4]	// x10<-i
	strb	w8, [x9, x10]	// w8->a[i](3->a[4])

	// (8) printf("a[3] = %d\n", a[3]);
	ldrb	w1, [sp, #3]
	adrp	x0, .L.str
	add	x0, x0, :lo12:.L.str
	bl	printf
	
	// (9) printf("a[4] = %d\n", a[4]);
	ldrb	w1, [sp, #4]
	adrp	x0, .L.str.1
	add	x0, x0, :lo12:.L.str.1
	bl	printf
	
	// (10) printf("i    = %d\n", i);
	ldur	w1, [x29, #-4]
	adrp	x0, .L.str.2
	add	x0, x0, :lo12:.L.str.2
	bl	printf
	
	.cfi_def_cfa wsp, 32
	ldp	x29, x30, [sp, #16]		// pop x29, x30
// 16-byte Folded Reload
	add	sp, sp, #32
	.cfi_def_cfa_offset 0
	.cfi_restore w30
	.cfi_restore w29
	ret
.Lfunc_end0:
	.size	test1, .Lfunc_end0-test1
	.cfi_endproc                                        // -- End function

まさにココ。

	// (5) w8 に i を取り出す
	ldur	w8, [x29, #-4]	// w8<-i
	
	// (6) i++
	add	w10, w8, #1			// w10<-w8+1
	stur	w10, [x29, #-4]	// w10->i

	// (7) a[4] = 3 (a[i++] = i)
	ldursw	x10, [x29, #-4]	// x10<-i
	strb	w8, [x9, x10]	// w8->a[i](3->a[4])


i を取り出してから i++ している(5)(6)。
(5)(6)が終わった時点で
 w8 が i
 [x29, #-4] が i++
なのねー。
上手いことしてるなぁ。こうやってみると、コンパイラの開発って難しそうだとつくづく感じる。

そして、 a[i] = i++ はというと…
 添字の [i] は ++ された i ([x29, #-4])を参照し、
 「= i++」は ++ される前の w8 を参照している。

後置なので「= i」が終わってから ++ する。
でも、優先順位が高いので「a[i] = 」よりも先に ++ する。

これで、後置であり、かつ優先順位が高いという、一見相反するような2つの性質が同時に成り立つわけである。

ふぅ~。
そういうことだったのか~(多分)。
スッキリしたー(個人的には)。

この記事が気に入ったらサポートをしてみませんか?