C言語#
プログラミング言語
教科書7章
講義2回分
序論#
本ページでは命令型言語に対する理解を深めるために,プログラムと機械語命令の対応を考える.例えば,i = 42という代入文に対応する機械語はLDC 42, STL 1である(1).文i = 42というプログラムの持つ意味はLDC 42, STL 1であると解釈できる.また文return 42の意味はLDC 42, STL 0, RET 0である.
- 機械語の詳細は後述するので,深く考えなくて良い.プログラムと機械語の1対1対応があると捉えれば良い.
このような,プログラムを計算ステップの連続として解釈する方法を,操作的意味論(operational semantics)という.プログラムの意味を数学的・形式的に捉える手段であり,他にも表示的意味論や公理的意味論などが存在する.
Note
言語学の一つとして意味論という学術分野もある.自然言語の意味論としては以下のフレーズが有名である.
Colorless green ideas sleep furiously
色無き緑の考えが猛烈に眠る
統語論(構文)としては正しいが意味としては明らかにおかしい.この場合の「意味」とは何かを洞察し体系化する理論が意味論である.
本ページはC言語プログラムを題材として操作的意味論を考える.これはコンパイラの入力と出力(プログラムと機械語)の対応を考える問題である.コンパイルの処理内容は扱わないので注意せよ.
理解を容易にするために以下のような簡単化を行う.
- コンパイル元の言語 :C言語のサブセット言語,SC言語(Small C言語)を定義し用いる
- コンパイル先の機械語:仮想計算機の上で動作する機械語命令,SCコードを定義し用いる
操作的意味論を考えるためには,SC言語とSCコードの両方を理解する必要がある.よって本ページで理解すべきコンテンツは多めである点に注意せよ.個々の要素は比較的単純である.単純な要素が多数あると考えよ.
SC言語#
C言語はかなり複雑な構文規則を持っており,その全体の把握は容易ではない.ここではC言語を簡素化したサブセット言語,SC言語(Small C言語)を定義する.
C言語と比較したときのSC言語の特徴は主に次のとおりである.
- 基本データ型は
void型とint型のみ(charやlongはNG) - 構造型は一次元配列とポインタ型のみ(
int a[3]やint *pはOK,二次元配列はNG) - 関数の引数と返り値は
voidintint *のみ staticexternconstは存在しない- 局所変数は複文の開始直後でのみ宣言できる(
... { int i; ...はOK) - 宣言時に初期化はできない(
int i = 0;はNG,int i; i = 0;はOK) - 文は複文,式文,if文,while文,break文,return文のみである(for文やgoto文はNG)
- 変数の有効範囲や存続範囲はC言語と同じ
具体的なSC言語のプログラムを示す.
int g;
int f(int n) {
return 4 * n;
}
void main() {
int i;
g = 2;
i = g + f(10);
printf("%d", i);
}
Quiz
上記プログラムの出力を考えよ.
Answer
42.本ページでは42という数字を題材共通の出力結果の意味で用いる.頭の片隅においておくと良い.
SC言語はC言語の完全なサブセット言語であるため,一般的なC言語コンパイラで実行できる.プログラムの振る舞いが理解できないと感じたら,オンラインIDE等で実行してみること.
構文定義#
構文図式(railroad diagram)を用いたSC言語の構文定義を示す.サブセット言語とはいえ相応の規模の構文規則が含まれている.全規則を理解する必要はない.適宜参照せよ.前提として式と文の違いをよく理解しておくこと.
再掲:式と文(構文)
C言語においては;や}を末尾に持つ要素が文である.式は四則演算などの評価結果となる値を持つ要素である.代入文の右辺の要素は全て式と考えてよい.文と式は入れ子構造を取る.
- 文:
n = i * 2;if (n > 5) return 1;return 1;return 0; - 式:
i * 2i2n > 5n510(1)
- 代入文の左辺
nは式ではない.その理由は後述する例題2を確認せよ.
構文図式によるSC言語の構文定義
num は任意の数字(1や99),id は任意の識別子(iやsum)を表す非終端記号である.
構文図式の詳細は構文#構文図式を参照せよ.railroad diagramとも呼ばれる.電車が直角に進路を変更できないことを考えれば理解は容易である.
Quiz
加算式と乗算式は次のように定義されている.
この定義は以下のように一つにまとめることも可能である.
なぜネスト構造で定義しているのか? 一つにまとめない理由は何か?
Answer
式の曖昧性排除や演算子の優先度の制御のためである.例えば式1 + 2 * 3に対する解析木は唯一である(曖昧性がない).
%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 10, 'rankSpacing': 30, 'padding': 5}}}%%
graph TD
classDef tr fill:#f6f6f6,stroke:#c0c0c0;
加算式 --- 1:::tr;
加算式 --- plus[#43;]:::tr;
加算式 --- 乗算式;
乗算式 --- 2:::tr;
乗算式 --- times[#42;]:::tr;
乗算式 --- 3:::tr; また乗算が優先されており,演算子の優先度も適切に制御できる.優先すべき演算子の生成規則を後で適用する.これにより解析木の深い位置に優先すべき演算子を配置する.
構文の無曖昧さや演算子の優先度については,構文#無曖昧な文脈自由文法を参照せよ.
SCマシン#
世の中には様々なプロセッサ,およびプロセッサに対応した機械語が存在している.機械語の命令群は命令セット(instruction set)と呼ばれる.PC環境においてはx86やx64が有名である.スマホ等のモバイル環境ではARM64が広く用いられている.
一言にC言語をコンパイルするといっても,どの命令セットにコンパイルするかによって出力は異なる.例えば以下のようなC言語プログラムに対して;
x64とARM64ではコンパイル結果は当然異なる.
実際のプロセッサで用いられる命令セットは極めて複雑なため,ここでは簡単な仮想計算機と命令セットを定義する.定義する仮想計算機はSCマシン(Small C machine)である.SCマシンはスタックマシンの一種であり,メモリをスタック形式で利用する単純な計算モデルである.SCコードはSCマシン用の仮想命令セットである.
加算式2 + 40に対するSCコード,およびSCマシンの振る舞いを示す.
図は左から右に向かってSCコードが一つずつ実行されている.青枠がメモリの単一セルであり,赤丸と 緑丸がスタックの範囲を指し示すレジスタである(1).LDC命令(load constant,定数のロード)によってスタックに定数2と定数40が積み上がる.BOP命令(binary operator,二項演算)によってスタックの最上位の2セルの加算が適用され,2と40を取り除いた後に42がスタックに積み上げられる.
- 詳細は次のSCマシンの構成で解説する.
SCマシン上での命令の順序は逆ポーランド記法(reverse polish notation,RPN,postfix notation,後置記法)とほぼ対応する.逆ポーランド記法は演算子がオペランドの後に続く式の表記方法である.ポーランド記法の詳細は関数型言語#ポーランド記法を参照せよ.
2 + 40 // 中間記法
+ 2 40 // 前置記法,ポーランド記法
2 40 + // 後置記法,逆ポーランド記法
2 + (4 * 10) // 中間記法
+ 2 * 4 10 // 前置記法(括弧が不要)
2 4 10 * + // 後置記法(括弧が不要)
2 40 +
// SCコード: LDC 2, LDC 40, BOP +
// スタック操作:push 2, push 40, +
2 4 10 * +
// SCコード: LDC 2, LDC 4, LDC 10, BOP *, BOP +
// スタック操作:push 2, push 4, push 10, *, +
SCマシンの構成#
SCマシンは2種類のメモリ領域を持つ.
図右側のプログラム領域には,実行すべき機械語命令SCコードが実行順に格納されている.図左側のスタック領域は,プログラム実行中の様々な値を保持するメモリ領域であり,SCコードを実行しながら適宜その値を書き換えていく.スタック領域での値のロードやストア処理は,当然スタック形式で実行される(1).
- どちらの領域もメモリ上の空間という意味で同じではあるが,利用方法と目的に大幅な差異があるため異なる表現で表すこととする.
SCマシンは上記2つのメモリ領域を制御する3種類のレジスタを持つ.
pc(プログラムカウンタ):プログラム領域のうち次に実行するSCコードを指すsp(スタックポインタ):スタック領域のうち実行中関数のスタック最上部を指すbs(ベースポインタ):スタック領域のうち実行中関数のスタック最下部を指す
pcは 一つのSCコードを実行すると値が1加算される.命令が逐次的に一つずつ実行されると考えると良い.プログラム領域自体はスタックではなく直列データとして扱われる点に注意せよ.関数呼び出しやif文などによる分岐(ジャンプ)が必要な場合は,pcの値を適切な分岐先に設定する.
実行中の関数のスタックはbsからspの範囲である.スタック最下部を示すbsは,関数を呼び出した瞬間を除いて値は不動である.スタック最上部を示すspはSCコードを実行する度に値が変更される.どのように変更されるかは実行するSCコードによって異なる.例えばLDC等のロード系命令はスタック上部に値を積み上げるプッシュに相当する操作であり,spの値は増加する.逆にストア系命令はポップに相当する操作であり,spの値は減少する.
SCコード#
機械語命令は一般的に<オペコード> <オペランド1> <オペランド2> ...の形式で表現される.オペコード(opcode,operation code)とは命令そのものであり,オペランドはオペコードの操作対象である.機械語命令LDC 42のオペコードはLDC,オペランドは42である.
SCマシン上での機械語命令列SCコードを定義する(1).以下の表を参照せよ.SCコードは19個の命令で構成される.
- 教科書との差異があるので注意せよ.簡単化している.
一番右の列は各SCコードを実行したときの,スタック領域と3つのレジスタに対する操作をC言語形式で表現している.これは説明のための表現方法であり,コンパイラの入力となるC言語プログラムとは無関係である点に注意せよ.スタック領域は配列t[],スタックポインタはspで表している.
全てを暗記する必要はない.続く操作的意味論の説明において,SCコードの振る舞いを考える際に適宜参照すれば良い.
| # | SCコード | 意味 | スタック領域とレジスタに対する操作 |
|---|---|---|---|
| 1 | LDC a | load constant | sp++;t[sp]=a; |
| 2 | LDL a | load local | sp++;t[sp]=t[bs+a]; |
| 3 | LDG a | load global | sp++;t[sp]=t[a]; |
| 4 | LDLA a | load local addr | sp++;t[sp]=bs+a; |
| 5 | LDGA a | load global addr | sp++;t[sp]=a; |
| 6 | STL a | store local | t[bs+a]=t[sp];sp--; |
| 7 | STG a | store global | t[a]=t[sp];sp--; |
| 8 | STI | store at indexed addr | t[t[sp-1]]=t[sp];sp--;sp--; |
| 9 | IND a | indexed fetch | t[sp]=t[t[sp]+a]; |
| 10 | JMP L | jump | pc=L; |
| 11 | JPZ L | jump if zero | if(t[sp]==0){pc=L;}sp--; |
| 12 | ENT a | enter block | sp=bs+a; |
| 13 | MST | mark stack | sp++;t[sp]=bs;sp++; |
| 14 | CUP a | call user procedure | t[sp-a]=pc+1;bs=sp-a-2;pc=t[bs]; |
| 15 | RET a | return from function | sp=bs-a;pc=t[bs+2];bs=t[bs+1]; |
| 16 | BOP o | binary operation | sp--;t[sp]=(t[sp])o(t[sp+1]); |
| 17 | UOP o | unary operation | t[sp]=o(t[sp]); |
| 18 | IBS a | increment base | bs+=a; |
| 19 | HLT | halt |
操作的意味論#
具体的なSC言語プログラムとSCコードの対応を確認しながら,SC言語の操作的意味論を考えていく.また,ここまでで未定義のSCマシンの詳細も具体例を交えながら説明する.図中の記号の意味は以下の通りである.適宜参照せよ.
- 作業領域セル
- 返値領域セル
- 制御領域セル
- 局所変数領域セル
- 大域変数領域セル
pc(プログラムカウンタ)sp(スタックポインタ)bs(ベースポインタ)
main関数#
まず空のmain関数を考える.SC言語プログラムとSCコード,および実行時スタックの挙動は以下の通りである.
関数の実行直後は,スタック最下部に返り値領域1つが確保されている.最初の命令ENTによって制御領域2つを確保する.main関数ではこれらの領域は使用しないが,それ以外の関数で活用する(1).スタックの最下部はだと考えておくと良い.
- 詳細は関数呼び出しで解説する.
局所変数#
先のプログラムに局所変数の定義と代入文を加える.
ENTによる領域確保は変数i用の局所変数1つを加えた計3つとなる.
関数内部の処理は作業領域で行われる.代入文を実行する際は,まず右辺の値42をLDC命令でスタックに積み上げてから,局所変数iのメモリセルにSTL命令でストアする.変数iはbsから3つ目の位置に配置されているため,ストア命令はSTL 3となる.
Note
コンパイラは各変数とスタック位置の対応表を作成して機械語を生成(コンパイル)していく.この対応表は記号表とも呼ばれる.
| 変数名 | どの関数で 定義されたか? | スタックの どの位置に保持するか? |
|---|---|---|
i | main | 3 |
j | main | 4 |
記号表は3年後期の言語処理工学Aでその理論を学び,同後期の情報科学演習Dで実際に作成する.
ロード系の命令LD*とストア系の命令ST*の流れは,ノイマン型コンピュータのload storeに対応する.
再掲:(特徴と分類)
%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 10, 'rankSpacing': 50, 'padding': 5}}}%%
graph LR
メモリ -- load --> CPU
CPU -- store --> メモリ 大域変数#
大域変数の宣言と代入は次の通りである.
ENT命令の前にIBS命令によって大域変数1つを確保する.この際ベースポインタbsは+1される.大域変数はmain関数のスタックの範囲外に配置されるということになる.
それ以外は基本的に局所変数と同様であるが,ストア命令がSTLからSTGに変更されている.STG命令で指定するストア先は変数gの絶対位置である1となる.
大域変数は常にスタック外に設置されるため,アドレス指定は絶対指定である.他方,局所変数はbsの上側に設置されるため,アドレス指定はbsからの相対指定となる.
右辺に式を持つ代入文#
さらに右辺に式を持つ代入文を考える.
全体的な挙動はこれまでの説明の繰り返しである.i = g + 40の操作をよく観察すると良い.
ここで操作的意味論を考える.代入文とは以下のような機械語操作列であると意味付けできる.
代入文の操作的意味
※<i>は変数iのbsからの相対位置であり,実際には非負整数を取る. 代入文の右辺は単なる値や二項演算,論理和など様々な式を取りうる.代入時には,まずその右辺に該当するSCコード列を実行する.その後,ストア命令を用いてスタック最上部にある式の処理結果の値を,代入先変数に対応するメモリセルに格納する.
《宿題1》#
制御文#
if文やwhile文等の制御文を考える.制御文はJMP命令(無条件ジャンプ)とJPZ命令(条件付ジャンプ)を用いて,プログラムカウンタpcを書き換えることにより実現される.適宜pcを書き換えてジャンプするというアイデアは,特徴と分類のRAマシンと同じである.
まず単純なif文は次のとおりである.
if文の条件式は定数ロードとBOP >で処理されている.比較演算子>の結果は真偽値であり,具体的な値としては真が1,偽が0である.JPZ命令はスタック最上部(条件式の結果)を確認し,値が0の場合pcをxに変更して分岐を実現する(1).
xはメモリの記号番地である.実際には46AE89F09Dのような数値である.
if文の操作的意味論は次の通りである.
if文の操作的意味
if-else文は●●●の処理と▲▲▲の処理がジャンプによって排他的である点に着目すれば良い.
Note
上記if文のSCコードを考えると,以下の(一見不自然な)プログラムの挙動が理解できるはず.
Quiz
結局偽とは何か?真とは何か?機械語のレベルで具体的な値は何なのか?100は真か?偽か?-100は?
続いてもう一つの制御文であるwhile文を考える.if文と同様,ジャンプ命令を適切なタイミングで実行すれば良い.なおこの題材は無限ループが発生するが,プログラムの操作的意味を考える上では本質ではない.
解説は省略する.ジャンプ命令に着目すれば理解は容易である.while文は以下2つのジャンプ命令で構成されると考えると良い.
- ループを終了するジャンプ(
JPZ条件付ジャンプ,下向きにジャンプする) - 繰り返しを実現するジャンプ(
JMP無条件ジャンプ,上向きにジャンプする)
whileの操作的意味は次の通りである.
while文の操作的意味
式#
これまでに見てきたように,式は次のような意味を持つ.
式の操作的意味
二項演算子(+や>)の場合:
単項演算子(-や!)の場合:
スタック領域のレイアウト#
続いて関数呼び出し文を考えるが,その前にここまで未定義であったスタック領域のメモリレイアウトを説明する.以下の図は,関数mainの中で関数fを呼び出した際のスタック領域を表している.
メモリのアドレスは一番下から連番が振られる.各区画は複数のメモリセルで構成される.スタックの最下部は大域変数の領域である.宣言された大域変数の数だけメモリセルが確保される.
大域変数の上側が個々の関数専用のメモリ領域であり,これをスタックフレームと呼ぶ(単にフレームとも呼ぶ)(1).プログラム実行時には,まず最初に関数mainが呼び出され関数mainのフレームが確保される.mainの中で別の関数fが呼び出されると,関数fのフレームがmainのフレームの上部に確保される.このとき,ベースポインタbsとスタックポインタspを移動させる.これにより,現在実行中の関数がmainからfに移動したことになり,以降の作業はfのフレームの上部で行われることになる.
- 命令型言語#実行時スタックで説明した駆動レコードと同じ概念である.
さらにスタックフレームの内部構成を次の図に示す.
最上部は作業領域であり,値のロードや式の計算等に用いられる.これまで説明してきたスタック上の処理はこの領域で行われていた.
局所変数領域には,その関数で定義された局所変数が割り当てられる.これまでの説明にあったmainの局所変数iはこの領域に格納されていた.
関数fへ渡される引数は引数領域に格納される.局所変数と同様,渡される引数の数だけメモリセルが確保される.引数領域は局所変数領域の一種である.関数fから見ると,引数と局所変数は同じ性質を持つデータである.
制御情報領域は,関数呼び出しにおいて最も重要な領域である.関数呼び出しは一種のジャンプであるため,逐次実行とは挙動が大きく異なる.ジャンプの前に次の2つの制御情報を保持しておく必要がある.
- 戻りアドレス:関数終了後に戻るSCコードの位置(現在の
pcの値) - 動的リンク :関数終了後に戻る呼び出し元関数のスタック最下部(現在の
bsの値)
具体的な挙動は続く関数呼び出しを参照せよ.
Note
読書中に別ページへ飛ぶ際は,栞や指を挟んでおくのと同じである.関数へジャンプする前に現在の状態を記憶しておく.
Quiz
3種類のレジスタと制御領域の対応を考えると;
pc→ 戻りアドレスとして保持されるbs→ 動的リンクとして保持されるsp→ ???
spを保持する必要はないのか?
Answer
答えは次節.
関数呼び出し#
関数呼び出しの操作的意味論を考える.まずはクイズから考えよ.
Quiz
次のC言語プログラムの結果はどうなるか?
int f() { return 42; }
void main() {
printf("%d\n", f()); // 42
printf("%d\n", f); // ???
}
Answer
出力は2090848569.関数fが格納されているメモリの位置である.関数f自体はプログラム領域に配置されているので,この値はプログラム領域内のどこかの位置である.
以下のプログラムを実行すると,スタック領域とプログラム領域が離れた位置に配置されていることが確認できる.
int f() {}
int g() {}
int h() {}
void main() {
printf("%d\n", f); // 2090848569(プログラム領域)
printf("%d\n", g); // 2090848576(プログラム領域)
printf("%d\n", h); // 2090848583(プログラム領域)
int i, j, k;
printf("%d\n", &i); // 681323068(スタック領域)
printf("%d\n", &j); // 681323064(スタック領域)
printf("%d\n", &k); // 681323060(スタック領域)
}
関数(プログラム)と変数(データ)がメモリ上に配置されている.まさにノイマン型コンピュータである.
再掲:式と文(特徴と分類)
ノイマン型コンピュータはプログラムとデータを記憶装置に格納し,順番に読み込んで実行するコンピュータである.現在のほとんど全てのコンピュータがノイマン型に相当する.
データだけでなく関数にもアドレスが付与されている点を認識せよ.
引数を持たず返り値のみを持つ関数を解説する.
関数呼び出しはLDC MST CUPの組み合わせで実現される.
-
LDC f: まず関数fを呼び出す前に,LDC fによって関数fのプログラム領域のアドレスfをスタックに積み上げておく.後のCUP(関数呼び出し命令)でこの値が利用される.今から呼び出す関数の位置をメモする操作である. -
MST: 続いてMST命令で動的リンクを確保する.保持すべき動的リンクは現在のベースポインタbsの値1である.同時に戻りアドレス用の空の領域も確保する. -
CUP 0:CUP a命令で実際に関数呼び出しを開始する.オペランドaは関数の引数の数である.今回は引数がないのでa=0である.実際に関数を呼び出す操作である.
CUP命令では大幅なスタックの変更が行われる.先述のSCコード定義では,CUP aの操作はt[sp-a]=pc+1;bs=sp-a-2;pc=t[bs];と定義されていた.これを一つずつ噛み砕くと次のとおりである.今回の場合a=0なので,簡単化のためにaを無視して話を進める.
t[sp-a]=pc+1;:pc+1をスタックに積み上げる(戻りアドレスを確保)bs=sp-a-2;:bsをsp-2に差し替える(-2は制御領域の分)pc=t[bs];:pcを次に実行すべき関数fに差し替える(関数fに移動)
ここまでの操作によって戻りアドレスと動的リンクを記憶し,bsとspを関数fのフレームに指定することに成功した.以降の関数fの本文の処理はmainと全く同様である.仮に関数fが再帰呼び出しであっても,LDC MST CUPを使って新たなフレームをスタック上部に積み上げれば良い.
今回の関数fでは定数42を返すため,LDC命令で42を積み上げ,STL 0でbs+0の位置に返り値を格納する.bs+0は返り値用の領域である.
最後にreturn文の処理としてRET 0を実行する(1).RET命令はLDC MST CUPで記憶した関数終了後の状態を復元する命令であり,次の3手順から成る.
RET aはvoidを返すときa=1,値を返すときa=0である.aは返り値用の領域が不要(void)か必要かを表している.
sp=bs-a;:spを現在のbsに差し替え(元フレームの再構築)pc=t[bs+2];:pcを戻りアドレスに差し替え(呼び出し元の関数へ移動)bs=t[bs+1];:bsを動的リンクに差し替え(元フレームの再構築)
関数呼び出しの操作的意味論を考える.関数呼び出しとは以下のような機械語操作列であると意味付けできる.
関数呼び出しの操作的意味
関数のreturn文は次の機械語操作列と対応する.
関数fが引数を持つ場合に対しては,以下のように一般化できる.
例えばf(5 + 5)の場合,MST命令の後にLDC 5, LDC 5, BOP +を実行してからCUP 1(1は引数の数)を呼び出せば良い.5 + 5という引数の値を処理して10をスタックの最上部に積んでおけば,その領域は関数fのフレーム内の引数領域に割り当てられる.この場合の具体的なスタック領域の挙動は,次の引数を持つ関数呼び出しを参照せよ.
引数を持つ関数呼び出し#
関数fが引数を持つ場合の例を示す.この例は本ページ冒頭のクイズの内容と同じである.ここでは詳細の解説は省略するが,基本的にこれまでの内容の繰り返しである.特にi = g + f(10)とreturn 4 * nの挙動をよく観察すると良い.なお,関数fの引数セルは局所変数セルと同じ表現である.関数から見ると引数も局所変数も差異はない.
ポインタ#
ポインタに対応する機械命令列を説明する.
スタック領域にストアされたアドレス値は灰色の文字で表している.
文p = &iに対しては,まず右辺の&iを処理する.変数iのアドレス4(メモリ上の絶対位置)をLDLA命令によってスタックに積み上げる.あとは代入文の処理としてSTL命令で適切な変数(今回の場合ポインタ変数p)に格納する.
文*p = 42は特殊な代入文であり,ポインタpが指し示すアドレスへ右辺値を代入する.間接アドレス指定である.この場合,まずLDL命令でポインタpの参照先をスタックに積み上げてから,右辺値を処理する(この場合定数42のロード).その後,STI命令によってスタック最上部の値をその一つ下のセルが指定するアドレスへ格納する.STIの詳細な操作はt[t[sp-1]]=t[sp];sp--;sp--;である.
ポインタの操作とは,以下のような機械語操作列であると意味付けできる.
ポインタ操作の操作的意味
具体例は紹介していないが,右辺値としての*pは以下のような機械語と等価である.
配列#
配列の操作を考える.C言語における配列操作はポインタ操作で置き換えが可能である.例えば,*(p + 3)とp[3]は機械語の視点では等価である(1).よって,配列の機械的操作はポインタ操作と共通する部分が多い.
- 実際には型エラーが発生するので単純に書き換えはできない.機械語のレベルでは同じ操作だという意味である.
配列が宣言された際には,配列の要素数+1を局所変数の領域として確保する.この+1は配列変数a自体のメモリセルとして利用される.配列変数aは配列を指し示すポインタだと解釈するとよい.
この配列変数aはLDLA命令を用いて&a[0]の値に初期化される.配列内部要素へアクセスする際には,このaに格納されたアドレスからの相対的な位置を用いる.
配列へ代入する際は,まずはLDLで配列変数aに格納された&a[0]をロードする.次に添字の値1を積み上げてからBOP +によって,代入先の配列のアドレス(この場合&a[1]の位置6)を計算する.続いて代入文の右辺値を処理し,その結果を積み上げる.あとはSTIで代入を行えば良い.STIによる値の代入は先に説明したポインタを用いた代入操作と全く同じである.
配列の操作的意味は以下の通りである.
配列の操作的意味
副作用を持つ単項演算子#
C言語における特殊な単項演算子として,インクリメント演算子++とデクリメント演算子--がある.この2つの演算子は,他の一般的な演算子にはない副作用(side effect)を持つ.例えばx = i++という文の主作用は変数xへの代入であり,副作用として変数iの加算が発生する.
インクリメント・デクリメント演算子は,前置++iと後置i++があり,その副作用のタイミングが異なる.ここでは,SCコードを用いながらその挙動を考える.
Quiz
次のC言語プログラムの出力を考えよ.
まず,インクリメント演算子を持つ式が式文として直接利用された場合の例を示す.
この例ではi++はi = i + 1と全く同じSCコードとなる.また前置++iも後置i++も全く同じ結果である.文i++の作用が変数iへの代入のみであり,副作用タイミングの影響が一切現れない.
一方,インクリメント演算子が代入文の右辺値や式内部で利用された場合,前置と後置で副作用タイミングの影響が発生する.
まず1つ目の代入文x = i++に対しては,これまで通り右辺値の処理から開始される.このとき右辺値i++は後置インクリメント演算子であるため,現在の変数iの値0が先にスタックに積み上げられる.その後,i++が処理されてi=1となる.最後に変数xへの代入が実行されるが,代入対象の右辺値はすでにi=0のときの値がスタックに積み上げられているため,x=0となる.
一方後置インクリメントの場合は,代入対象の右辺値をスタックに積み上げる前にi++が実行される.よってx=1となる.
先のクイズの機械語命令を示す.インクリメント演算子を含む式が別の式の内部にあった場合の例である.
インクリメント・デクリメント演算は次のように一般化できる.
副作用を持つ単項演算子の操作的意味
- この式は逆ポーランド記法で記述されていると考えよ.先の例
i++ + ++iはi++ ++i +である.
以下のように考えると良い.
- 式の途中に式
i++があった場合:その式の評価値はi→ 副作用としてi++を実行 - 式の途中に式
++iがあった場合:副作用としてi++を実行 → その式の評価値はi
なお,単一文の中で同一変数に対する複数のインクリメント演算は避けるべきである.処理系やコンパイラによって挙動が変わるためである.
void main() {
int i;
i = 0; // clang gcc msvc
printf("%d\n", i++ + ++i); // 2 2 2 クイズの処理
i = 0;
printf("%d\n", i++ + i++); // 1 1 0
i = 0;
printf("%d\n", ++i + ++i); // 3 4 4
}
clangではコンパイル時に警告が発生する.
<source>:6:21: warning: multiple unsequenced modifications
to 'i' [-Wunsequenced]
6 | printf("%d\n", i++ + i++);
| ^ ~~
まとめ#
命令型言語に対する理解の深化を目的として,C言語の操作的意味論を説明した.