情報構造論の追試のための解答例
情報構造論の成績不振者のための追試を、8/9(木) 15:30 より 4EI 教室で実施します。
インターンシップなどで当日参加困難な場合は、別日に実施しますので、連絡してください。
テスト問題の解答および解説を、以下に示します。
追試では、問題を変更して出題しますので、決して文字の暗記で臨まないこと。
局所変数配列をreturn
情報構造論の前期期末試験で、局所変数返しの解答が多かったので、メモ。
JavaScript でプログラムを書いていると、動くネタだけど、C言語では初心者がよく間違って書いてしまう定番であり、それなりに動いたりするから、誤解が増えてしまう可能性もある。
スタック変数返し
char* foo() { char str[ 20 ] ; strcpy( str , "Hello World" ) ; return str ; } char* bar() { char baz[ 20 ] ; memset( baz , 'A' , sizeof( baz ) ) ; return NULL ; } void main() { char* ans = foo() ; printf( "%s¥n" , ans ) ; // Hello World // 一見正しく動いているように思うかも。 bar() ; // 局所変数を触るだけの処理 printf( "%s¥n" , ans ) ; // AAAAAAAAAAA // ans の中身が壊れている。 }
静的局所変数返し
上記のスタック変数を返す問題点を示すと、じゃあ静的局所変数でいいじゃん….という話がでてくる。
char* foo() { static char str[20] ; strcpy( str , "Hello World" ) ; return str ; }
この方式なら、スタック領域ではなく静的変数領域なので、関数呼び出しで破壊されることはない。
しかし、この方式は「スレッドセーフ」ではない。foo() の処理後に、スレッドを起動した場合、スレッドではメモリ空間が共有されるため、foo() を保持していて、別スレッドがそのメモリを書き換えると、他方は中身が変わってしまう。
(参考「スレッドセーフではないCライブラリ関数」)
集合とリスト処理
リスト構造は、必要に応じてメモリを確保するデータ構造であり、データ件数に依存しないプログラム が記述できる。その応用として、集合処理を考えてみる。
2進数を用いた集合計算
リストによる集合の前に、もっと簡単な集合処理を考える。データ件数の上限が少ない場合には、「2進数の列」の各ビットを集合の各要素に対応づけし、要素の有無を0/1で表現する。この方法を用いるとC言語のビット演算命令で 和集合、積集合を計算できるので、処理が極めて簡単になる。
以下のプログラムは、0〜31の数字を2進数の各ビットに対応付けし、 ba = {1,2,3} , bb = {2,4,6} , bc= {4,6,9} を要素として持つ集合で、ba ∩ bb , bb ∩ bc の計算を行う例である。
void bit_print( unsigned int x ) { for( int i = 0 ; i < 32 ; i++ ) if ( (x & (1 << i)) != 0 ) printf( "%d " , i ) ; printf( "\n" ) ; } void main() { // 98,7654,3210 // ba = {1,2,3} = 00,0000,1110 unsigned int ba = (1<<1) | (1<<2) | (1<<3) ; // bb = {2,4,6} = 00,0101,0100 unsigned int bb = (1<<2) | (1<<4) | (1<<6) ; // bc = {4,6,9} = 10,0101,0000 unsigned int bc = (1<<4) | (1<<6) | (1<<9) ; bit_print( ba & bb ) ; // ba ∩ bb = {2} bit_print( bb & bc ) ; // bb ∩ bc = {4,6} bit_print( ba | bc ) ; // ba ∪ bc = {1,2,3,4,6,9} }
このような、2進数を用いた処理で有名なものとして、エラトステネスのふるいによる素数計算がある。このアルゴリズムでは、各bitを整数に対応付けし、素数で無いと判断した2進数の各桁に1の目印をつけていく方式である。
unsigned int prime = 0 ; void filter() { for( int i = 2 ; i < 32 ; i++ ) { if ( (prime & (1 << i)) == 0 ) { // iの倍数には、非素数の目印をつける for( int j = 2*i ; j < 32 ; j += i ) prime |= (1 << j) ; } } for( int i = 2 ; i < 32 ; i++ ) { // 目印のついていない数は素数 if ( (prime & (1 << i)) == 0 ) printf( "%d\n" , i ) ; } }
リスト処理による積集合
前述の方法は、リストに含まれる/含まれないを、2進数の0/1で表現する方式である。しかし、2進数であれば、unsigned int で 32要素、unsigned long long int で 64 要素が上限となってしまう。 (32bitコンピュータ,gccの場合)
しかし、リスト構造であれば、リストの要素として扱うことで、要素件数は自由に扱える。また、今までの授業で説明してきた cons() などを使って表現すれば、簡単なプログラムでリストの処理が記述できる。
// 先週までに説明してきたリスト構造と補助関数 struct List { int data ; struct List* next ; } ; struct List* cons( int x , struct List* n ) { struct List* ans ; ans = (struct List*)malloc( sizeof( struct List ) ) ; if ( ans != NULL ) { ans->data = x ; ans->next = n ; } return ans ; } void print( struct List* p ) { for( ; p != NULL ; p = p->next ) { printf( "%d " , p->data ) ; } printf( "\n" ) ; } int find( struct List* p , int key ) { for( ; p != NULL ; p = p->next ) if ( p->data == key ) return 1 ; return 0 ; }
例えば、積集合(a ∩ b)を求めるのであれば、リストa の各要素が、リストb の中に含まれるか find 関数でチェックし、 両方に含まれたものだけを、ans に加えていく…という考えでプログラムを作ると以下のようになる。
// 集合積の計算 struct List* set_prod( struct List* a , struct List* b ) { struct List* ans = NULL ; for( ; a != NULL ; a = a->next ) { // aの要素がbにも含まれていたら、ansに加える if ( find( b , a->data ) ) ans = cons( a->data , ans ) ; } return ans ; } void main() { struct List* a = cons( 1, cons( 2, cons( 3, NULL ) ) ) ; struct List* b = cons( 2, cons( 4, cons( 6, NULL ) ) ) ; struct List* c = cons( 4, cons( 6, cons( 9, NULL ) ) ) ; print( set_prod( a , b ) ) ; print( set_prod( b , c ) ) ; }
例題として、和集合、差集合などを考えてみよう。
リストの共有と削除の問題
リスト処理では、mallocを使うが、メモリリークをさせないためには、使用後のリストの廃棄は重要である。リストの全要素を捨てる処理であれば、以下のようになるであろう。
void list_free( struct List* p ) { while( p != NULL ) { struct List* d = p ; p = p->next ; free( d ) ; // 順序に注意 } }
一方、前説明の和集合(a ∪ b)のプログラムを以下のように作った場合、list_freeの処理は問題となる。
// 集合和 struct List* set_union( struct List*a, struct List*b ) { struct List* ans = b ; for( ; a != NULL ; a = a->next ) if ( !find( b , a->data ) ) ans = cons( a->data , ans ) ; return ans ; } void main() { struct List*a = cons( 1, cons( 2, cons( 3, NULL ) ) ) ; struct List*b = cons( 2, cons( 3, cons( 4, NULL ) ) ) ; struct List*c = set_union( a , b ) ; // a,b,cを使った処理 // 処理が終わったので、a,b,cを捨てる list_free( a ) ; list_free( b ) ; list_free( c ) ; // c = { 1 , (bのリスト) } // (b)の部分は先のlist_free(b)で解放済み }
このような、リストb,リストcで共有されている部分があると、データの廃棄処理をどのように記述すべきなのか、問題となる。
これらの解決方法としては、(1) set_union() の最初で、ans=b となっている部分を別にコピーしておく、(2) 参照カウンタ法を用いる、(3) ガベージコレクタのある言語を用いる…などがある。(2),(3)は後期授業で改めて解説を行う。
struct List* copy( struct List*p ) { struct List*ans = NULL ; for( ; p != NULL ; p = p->next ) ans = cons( p->data , ans ) ; return ans ; } struct List* set_union( struct List*a, struct List* b ) { struct List* ans = copy( b ) ; : }
理解確認
- 2進数を用いた集合処理は、どのように行うか?
- リスト構造を用いた集合処理は、どのように行うか?
- 積集合(A ∩ B)、和集合(A ∪ B)、差集合(A – B) の処理を記述せよ。
スタックと待ち行列
計算処理中に一時的なデータの保存として、stackとqueueがよく利用されるが、それを配列を使って記述したり、任意の大きさにできるリストを用いて記述する。
# 授業は、前回の演習時間が不十分だったので、前半講義、後半演習時間。
スタック
一時的な値の記憶によく利用されるスタックは、一般的にLIFO( Last In First out )と呼ばれる。配列を使って記述すると以下のようになるであろう。
#define STACK_SIZE 32 int stack[ STACK_SIZE ] ; int sp = 0 ; void push( int x ) { stack[ sp++ ] = x ; } int pop() { return stack[ --sp ] ; } void main() { push( 1 ) ; push( 2 ) ; push( 3 ) ; printf( "%d\n" , pop() ) ; // 3 printf( "%d\n" , pop() ) ; // 2 printf( "%d\n" , pop() ) ; // 1 }
しかし、この中にSTACK_SIZE以上のデータは貯えられない。同じ処理をリストを使って記述すれば、ヒープメモリを使い切るまで使うことができるだろう。
struct List* stack = NULL ; void push( int x ) { stack = cons( x , stack ) ; } int pop() { int ans = stack->data ; struct List* d = stack ; stack = stack->next ; free( d ) ; return ans ; }
キュー(QUEUE)
2つの処理の間でデータを受け渡す際に、その間に入って一時的にデータを蓄えるためには、待ち行列(キュー)がよく利用される。 FIFO(First In First Out)
配列にデータを入れる場所(wp)と取り出す場所のポインタ(rp)を使って蓄えれば良いが、配列サイズを超えることができないので、データを取り出したあとの場所を循環して用いるリングバッファは以下のようなコードで示される。
#define QUEUE_SIZE 32 int queue[ QUEUE_SIZE ] ; int wp = 0 ; int rp = 0 ; void put( int x ) { queue[ wp++ ] = x ; if ( wp >= QUEUE_SIZE ) wp = 0 ; } int get() { int ans = queue[ rp++ ] ; if ( rp >= QUEUE_SIZE ) rp = 0 ; return ans ; } void main() { put( 1 ) ; put( 2 ) ; put( 3 ) ; printf( "%d\n" , get() ) ; // 1 printf( "%d\n" , get() ) ; // 2 printf( "%d\n" , get() ) ; // 3 }
このようなデータ構造も、get() の実行が滞るようであれば、wp が rp に循環して追いついてしまう。
そこで、このプログラムもリストを使って記述すると以下のようになる。
struct List* queue = NULL ; struct List** tail = &queue ; void put( int x ) { *tail = cons( x , NULL ) ; tail = &( (*tail)->next ) ; } int get() { int ans = queue->data ; struct List* d = queue ; queue = queue->next ; free( d ) ; return ans ; }
ただし、上記のプログラムは、データ格納後にget()で全データを取り出してしまうと、tail ポインタが正しい位置になっていないため、おかしな状態になってしまう。
また、このプログラムでは、rp,wp の2つのポインタで管理することになるが、 2重管理を防ぐために、リストの先頭と末尾を1つのセルで管理する循環リストが使われることが多い。
理解確認
- 配列を用いたスタック・待ち行列は、どのような処理か?図などを用いて説明せよ。
- リスト構造を用いたスタック・待ち行列について、図などを用いて説明せよ。
- スタックや待ち行列を、配列でなくリスト構造を用いることで、どういう利点があるか?欠点があるか説明せよ。
リスト追加処理
最初のリスト生成の説明では、補助関数 cons を用いて、直接リストを生成していた。
しかし、実際にはデータを入力しながらの処理となるであろう。
最も単純なリスト挿入
struct List* top = NULL ; int main() { int x ; while( scanf( "%d" , &x ) == 1 ) { top = cons( x , top ) ; } print( top ) ; // 前回示したリスト全要素表示 return 0 ; }
ここで示したコードは、新しい要素を先頭に挿入していく処理となる。このため、作られたリストは、与えられた要素順とは逆順となる。この方法は、リストを管理するポインタが1つで分かりやすい。
要素を末尾に追加
前に示した方法は、逆順になるので、与えられた要素が末尾に追加する方法を示す。
struct List* top = NULL ; struct List** tail = &top ; int main() { int x ; while( scanf( "%d" , &x ) == 1 ) { *tail = cons( x , NULL ) ; tail = &((*tail)->next) ; } print( top ) ; // 前回示したリスト全要素表示 return 0 ; }
この方法は、次回にデータを追加する場所(末尾だからNULLが入っている)を覚える方式である。ただし、リストへのポインタのポインタを使う方法なので、少しプログラムがわかりづらいかもしれない。
レポート課題
以下に示すようなデータを扱うリスト構造を作り、そのリストを扱うプログラムを作成せよ。
( 出席番号 % 3 ) + 1 の番号の課題に取り組むこと。
- 名前と誕生日(指定した開始月日・終了月日の範囲のデータのみ表示)
- 名前と身長・体重(指定したBMI指数以上(もしくは以下)のデータのみ表示)
- 名前と学科と学年と学籍番号(指定した学籍番号などで検索)
このようなプログラムを作るのであれば、以下の例を参考に。
struct NameAgeList { char name[ 20 ] ; // 名前 int age ; // 年齢 struct NameAgeList* next ; // 次のデータへのポインタ } ; struct NameAgeList* na_cons( char* nm, int ag, struct NameAgeList*p ) { struct NameAgeList* ans ; ans = (struct NameAgeList*)malloc( sizeof( struct NameAgeList ) ) ; if ( ans != NULL ) { strcpy( ans->name , nm ) ; ans->age = ag ; ans->next = p ; } return ans ; }
リスト構造でのプログラミング
前回説明した、リスト構造について簡単なプログラム作成を通して、プログラミングに慣れてみよう。
簡単なリスト処理の例
// 全要素を表示する関数 void print( struct List* p ) { for( ; p != NULL ; p = p->next ) printf( "%d " , p->data ) ; printf( "¥n" ) ; } // データ数を返す関数 int count( struct List* p ) { int c = 0 ; for( ; p != NULL ; p = p->next ) c++ ; return c ; } void main() { struct List* top = cons( 111 , cons( 444 , cons( 333 , NULL ) ) ) ; print( top ) ; printf( "%d¥n" , count( top ) ) ; }
リスト処理を自分で考えて作成
以下のようなプログラムを作ってみよう。意味がわかって慣れてくれば、配列の部分の for の回し方が変わっただけということに慣れてくるだろう。
// 全要素の合計 int sum( struct List* p ) { // sum( top ) → 888 } // リストの最大値を返す int max( struct List* p ) { // max( top ) → 444 (データ件数0の場合0を返す) } // リストの中から指定した値の場所を返す int find( struct List* p , int key ) { // find( top , 444 ) = 1 (先頭0番目) // 見つからなかったら -1 }
途中でデータ挿入・データ削除
リスト構造の特徴は、途中にデータを入れたり、途中のデータを抜くのが簡単にできる所。そのプログラムは以下のようになるだろう。
void insert( struct List*p , int data ) { // あえて、補助関数consを使わずに書いてみる struct List* n ; n = (struct List*)malloc( sizeof( struct List ) ) ; if ( n != NULL ) { n->data = data ; n->next = p->next ; p->next = n ; } // p->next = cons( data , p->next ) ; }
void remove_after( struct List* p ) { struct List* del = p->next ; p->next = del->next ; free( del ) ; }
再帰呼び出しでリスト処理
リスト処理の応用のプログラムを作るなかで、2分木などのプログラミングでは、リスト処理で再帰呼出しを使うことも多いので、先に示したプログラムを再帰呼び出しで書いたらどうなるであろうか?
// 全データを表示 void print( struct List* p ) { if ( p == NULL ) { printf( "¥n" ) ; } else { printf( "%d " , p->data ) ; print( p->next ) ; // 末尾再帰 } } // データ数を返す関数 int count( struct List* p ) { if ( p == NULL ) return 0 ; else return 1 + count( p->next ) ; // 末尾再帰 } // 全要素の合計 int sum( struct List* p ) { // sum( top ) → 888 自分で考えよう } // リストの最大値を返す int max( struct List* p ) { // max( top ) → 444 (データ件数0の場合0を返す) 自分で考えよう } // リストの中から指定した値を探す。 int find( struct List* p , int key ) { // find( top , 444 ) = 1 // 見つかったら1 , 見つからなかったら 0 自分で考えよう }
リスト構造について
データ処理において、配列は基本的データ構造だが、動的メモリ確保の説明で述べたように、基本の配列では大きさを変更することができない。これ以外にも、配列は途中にデータを挿入・削除を行う場合、の処理時間を伴う。以下にその問題点を整理し、その解決策であるリスト構造について説明する。
配列の利点と欠点
今までデータの保存には、配列を使ってきたが、配列は添字で場所を指定すれば、その場所のデータを簡単に取り出すことができる。配列には苦手な処理がある。例えば、配列の中から目的のデータを高速に探す方式として、2分探索法を用いる。
int find( int array[] , int left , int right , int key ) { // データは left から right-1までに入っているとする。 while( left < right ) { int mid = (left + right) / 2 ; // 中央の場所 if ( array[ mid ] == key ) return mid ; // 見つかった else if ( array[ mid ] > key ) right = mid ; // 左半分にある else left = mid + 1 ; // 右半分にある } return -1 ; // 見つからない }
しかし、配列の中に新たに要素を追加しようとするならば、データは昇順に並んでいる必要があることから、以下のようになるだろう。
void entry( int array[] , int* psize , int key ) { // データを入れるべき場所を探す処理 for( int i = 0 ; i < *psize ; i++ ) // O(N) の処理だけど、 if ( array[ i ] > key ) // O(log N) でも書けるけど break ; // 単純に記載する。 if ( i < *psize ) { // 要素を1つ後ろにずらす処理 for( int j = *psize ; j > i ; j-- ) // O(N)の処理 array[ j ] = array[ j - 1 ] ; array[ i ] = key ; } else { array[ *psize ] = key ; } (*psize)++ ; }
これで判るように、データを配列に追加する場合、途中にデータを入れる際にデータを後ろにずらす処理が発生する。
この例は、データを追加する場合であったが、不要となったデータを取り除く場合にも、データの場所の移動が必要である。
順序が重要なデータ列で途中へのデータ挿入削除
例えば、アパート入居者に回覧板を回すことを考える。この中で、入居者が増えたり・減ったりした場合、どうすれば良いか考える。
通常は、自分の所に回覧板が回ってきたら、次の入居者の部屋番号さえわかっていれば、回覧板を回すことができる。
101 102 103 104 105 106 [ 105 | 106 | -1 | 102 | 104 | 103 ]
このように次のデータの場所という概念を使うと、データの順序を持って扱うことができる。
struct LIST { int data ; int next ; } ; struct LIST array[] = { /*0*/ { 11 , 2 } , /*1*/ { 67 , 3 } , // 末尾にデータ34を加える /*2*/ { 23 , 4 } , // { 23 , 5 } , /*3*/ { 89 , -1 } , // 末尾データの目印 /*4*/ { 45 , 1 } , /*5*/ { 0 , 0 } , // { 34 , 4 } , } ; for( int idx = 0 ; idx >= 0 ; idx = array[ idx ].next ) { printf( "%d¥n" , array[ idx ].data ) ; }
この方法を取れば、途中にデータ入れたり、抜いたりする場合に、データの移動を伴わない。
しかし、配列をベースにしているため、配列の上限サイズを超えて格納することはできない。
リスト構造
リスト構造は、データと次のデータへのポインタで構成され、必要に応じてメモリを確保することで、配列の上限が制限にならないようにする。また、次のデータへのポインタでつなげているため、途中へのデータ挿入が簡単にできるようにする。
struct List { int data ; struct List* next ; } ; struct List* top ; top = (struct List*)malloc( sizeof( struct List ) ) ; top->data = 111 ; top->next = (struct List*)malloc( sizeof( struct List ) ) ; top->next->data = 222 ; top->next->next = (struct List*)malloc( sizeof( struct List ) ) ; top->next->next->data = 333 ; top->next->next->next = NULL ; // 末尾データの目印 struct List*p ; for( p = top ; p != NULL ; p = p->next ) { printf( "%d¥n" , p->data ) ; }
補助関数
上記のプログラムでは、(struct…)malloc(sizeof(…))を何度も記載し、プログラムが分かりにくいので、以下に示す補助関数を使うと、シンプルに記載できる。
struct List* cons( int x , struct List* n ) { struct List* ans ; ans = (struct List*)malloc( sizeof( struct List ) ) ; if ( ans != NULL ) { ans->data = x ; ans->next = n ; } return ans ; } struct List* top ; top = cons( 111 , cons( 222 , cons( 333 , NULL ) ) ) ;
授業アンケートで板書の字が読みづらいといわれつつも、パッパッパッってコードを書いていると「その関数名読めない…」とツッコミが。cons : constructor の意味ですがな…と話しつつ、例年どおり「どーでもいいウンチク」を話す。
今日以降説明していくリスト構造は、古くから使われていて、LISP というプログラム言語があり、この言語でリスト(セル)を生成する関数が cons 。
LISPと関数型プログラミング言語
LISPの歴史は長く、最古のFORTRAN,COBOLに次ぐ3番目ぐらい。最初は、人工知能のプログラム開発のための関数型プログラミング言語として作られた。特徴として、データもプログラムもすべてリスト構造(S式)で表すことができ、プログラムは関数型に基づいて作られる。
関数型プログラミングは、Ruby や Python でも取り入れられている。関数型プログラミングは、処理を関数をベースに記述することで「副作用を最小限にすることができ」、極端な話をすればループも再帰呼出しで書けばいい…。
LISPの処理系は、最近では Scheme などが普通だが、プログラムエディタの Emacs は、内部処理が LISP で記述されている。
前期中間テストの答案返却
前期中間試験の返却と解答の解説を行う。
今年の学生は、3年で私がプログラミング応用を担当していないので、私が例年3年に出題していた以下のような問題が苦手だろうということで、出題した問題。ポインタと構造体と配列が絡む問題は、式の意味を理解するためにも、C言語の演算子の優先順位などを踏まえ、こういった問題を知っておくべき。
また、上記のような問題では、C言語でのポインタと配列の意味を理解してもらうために、例年説明する極端なコーディングも紹介しておいた。
int a[5] = { 11 , 22 , 33 , 44 , 55 } ; int* pi ; pi = a + 2 ; printf( "%d¥n" , *pi ) ; // *( pointer + offset ) printf( "%d¥n" , pi[0] ) ; // と pointer[ offset ] は同じ printf( "%d¥n" , *(pi + 1) ) ; // 44 printf( "%d¥n" , pi[ 1 ] ) ; // 44 printf( "%d¥n" , (-1)[ pi ] ) ; // 22 = *( -1 + pi ) printf( "%c¥n" , "abcdef"[ 2 ] ) ; // c
「 printf( “%d¥n” , -1[ pi ] ) ; 」の結果が -44 になった。一瞬なんでや…って思ったけど、-( 1[pi] ) なのね。
様々なメモリ確保
前回の授業で説明していたような、必要に応じて確保するメモリは、動的メモリと呼ばれる。
動的メモリも、局所変数やalloca()を用いたスタック領域と、malloc()とfree()を使うヒープメモリ領域に分類される。
strdup
前回の文字列の確保の説明では、malloc()とstrcpy()をあわせて実行していたが、C言語ではこういった処理が多いので、専用の関数 strdup() がある。
char str[] = "abcdefg" ; char*pc ; if ( (pc = (char*)malloc( strlen( str ) + 1 )) != NULL ) { strcpy( pc , str ) ; } // おなじことを strdup では... pc = strdup( str ) ;
様々なメモリ確保
自分で定義した構造体を、malloc で領域確保しながら使う場合、1次元配列や2次元配列を作る場合、色々な確保の方法がある。
// 複素数を例に struct Complex { double re ; double im ; } ; // 基本 struct Complex a ; a.re = 1.0 ; a.im = 2.0 ; // ポインタで確保 struct Complex* b ; b = (struct Complex*)malloc( sizeof( struct Complex ) ) ; if ( b != NULL ) { b->re = 1.0 ; b->im = 2.0 ; } // 一次元配列 struct Complex c[ 2 ] ; // 通常の使い方 c[0].re = 2.0 ; c[0].im = 3.0 ; c[1].re = 4.0 ; c[1].im = 5.0 ; // 一次元配列を動的に確保 struct Complex* d ; // Complexの配列 d = (struct Complex*)malloc( sizeof( struct Complex ) * 2 ) ; if ( d != NULL ) { d[0].re = 2.0 ; d[0].im = 3.0 ; d[1].re = 4.0 ; d[1].im = 5.0 ; } // 一次元のポインタ配列 struct Complex* e[ 2 ] ; // Complexのポインタの配列 e[0] = (struct Complex*)malloc( sizeof( struct Complex ) ) ; if ( e[0] != NULL ) { e[0]->re = 2.0 ; e[0]->im = 3.0 ; } e[1] = (struct Complex*)malloc( sizeof( struct Complex ) ) ; if ( e[1] != NULL ) { e[1]->re = 4.0 ; e[1]->im = 5.0 ; }
C++での new, delete 演算子
複雑なデータ構造のプログラムを作成する場合には、このような malloc() , free() をよく使うが煩雑であるため、C++ではこれらをすっきりと記述するために、new 演算子、delete 演算子があり、それぞれ malloc(), free() に相当する。
// 単独 Complex* b = new Complex ; b->re = 1.0 ; b->im = 2.0 ; delete b ; // 配列 Complex* d = new Complex[2] ; d[0].re = 2.0 ; d[0].im = 3.0 ; d[1].re = 4.0 ; d[1].im = 5.0 ; delete[] d ; // 配列のdeleteには[]が必要 // ポインタの配列 Complex* e[2] ; e[0] = new Complex ; e[0]->re = 2.0 ; e[0]->im = 3.0 ; e[1] = new Complex ; e[1]->re = 4.0 ; e[1]->im = 5.0 ; delete e[0] ; delete e[1] ;
2次元配列
2次元配列の扱いでも、注意が必要。
int cs = 何らかの値 ; // データ列数 int rs = 何らかの値 ; // データ行数 int a[ rs ][ cs ] ; // C言語ではエラー a[ y ][ x ] = 123 ; // 1次元配列を2次元配列のように使う int* b ; b = (int*)malloc( sizeof( int ) * rs * cs ) ; b[ y * cs + x ] = 123 ; // b[ y ][ x ] への代入 // 配列へのポインタの配列 int** c ; c = (int**)malloc( sizeof( int* ) * rs ) ; // NULLチェック省略 c[0] = (int*)malloc( sizeof( int ) * cs ) ; c[1] = (int*)malloc( sizeof( int ) * cs ) ; : c[ y ][ x ] = 123 ;
レポート課題
メモリの動的確保の理解のために、自分の理解度に応じて以下のプログラムのいずれかを作成せよ。
ただし、プログラム作成時には、配列サイズは未定で、プログラム起動時に配列サイズを入力するものとする。
- 固定長の名前で、人数が不明。
- 長い名前かもしれない名前で、人数も不明
- 複素数のデータで、データ件数が不明。
- 名前と電話番号のデータで、データ件数が不明。
このような状況で、データを入力し、検索などの処理を通して自分のプログラムが正しく動くことを検証せよ。
レポートには、プログラムリスト、プログラムの説明、動作確認した結果、考察を記載すること。
C++のvectorクラスを使ったら
// C++であればvectorクラスを使えば配列なんて簡単 #include <vector> int main() { // 1次元配列 std::vector<int> a( 10 ) ; for( int i = 0 ; i < 10 ; i++ ) a[ i ] = i ; // 2次元配列 std::vector< std::vector<int> > b( 9 , std::vector<int>(9) ) ; // ↑ ここの空白は重要 for( int i = 0 ; i < 9 ; i++ ) { // ">>" と書くとシフト演算子 for( int j = 0 ; j < 9 ; j++ ) { // "> >" と書くと2つの">" b[i][j] = (i+1) * (j+1) ; } } return 0 ; }
mallocとfree
前回の講義での、「長いかもしれない名前」を覚える処理は、最悪の場合をどう扱うかでメモリのムダが発生する。
ここで、前回講義で説明した、大きな配列を少しづつ分けて使う処理を考える。
大きな配列を少しづつ貸し出す処理
char str[ 10000 ] ; char* sp = str ; char entry( char* s ) { char* ret = sp ; strcpy( sp , s ) ; sp += strlen( s ) + 1 ; return ret ; } int main() { char* names[ 10 ] ; names[ 0 ] = entry( "saitoh" ) ; names[ 1 ] = entry( "tomoko" ) ; return 0 ; } // str[] s a i t o h ¥0 t o m o k o ¥0 // ↑ ↑ // names[0] names[1]
このプログラムでは、貸し出す度に、sp のポインタを後ろに移動していく。
スタック
この貸し出す度に、末尾の場所をずらす方式にスタックがある。
int stack[ 100 ] ; int* sp = stack ; void push( int x ) { *sp = x ; // 1行で書くなら sp++ ; // *sp++ = x ; } int pop() { sp-- ; return *sp ; // return *(--sp) ; } int main() { push( 1 ) ; push( 2 ) ; push( 3 ) ; printf( "%d¥n" , pop() ) ; printf( "%d¥n" , pop() ) ; printf( "%d¥n" , pop() ) ; return 0 ; }
スタックは、最後に保存したデータを最初に取り出せる(Last In First Out)から、LIFO とも呼ばれる。
このデータ管理方法は、最後に呼び出した関数が最初に終了することから、関数の戻り番地の保存や、最後に確保した局所変数が最初に不要となることから、局所変数の管理に利用されている。
スタック上の動的メモリ確保 alloca
最初のプログラム例のような方法で、スタック上にメモリを確保する関数として、alloca() がある。
// C言語では、配列サイズに変数を使えない。
int size = ... ;
int array[ size ] ;
// これを alloca で書くと...
int size = ... ;
int* array ;
array = (int*)alloca( sizeof( int ) * size ) ;
if ( array != NULL ) { // スタック溢れはNULLで検知できないか...
:
// array[]を使った処理
:
}
ただし、alloca はスタック領域を使用するため、数MBといった巨大なデータを確保するのには使えない。
この領域は、スタックのように末尾だけを覚えておけばいいので、管理が簡単である。一方で、関数の局所変数として確保して、「この場所を使ってこの計算してね」的な使い方をしなければならない。「この場所を返すから後は自由に使って」的な使い方はできない。
malloc()とfree()
alloca を使うような処理は、スタックのように「最後に確保したものが最初に不要となる」という状況でしか使えない。
確保した領域が不要となる順序が判らない場合には、malloc() を使う必要がある。
ポインタ = malloc( 確保するbyte数 ) ; メモリ不足で malloc に失敗したら NULL を返す。 free( ポインタ ) ; 確保したメモリ領域を解放する。 解放されたメモリは、mallocで再利用してくれる。
最初に説明した、入力された文字を次々と保存する処理を malloc で記述すると以下のようになる。
char* names[ 100 ] ; char buff[ 1000 ] ; int size ; // データ入力 for( size = 0 ; size < 100 ; size++ ) { fgets( buff , sizeof( buff ) , stdin ) ; names[ size ] = (char*)malloc( strlen( buff ) + 1 ) ; if ( names[ size ] == NULL ) break ; strcpy( names[ size ] , buff ) ; } // データを使う処理 for( int i = 0 ; i < size ; i++ ) { // names[] を使う処理... printf( "%s" , names[ i ] ) ; } // データ領域をfreeで解放 for( int i = 0 ; i < size ; i++ ) free( names[ i ] ) ;
malloc() で確保したメモリ領域は、free() で解放しない場合、メモリ領域は使われないムダな領域が蓄積して、最終的にはメモリ不足で止まるかもしれない。また、大量のムダなメモリ領域ができると、仮想メモリが使われ処理速度の低下が発生するかもしれない。
このような、解放されないメモリ領域が発生することは、メモリーリークと呼ばれる。
確保したメモリは、プロセス毎に管理されているので、長時間動きっぱなしのプログラムでメモリリークが発生すると問題となる。
ただし、プロセス終了と共に確保されているメモリはOSに回収されるので、処理が終わってすぐにプロセス自体も終わるのであれば、free() を書き忘れても問題は発生しない。