🕌

ポインタ変数を参照目的で渡す時はちゃんと参照渡しすべき

2021/02/28に公開

はじめに

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