Pythonのような高級な言語をある程度習得し, プログラミングというものをなんとなく理解しているという前提で, C++に固有の仕様について説明します.
今回はC/C++の「*」とか「&」とかいう記号について説明します.
データはどこにあるのか
まず, PCを組み立ててください. PCを組み立てたことがない人は, PCを組み立てる動画でも見てきてください. PCの構造が理解できましたか? 理解できたという前提で話を進めます.
プログラム上で現れるデータはメモリ上のどこかに必ず存在します. Pythonではこれを見えなくしているので意識してこなかったかもしれませんが, すべてのデータはCPUの隣に差してあるメモリ上のどこかに物理的に書きこまれます. そのため, すべてのデータは次の二つの情報によって決定されます.
- どこからどこまでに書かれているか.
- 何が書かれているか.
「どこから」という情報をポインタ(アドレス), 「何が」という情報を値と言います. ここで, 「どこまで」という情報を決めるのが値の種類, 型です. そのために静的型付けでないと「どこまで」を決定できなくなってしまいます.
Pythonでは値だけを見ていましたが, C/C++ではポインタが追加されます. これは何のメリットがあるのでしょうか? 一番のメリットはコピー回数削減です. あるデータを何度も更新したいとき, 更新のたびに新たな領域にコピーするのは効率が悪いです. ポインタを固定し, そこに書いてある値のみを更新していく方が空間的な効率がいいです.
アドレスとポインタ
この二つはよく似ていますが, ちょっとだけ違います. どのくらい違うかというと, 整数$\mathbb{Z}$と整数型int
くらい違います.
アドレスというのはメモリの番地のことです. 配列の添え字と同じ概念です. C/C++ではメモリを1バイトごとに切り分け, それぞれにアドレスが与えられています. (たいていの場合, アドレスというと1アドレス=1バイトですが, たまにそうではないプログラミング言語もあります.)
ポインタというのはint
などと同じ「型」です. ポインタ型とも言い, アドレスを扱う型です. ここで重要なのが, アドレスを知りたいかどうかです.
いま, メモリのどこかに10
という値を格納したとします. このとき実はコンパイラがそのアドレスを記憶します. なのでプログラマがアドレスを知らなくても, この値を紛失することはありません. プログラマがアドレスを扱いたいと思ったときのみ, アドレスを記憶します. このときアドレスを扱うプログラミング上のデータ型をポインタと言います. メモリを配列に例えると, 10
という値があり, 別のところには10
を格納した添え字を意味するアドレスが格納されていて, [10, , , ,0 , , , ...]
のようになっているイメージです. したがってポインタもアドレスを持ち, それをポインタとして扱うことができます. これを二重ポインタといいます. 配列で言うと [10, , , ,0 , ,4 , , , ...]
のようになります.
逆に, 値が無いのにアドレスを先にポインタとして定義できます. これをnullポインタと言います. nullポインタは値が無いのではなく, そもそも(有効な)アドレスを持ちません.
値→ポインタ, ポインタ→値
int n = 10;
と書けば, n
は「値」として定義され, 10
で初期化されます. 簡単ですね. さて, このn
はどこにあるか知りたいとします. そういう時は,&n
と書きます.
#include <iostream>
int main(void){
int n = 10;
std::cout << &n << std::endl;
}
これを実行すると,
0xfffff6ea425c
のようにアドレスが得られます. この&
は値を入力するとポインタを返す関数のように機能します.
逆に, あるポインタp
に格納されている値を知りたい場合は*p
と書きます. つまり*
は&
の逆関数のように機能します.
#include <iostream>
int main(void){
int n = 10;
std::cout << *&n << std::endl;
}
これを実行すると,
10
となります.
ポインタ型 int*
関数&
でreturnされるのはintのポインタ型int*
です. どこから「どこまで」という領域を決定するために, ポインタ型は値の型ごとに作られます. &n
の戻り値を保持するためにはint* p = &n
と書きます.
#include <iostream>
int main(void){
int n = 10;
int* p = &n;
std::cout << p << std::endl;
}
0xffffc10a7b6c
関数としては, *
という記号は値を返すのですが, int*
という型は値ではなくポインタです. 値の意味する型は普通にint
です. ちょっとややこしいですが慣れてください.
値渡しとポインタ渡し
#include <iostream>
void func1(int n){
std::cout << &n << std::endl;
}
void func2(int* n){
std::cout << n << std::endl;
}
int main(void){
int n = 10;
std::cout << &n << std::endl;
func1(n);
func2(&n);
}
0xffffc7288c28
0xffffc7288c2c
0xffffc7288c28
func1()
は引数がint
で, func2()
は引数がint*
です. func1()
には値が渡され, これを「値渡し」といいます. func2()
にはポインタが渡され, これを「ポインタ渡し」と言います. 出力を見てみると, 値渡しでは別のメモリ領域が新たに確保され, ポインタ渡しでは元のアドレスが出力されていることが分かります. 値渡しでは空間的な効率が悪いので, もとのデータを保持しておく必要がないならばポインタ渡しの方が効率がいいです.
参照型 int&
ポインタ渡しにはいくつかの問題があります:
- null渡し.
- 二重解放/解放忘れ.
- ポインタ算術によるスタック破壊.
このような問題点が指摘され, 「ポインタ渡しより安全で, 値渡しよりも効率的」な渡し方が求められ, 参照という概念が導入されました.
#include <iostream>
int main(void){
int n = 10;
int& r = n;
std::cout << r << std::endl;
}
10
これはポインタ型のコードにおけるint*
をint&
にしたものです. 結果がアドレスから値になりました. このr
はn
のコピーではなく, n
の「参照」といい, n
の分身のように振る舞います. n
を更新するとr
も更新されます.
#include <iostream>
int main(void){
int n = 10;
int& r = n;
n++;
std::cout << r << std::endl;
}
11
逆に参照r
を更新することでn
を更新することもできます.
#include <iostream>
int main(void){
int n = 10;
int& r = n;
r++;
std::cout << n << std::endl;
}
11
参照渡し
#include <iostream>
void func3(int& r){ // 参照渡し
std::cout << r << std::endl;
std::cout << &r << std::endl;
r++; // 呼び出し元変数に影響
}
int main(void){
int n = 10;
std::cout << &n << std::endl;
func3(n);
std::cout << n << std::endl;
}
0xffffcd727eec
10
0xffffcd727eec
11
呼出し元変数を使用する(変数共有する)ための何かを渡していることが分かります.
参照という概念: 名前と実体の分離
目の前に犬がいます. 名前をポチとしましょう. この犬があなたの元を離れて遠くへ行き, 他の誰かにFidoと名付けられたとします. 一匹の犬に二つの名前が付けられました. ポチの飼い主とFidoの飼い主が死に, ポチ/Fidoはその名を呼ぶ者がいなくなりました. 一匹の犬に0個の名前が付けられました. 名前を失っても, 犬は元気に生きています.
人間は名前を介して事物を了解しますが, 名前は実体ではありません. 名前とは人間が認識やコミュニケーションのために適当に張り付けたものに過ぎず, 剥がすことも複数貼り付けることも可能な都合のいいものです.
メモリ上のデータはすべてアドレスによって指定できるので, アドレスを名前に使えばいいのですが, それは人間の脳に厳しすぎます. そこで, メモリ上のビット列に自由に名前をつけることが許されました. それが, 変数です. これは人間のために行ってるサービスに過ぎず, コンパイル時にすべてのデータは名前を失います.
「参照」という機能は, 一つの変数に複数の名前を付けることを許可するというものに他ありません. ゆえに, しばしば参照とは別名であると言われます.
馴染みのない言葉を使って「値渡し」「参照渡し」と言うからややこしくなるのであって, 「実体渡し」「名前渡し」と言えば明確に違いがわかると思います. 「ポインタ渡し」は「場所渡し」と言えるでしょうか.
名前のコピーと実体のコピー
学習を進めると「コピー」というものには二種類あり, それぞれ「シャロ―コピー(浅いコピー)」「ディープコピー(深いコピー)」と呼ばれていると知ることになります. この二つの違いがわからないというのがよくあるつまづきポイントなのですが, 結局のところ名前と実体を混同しているのが理解を妨げる要因となります.
そもそも, シャロ―コピー/ディープコピーの違いが問題となるのはクラスや構造体, あるいは配列など, 複数の要素からなる集合体をコピーするとき, さらにその要素として他の変数の参照を持っているときのみとなります. ちょうど「名簿」のようなものをコピーするときのみ, その深度が問題になります.
名簿そのもの, つまり名前をコピーすることをシャロ―コピーと言います. 普通, 名簿をコピーすると言えばこっちを想像すると思います. 参照型のコピーもシャロ―コピーです.
一方, 名前と, その名前に付随する実体を同時にコピーすることをディープコピーといいます.
まとめ
単項演算子として
*n
→ 値
&n
→ ポインタ
型宣言として
int*
→ ポインタ型
int&
→ 参照型
予習
こう見ると参照がとても便利で使いやすそうですが, 実際は欠点も知られています. オブジェクト指向において, クラスや構造体を作ったとします. 自作のクラスや構造体に対しても*
や&
をつけることができます. オブジェクト指向ではコンストラクタやデストラクタでオブジェクトの生成や破壊を行いますが, このとき, 自身を参照している変数があるにもかかわらず, 自身を破壊するとダングリング参照というエラーが起きます.
そこで, スマートポインタという機能が実装されました.
補足: 容量が定まらないデータの格納
配列や文字列のように, コンパイル時に必要な容量が定まらず, どのくらいメモリ領域を確保すればいいのかわからない情報もあります. これらはどのようにしてメモリに格納するのでしょうか?
C++ではstd::vector
やstd::string
も他の型と同様にメモリ(ヒープ領域)の連続する領域に格納されます. たとえ必要な空き容量が不明だとしても, データはメモリ上で散り散りに配置しないというのが現在のC++の仕様です.
空の配列を宣言するとまず容量0のデータ領域が確保され, 要素を追加していくごとに確保する容量が増えていきます. 今の仕様では, 容量が足りなくなると容量を(多くの場合)2倍にしていきます.
この仕様は次のような問題を引き起こすことが容易に想像できます: 「データを追加していくと, いつか連続する空き領域が足りなくなる」. この現象が起きると, C++では別のより広い空き領域にデータを移動します. そうです, 古いポインタが無効になります. 救済処置があるとかではなく, 普通にエラーが起きます. 特にオブジェクトの生成と破壊を繰り返すとメモリ上でフラグメンテーションが起き, この現象のよる弊害が顕著になります. そのため配列のサイズはできるだけ先に宣言し, サイズが不明の配列を扱う際は慎重に実装した方がいいです. (現在のメモリアロケータはこの問題が起きないように色々工夫しているらしいので, そこまで気にしなくていいかもしれない)