C++の参照渡しとCのポインタの値渡しの違いをマシン語で比較してみた結果

  • 42
    いいね
  • 6
    コメント

ポインタの値渡し、参照渡しはしばしば話題になる事柄なので、興味本位でマシン語(アセンブリ言語)レベルの比較を行ってみました。

OS

CentOS release 6.8

コンパイラ

gcc (GCC) 4.4.7 20120313 (Red Hat 4.4.7-17)
g++ (GCC) 4.4.7 20120313 (Red Hat 4.4.7-17)

参照渡しと値渡しの違い

ググってもQiita内で調べても、詳しいエントリは既にありますので、そもそもの違いはそちらのエントリを参考にして見て下さい。

要するに

C言語では、あるオブジェクトへのポインタを引数として渡せば、そのポインタから参照外しをして呼び出し元のオブジェクトにアクセスができます。

ただし、それはあくまで元のオブジェクトに対して、アドレス演算子を作用させて取り出したオブジェクトのアドレスや、それを代入した別のオブジェクトを渡しているのであって、元のオブジェクト自体とは縁もゆかりもないものを渡しています。

なので呼び出し側で、オブジェクトそのものを渡したら、呼び出された側でそのオブジェクトそのものにアクセスできているわけではありません。

そして、その方法もありません。

ソースコードで示すと、C言語で参照渡しができるなら以下の記述の通りのことが実現できなければなりませんが、そんな方法はありません。

int a = 10;

test(a) // 呼び出し先の仮引数に対して5を代入する。

a; // 5

以下の二つの方法でaの値にアクセスできることはできますが、上述したようにaには縁もゆかりもないものを渡していますので、間接的にアクセスできるという表現を使わざるをえません。

int a = 10;
int *b = &a

test(b); // bはaとはまったく関係ないオブジェクト 

test(&a); // &aはaのオブジェクトのアドレスでありaそのものではない

C++では、C言語ではできなかった下記のことが実現できるので、参照渡しができると言えます。

int a = 10;

test(a) // 呼び出し先の仮引数に対して5を代入する。

a; // 5

私がイメージしてしまっていたもの

C言語では実引数から新たなスタックフレームにコピーされた領域が仮引数となる(今の大抵のプロセッサならできる限りレジスタで完結させるが)ので、参照渡しされた場合はスタックとは別の領域にオブジェクトが格納されるのか?

静的なデータセグメントに置かれたら書き換えることができるので、そんなところには置かないだろうし、まさにオブジェクトそのものが置かれるってどういう感じなんだ? まさかヒープ領域?

なんとなく参照渡しの言葉の表現的に、スタックに値がコピーされずに、物理的に呼び出し元のオブジェクトそのものにアクセスしているのだろうか?スタックフレームを飛び越えたアクセス?

…そんなワケないと思いながら、少しモヤモヤしながら日々を過ごしていくうち、内部的な動作に気を取られなくなりC++ではああいう書き方ができるくらいな感覚に風化してしまっていました。

今回、そんな子供心のような探究心を思い出し、確認してみた次第です。

まずC言語のポインタの値渡しのC言語のソースコードです。

#include <stdio.h>

void test (int *x) {
    *x = 10; 
}

int main(int argc, char* argv[]) {

    int a = 5;

    test(&a);

    return 0;
}

これをアセンブリコードに出力して、呼び出し元と呼び出し先で確認してみます。
アセンブリコードの出力形式はAT&T記法で、CPUはx86_64です。

    movl    $5, -4(%rbp)
    leaq    -4(%rbp), %rax
    movq    %rax, %rdi
    call    test

上記はmain関数の中の一部です。

現在のスタックフレームの中でベースポインタから4引いたアドレスに即値5を代入しています。つまり、これがint型の変数aであることがわかります。

leaq命令により変数aのアドレスがraxレジスタに入れられ、raxレジスタからrdiレジスタに入れられています。これが引数としてtestに渡されます。

ちなみに、何でleaq -4(%rbp), %rdiじゃなくてleaq -4(%rbp), %raxなんですかね。前者の方が無駄がないとは思うんですが。。(書き直して実行コードにしてみたら問題なく動作しました。)
恐らく最適化の観点の面で一度raxに転送した方が速いんだとは思いますが、あまりしっくり来ませんでした。
Why are values passed through useless copies?

はい。勉強します!

話がそれましたが、このrdiレジスタが引数として渡されます。下記がtest関数のアセンブリコードの一部抜粋です。

    movq    %rdi, -8(%rbp)
    movq    -8(%rbp), %rax
    movl    $10, (%rax)

新たなスタックフレームのベースポインタから8ひいたアドレスにrdiレジスタの値、すなわち変数aのアドレスを格納してから、raxレジスタに格納しています。そのアドレスのオブジェクトに対して即値10を格納しているので、結果として変数aの値が変わり、呼び出し元の引数にアクセスできたことがわかります。

この挙動はイメージ通りです。

続いて、参照渡しを実現するC++のコードで確認してみます。
CにしてもC++にしても今回IO系のライブラリは必要ありませんが、言語の違いを明確にする為に念のためincludeしています。

#include <iostream>

void test (int &x) {
    x = 10; 
}

int main (int argc, char* argv[]) {

    int a = 5;

    test(a);

    return 0;
}

コードが冗長になるので、表示していませんが、testに渡されたオブジェクトそのものである変数aはtestの実効後、値が10に変化しています。

くどいようですが、C言語の時と違い、引数としてint型のオブジェクトaそのものを渡しています。

    movl    $5, -4(%rbp)
    leaq    -4(%rbp), %rax 
    movq    %rax, %rdi 
    call    _Z4testRi

なんと、アセンブリコードレベルで見るとまったく同じコードが吐き出されています。
呼び出し側のtest関数の方も確認してみましょう。

    movq    %rdi, -8(%rbp)
    movq    -8(%rbp), %rax
    movl    $10, (%rax)

こちらも全く同じコードになりました。

念のため、複合型のオブジェクトについても見てみましょうか。 いきなりC++のコードです。
C言語ならポインタと一緒に、要素数も別途渡してあげなければならないようなケースですが、C++では以下のようにスッキリ書けます。

#include <iostream>                                                    

void test (int (&x)[5]) {                                              
    int y = x[0];                                                      
    int z = x[1];                                                      
}                                                                      

int main (int argc, char* argv[]) {                                    

    int a[] = {1,2,3,4,5};                                             

    test(a);                                                           

    return 0;                                                          
}     

アセンブリコードに出力します。main関数内でtest関数を呼ぶところです。

    movl    $1, -32(%rbp)                                              
    movl    $2, -28(%rbp)                                              
    movl    $3, -24(%rbp)
    movl    $4, -20(%rbp)
    movl    $5, -16(%rbp)
    leaq    -32(%rbp), %rax
    movq    %rax, %rdi
    call    _Z4testRA5_i

予想通り、配列の先頭要素へのポインタを渡していますね。C言語と同じです。

    movq    %rdi, -24(%rbp)
    movq    -24(%rbp), %rax
    movl    (%rax), %eax
    movl    %eax, -8(%rbp)
    movq    -24(%rbp), %rax
    movl    4(%rax), %eax
    movl    %eax, -4(%rbp)

test関数内です。こちらも予想通りですね。C言語で配列の先頭要素へのポインタを渡して、ポインタ演算なり添字演算などしてオブジェクトにアクセスする方法と同じです。

このようにC++の参照渡しもC言語のポインタの値渡しも、関数間での引数のやりとりの前後はマシン語レベルで見ると同じ処理でした。

C++の方が裏で自動的に色々やってくれるという感じでしょうか。

C++の場合はオブジェクトそのものを渡した場合、内部的にはそのオブジェクトのアドレスが渡されるものの、仮引数に対して何の演算子も作用させる必要なく、渡されたオブジェクトそのものとして振る舞うようです。

とはいえ新たなスタックフレームに渡されたオブジェクトのアドレスをコピーし、そのアドレスからもとのオブジェクトに対してアクセスするという原理的な挙動はCでもC++でも同じなので、あくまでプログラムの記述の仕方だけの違いということがわかりました。

書き方だけの違いで、CPU的に違いがないのなら、個人や団体の好み、プロジェクト、効率性、どういったソフトウェアを開発するかによって適切な手段を選択すれば良さそうです。

参考URL

x86 Assembly Guide