C++言語解説:1-5-2.ポインタを意識する場面
2002-06-01 |
[C++ Index Top] [Prev] [Next] |
[概要] ポインタ--メモリアドレスはC/C++において好みに関わらず、意識する場面が多い ものです。今回は具体的な例を見ながらポインタへの理解を深めていきます。 [構成]・値渡しと参照渡し * 関数間でデータをやり取りする(その1) * 関数間でデータをやり取りする(その2) ・配列とポインタ * 配列名と配列要素 * 配列を関数へ渡す * 文字列を関数で扱う ・多次元配列とポインタ * 多次元配列の定義とアドレス ●値渡しと参照渡し ◆関数間でデータをやり取りする(その1) ・C/C++言語でプログラムを作る際の大前提は「構造化」です。そして各論理の構造 は「関数」という単位で分割されます。よって、あるデータに対して処理を重ねて いく場合、複数の関数が対象データへアクセスすることになります。 ・関数に値を渡す場合、通常は値渡し(call-by-value)になります。値渡しの場合、デ ータは引き渡された関数内でコピーされます。 [List1.データの値渡し] ┌───────────────────────────────────┐ #include <iostream> using namespace std; void FuncCall(int i_a); //プロトタイプ int main() { int i_a = 10; //10で初期化 FuncCall(i_a); cout << "main側 i_a 数値 :" << i_a << endl; cout << "main側 i_a アドレス:" << &i_a << endl; return 0; } void FuncCall(int i_a) { cout << "call側write前 i_a 数値 :" << i_a << endl; cout << "call側write前 i_a アドレス:" << &i_a << endl; i_a = 20; cout << "call側write後 i_a 数値 :" << i_a << endl; cout << "call側write後 i_a アドレス:" << &i_a << endl; return; } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] call側write前 i_a 数値 :10 call側write前 i_a アドレス:0012FF84 call側write後 i_a 数値 :20 call側write後 i_a アドレス:0012FF84 main側 i_a 数値 :10 main側 i_a アドレス:0012FF88 └───────────────────────────────────┘ ・出力結果をみればわかるように、FuncCall関数側で処理された内容は、main側の引 き渡しデータi_aには何も影響を与えていません。データの置かれるアドレスも全く 別ものであることがわかると思います。 ◆関数間でデータをやり取りする(その2) ・「2つの変数の値を交換」という処理を行いたいとします。そして、構造化の原則か らこの処理を独立関数化するには、どうすれば良いでしょうか。 ..... thinking time ..... ・恐らく前述の値渡しでは手詰まりになると思います。このように単純な計算処理な どではなく、データそのものが処理対象となる場合、データ本体のある場所、つま りメモリアドレスを渡す必要があります。そして関数へメモリアドレスを渡す方法 を参照渡し(call-by-reference)と呼びます。 ・渡されたメモリアドレスを受け取る...前回学習したポインタの出番です。 [List2.データの参照渡し] ┌───────────────────────────────────┐ #include <iostream> using namespace std; void Swap(int* i_a, int* i_b); //プロトタイプ int main() { int i_a = 10; //10で初期化 int i_b = 20; //20で初期化 cout << "Swap前 i_a :" << i_a << endl; cout << "Swap前 i_b :" << i_b << endl; Swap(&i_a, &i_b); //アドレスを渡す(参照渡し) cout << "Swap後 i_a :" << i_a << endl; cout << "Swap後 i_b :" << i_b << endl; return 0; } void Swap(int* i_a, int* i_b) //アドレスを受ける { int i_tmp; i_tmp = *i_a; //i_a中身 → i_tmp *i_a = *i_b; //i_b中身 → i_a中身 *i_b = i_tmp; // i_tmp → i_b中身 return; } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] Swap前 i_a :10 Swap前 i_b :20 Swap後 i_a :20 Swap後 i_b :10 └───────────────────────────────────┘ ・main()からSwap()への引数として、&演算子により変数i_aとi_bのアドレスを渡して います。Swap()側では、それをポインタ変数で受け、間接参照によるデータ処理を 行っています。 ●配列とポインタ ◆配列名と配列要素 ・配列とは、指定した基本型サイズのメモリ領域が、連続で指定個数確保されたもの です。 ・この場合、配列データのメモリ管理は データの先頭メモリアドレス 基本型(サイズ) 個数 で表すことができると考えられます。 [Fig1.配列名とアドレスの関係] ┌───────────────────────────────────┐ (例)int i_a[7]; 4byte 4byte 4byte 4byte 4byte 4byte 4byte ←──→←──→←──→←──→←──→←──→←──→ ┌───┬───┬───┬───┬───┬───┬───┐ │ [0] │ [1] │ [2] │ [3] │ [4] │ [5] │ [6] │ └───┴───┴───┴───┴───┴───┴───┘ ↑ 先頭アドレス(i_a) └───────────────────────────────────┘ ・実は、配列名は確保したメモリ領域の先頭アドレスを示します。例えば int i_a[7]; と記述した場合、配列名i_aは確保領域の先頭アドレスを持っています。最初の要素 であるi_a[0]はint型変数として扱えますが、結果的に i_a と &i_a[0] は等しい ことになります。要素のアドレスと配列名の関係を図示すると以下になります。 [Fig2.配列要素と配列名の関係] ┌───────────────────────────────────┐ (例)int i_a[7]; ┌───┐ ← &i_a[0] == i_a │i_a[0]│ ├───┤ ← &i_a[1] == i_a + 1 //ポインタの演算(基本型単位) │i_a[1]│ ├───┤ ← &i_a[2] == i_a + 2 │i_a[2]│ ├───┤ ← &i_a[3] == i_a + 3 │i_a[3]│ ├───┤ ← &i_a[4] == i_a + 4 │i_a[4]│ ├───┤ ← &i_a[5] == i_a + 5 │i_a[5]│ ├───┤ ← &i_a[6] == i_a + 6 │i_a[6]│ └───┘ └───────────────────────────────────┘ ・実際のコードを通して、配列名と要素の関係を理解して下さい。(*1) [List3.配列名と要素の関係] ┌───────────────────────────────────┐ #include <iostream> using namespace std; int main() { int i_a[7]; int i; for (i=0; i<7; i++){ i_a[i] = i; //要素番号とデータは同じ } cout << "配列名 i_a :" << i_a << endl; cout << "要素0 i_a[0] :" << i_a[0] << endl; cout << "要素0 &i_a[0] :" << &i_a[0] << endl; cout << "中身 *i_a :" << *i_a << endl; cout << endl; cout << "配列名+3 (i_a + 3) :" << (i_a + 3) << endl; cout << "要素3 i_a[3] :" << i_a[3] << endl; cout << "要素3 &i_a[3] :" << &i_a[3] << endl; cout << "中身 *(i_a + 3) :" << *(i_a + 3) << endl; return 0; } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] 配列名 i_a :0012FF70 要素0 i_a[0] :0 要素0 &i_a[0] :0012FF70 中身 *i_a :0 配列名+3 (i_a + 3) :0012FF7C 要素3 i_a[3] :3 要素3 &i_a[3] :0012FF7C 中身 *(i_a + 3) :3 └───────────────────────────────────┘ ・しかし、重要な注意点があります。一見ポインタのように見える配列名ですが、配 列名はポインタではありません。配列名が持つアドレス値は書き換える事ができま せん。 ・ポインタは「アドレスを保有する変数」であり、単純なラベル的存在である配列名 とは根本的に異なります。ケアレスミスしやすいので、この点は要注意です。 ◆配列を関数へ渡す ・今回のテキストの冒頭でも説明したように、関数への引数は通常値渡し、つまりコ ピーとなりますが、配列は参照渡しになります。これは配列を利用する際に初心者 が混乱する要因となっています。 ・実際にコードを書いて確かめてみましょう。 [List4.配列データは参照渡し] ┌───────────────────────────────────┐ #include <iostream> using namespace std; void Twice(int i_a[], int index); //プロトタイプ。引数は配列 int main() { int i_a[10]; int i; for (i=0; i<10; i++){ i_a[i] = i; cout << " " << i_a[i]; //引き渡し前 } Twice(i_a, 10); //配列データ先頭アドレスを渡す cout << endl; for (i=0; i<10; i++){ cout << " " << i_a[i]; //引き渡し後 } return 0; } void Twice(int i_a[], int index) { int i; for (i=0; i<index; i++){ i_a[i] = i_a[i] * 2; //2倍にする } return; } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] 0 1 2 3 4 5 6 7 8 9 0 2 4 6 8 10 12 14 16 18 └───────────────────────────────────┘ ・List4は、一見配列データが値渡しでコピーされているように見えますが、実は i_a のアドレスが渡されていて、処理対象のデータは引き渡し元と同じものになってい ます。 ・コンパイラがこのような機構を取る理由についてですが、配列の場合はデータが大 きくなる傾向にあるので、値渡しを行うと データコピーのオーバーヘッドにより実行時間が伸びる コピー可能なメモリ領域を確保できるとは限らない という2つの心配事があるからです。 ・配列の名前は先頭アドレスであることを素直に受け入れれば、List4は次のように書 くこともできます。 [List5.配列データは参照渡し(ポインタで書く)] ┌───────────────────────────────────┐ #include <iostream> using namespace std; void Twice(int* ip_a, int index); //プロトタイプ。引数はポインタ int main() { int i_a[10]; int i; for (i=0; i<10; i++){ i_a[i] = i; cout << " " << i_a[i]; //引き渡し前 } Twice(i_a, 10); //配列データ先頭アドレスを渡す cout << endl; for (i=0; i<10; i++){ cout << " " << i_a[i]; //引き渡し後 } return 0; } void Twice(int* ip_a, int index) { int i; for (i=0; i<index; i++){ *ip_a = *ip_a * 2; //中身を2倍にする ip_a++; //アドレスインクリメント } return; } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] 0 1 2 3 4 5 6 7 8 9 0 2 4 6 8 10 12 14 16 18 └───────────────────────────────────┘ ・ちなみに筆者の場合、参照側ではポインタ表記を使用しています(List5のタイプ)。 いま扱っているデータが関数内で定義されている場合のみ配列表記にしています。 ◆文字列を関数で扱う ・文字列は、最後がnull文字となる文字型配列データです。つまり、文字列データ全 体を扱う場合、先頭型データさえあれば、処理対象のメモリ領域は自明となります。 ・例えば、以前のテキスト(配列と文字列の基本)で標準ライブラリ関数gets()を扱い ましたが、この関数のプロトタイプは char* gets(char* s); となっています。 ・今見るとわかると思うのですが、gets()にはchar型配列データの先頭アドレスを与 えます。gets()はそれをchar型ポインタで受けて、対象メモリ領域にデータを置い た後、最後の領域にnull文字を書き込みます。戻り値がchar型ポインタになってい ますが、gets()関数はデータ取り込みに失敗するとnullポインタ(空のアドレス)を 返します。(*2) ・gets()関数の他に、文字列を扱う代表的な関数には以下のものがあります。これら の関数を使うには、<string.h>ヘッダが必要です。(*3) ┌───────────────────────────────────┐ char* strcpy(char* dest, const char* src); //文字列コピー →srcの示す文字列データを、destへコピーする char* strcat(char* dest, const char* src); //文字列の連結 →srcの示す文字列データを、destに連結する size_t strlen(const char* s); //文字列の長さ →sの示す文字列データの文字列長を返す(null文字は抜かす) int strcmp(const char* s1, const char* s2); //文字列の比較 →s1とs2の示す文字列を比較する。一致すれば 0 を返す。 char* strchr(const char* s, int c); //文字の検索 →sの示す文字列からcが示す1byte文字を検索し、位置を返す。無ければnull ポインタを返す。 └───────────────────────────────────┘ ・これらの関数については演習を通して慣れることとします。以下の課題に挑戦して 下さい。 ┌───────────────────────────────────┐ [演習課題] (1)文字列"Hello"をデータに持つchar型配列s1から、char型配列s2にデータを コピーし、画面にs1, s2の内容を表示すること。 尚、文字配列は以下の形で初期化定義可能である(添字省略可能)。 char s1[] = "Hello"; (2)文字列データ"Good"を持つ文字型配列s1に、文字列データ"Morning"を連結 して表示すること。その際、GoodとMorningの間には1byteスペースを入れ ること。 (3)文字型配列s1は、null文字を含めて80文字までのデータを持てる。キーボ ードから入力した80文字未満の任意文字列データを画面表示後、全ての文 字データを * へ置換して再度表示すること。 (4)パスワードは"I am Great"である。キーボードから入力された文字列がパ スワードと一致する場合は"Welcome!!"を画面に表示し、パスワードが異な る場合は"Get Out!!"と表示すること。 (5)文字型配列s1は、null文字を含めて80文字までのデータを持てる。キーボ ードから入力した80文字未満の任意文字列データを画面表示後、最初の文 字'A'のある配列添字番号を表示すること。'A'が存在しない場合には "Not found"を画面へ表示すること。 └───────────────────────────────────┘ ●多次元配列とポインタ ◆多次元配列の定義とアドレス ・多次元配列を使用するときは、以下の形式で定義します。 ┌───────────────────────────────────┐ 型指定子 配列名[サイズ1][サイズ2]・・・[サイズN]; (例) int a[3][4]; └───────────────────────────────────┘ ・いままで学習したように変数名は、内部的にはメモリアドレスであり、1次元配列も 先頭アドレス+添字番号によるオフセット演算で、データの置き場所を確定していま した。それでは多次元配列の場合どうなるでしょうか? ・2次元を例にしてみてみましょう。例えば int i_a[2][4]; と定義した場合、メモリ 領域としては4byteデータが2×4の8個確保される訳ですが、メモリ空間は線形なも のなので、実際に2次元的な形になるわけではありません。以下に示した図のように なっていると考えられます。 [Fig3.多次元配列とアドレスの関係] ┌───────────────────────────────────┐ 座標的イメージは以下 ┌─────┬─────┬─────┬─────┐ │ i_a[0][0]│ i_a[0][1]│ i_a[0][2]│ i_a[0][3]│ ├─────┼─────┼─────┼─────┤ │ i_a[1][0]│ i_a[1][1]│ i_a[1][2]│ i_a[1][3]│ └─────┴─────┴─────┴─────┘ 実際のアドレスイメージは以下 : 4byte単位 :4×4byte単位 --------------:------------:------------ ┌─────┐ ← i_a[0]+0 : *(i_a+0)+0 : i_a │ i_a[0][0]│ ├─────┤ ← i_a[0]+1 : *(i_a+0)+1 │ i_a[0][1]│ ├─────┤ ← i_a[0]+2 : *(i_a+0)+2 │ i_a[0][2]│ ├─────┤ ← i_a[0]+3 : *(i_a+0)+3 │ i_a[0][3]│ ├─────┤ ← i_a[1]+0 : *(i_a+1)+0 : i_a+1 │ i_a[1][0]│ ├─────┤ ← i_a[1]+1 : *(i_a+1)+1 │ i_a[1][1]│ ├─────┤ ← i_a[1]+2 : *(i_a+1)+2 │ i_a[1][2]│ ├─────┤ ← i_a[1]+3 : *(i_a+1)+3 │ i_a[1][3]│ └─────┘ └───────────────────────────────────┘ ・メモリへの置かれ方が上図のイメージ通りかどうかを、実際のコードにより確認し てみます。 [List6.多次元配列とアドレスの関係] ┌───────────────────────────────────┐ #include <iostream> using namespace std; int main() { int i_a[2][4] = { 0, 1, 2, 3, //i_a[0][0]〜[3], 最後はカンマで連続 10,11,12,13 //i_a[1][0]〜[3] }; //(*4)を見て下さい cout << " i_a :" << i_a << endl; cout << " i_a[0] :" << i_a[0] << endl; cout << " i_a[0][0] :" << i_a[0][0] << endl; cout << "&i_a[0][0] :" << &i_a[0][0] << endl; cout << " i_a[0][1] :" << i_a[0][1] << endl; cout << "*(i_a+0)+1 :" << *(i_a+0)+1 << endl; cout << "*(*(i_a+0)+1) :" << *(*(i_a+0)+1) << endl; cout << endl; cout << " i_a+1 :" << i_a+1 << endl; cout << " i_a[1] :" << i_a[1] << endl; cout << " i_a[1][0] :" << i_a[1][0] << endl; cout << "&i_a[1][0] :" << &i_a[1][0] << endl; cout << " i_a[1][1] :" << i_a[1][1] << endl; cout << "&i_a[1][1] :" << &i_a[1][1] << endl; cout << "*(i_a+1)+1 :" << *(i_a+1)+1 << endl; cout << "*(*(i_a+1)+1) :" << *(*(i_a+1)+1) << endl; return 0; } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] i_a :0012FF6C i_a[0] :0012FF6C i_a[0][0] :0 &i_a[0][0] :0012FF6C i_a[0][1] :1 *(i_a+0)+1 :0012FF70 *(*(i_a+0)+1) :1 i_a+1 :0012FF7C i_a[1] :0012FF7C i_a[1][0] :10 &i_a[1][0] :0012FF7C i_a[1][1] :11 &i_a[1][1] :0012FF80 *(i_a+1)+1 :0012FF80 *(*(i_a+1)+1) :11 └───────────────────────────────────┘ ・従って、多次元配列とは、メモリアドレス管理がツリー状につながったものとなっ ていることがわかります(アドレスイメージ図を参照下さい)。ポインタだけで考え るとn次元の配列はn次の多重間接参照(multiple indirection)になるわけです。 ・今回の例の場合、int型の領域4個単位を管理する配列を、2個作ったという形になっ ています。なので配列名i_a自身は int×4個を基本単位としたメモリ領域への先頭 アドレス(*5)となっています。i_a+1の指す先は、4byte×4個=16byte飛んだところ です。 ・そしてi_a及びi_a+1の中身は4個のint型を管理するメモリ領域の先頭アドレスです。 よって、各要素へアクセスするには *(i_a+0), *(i_a+1) ----> int×1個の基本単位となるアドレス に対して添え字番号によるオフセット演算を行うことになります。 ・初めてだと非常にややこしいですが、ゆっくりと考えて理解するようにして下さい。 実際のプログラミングで、多次元的なメモリ配置を行う際、配列をそのまま使うケ ースは少なく、むしろポインタを使った多重間接参照による動的確保を行うのが圧 倒的に多くなります。 ・動的メモリ確保手法については、後に扱いますが、ポインタによる多次元メモリ配 置の概念には慣れておくようにして下さい。 (*1)これでわかったと思いますが、アセンブラレベルで考えた場合、添え字はメモリアド レスへのオフセット演算となります。例えば a[3] は、アドレスで考えると a[3] → a + 3 です。もちろん 3 + a も同じアドレスを示します。よって 3[a] と表記しても実はOKです。コンパイルエラーも出ません。ただし、これは理解を深め るための悪戯であり、実際にこのよう表記が許されているわけではありません。 (*2)nullポインタは、マクロ名として NULL と表記できます。NULLマクロは <stddef.h> / <stdio.h> / <stdlib.h> / <string.h> / <time.h> の何れかを組み込めば使用できます。 最近のC++コンパイラでは、ヘッダファイルを意識せずに使える場合もあります。 (*3)strlen()関数の戻り値型 size_tは、一般にオブジェクトの大きさを受ける型として用 意されています。実際はunsigned intです。size_tは <stddef.h> / <stdio.h> / <stdlib.h> / <string.h> / <time.h> の何れかを組み込めば使用できます。 最近のC++コンパイラでは、ヘッダファイルを意識せずに使える場合もあります。 引数内で記述される const はアクセス修飾子(access modifier)と呼ばれるものの一 つで、constが付けられた変数はプログラム実行中にその値を変更できなくなります。 (*4)数値タイプの多次元配列初期化記述です。4データ単位が2個あるイメージで記述して いますが、実際にはカンマでつなげており、データは線形に並べられています。 1次元配列の初期化記述もここで説明しますが、例えば int i_a[4] や char c_a[4] の場合 int i_a[4] = {10,11,12,13}; char c_a[4] = {'a','b','c','\0'}; //null文字を入れれば文字列 になることは容易に理解できるでしょう。 (*5)ポインタの型は被参照オブジェクトの型で決まるからです。ポインタの型の考え方 については、次回解説します。 [Revision Table] |Revision |Date |Comments |----------|-----------|----------------------------------------------------- |1.00 |2002-06-01 |初版 |1.01 |2002-06-07 |誤字/内容修正 |1.02 |2002-06-08 |リンク追加 |1.03 |2002-06-30 |語句修正 [end] |
Copyright(C) 2002 Altmo
|
[C++ Index Top] [Prev] [Next] |