Pythonのような高級な言語をある程度習得したという前提で, 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
の値として振る舞います. n
の参照r
は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;
}
int main(void){
int n = 10;
int& r = n; // nを参照する変数rを定義
std::cout << &n << std::endl;
func3(r);
}
0xffffcd727eec
10
0xffffcd727eec
同じポインタを指す何かを渡していることが分かります.
参照という概念: 名前と実体の分離
目の前に犬がいます. 名前をポチとしましょう. この犬があなたの元を離れて遠くへ行き, 他の誰かにFidoと名付けられたとします. 一匹の犬に二つの名前が付けられました. ポチの飼い主とFidoの飼い主が死に, ポチ/Fidoはその名を呼ぶ者がいなくなりました. 一匹の犬に0個の名前が付けられました. 名前を失っても, 犬は元気に生きています.
人間は名前を介して事物を了解しますが, 名前は実体ではありません. 名前とは人間が認識やコミュニケーションのために適当に張り付けたものに過ぎず, 剥がすことも複数貼り付けることも可能な都合のいいものです.
メモリ上のデータはすべてアドレスによって指定できるので, アドレスを名前に使えばいいのですが, それは人間の脳に厳しすぎます. そこで, メモリ上のビット列に自由に名前をつけることが許されました. それが, 変数です. これは人間のために行ってるサービスに過ぎず, コンパイル時にすべてのデータは名前を失います.
「参照」という機能は, 一つのデータに複数の名前を付けることを許可するというものに他ありません. ゆえに, しばしば参照とは別名であると言われます.
馴染みのない言葉を使って「値渡し」「参照渡し」と言うからややこしくなるのであって, 「実体渡し」「名前渡し」と言えば明確に違いがわかると思います. 「ポインタ渡し」は「場所渡し」と言えるでしょうか.
まとめ
単項演算子として
*n
→ 値
&n
→ ポインタ
型宣言として
int*
→ ポインタ型
int&
→ 参照型
(念のため注意ですが, 今は例としてint
を挙げているだけで, 任意の型に*
や&
をつけることができます)
予習
こう見ると参照がとても便利で使いやすそうですが, 実際は欠点も知られています. オブジェクト指向において, クラスや構造体を作ったとします. 自作のクラスや構造体に対しても*
や&
をつけることができます. オブジェクト指向ではコンストラクタやデストラクタでオブジェクトの生成や破壊を行いますが, このとき, 自身を参照している変数があるにもかかわらず, 自身を破壊するとダングリング参照というエラーが起きます.
そこで, スマートポインタという機能が実装されました.