命令型言語#
教科書4章 パラダイム 講義2回分
序論#
%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 40, 'rankSpacing': 40, 'padding': 5}}}%%
graph TD
classDef focus stroke-width:4px
パラダイム --> 命令型:::focus;
パラダイム --> 宣言型;
命令型 -->|ほぼ同義| 手続き型:::focus;
命令型 --> オブジェクト指向;
宣言型 --> 関数型;
宣言型 --> 論理型;
プログラミング言語の基本構成要素は機械的操作・制御構造・データ構造の3つである.機械的操作によってCPUへの命令を与え,制御構造によって機械的操作の繰り返しや条件分岐を実現し,データ構造によって操作対象のデータを構造化する.
命令型言語(手続き型言語)においては機械的操作とは代入文の実行である.
再掲:命令型言語の特徴(特徴と分類)
命令型言語はこのような代入文の繰り返しによって,変数の値を動的に変化させ計算を行う点に特徴がある.まとめると命令型言語は以下の特徴を持つ.
- 基本要素:
- 変数 ... データと計算結果を保持
- 文 ... 代入文(
=
)や制御文(if
for
)など - 特徴:
- ①代入文の繰り返しで計算を行う
- ②プログラムは文の並びであり,逐次的に実行する
- ③代入文によって変数の値を動的に変化させて計算する
本ページでは命令型言語における残り2つの構成要素である,制御構造とデータ構造について解説する.C言語の経験を持つ読者が想像しやすいように,あらかじめ3つの構成要素の具体例を示しておく.
- 機械的操作:
i = 0;
c = 'a';
n = i + 10;
- 制御構造 :
if()
for()
while()
continue;
break;
- データ構造:
int i;
char c;
long arr[10];
int *p;
struct {...}
さらに,本ページではプログラムの一部を部品化する手続き(関数),およびデータを格納するメモリについて解説する.
なお説明題材には命令型言語の代表としてC言語を用いるが,JavaやPythonなどの他の命令型言語にも共通する内容である.また構文定義にはEBNF(拡張BNF)を用いる.<>
で囲まれた要素は非終端記号であり,文末の;
はEBNFとしての区切り文字である点に注意せよ.
制御構造#
命令型言語における文はおよそ次のとおりである.文はプログラミング言語の必須構成要素の2つ,機械的操作と制御構造を内包する.
// ↓機械的操作 ↓制御構造
<文> ::= <代入文> | <制御文> | <複文> ;
<制御文> ::= <条件文> | <繰り返し文> | <ジャンプ文> ;
<複文> ::= { <文>* } // (1)!
<文>*
は文の0個以上の並びである.
まずは,C言語の簡単なプログラムを題材として文を考える.特徴と分類#RAMで扱った\(\sum_{n=1}^{50} n^2\)の題材と同じである.
{ // 複文
sum = 0; // 代入文 |
i = 1; // 代入文 |
while (i <= 50) // 繰り返し文 |
{ // 複文 | |
square = i * i; // 代入文 | | |
sum = sum + square; // 代入文 | | |
i = i + 1; // 代入文 | | |
} // 複文 繰り返し文 |
} // 複文
以下の点を確認せよ.
- プログラムが文で構成されていること
- 制御文(この場合while文)を除けば,文は上から逐次的に実行されること
- 機械的操作は代入文であり,制御構造(while文)によりその実行順序を制御していること
以降は<制御文>
を構成する3つの文,<条件文>
<繰り返し文>
<ジャンプ文>
について説明する.
条件文#
<制御文>
の一つである<条件文>
の代表例はif文とifelse文である.
<if文>
は<式>
が真のときは<文>
を実行し,偽のときは何も実行しない.<ifelse文>
は<式>
が真のときは<文>1
を実行し,偽のときは<文>2
を実行する.
繰り返し文#
<繰り返し文>
の一例はwhile文である.
<while文>
は<式>
が真の間のみ<文>
を繰り返し実行する.
C言語の<繰り返し文>
はwhile文のほかに,for文が存在する.
for文はwhile文に変換可能である.
<式>1
と<式>3
は;
で区切られており式ではなく文だと感じるかもしれない.C言語においては式は文の一種であり,これを式文と呼ぶ.
以下のように記述しても構文エラーは発生しない.
回数指定のループは繰り返し処理の典型パターンである.これを一文にまとめたものがfor文といえる.for文はプログラム記述時の利便性と可読性の向上を目的としており,積極的に活用すべきである.
Note
for文における繰り返し回数の制御変数i
は誘導変数と呼ばれる.数学から由来している.特段の理由がない限りfor文ではi
という変数名を用いるべきである.プログラマの共通認識,すなわち文化なためである.
ジャンプ文#
最後の<制御文>
である<ジャンプ文>
を説明する.C言語の<ジャンプ文>
は,goto文,break文,continue文の3つである.break文とcontinue文はループの中でのみ使用できる.
break文とcontinue文は特殊なgoto文であり,以下のプログラムと等価である.
L:
while (<式>1) {
if (<式>2) goto M; // breakと等価
if (<式>3) goto L; // continueと等価
}
M: ...
goto文有害論#
プログラム中の全てのループが繰り返し文(for文やwhile文)によってのみ作られるとき,プログラムは構造化(well-structured)されているという.これまでに述べてきた題材は全て構造化されていた.逆に,if文とジャンプ文を用いて繰り返し構造を作る場合を構造化されていないという.
構造化の対比として構造的でないプログラムを以下に示す.処理自体はこれまでの題材\(\sum_{n=1}^{50} n^2\)と等価な内容である.
int i, sum = 0;
L: if (i < 50) { // if文を使って繰り返す
i++;
sum += i * i;
goto L; // 繰り返しのための強制ジャンプ
}
printf("%d", sum); // 42925
Quiz
次の非構造的なC言語プログラムdo_something()
の処理内容を考えよ.
void do_something(int array[], int size) {
int i = 1;
LOOP_I:
int j = i;
int current = array[i];
LOOP_J:
if (array[j - 1] <= current)
goto NEXT;
array[j] = array[j - 1];
if (--j)
goto LOOP_J;
NEXT:
array[j] = current;
if (++i != size)
goto LOOP_I;
}
Answer
do_something()
は挿入ソートである.構造化すると以下のプログラムと等価である.
void do_something_structured(int array[], int size) {
for (int i = 1; i < size; ++i ) {
int current = array[i];
int j = i;
while ((j > 0) && (array[j - 1] > current)) {
array[j] = array[j - 1];
--j;
}
array[j] = current;
}
}
goto文を多用した非構造プログラムは可読性が低く,特に実行経路のトレースが難しい.goto文では「逐次的に実行する」という命令言語の原則を容易に破壊する.
非構造プログラムは低水準であり,アセンブリや機械語の振る舞いに近い.
構造化されたプログラムは読みやすく,その意図がつかみ取りやすい.このようなプログラムの作り方を構造化プログラミング(structured programming)と呼び,強く推奨されている.
構造化プログラミングはE.W.Dijkstraによって提唱された概念である.goto文による構造の破壊や可読性の低下に気づいたDijkstraは,1968年に "Goto Statement Considered Harmful" という論文の中でgoto文有害論を展開した.
Edsger W. Dijkstra
©1
Note
"X considered harmful" はコンピュータサイエンス分野のおける有名なフレーズであり,様々な論文や記事で用いられている.その起源はDijkstraの "Goto Statement Considered Harmful" である.
continue文とbreak文もジャンプ命令の一種ではあるが,その利用は推奨される.「全てのループが繰り返し文のみで構成される」という構造化プログラミングの原則に違反しないためである.また,いずれも用途が限定的(ループ内でのみ利用可能)であり,その意図も明白(1)なため可読性の低下には繋がらない.
- continue文の意図は文字通りループ継続,break文の意図はループ脱出.
C言語はノイマン型コンピュータの基本動作,すなわち機械語に強く影響を受けて設計されており,無条件のジャンプ命令(goto文)を持つ.他方,goto文有害論が広まった後に生まれた命令型言語の多くはgoto文を設けていない.例えばJavaではgoto
という文字が予約語に含まれているが,一切意味を持たない.誤ってgoto文を記述するとコンパイルエラーとなる.また変数名としても利用できない.
Note
ジャンプ命令はプログラムの必須要素であるという点に注意せよ.低水準の機械語ではジャンプを多用するが,高級言語においては無条件ジャンプを排除すべきという話である.
goto文有害論からは次の学びが得られる.すなわち高級言語とは,低水準の機械語命令列を隠蔽化し人の思考レベルまで抽象化した言語である.この抽象度が人の思考に近いほど人にとって理解しやすい言語となる.
データ構造#
プログラミング言語の構成要素の3つ目であるデータ構造について解説する.プログラミング言語は多種多様なデータ構造の構築方法を提供する必要がある.ここでは基本データ型と配列型,ポインタ型の3つを中心に説明する.
データを格納する変数はどのような種類(型)のデータを格納するかを宣言する必要がある.この型宣言を必要とするプログラミング言語を型付き言語(typed language)と呼ぶ.現在のほとんどの命令型言語は型付き言語である.
Note
PythonやJavaScriptは型宣言を必要としない.
i = 0 # 型宣言なしで利用できる
c = 'a' # 型宣言なし
print(type(i)) # <class 'int'>
print(type(c)) # <class 'str'>
一見すると型付き言語ではないように見えるが,プログラム実行時に型を確定している.このような言語を動的型付け言語と呼ぶ.C言語やJavaなどは静的型付け言語であり,プログラマが型を明示的に指定する.
型が存在することで確保すべき対象データのメモリサイズを確定できる.C言語の型のサイズは処理系に依存するが,多くの場合int型は2バイトか4バイトである(1).JavaScriptのNumber型は8バイトである.
- 3年前期の情報科学実験Bで用いるArduinoは2バイト(-32,767~32,768)である.
また型が存在することでデータ操作の検証を行うことも可能になる(int
+ char
はエラー等).
Quiz
次のプログラムの実行結果はどうなるか?
Answer
C言語:100 d
Java:100
Python:TypeError: unsupported operand type(s) for +: 'int' and 'str'
基本データ型#
基本データ型は単一の値を持つ型であり,整数や文字列,真偽値などが存在する.C言語における基本データ型は文字型char,整数型int,long,unsigned int,実数型float,doubleなどである.
配列型#
配列型は複数の要素を持つ型である.配列型が持つ要素集合は同じ型である.
配列型の要素はメモリ上の連続した領域に割り当てられる.例えば,int型2バイトの処理系においてint a[3] = {5, 65535, 10}
としたときのメモリの内容は次のとおりとなる.
Quiz
次のC言語プログラムの実行結果はどうなるか?
int a[2] = {UINT_MAX, 4}; // UINT_MAXは符号なしintの最大(FF..FF)
a[0] = a[0] + 1; // ??
printf("%d %d\n", a[0], a[1]);
Answer
答えは0 4
.型指定によって演算結果が溢れないように保証される.0 5
になる場合をバッファーオーバーフローと呼ぶ.
レコード型#
レコード型は複数種類のデータを一つにまとめたデータ型であり,構造体とも呼ばれる.宣言はstruct student { int id; char[20] name; }
のように指定する.本ページでは詳細は省略する.
ポインタ型#
プログラム中で宣言された変数には,特定のメモリ領域が割り当てられる.このメモリ領域の位置をアドレスや左辺値,番地などと呼ぶ(以降は単にアドレスと呼ぶ).アドレスが指定するメモリ上の値を右辺値と呼ぶ.例えばx = 5;
のとき,変数x
が格納している値5
が右辺値である(1).
- 一般的には右辺値は単に「値」とも呼ばれるが,本ページではアドレス(左辺値)との区別のために右辺値と呼ぶ.
ポインタ型はそれ自体の右辺値として変数のアドレスを持つ.ポインタの概念は計算モデルRAMにおける間接アドレス指定の概念と同様である.
再掲:RAMのアドレス指定(特徴と分類)
ポインタ型が存在する理由の一つはプログラムの実行効率の改善にある.例えば巨大なメモリ領域を要する変数(配列や構造体など)があったときに,その領域全体をコピーして渡す代わりに,その領域へのポインタを渡すことで代用できる.
HUGE_OBJECT x = huge_object; // 巨大な領域全体をコピーするため低速
HUGE_OBJECT *x = &huge_object; // アドレスだけをコピーするので高速
Note
巨大なPDFファイルをメールで直接送付する代わりに,Dropbox等のURLを送るのと同様である.「間接的に」データを渡す.どれだけ巨大なファイルでもURLの長さは一定である.
処理系に依存するがアドレスの値自体は高々8バイト程度である.long型の代入とほぼ同じ速度で実行できる.ポインタ型のサイズ(アドレス値の大きさ)はprintf("%d", sizeof(int*));
で確認できる.
Quiz
次のC言語プログラムの実行結果はどうなるか?
printf("%d %d %d %d\n", // 4 2 8 ?
sizeof(int), // int型のサイズは4バイト
sizeof(short), // short型のサイズは2バイト
sizeof(int*), // int型のポインタサイズは8バイト
sizeof(short*)); // short型のポインタサイズは?
Answer
答えは4 2 8 8
.型の(右辺値の)大きさに依らずアドレス自体は常に一定サイズである.PDFのファイルサイズに依らずURLの長さは変わらないのと同じである.
以降ではC言語のポインタ型について解説する.
あらゆる変数はメモリに確保される.すなわち必ずアドレスを持つ.このアドレスの値を直接取り出す単項演算子は&
である.逆に,あるアドレスから右辺値を取り出す単項演算子は*
である.
int x = 5;
printf("%d\n", x); // 5 右辺値
printf("%d\n", &x); // 70 アドレス(値は仮のもの,実行時に変動する)
printf("%d\n", *(&x)); // 5 アドレス70の右辺値
//printf("%d\n", *x); // 理論上アドレス5の右辺値だがCではコンパイルエラー
上記プログラムのメモリ領域に対する操作は以下のように解釈するとよい.
&x // アドレスの参照
↓
... 05 ... 70 71 72 73 ... // メモリのアドレス(値は仮)
[ ... EC ... 05 00 A5 F8 ... ] // メモリの値(右辺値)
↑ ↑
*x x // 右辺値の参照
ポインタ型は間接アドレス指定のためのデータ型であり,右辺値としてアドレスを格納する.ポインタ操作を次に示す.メモリ領域に対する操作と併せて挙動をよく考えよ.
int x = 5;
int *p; // ポインタ型の宣言
p = &x; // ポインタ型pの右辺値にxのアドレスを代入
printf("%d %d\n", x, p); // 5 70
printf("%d %d\n", &x, &p); // 70 72
printf("%d %d\n", 0, *p); // 0 5
&x &p
↓ ↓
... 70 71 72 73 74 ... // メモリのアドレス(値は仮)
[ ... 05 00 70 00 AC ... ] // メモリの値(右辺値)
↑ ↑
x p // xのアドレスを右辺値としてコピー(p = &x)
*p
ポインタ型を使って間接的に右辺値を操作(read/write)することもできる.
int x = 5;
int *p = &x;
*p = *p + 1; // x = x + 1 と等価
printf("%d\n", x); // 6
printf("%d\n", *p); // 6
Note
ポインタが苦手な人は,int *p = &x;
という文で混乱している場合が多い.このような情報量が多い文は分解して考えると良い.この文はint *p;
とp = &x;
の2つの文に分解できる.前者は単なるint型のポインタp
の宣言であり,後者はポインタ変数p
へx
のアドレスを代入しているだけである.
Quiz
次のC言語プログラムの実行結果はどうなるか?
int a[2] = {UINT_MAX, 4};
int *p = &a[0];
*p = *p + 1; // 前回クイズのa[0] = a[0] + 1;をポインタで差し替え
printf("%d %d\n", a[0], a[1]);
Answer
答えは0 4
.
さらに,次のC言語プログラムの実行結果はどうなるか?
int a[2] = {UINT_MAX, 4};
long *p = &a[0]; // int型ポインタをlong型ポインタに変更
*p = *p + 1;
printf("%d %d\n", a[0], a[1]);
Answer
答えは0 5
.加算時*p = *p + 1
には元の変数a[0]
がint型であったという情報が失われ,long
の加算だとみなされる.結果的にバッファーオーバーフローが発生し,繰り上がった1が配列の隣の領域に書き込まれる.
なお,上記プログラムはコンパイル時に型のミスマッチであるという警告が出る.言語によってはこのような型ミスマッチの操作を厳しく制限(警告ではなくエラー)している.
《宿題1》#
Homework 約30分
答え
次のC言語プログラムを適当な実行環境で実行し,その出力結果を答えよ.さらに各出力行に対してその値の意味を付記せよ.
#include <stdio.h>
int main() {
int array[3] = {1, 2, 3};
for (int i = 0; i < 3; i++) {
printf("%d %x\n", array[i], &array[i]);
}
printf("\n");
int *p = array;
int offset = 1;
printf("%d %x\n", *(p+offset), p+offset);
*(p+offset) += 5;
printf("%d %x\n", *(p+offset), p+offset);
p++;
printf("%d %x\n", *p, p);
}
以下の書式をコピーして回答せよ.左側が出力結果,//
以降が値の意味である
手続き#
大規模で複雑なソフトウェアを設計する場合,システム全体をより簡単化された機能に分解し,その組み合わせによって全体の機能を実現する.この考えを部品化(モジュール化)と呼ぶ.より強調するなら,部品化は人間にとって複雑さを軽減できる唯一の方法である(1).
- いわゆる分割統治である.
René Descartes
「困難は分割せよ」
"Divide each difficulty into as many parts as is feasible and necessary to resolve it."
©2
部品化は規模の大きなシステムだけでなく小さなシステムでも有効なアプローチであり,個々の部品が無意味となるまで繰り返される.
命令型言語では部品化されたプログラム単位を手続き(procedure)と呼ぶ.部品化の機能を持たない命令型言語はほぼ存在しないので,命令型言語と手続き型言語はほぼ同義となる.
再掲:プログラミングパラダイム(特徴と分類)
%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 40, 'rankSpacing': 40, 'padding': 5}}}%%
graph TD
classDef focus stroke-width:4px,color:#f00
パラダイム --> 命令型;
パラダイム --> 宣言型;
命令型 -->|ほぼ同義| 手続き型;
命令型 --> オブジェクト指向;
宣言型 --> 関数型;
宣言型 --> 論理型;
アセンブリ言語では,プログラム中のあるまとまりをサブルーチンや副プログラムと呼ぶ.基本的には手続きと同様の考えである.
手続きの中で特に計算結果を返すものを関数(function)と呼ぶ(1).数学における関数と同じ考えであり,いくつかの引数を受け取り特定の値を返す.例えば二乗を計算する関数square()
は以下のように宣言される.
int xxx()
は関数,void xxx()
は手続きである. 近年では両者を区別せずに関数と呼ぶことも多い.
この関数定義において,x
を仮引数(parameter)と呼ぶ.それに対し,関数square()
を呼び出す際に仮引数に割り当てられる実際の値を実引数(argument)と呼ぶ.
int square(int x) { // xは仮引数
return x * x;
}
int main() {
square(3); // 3は実引数
int i = 5;
square(i); // iも実引数
}
上記の例においてsquare(3)
とsquare(i)
を関数呼び出しと呼ぶ.
なお,仮引数に実引数を渡す方法を引数結合(argument binding)という.この例では値渡し(pass-by-value)による引数結合が行われている.値渡しでは実引数の右辺値そのものが,仮引数の右辺値としてコピーされる.他の引数結合方法も存在するが,これについては後述する引数の結合方法で述べる.
手続きの構文#
手続き定義のBNFは次のとおりである.
<手続き定義> ::= <返り値の型名> <手続き名> (<仮引数>*) <手続き本体> ;
<仮引数> ::= <型名> <仮引数名> ;
<手続き本体> ::= <複文> ; // {}で囲われた文
Quiz
なぜ<手続き本体>
は<複文>
のみなのか?
なぜint square(int x) return x * x;
は構文的に許されないのか?
Answer
手続きがプログラム中のあるまとまりであるため.おそらくC言語設計者が意図的に<手続き本体>
を<複文>
に限定している.
手続き呼び出しは以下のとおりである.
引数結合方法が値渡しの場合,<実引数>
の<式>
の値を計算した後に,その値を<仮引数名>
にコピーする.例えば,square(1+2);
はsquare(3);
に計算してから仮引数x
に3
が割り当てられる.
<手続き本体>
の<複文>
の中で宣言された変数をローカル変数(局所変数)と呼ぶ.仮引数もローカル変数の一つである.ローカル変数の有効範囲はその変数が宣言された複文の中に限定される.他方,プログラム全体で有効な宣言をグローバル変数(大域変数)と呼ぶ.これらはスコープと存続範囲で説明する.
引数の結合方法#
手続きの仮引数に実引数を割り当てる方法として,C言語では値渡しが用いられる.他のプログラミング言語では,値渡し以外の引数結合方法を利用できる場合がある.Pascal言語では値渡しと参照渡し(pass-by-reference)の両方を利用できる.参照渡しでは実引数の右辺値ではなく,アドレスが仮引数にコピーされる.よって,実引数と仮引数はメモリの参照先が全く同一になる.従って,手続き内で仮引数の値が変化すると実引数も同様に変化する.
program Hello;
var x, y: integer;
{ ↓デフォルトは値渡し }
procedure increment(a: integer; var b: integer);
begin { ↑ varをつけると参照渡し }
a := 10; { 変数aへの代入 }
b := 10; { 参照bへの代入 }
end;
begin
x := 5;
y := 5;
increment(x, y);
writeln(x, ' ', y); { 5 10 }
end.
Quiz
なぜ参照渡しが存在するのか? どういう場合に参照渡しを使うべきか?
C言語は原則値渡しのみであるが,ポインタを利用した参照渡しも可能である.変数のアドレスを実引数に与えれば,アドレスそのものが(値渡しによって)仮引数の値となる.
void increment(int a, int *b) { // アドレスを仮引数bで受ける
a = 10;
*b = 10; // ポインタbの参照先を10に
}
int main() {
int x = 5;
int y = 5;
increment(x, &y); // アドレス自体(&y)を値渡しする
printf("%d %d\n", x, y) ; // 5 10
}
上記は無意味な例だが,参照渡しは配列や構造体などの巨大なデータを手続きに渡す際に用いられる.より厳密に言えば配列や構造体は値渡しができない.自動的に参照渡し(ポインタを用いた値渡し)となる.
Note
scanf("%d", &x);
の第二仮引数x
に&
を付与する必要が理由は,参照渡しをするためである.x
のアドレスをscanf()
の仮引数に渡し,x
の右辺値をポインタ経由で書き換える.
JavaやPythonもC言語と同様の引数結合方法を採用している.すなわち引数結合は原則値渡しのみであり,引数の型に応じて(アドレスの値渡しを使った)参照渡しが用いられる.
他の引数結合方法として名前渡し(call-by-name)も存在するが,本ページでは省略する.ALGOL 60やScalaで採用されているが,現在ではほとんど使われていない.
Quiz
次のC言語プログラムの実行結果はどうなるか?
void increment(int *p) {
(*p)++;
}
int main() {
int *p; // 初期化されていないポインタ
increment(p);
printf("%d\n", *p);
}
Answer
答えはSegmnentation fault
.参照先がない,いわゆるNULLポインタへのアクセスが実行時エラーに発生する(環境にも依存する).
コンパイルエラーではなく実行時エラーである点に注意せよ.コンパイル時点では検知できず,実行して直面するまで気づきにくいバグである.
言い換えれば,コンパイラがうるさい(警告やエラーをよく吐く)言語ほど安全なプログラムを作りやすいといえる.
スコープと存続範囲#
宣言された変数の有効範囲をスコープと呼ぶ.スコープ外(有効範囲の外)で変数を参照するとコンパイルエラーが発生する.
命令型言語の多くは静的スコープ(static scope)を採用している.その有効範囲はプログラムの文面上の宣言位置と構造に依存して定められる.プログラムの字句(lexical)のみから決定できるため,レキシカルスコープとも呼ばれる(1).
- 静的スコープの対義語として動的スコープも存在する. Lispが動的スコープの代表であるが近年では静的スコープが主流である.
ここでは単純化のために分割コンパイルやextern
宣言,static
宣言については考慮しない.より厳密なスコープの定義は教科書等を参考にせよ.
C言語における静的スコープの例を以下に示す.
int global1; // global1
int f(int param) { // | param
... // | ↓
} // |
int global2; // | global2
int main() { // | |
int local1; // | | local1
if (...) { // | | |
... // | | |
int local2; // | | | local2
... // | | | ↓
} // | | |
... // ↓ ↓ ↓
} //
スコープの範囲をまとめると次のとおりである.
変数の種類 | スコープの範囲 |
---|---|
グローバル変数 | 宣言からプログラム終了まで |
ローカル変数(仮引数を含む) | 宣言から属する複文の終了まで |
スコープが異なる場合は同じ名前の変数の宣言が許される.上記の例の場合,global2
を除く全ての変数名をx
に書き換えても問題ない.その場合,変数名x
へのアクセスは最も内側のローカル変数が優先される.
スコープが存在することで,その範囲内で利用できる変数が限定的になり,考慮すべき事項を減らすことができる.結果的に可読性の高いプログラムが実現できる.逆にグローバル変数を多用するプログラムは,当該スコープ内でどの変数が使えるのか,各種変数がいつどこで変更されるかなどが把握しにくく,予期せぬ誤動作につながりやすい.
スコープを狭め考慮すべき変数の数を削減する考えは,プログラム全体を部品化して複雑さを軽減する試み(分割統治)と同じ考えである.この考えはプログラミング言語の目的に則っている.
再掲:プログラミング言語とは?(序論)
- 目的:
- プログラミングの労力を軽減すること
変数の存続範囲(extent)とは変数に対してメモリ領域を割り当てている期間のことである.スコープと存続範囲は同一視されがちであるが,異なるものである(1).例えばC言語では,ローカル変数のスコープは「宣言から複文終了まで」であるが,存続範囲は「所属する関数の実行開始から終了まで」である.
- もちろん
スコープ
∈存続範囲
である.メモリ上の割当領域がない変数へのアクセスはありえない.
void main() {
int *p;
{
int x = 5;
p = &x;
} // xのスコープはここまでだが存続範囲は続く
//printf("%d\n", x); // スコープ外なのでエラー
printf("%d\n", *p); // 5
*p = 10; // 値の書き換えも可能
printf("%d\n", *p); // 10
}
これはプログラミング言語が採用するメモリ管理の方針と強く紐づく性質である.詳細は続く実行時スタックで述べる.
メモリ#
プログラム中で宣言された変数は必ず適当なメモリ領域が割り当てられる.本節では,この変数のメモリ領域がどのように割り当てられるかについて説明する.
ほとんどの場合,以下の図ように宣言された順に直列にメモリ領域が割り当てられる.ただし手続きを呼び出した場合,より複雑な挙動を取る.
再掲:メモリに格納された値
実行時スタック#
手続きが自分自身を再帰的に呼び出す方法を再帰呼び出し(recursive call)と呼び,再帰呼び出しを行う手続きや関数を再帰的手続き,再帰的関数と呼ぶ.例えば,次の\(n\)の階乗の関数は再帰的関数である.
\(n \gt 1\)のときに関数f()
自身が再帰的に呼び出されている.C言語では以下のように記述される.
- この条件文は再帰の基底と呼ばれる.再帰呼び出しの中でそれ以上再帰しない部分である.
再帰呼び出しはアルゴリズム設計において非常に重要であり,命令型言語のほとんど全てで再帰呼び出しの使用を許している.再帰的手続きを実行する場合,自分自身を呼び出すたびに手続き内のローカル変数に新たなメモリ領域を割り当てる必要がある.すなわち,同じローカル変数名に対して複数のメモリ領域の割り当てが必要となる.
例えば,上記の手続きに対してf(3)
を実行すると,f(3)
は実行途中でf(2)
を呼び,f(2)
は実行途中でf(1)
を呼ぶ.この時点で仮引数n
に対して,n=3
n=2
n=1
を順に割り当てるために3個のint型のメモリ領域が必要となる.仮引数だけでなく,関数内で利用される全てのローカル変数(i
やsum
等)についても同様のメモリ確保が必要である.
関数の呼び出しごとに必要な情報を格納するためのメモリ領域を駆動レコード(activation record)と呼ぶ.ここでは単にレコードと呼ぶ.先の階乗関数f(n)
の場合,仮引数n
のための一つのint型のメモリ領域がレコードに該当する.
実行時のレコードを格納するためのデータ構造として,スタック(1)が用いられる.このスタックを実行時スタックと呼ぶ.
- 先入れ先出しのデータ構造のこと.
スタックの振る舞い(参考)
©1
先のf(3)
を題材に,実行時スタックの変化を確認する.
R = レコードの意味
[f(1)のR] // メモリ領域
[f(2)のR] [f(2)のR] [f(2)のR] // メモリ領域
[f(3)のR] [f(3)のR] [f(3)のR] [f(3)のR] [f(3)のR] // メモリ領域
-----------------------------------------------------> t
call call call done done // イベント
f(3) f(2) f(1) f(1) f(2)
まずf(3)
が呼び出されるとメモリ上にはf(3)
のレコードがプッシュされる.次にf(2)
とf(1)
を順にプッシュし,f(1)
の実行が終了するとf(1)
のレコードをポップする.さらに計算が終わるたびにf(2)
とf(3)
を順にポップする.
このように再帰呼び出しはスタックを用いて実現される.再帰の呼び出し構造とスタックのデータ構造は類似しているため,スタックの使用は必然といえる.
より複雑な再帰的手続きを用いて実行時スタックを確認する.計算内容には意味がないので注意せよ.一つのグローバル変数と複数のローカル変数の存在がポイントである.
int g = 2;
int f(int n) {
int x;
if (n == 0) {
x = 1;
} else {
x = f(n - 1); // 再帰呼び出し
}
return n + x + g;
}
int main() {
printf("%d\n", f(1));
}
この例では再帰的手続きの中に複数のローカル変数を持つため,レコード内に複数のメモリ領域が存在する.
[n=0 x=1] →3 // f(0)のレコード
[n=1 x=?] [n=1 x=?] [n=1 x=3] →6 // f(1)のレコード
[g=2] [g=2] [g=2] [g=2] [g=2] // mainのレコード
---------------------------------------------> t
call call done done // イベント
f(1) f(0) f(0) f(2)
命令型言語の計算モデルはRAMであることを特徴と分類で紹介した.RAMはレジスタを中心として計算処理を行うレジスタ計算機の一種である.しかし手続きの集まりからなるプログラムの計算モデルとしては,RAMよりもスタック計算機の方が適切である.スタック計算機ではスタックをデータの記憶領域として用い,演算をスタックの一番上で行う.この点はC言語で詳細を解説する.
Quiz
Javaでよく見かける実行時エラーStackOverflowError
はどういう時に発生するか?
Answer
無限に関数呼び出しを行った際に発生する.無限ループによって,文字通りスタックがメモリ領域を圧迫してプログラムがクラッシュする.
class Main {
public static void f() {
f();
}
public static void main(String[] args) {
f();
}
}
Exception in thread "main" java.lang.StackOverflowError
at Main.f(Main.java:3)
at Main.f(Main.java:3)
at Main.f(Main.java:3)
at Main.f(Main.java:3)
...
プログラミングのQ&Aサービス,Stack Overflow の 名前の由来でもある.
動的メモリ確保#
プログラム中で利用するメモリサイズがあらかじめ決められない場合がある.この場合,プログラム実行中にメモリ領域を確保する操作が必要となる.この操作を動的メモリ確保と呼ぶ.
int size;
scanf("%d", &size); // 入力する要素数を指定
int array[ /*?*/ ]; // 入力値に応じて配列サイズを実行時に決める必要がある
全てのプログラミング言語で動的メモリ確保が可能である.C言語ではmalloc()
とfree()
が利用できる.malloc()
(memory allocation,エムアロック)でメモリを確保し,free()
によって確保したメモリを解放する.
int size;
scanf("%d", &size);
int *arrayp = malloc(size * sizeof(int)); // int型バイトをsize個確保
*(arrayp + 0) = 10;
*(arrayp + 1) = 20;
...
free(arrayp); // メモリを解放する
Note
なお,上記の例は1999年にISOで標準化されたCの改訂版(C99)では,malloc()
を使わずに以下のようにも宣言できる.
いずれにせよ,連結リストや可変長配列などでmalloc()
free()
が必要になる状況は存在する.
C言語はこのようにプログラマによる動的メモリ確保の操作が用意されているが,メモリリーク(1)などの問題にもつながりやすい.malloc()
free()
はgoto文と同様に低水準な命令であるといえる.
- メモリの解放忘れによって利用可能なメモリ領域が失われる状況のこと. バッファーオーバーフローと同様,脆弱性に繋がることもある.
近年の言語ではこのような低水準のメモリ操作命令は排除されている.言い換えれば,プログラマがメモリ操作をする必要がなくなっている.JavaやPythonでは自動的なメモリ解放の仕組み,ガベージコレクションが導入されている.Rustではより先進的なメモリ管理の仕組みが取り入れられている.
メモリ領域の構成#
C言語におけるメモリ領域はいくつかの領域から構成されている.
┏━━━━━━━━━━━━━━━┓ // メモリ領域の前方(アドレス値 = 00 .. 00)
┃コード,大域変数,静的変数 /* (1)! */┃
┣━━━━━━━━━━━━━━━┫
┃ヒープ(動的データ) ┃
┣━━━━━━━━━━━━━━━┫
┃ ↓ ┃ // 下に向かって格納
┃ ┃
┃ ↑ ┃ // 上に向かって格納(積み上げ)
┣━━━━━━━━━━━━━━━┫
┃実行時スタック(ローカル変数)┃
┗━━━━━━━━━━━━━━━┛ // メモリ領域の後方(アドレス値 = FF .. FF)
- コードにはコンパイルされた機械語命令列が設置される. グローバル変数や
static
宣言された変数はコード領域の下側に設置される.
ヒープは動的データを格納するための領域であり,メモリ領域前方から順に割り当てられる.実行時スタックはローカル変数を格納する領域であり,メモリ領域後方から前方に向かってスタック形式でデータが積み上げられる.先の駆動レコードの格納先もスタック領域である.
ヒープは動的メモリ確保で述べたように,プログラマがmalloc()
free()
を通じて自由に操作可能な領域であり柔軟性がある.しかしながら,プログラマが責任を持って管理する必要がある.メモリを確保したにも関わらず解放を忘れると,メモリリーク等のバグにつながる.
他方,スタックは実行時スタックでも確認したように,手続き呼び出しが行われるたびに自動的に積み上げられ,手続きの処理が終わると自動的に解放される.
基本的にスタックの方がアクセスが高速である.データ構造が単純であり,その操作はpush
pop
のみで実現できるためである.
実際にプログラムを実行してヒープとスタックの領域の違いを確認することもできる.
#include <stdio.h>
#include <stdlib.h>
void main() {
int *p1 = malloc(sizeof(int)); // ヒープ領域の確保
int *p2 = malloc(sizeof(int));
int *p3 = malloc(sizeof(int));
int i1, i2, i3; // スタック領域の確保
int *p4 = &i1;
int *p5 = &i2;
int *p6 = &i3;
printf("%8x %8x %8x\n", p1, p2, p3);// b482410 b482430 b482450
printf("%8x %8x %8x\n", p4, p5, p6);// c45ff7ac c45ff7a8 c45ff7a4
}
malloc()
で確保された動的領域はヒープ領域にあり,メモリ領域前方から順に割り振られている.逆にローカル変数はスタック領域にあり,メモリ領域後方に割り振られており,その割当順もヒープとは逆方向となっている.
まとめ#
命令型言語における制御構造とデータ構造について解説した.またプログラムの一部を部品化する手続き(関数),およびデータを格納するメモリについて解説した.
《宿題2》#
Homework 約30分
答え
次のC言語プログラムと等価なPythonプログラム,およびJavaプログラムを答えよ.
以下の書式をコピーして回答せよ.様々な回答がありうるが機能的に等価であれば良い.定数9
を用いた最適化は禁止とする.