【C++】値渡し・ポインタ渡し・参照渡しの違いとは?|コピーの回数・書き換わるか・速さを実測して、使い分けを整理

【C++】値渡し・ポインタ渡し・参照渡しの違いとは?|コピーの回数・書き換わるか・速さを実測して、使い分けを整理 C++

関数の引数を、int xと書くか、int* xと書くか、int& xと書くか。どれでも、渡したい値は関数に届くので、なんとなく選んで、そのまま動いてしまう場面は多いです。

ただ、この3つは、コピーされるものと、元の変数に届くかが、はっきり違います。違いを言葉で覚えるより、数えて、確かめたほうが早いので、コピーの回数、元の変数が書き換わるか、速さの3点を、実際に動かして測りました。

検証は、Visual Studio 2022付属のMSVC(19.44、x64)を、Release(/O2 /MD)で動かして行いました。

値渡し・ポインタ渡し・参照渡しとは?

3つの渡し方を、一言で言うと、次のとおりです。

・値渡し(T x):渡された値を、コピーして、関数の中の別の変数にする

・ポインタ渡し(T* x):渡したい変数の「場所(アドレス)」を、コピーして渡す

・参照渡し(T& x):渡した変数そのものに付ける、別名を受け取る

▼4つの書き方
void byValue(int x);      // 値渡し
void byPointer(int* x);   // ポインタ渡し
void byRef(int& x);       // 参照渡し
void byConstRef(const int& x);  // const参照渡し(読むだけ)

呼び出す側は、byValue(n)、byPointer(&n)、byRef(n)と書きます。ポインタ渡しだけ、&で場所を取り出して渡す点が、見た目の違いです。

実験①:コピーは、何回起きるか

コピーコンストラクタが呼ばれた回数を数える構造体を作って、4つの渡し方で、それぞれ呼んでみました。

▼e1_count.cpp
// 引数の渡し方ごとに、コピーが何回起きるかを数える
#include <cstdio>

struct Tracker
{
    int id;
    static int copies;
    explicit Tracker(int i) : id(i) {}
    Tracker(const Tracker& o) : id(o.id) { ++copies; }
};
int Tracker::copies = 0;

void byValue(Tracker t)           { (void)t; }
void byPointer(Tracker* t)        { (void)t; }
void byRef(Tracker& t)            { (void)t; }
void byConstRef(const Tracker& t) { (void)t; }

int main()
{
    Tracker a(1);

    Tracker::copies = 0;  byValue(a);      std::printf("値渡し        byValue(a)      : コピー %d 回\n", Tracker::copies);
    Tracker::copies = 0;  byPointer(&a);   std::printf("ポインタ渡し  byPointer(&a)   : コピー %d 回\n", Tracker::copies);
    Tracker::copies = 0;  byRef(a);        std::printf("参照渡し      byRef(a)        : コピー %d 回\n", Tracker::copies);
    Tracker::copies = 0;  byConstRef(a);   std::printf("const参照渡し byConstRef(a)   : コピー %d 回\n", Tracker::copies);
    return 0;
}
値渡し        byValue(a)      : コピー 1 回
ポインタ渡し  byPointer(&a)   : コピー 0 回
参照渡し      byRef(a)        : コピー 0 回
const参照渡し byConstRef(a)   : コピー 0 回

コピーが起きたのは、値渡しの1回だけでした。ポインタ渡しも、参照渡しも、const参照渡しも、コピーは0回です。構造体の中身が大きくなるほど、この「1回」が重くなります。

実験②:元の変数は、書き換わるか

3つの関数で、受け取った値に10を足します。呼び出したあとで、元の変数nが、どうなったかを見ました。あわせて、関数の中の引数が、元の変数と同じ場所にあるかどうかも、比べています。

▼e2_modify.cpp
// 呼び出し側の変数は、書き換わるか。引数の「住所」は、元の変数と同じか
#include <cstdio>

const int* g_valueAddr = nullptr;

void addByValue(int x)    { x += 10;  g_valueAddr = &x; }
void addByPointer(int* x) { *x += 10; g_valueAddr = x;  }
void addByRef(int& x)     { x += 10;  g_valueAddr = &x; }

int main()
{
    int n = 1;

    n = 1; addByValue(n);
    std::printf("値渡し      : n = %2d   引数の住所は元の n と%s\n", n, g_valueAddr == &n ? "同じ" : "別");

    n = 1; addByPointer(&n);
    std::printf("ポインタ渡し: n = %2d   引数が指す先は元の n と%s\n", n, g_valueAddr == &n ? "同じ" : "別");

    n = 1; addByRef(n);
    std::printf("参照渡し    : n = %2d   引数の住所は元の n と%s\n", n, g_valueAddr == &n ? "同じ" : "別");
    return 0;
}
値渡し      : n =  1   引数の住所は元の n と別
ポインタ渡し: n = 11   引数が指す先は元の n と同じ
参照渡し    : n = 11   引数の住所は元の n と同じ

値渡しだけ、nが1のままでした。引数の場所が、元のnと別だったので、関数の中で10を足しても、元のnには届きません。ポインタ渡しと参照渡しは、どちらも元のnを書き換えて、11になっています。

【重要】実験③:ポインタ渡しの「ポインタ」自身は、値渡しされている

ここに、よくある勘違いがあります。ポインタ渡しなら、関数の中で、ポインタを別のものに向け直せば、呼び出し側のポインタも変わるように思えます。確かめてみます。

▼e3_reseat.cpp
// ポインタ渡しの「ポインタ自身」は、値渡しされている
#include <cstdio>

int g_other = 999;

void reseat(int* p)       { p = &g_other; }      // 関数の中のコピーだけが、向き先を変える
void reseatByRef(int*& p) { p = &g_other; }      // ポインタへの参照: 呼び出し側のポインタが変わる
void reseatByPP(int** pp) { *pp = &g_other; }    // ポインタのポインタ: 呼び出し側のポインタが変わる

int main()
{
    int mine = 1;

    int* p = &mine;
    reseat(p);
    std::printf("reseat(p)        のあと: *p = %3d\n", *p);

    p = &mine;
    reseatByRef(p);
    std::printf("reseatByRef(p)   のあと: *p = %3d\n", *p);

    p = &mine;
    reseatByPP(&p);
    std::printf("reseatByPP(&p)   のあと: *p = %3d\n", *p);
    return 0;
}
reseat(p)        のあと: *p =   1
reseatByRef(p)   のあと: *p = 999
reseatByPP(&p)   のあと: *p = 999

reseat(p)のあと、*pは1のままで、呼び出し側のポインタは、向き先が変わっていません。ポインタ渡しで渡されるのは、「場所」という値で、その値は、コピーされています。関数の中でp = &g_other;と書いても、変わるのは、コピーのほうです。

向き先まで変えたいときは、int*&(ポインタへの参照)か、int**(ポインタのポインタ)で受け取ります。実行結果のとおり、どちらも999に変わりました。「中身を書き換える」のと、「向き先を変える」のは、別の話です。

実験④:nullptrが入り込めるのは、ポインタだけか

「参照は、nullにならない」と言われます。本当にそうか、nullptrを渡す3通りで試しました。

▼e4_null.cpp
// nullptr が入り込めるのは、ポインタだけか
#include <cstdio>
#include <cstring>

int readPtr(const int* p) { return *p; }
int readRef(const int& r) { return r; }
int readPtrSafe(const int* p) { return p ? *p : -1; }

int main(int argc, char** argv)
{
    std::setvbuf(stdout, nullptr, _IONBF, 0);
    int* np = nullptr;
    const char* mode = argc > 1 ? argv[1] : "safe";

    if (std::strcmp(mode, "safe") == 0)
    {
        std::printf("nullptrチェック付きのポインタ版: %d\n", readPtrSafe(np));
    }
    else if (std::strcmp(mode, "ptr") == 0)
    {
        std::printf("ポインタ版に nullptr を渡す\n");
        std::printf("%d\n", readPtr(np));
    }
    else if (std::strcmp(mode, "ref") == 0)
    {
        std::printf("参照版に *nullptr を渡す(コンパイルは通る)\n");
        std::printf("%d\n", readRef(*np));
    }
    return 0;
}
呼び方結果終了コード
ポインタ版に、nullptrチェックを入れた-1が返った(安全に処理できた)0x0
ポインタ版に、nullptrをそのまま渡す落ちた0xC0000005
参照版に、*np(nullptrの中身)を渡すコンパイルは通り、実行すると落ちた0xC0000005
np は nullptr のポインタ。どちらも、読もうとした瞬間にアクセス違反

3行目が、注目点です。readRef(*np)は、警告も出ずに、ビルドできました。参照は、「nullにならない」と決まっているだけで、コンパイラが、守ってくれるわけではありません。ポインタを*で外して参照に渡せば、nullの参照が作れてしまい、使った瞬間に落ちます。(落ちたときのアドレスの読み方は、アクセス違反の記事で扱っています。)

それでも、「関数の中でnullを考えなくてよい」という約束を、型で表せるのは、参照の強みです。「無いかもしれない」引数なら、ポインタで受け取って、nullを調べます。「必ずある」引数なら、参照で受け取り、呼び出し側が責任を持ちます。

実験⑤:値渡しは、どれくらい遅いのか

実験①のとおり、値渡しは、コピーを作ります。サイズ別に、1回の呼び出しの時間を、測りました。関数が、インライン展開で消えないように、__declspec(noinline)を付けています。

▼e5_speed.cpp
// 値渡しと const 参照渡しの速さを、サイズ別に測る
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>

template <int N> struct Blob { char data[N]; };

// 呼び出しが消えないように、インライン展開を禁止する
template <int N> __declspec(noinline) int sumByValue(Blob<N> b)          { return b.data[0] + b.data[N - 1]; }
template <int N> __declspec(noinline) int sumByRef(const Blob<N>& b)     { return b.data[0] + b.data[N - 1]; }

__declspec(noinline) int lenByValue(std::string s)        { return (int)s.size(); }
__declspec(noinline) int lenByRef(const std::string& s)   { return (int)s.size(); }

static volatile int g_sink = 0;

template <class F> double measure(F f, int calls)
{
    auto t0 = std::chrono::steady_clock::now();
    f(calls);
    auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration<double, std::nano>(t1 - t0).count() / calls;
}

template <int N> void runBlob()
{
    Blob<N> b;
    std::memset(b.data, 1, N);
    const int calls = 20000000;
    double v = measure([&](int n) { int s = 0; for (int i = 0; i < n; ++i) { b.data[0] = (char)i; s += sumByValue<N>(b); } g_sink = s; }, calls);
    double r = measure([&](int n) { int s = 0; for (int i = 0; i < n; ++i) { b.data[0] = (char)i; s += sumByRef<N>(b); } g_sink = s; }, calls);
    std::printf("%5d バイトの構造体 : 値渡し %6.2f ns/回   const参照渡し %6.2f ns/回   (値渡しは %.1f 倍)\n", N, v, r, v / r);
}

int main()
{
    runBlob<4>();
    runBlob<16>();
    runBlob<64>();
    runBlob<256>();
    runBlob<1024>();
    runBlob<4096>();

    std::string small = "hello";
    std::string big(200, 'x');
    const int calls = 5000000;
    double sv = measure([&](int n) { int s = 0; for (int i = 0; i < n; ++i) s += lenByValue(small); g_sink = s; }, calls);
    double sr = measure([&](int n) { int s = 0; for (int i = 0; i < n; ++i) s += lenByRef(small); g_sink = s; }, calls);
    std::printf("std::string(5文字)   : 値渡し %6.2f ns/回   const参照渡し %6.2f ns/回   (値渡しは %.1f 倍)\n", sv, sr, sv / sr);
    double bv = measure([&](int n) { int s = 0; for (int i = 0; i < n; ++i) s += lenByValue(big); g_sink = s; }, calls);
    double br = measure([&](int n) { int s = 0; for (int i = 0; i < n; ++i) s += lenByRef(big); g_sink = s; }, calls);
    std::printf("std::string(200文字) : 値渡し %6.2f ns/回   const参照渡し %6.2f ns/回   (値渡しは %.1f 倍)\n", bv, br, bv / br);
    return 0;
}
    4 バイトの構造体 : 値渡し   1.29 ns/回   const参照渡し   0.64 ns/回   (値渡しは 2.0 倍)
   16 バイトの構造体 : 値渡し   7.61 ns/回   const参照渡し   0.63 ns/回   (値渡しは 12.1 倍)
   64 バイトの構造体 : 値渡し   7.68 ns/回   const参照渡し   0.63 ns/回   (値渡しは 12.1 倍)
  256 バイトの構造体 : 値渡し   8.93 ns/回   const参照渡し   0.63 ns/回   (値渡しは 14.1 倍)
 1024 バイトの構造体 : 値渡し  16.58 ns/回   const参照渡し   0.64 ns/回   (値渡しは 26.0 倍)
 4096 バイトの構造体 : 値渡し  32.85 ns/回   const参照渡し   0.64 ns/回   (値渡しは 51.5 倍)
std::string(5文字)   : 値渡し   2.41 ns/回   const参照渡し   0.38 ns/回   (値渡しは 6.4 倍)
std::string(200文字) : 値渡し  41.14 ns/回   const参照渡し   0.40 ns/回   (値渡しは 101.8 倍)
値渡しとconst参照渡しで、関数を1回呼ぶのにかかった時間。構造体のサイズ別
値渡しは、構造体のサイズが大きくなるほど遅くなる。const参照渡しは、サイズによらず、ほぼ一定

const参照渡しは、4バイトから4096バイトまで、約0.63ナノ秒のまま、変わりませんでした。渡しているのが、「場所」だけだからです。一方、値渡しは、1024バイトで約16.6ナノ秒(const参照の約26倍)、4096バイトで約32.9ナノ秒(約51倍)まで増えました。

4バイトのときだけ、値渡しとの差は、約0.65ナノ秒と、とても小さい値でした。MSVCのx64の呼び出し規約では、サイズが1・2・4・8バイトの構造体は、レジスタで渡せます。それ以外は、呼び出し側が、コピーを作って、そのアドレスを渡す決まりです。16バイトで、時間がいきなり約7.6ナノ秒に跳ねているのも、この規則と、つじつまが合います。

std::stringでも、同じ傾向でした。5文字(短い文字列は、内部のバッファに収まる)では、値渡しが約2.4ナノ秒、const参照が約0.38ナノ秒。200文字(ヒープにメモリを確保する長さ)では、値渡しが約41ナノ秒で、const参照の約100倍でした。vectorのように、中身を持つ型の値渡しは、さらに重くなります。

実験⑥:一時的な値は、どの渡し方で受け取れるか

5のようなリテラルや、関数の戻り値(一時オブジェクト)を、そのまま渡せるか、試しました。

▼e6_rvalue_bad.cpp
// 参照渡しに、一時的な値(リテラル)を渡すとどうなるか
void setByRef(int& x)            { x = 1; }
void readByConstRef(const int& x) { (void)x; }

int main()
{
    setByRef(5);          // 非const参照に、リテラルは渡せない
    readByConstRef(5);    // const参照なら、渡せる
    return 0;
}
e6_rvalue_bad.cpp
e6_rvalue_bad.cpp(7): error C2664: 'void setByRef(int &)': 引数 1 を 'int' から 'int &' へ変換できません。
e6_rvalue_bad.cpp(7): note: 非定数の参照は左辺値へのみバインドされます。
e6_rvalue_bad.cpp(2): note: 'setByRef' の宣言を確認してください
e6_rvalue_bad.cpp(7): note: 引数リスト '(int)' を一致させようとしているとき

非constの参照(int&)は、リテラルを受け取れません(C2664)。メッセージにも、「非定数の参照は左辺値へのみバインドされます」と出ています。一方、const int&は、同じ5を、そのまま受け取れました。constの参照は、「読むだけ」の約束と引き換えに、一時的な値も受け取れる、と覚えておくと、関数の引数を書くときに、迷いません。

使い分けの目安

やりたいこと書き方理由
intやfloatなど、小さい値を、読むだけT x(値渡し)コピーが安い。参照にしても、速くならない
大きな構造体・string・vectorを、読むだけconst T& xコピーが、実測で数十倍かかる
渡した変数を、関数の中で書き換えたい(必ずある)T& x呼び出し側の変数を、直接変えられる
「無いかもしれない」引数。配列の先頭を渡すT* xnullptrで、「無い」を表せる
関数の中で、コピーを持ちたい(受け取って、保存する)T x(値渡し)+std::move呼び出し側が、コピーか移動かを選べる

最後の行は、ムーブセマンティクスの記事で、詳しく扱っています。

よくある失敗例と対処法

①大きなオブジェクトを、値渡しにしている

構造体やstring、vectorを、読むだけなのに、T xで受け取ると、呼び出すたびに、コピーが走ります。実験⑤では、200文字のstringで約100倍でした。読むだけなら、const T&にします。

②値渡しで書き換えて、「反映されない」と悩む

実験②のとおり、値渡しは、コピーを書き換えるだけです。呼び出し側の変数を変えたいなら、参照か、ポインタで受け取ります。

③ポインタを、関数の中で差し替えて、「反映されない」

実験③のとおり、ポインタ自身も値渡しです。ポインタの向き先まで変えたいなら、T*&かT**で受け取ります。

④ポインタ引数の、nullptrチェック忘れ

実験④のとおり、nullptrを渡すと、読んだ瞬間に0xC0000005で落ちます。「無いことがある」ポインタ引数は、先頭で、if (!p) return;のように、調べておきます。

⑤参照を、一時的な値に付けたまま、持ち続ける

const T&は、一時オブジェクトも受け取れますが、関数から出たあとも、その参照を持ち続けると、持ち主が、すでに消えています。ローカル変数の参照を返す失敗は、ぶら下がり参照の記事で、実測しています。

注意点

  • コピーが起きるのは、値渡しだけ。ポインタ渡しと参照渡しは、コピー0回だった
  • 元の変数を書き換えられるのは、ポインタ渡しと参照渡し。値渡しは、元の変数に届かない
  • ポインタ自身は値渡し。向き先まで変えるなら、T*&かT**を使う
  • 参照は「nullにならない」と決めてあるだけ。*nullptrを渡すとコンパイルは通り、実行で落ちた
  • 値渡しは、1024バイトで約26倍、4096バイトで約51倍、string(200文字)で約100倍、const参照より遅かった
  • 非constの参照は、リテラルを受け取れない(C2664)。const T&なら受け取れる
  • 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64、/O2 /MD)で行った。時間の数値は、環境や、CPUの状態で変わる

この記事のポイント

  • 値渡しは「コピー」、ポインタ渡しは「場所のコピー」、参照渡しは「同じものの別名」
  • 読むだけなら、小さい型は値渡し、大きい型はconst T&
  • 書き換えたいなら、必ずあるときはT&、無いことがあるときはT*
  • 「ポインタが場所、参照が本体、値がコピー」という整理は、ポインタ自身も値渡しという一点を足すと、完成する

どの渡し方にするか迷ったら、「コピーしてよいか」「書き換えてよいか」「無いことがあるか」の3つを、順に、自分に聞いてみてください。その答えが、そのまま、引数の書き方になります。

あわせて読みたい