関数の引数を、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 = 999reseat(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 |
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参照渡しは、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* x | nullptrで、「無い」を表せる |
| 関数の中で、コピーを持ちたい(受け取って、保存する) | 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つを、順に、自分に聞いてみてください。その答えが、そのまま、引数の書き方になります。

