ポインタ変数を参照目的で渡す時はちゃんと参照渡しすべき
はじめに
C言語で躓きやすいと言われるポインタ。
使い慣れてくれば特に問題が起こることもないはず…なんですが、
適当なコードを書いていると、しょうもないやらかしを引き起こすこともあります。
問題のコード
こんな感じのコードを書いてみたとします。
void creator(int* p){
p = new int;
*p = 1;
}
void release(int* p){
if(p != nullptr) delete p;
}
int main(){
int* test = nullptr;
creator(test);
std::cout << "value: "<< *test << std::endl;
release(test);
}
結果としては下記出力が期待されるのですが、
value: 1
実際にはSegmentation Faultが発生します。
なぜポインタ渡しして動的メモリ確保もしている変数に値が入ってないのか?
調べてみましょう。
void creator(int* p){
std::cout << "pre_creator_address=" << p << std::endl;
p = new int;
*p = 1;
std::cout << "post_creator_address=" << p << std::endl;
}
void release(int* p){
if(p != nullptr) delete p;
}
int main(){
int* test = nullptr;
std::cout << "pre_main_address=" << test << std::endl;
creator(test);
std::cout << "post_main_address=" << test << std::endl;
//std::cout << "value: "<< *test << std::endl;
release(test);
}
出力はこうなります。
pre_main_address=0
pre_creator_address=0
post_creator_address=0x55fcad30d280
post_main_address=0
出力を見て気が付きます。
「creatorに渡しているtestとcreator内のpって別物になってる?」
つまり、「ポインタ変数が値渡しされているなこれ!」というわけです。
参照目的ならこうしよう
void creator(int*& p){
std::cout << "pre_creator_address=" << p << std::endl;
p = new int;
*p = 1;
std::cout << "post_creator_address=" << p << std::endl;
}
void release(int*& p){
if(p != nullptr) delete p;
}
int main(){
int* test = nullptr;
std::cout << "pre_main_address=" << test << std::endl;
creator(test);
std::cout << "post_main_address=" << test << std::endl;
std::cout << "value: "<< *test << std::endl;
release(test);
}
参照渡しで渡すようにちゃんと明示してあげれば、下記のように期待出力が出てきます。
pre_main_address=0
pre_creator_address=0
post_creator_address=0x55f7d50c3280
post_main_address=0x55f7d50c3280
value: 1
ポインタ変数のポインタ渡しが機能する条件
でも普段こんなこと気にしなくても動くコード、ありますよね。
void creator(int* p){
std::cout << "pre_creator_address=" << p << std::endl;
*p = 1;
std::cout << "post_creator_address=" << p << std::endl;
}
void release(int* p){
if(p != nullptr) delete p;
}
int main(){
int* test = new int;
std::cout << "pre_main_address=" << test << std::endl;
creator(test);
std::cout << "post_main_address=" << test << std::endl;
std::cout << "value: "<< *test << std::endl;
release(test);
}
この場合はcreatorに渡している引数がポインタ渡しとして機能します。
pre_main_address=0x55b7db98ce70
pre_creator_address=0x55b7db98ce70
post_creator_address=0x55b7db98ce70
post_main_address=0x55b7db98ce70
value: 1
このようにmain側でポインタ変数がいわゆる参照済みの状態であれば動くようになります。
ただし、コードをパッと見ただけだと読み取れないですよね、これ…
最後に
そもそも論ではあるのですが、
ポインタ変数を扱う際には宣言したスコープ内で動的確保を施すべきだと思います。
C++ではこのように逃げ道があるが、Cでも同様のケースは発生しうるはずで、
Cの場合はポインタ変数の参照渡しという逃げ道を作ろうとすると
下記のように引数の型をダブルポインタで実装するので辛いところがあります。
void creator(int** p){
printf("pre_creator_address: 0x%x\n", *p);
*p = (int*) malloc(1);
**p = 1;
printf("post_creator_address: 0x%x\n", *p);
}
int main(){
int* test = NULL;
printf("pre_creator_address: 0x%x\n", test);
creator(&test);
printf("pre_creator_address: 0x%x\n", test);
printf("value: %d\n", *test);
free(test);
}
なるべくなら、このようなケースが起きないような作りにしてしまった方が良いし、
このようなケースを実装するにせよ、参照渡しであることは明示したほうが
可読性的にも色々と都合は良いはずです。
Appendix
逆アセンブルして差異を確認
アセンブルコードレベルでは何か違いがわかるだろうか?
アセンブルコードをそこまで読めるわけではないがわかる範囲で見てみようと思います。
下記のようなコンパクトなコードで観測してみました。
※実行環境はx86-64、コンパイラはgccで非最適化オプションで出力したので、
お試しの環境によって出力結果は違うと思います。
// test0
void creator(int* p){
}
int main(){
int* test = nullptr;
creator(test);
}
// test1
void creator(int*& p){
}
int main(){
int* test = nullptr;
creator(test);
}
// test2
void creator(int* p){
}
int main(){
int* test = new int;
creator(test);
}
まずはcreator側の逆アセンブル結果を比較。
// test0
000000000000073a <_Z7creatorPi>:
73a: 55 push %rbp
73b: 48 89 e5 mov %rsp,%rbp
73e: 48 89 7d f8 mov %rdi,-0x8(%rbp)
742: 90 nop
743: 5d pop %rbp
744: c3 retq
// test1
00000000000007aa <_Z7creatorRPi>:
7aa: 55 push %rbp
7ab: 48 89 e5 mov %rsp,%rbp
7ae: 48 89 7d f8 mov %rdi,-0x8(%rbp)
7b2: 90 nop
7b3: 5d pop %rbp
7b4: c3 retq
00000000000007fa <_Z7creatorRPi>:
7fa: 55 push %rbp
7fb: 48 89 e5 mov %rsp,%rbp
7fe: 48 89 7d f8 mov %rdi,-0x8(%rbp)
802: 90 nop
803: 5d pop %rbp
804: c3 retq
creator関数内では実行している命令は全部同じものになっています。
(個人的に面白いなと思ったのは、test2パターンの参照渡しは関数名以降にRが付与される点。
記述はtest0と同じでもコンパイラによって参照渡しと解釈されているかどうかで変わる模様。)
じゃあ、creator関数を呼び出すmain側で色々変化があるのでしょうか。
// test0
0000000000000745 <main>:
745: 55 push %rbp
746: 48 89 e5 mov %rsp,%rbp
749: 48 83 ec 10 sub $0x10,%rsp
74d: 48 c7 45 f8 00 00 00 movq $0x0,-0x8(%rbp)
754: 00
755: 48 8b 45 f8 mov -0x8(%rbp),%rax
759: 48 89 c7 mov %rax,%rdi
75c: e8 d9 ff ff ff callq 73a <_Z7creatorPi>
761: b8 00 00 00 00 mov $0x0,%eax
766: c9 leaveq
767: c3 retq
movq $0x0,-0x8(%rbp)という命令から
rbpがポインタ変数で中身にNULL(=0)が格納されることがわかります。
mov -0x8(%rbp),%raxという命令でraxに-0x8(%rbp)の中身であるNULLが格納。
mov %rax,%rdiという命令でrdi(関数の引数)に格納。
creator関数が呼ばれる、という流れ。
この場合、creatorに対しては、あくまで値であるNULLだけが渡っている状態なので
値渡しなのだろうと推測できます。
// test1
00000000000007b5 <main>:
7b5: 55 push %rbp
7b6: 48 89 e5 mov %rsp,%rbp
7b9: 48 83 ec 10 sub $0x10,%rsp
7bd: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
7c4: 00 00
7c6: 48 89 45 f8 mov %rax,-0x8(%rbp)
7ca: 31 c0 xor %eax,%eax
7cc: 48 c7 45 f0 00 00 00 movq $0x0,-0x10(%rbp)
7d3: 00
7d4: 48 8d 45 f0 lea -0x10(%rbp),%rax
7d8: 48 89 c7 mov %rax,%rdi
7db: e8 ca ff ff ff callq 7aa <_Z7creatorRPi>
7e0: b8 00 00 00 00 mov $0x0,%eax
7e5: 48 8b 55 f8 mov -0x8(%rbp),%rdx
7e9: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx
7f0: 00 00
7f2: 74 05 je 7f9 <main+0x44>
7f4: e8 77 fe ff ff callq 670 <__stack_chk_fail@plt>
7f9: c9 leaveq
7fa: c3 retq
test0との差異として、
mov %fs:0x28,%raxと言う命令が追加。
調べてみるとStackGuard機能というバッファオーバーフロー検知機能のようです。
(xor %fs:0x28,%rdxでスタックオーバーフローが起きたか確認している。)
ここは飛ばして見ていくとして、気になるのは下記の命令群。
mov %rax,-0x8(%rbp)
movq $0x0,-0x10(%rbp)
lea -0x10(%rbp),%rax
mov命令でraxをポインタ変数に格納しているようです。
その後、-0x10(%rbp)に0x0(おそらくNULL)を格納。
その後lea命令でraxを-0x10(%rbp)というアドレスに書き換えている状態です。
細かくは分析しきれないのだが、
この場合は、-0x10(%rbp)アドレス自体をcreatorに伝える形になるので、
参照渡ししているということなのでしょう。
// test2
0000000000000805 <main>:
805: 55 push %rbp
806: 48 89 e5 mov %rsp,%rbp
809: 48 83 ec 10 sub $0x10,%rsp
80d: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
814: 00 00
816: 48 89 45 f8 mov %rax,-0x8(%rbp)
81a: 31 c0 xor %eax,%eax
81c: bf 04 00 00 00 mov $0x4,%edi
821: e8 8a fe ff ff callq 6b0 <_Znwm@plt>
826: 48 89 45 f0 mov %rax,-0x10(%rbp)
82a: 48 8d 45 f0 lea -0x10(%rbp),%rax
82e: 48 89 c7 mov %rax,%rdi
831: e8 c4 ff ff ff callq 7fa <_Z7creatorRPi>
836: b8 00 00 00 00 mov $0x0,%eax
83b: 48 8b 55 f8 mov -0x8(%rbp),%rdx
83f: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx
846: 00 00
848: 74 05 je 84f <main+0x4a>
84a: e8 71 fe ff ff callq 6c0 <__stack_chk_fail@plt>
84f: c9 leaveq
850: c3 retq
こちらも途中でnewが呼ばれている点を除けばtest1と同じ流れです。
ただ逆アセンブルの結果だけ見ても、
どの要因が値渡しか参照渡しかを判断しているかは不明だったので、
コンパイラの中でうまく解釈を行っているのだろうと推測されます。
結論がしょうもなくて申し訳ないですが、確認はここまで。
Discussion