C++の関数・オーバーロード・std::vector・文字列
C++講座2。Apple clang 21 で実行:オーバーロードの曖昧エラー、デフォルト引数、vector 値渡しのコピーコスト実測、push_back の再確保回数、ASan で捕まえるイテレータ無効化。
シリーズ:C++講座 2/6(初級)。 C++ のプログラムをビルドでき、参照と const を知っている人(講座 1)向けです。読み終えると、曖昧エラーを起こさずに関数を書いてオーバーロードでき、引数の渡し方を実測の根拠つきで選べ、std::vector と std::string を安全に扱い、vector への参照が死ぬバグを見分けられるようになります。
C++ の現場で最初の数か月に書くコードの大半は、文字列や vector を受け取って結果を返す関数です。そのコードレビューで繰り返し指摘されるバグは 3 つ。誰かがオーバーロードを足した途端に曖昧になる呼び出し、誰も気づかないまま毎回コピーされる大きな vector、そして vector が中身を引っ越した後に使われるイテレータや参照です。最後のものは確実には落ちないので生き残ります。以下はすべて Apple Silicon Mac(M1 Pro)、macOS 26、Apple clang 21.0.0 + libc++ で clang++ -std=c++23 -Wall -Wextra -pedantic を使って実行しました。
AtCoder で再帰関数に vector を値渡しして TLE を出した経験がある人なら、この講座の真ん中の表に見覚えのある数字が並ぶはずです。
関数:使う前に宣言し、必ず return する
C++ のコンパイラは上から順に読みます。宣言より前で関数を呼ぶとエラーです。
decl.cpp:2:27: error: use of undeclared identifier 'square'
2 | int main() { std::cout << square(4) << '\n'; }
定義を上へ移すか、宣言(関数の 1 行目に ; を付けたもの。普通はヘッダファイルに書く)int square(int x); を先に置きます。
ある経路で return を忘れても警告止まりで、結果は未定義です。
int grade(int score) {
if (score >= 90) return 'A';
if (score >= 80) return 'B';
} // score 50: falls off the end
int main() { return grade(50); }
noret.cpp:4:1: warning: non-void function does not return a value in all control paths [-Wreturn-type]
それでも実行してみました。-O0 では終了コード 1、-O2 では 66。66 は 'B' です。最適化器が「末尾には到達しない」と仮定して 2 つ目の判定を消したのです。-fsanitize=undefined なら runtime error: execution reached the end of a value-returning function without returning a value。-Wreturn-type はエラー扱い(-Werror=return-type)にしておきます。
オーバーロードと曖昧エラー
オーバーロードとは、同じ名前で引数の型が違う関数を複数持つことです。コンパイラが最も合うものを選びます。
#include <iostream>
#include <string>
void show(int x) { std::cout << "int: " << x << '\n'; }
void show(double x) { std::cout << "double: " << x << '\n'; }
void show(const std::string& s) { std::cout << "string: " << s << '\n'; }
int main() {
show(42);
show(3.14);
show('A'); // char -> int (promotion)
show(2.5f); // float -> double (promotion)
show("hi"); // const char* -> std::string (user-defined conversion)
}
int: 42
double: 3.14
int: 65
double: 2.5
string: hi
'A' が 65 になったのは、char が int に昇格したからです。では long や unsigned を渡すとどうなるか。
void show(int) {}
void show(double) {}
int main() {
long n = 5;
show(n);
show(5u);
}
amb.cpp:5:5: error: call to 'show' is ambiguous
5 | show(n);
| ^~~~
amb.cpp:1:6: note: candidate function
1 | void show(int) {}
amb.cpp:2:6: note: candidate function
2 | void show(double) {}
amb.cpp:6:5: error: call to 'show' is ambiguous
6 | show(5u);
順位をざっくり言うと、完全一致 → 昇格(char→int、float→double)→ 標準変換(long→int、long→double、int→double)→ ユーザー定義変換。long から int も double も標準変換で同順位なので、C++ は推測を拒否します。
nullptr がある理由も同じ規則で説明できます。find(int) と find(const char*) を用意すると:
find(0) -> find(int) 0
find(nullptr) -> find(const char*) (null)
find(NULL) -> error: call to 'find' is ambiguous
このプラットフォームの NULL は整数のような定数なので両方に合ってしまいます。「ポインタなし」は必ず nullptr と書きます。
デフォルト引数
デフォルト引数は、呼び出し側が省略した値を補います。
std::string pad(const std::string& s, int width = 8, char fill = ' '); // declaration
std::string pad(const std::string& s, int width, char fill) { // definition
std::string out = s;
while ((int)out.size() < width) out.insert(out.begin(), fill);
return out;
}
// pad("42") -> "[ 42]" pad("42", 5) -> "[ 42]" pad("42", 5, '0') -> "[00042]"
罠が 2 つあり、どちらも本物のエラーになります。
defarg_err.cpp:3:43: error: redefinition of default argument
defarg_err.cpp:2:43: note: previous definition is here
defarg_err.cpp:6:14: error: call to 'log' is ambiguous
1 つ目は定義側にも = 8 を書いたため。デフォルトは宣言に 1 回だけ書きます。2 つ目は void log(const char*, int level = 0); と void log(const char*); を並べたため。引数 1 つの呼び出しが両方に合います。オーバーロードかデフォルト引数か、どちらか一方にします。
値渡し・参照・const 参照を実測する
値渡しは引数をコピーします。int ならタダ同然ですが、std::vector ならメモリ確保と全要素のコピーが走ります。引数の型だけが違う 2 つの関数で vector を合計しました。
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <random>
#include <vector>
[[gnu::noinline]] long long sum_by_value(std::vector<int> v) {
long long s = 0; for (int x : v) s += x; return s;
}
[[gnu::noinline]] long long sum_by_cref(const std::vector<int>& v) {
long long s = 0; for (int x : v) s += x; return s;
}
template <class F> double ms_per_call(F f, int calls, long long& sink) {
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < calls; ++i) sink += f();
auto t1 = std::chrono::steady_clock::now();
return std::chrono::duration<double, std::milli>(t1 - t0).count() / calls;
}
int main(int argc, char** argv) {
const int N = argc > 1 ? std::atoi(argv[1]) : 1'000'000;
std::mt19937 rng(42); // fixed seed
std::vector<int> v(N);
for (int& x : v) x = static_cast<int>(rng() % 100);
long long sink = 0;
double a = ms_per_call([&] { return sum_by_value(v); }, 100, sink);
double b = ms_per_call([&] { return sum_by_cref(v); }, 100, sink);
std::printf("N=%d by value %.3f ms by const& %.3f ms (sink %lld)\n", N, a, b, sink);
}
[[gnu::noinline]] は、最適化器が関数を呼び出し元に溶かし込んでコピーを隠すのを防ぎます。100 回呼んだ平均を 7 回測った中央値、-O2 です。
| 要素数 | 値渡し | const& |
|---|---|---|
| 1,000,000(4 MB) | 0.116 ms | 0.047 ms |
| 10,000,000(40 MB) | 1.642 ms | 0.698 ms |
全要素を読む関数でさえ、コピーで 1 回のコストが 2 倍以上になりました。v[0] と v.size() だけを読む関数では、値渡しが 1 回 124.5 µs、const& はほぼゼロでした(100 万要素、1,000 回)。1,000 要素未満では差がタイマーの分解能を下回りました。再帰で毎回値渡ししていれば、この差が呼び出し回数だけ積み上がります。
| 引数の型 | 使う場面 |
|---|---|
T(値渡し) |
小さい型(int、double、ポインタ)、どのみち関数内でコピーが要るとき |
const T& |
読むだけの大きい型:std::string、std::vector、自作クラス |
T& |
呼び出し元のオブジェクトを変える必要があるとき |
std::string_view |
std::string からもリテラルからも受け取る読み取り専用の文字列(C++17) |
std::vector:成長・capacity・reserve
std::vector<T> は伸びる配列で、要素は 1 つのヒープ領域に隙間なく並びます。size() は入っている要素数、capacity() は大きい領域を確保し直さずに入る数です。push_back を 100 万回呼びながら、capacity が変わるたびに表示しました(中間の行はプログラム側で省略)。
size 0: capacity 0
size 1: capacity 0 -> 1
size 2: capacity 1 -> 2
size 3: capacity 2 -> 4
size 5: capacity 4 -> 8
size 9: capacity 8 -> 16
size 17: capacity 16 -> 32
size 33: capacity 32 -> 64
size 65: capacity 64 -> 128
size 262145: capacity 262144 -> 524288
size 524289: capacity 524288 -> 1048576
reallocations for 1,000,000 push_back: 21
with reserve(1000000): 0 reallocations
libc++ は capacity を 2 倍ずつ増やすので、100 万要素で再確保は 21 回、その途中で合計約 1,048,575 要素をコピーします。要素 1 つにつき余分なコピーがおよそ 1 回という計算です。reserve(n) は最初に 1 回だけ確保します。
reserve でどれだけ速くなるか。これは測り方次第で、最初の測定は間違っていました。1 つのプロセスで両方を続けて走らせると、reserve が 3 倍速く見えました(1,000 万個で 14.55 ms 対 4.61 ms)。しかし 2 回目は、1 回目が既に触ったメモリを再利用していたのです。別々のプロセスで各 7 回、中央値を取り直しました。
1,000 万回の push_back |
中央値 |
|---|---|
reserve なし |
31.85 ms |
先に reserve(N) |
24.83 ms |
新しいプロセスでは、時間の大半が OS から 40 MB の新しいページを受け取る処理に使われ、これは両方が払います。サイズが分かるなら reserve する価値はありますが、もっと大きな利点は次の節です。再確保がなければ参照もぶら下がりません。
イテレータ無効化:ASan が名指しするバグ
イテレータはコンテナ内の位置(v.begin()、v.end())で、範囲 for も裏で使っています。vector が再確保すると、古い領域を指すイテレータ・ポインタ・参照はすべて無効になります。
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores = {70, 85, 90};
int& best = scores[2]; // reference into the vector
std::cout << "capacity before: " << scores.capacity() << '\n';
scores.push_back(100); // needs more room -> reallocates
std::cout << "capacity after: " << scores.capacity() << '\n';
std::cout << "best = " << best << '\n'; // dangling!
}
警告はゼロ。ビルド方法を変えると 3 通りの結果になりました。
-O0: capacity before: 3 / capacity after: 6 / best = 2
-O2: capacity before: 3 / capacity after: 6 / best = 5
-fsanitize=address:
==43075==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000f8
READ of size 4 at 0x6020000000f8 thread T0
#0 0x000104c40d54 in main invalid.cpp:10
freed by thread T0 here:
...
#10 ... in std::__1::vector<int>::push_back(int&&) vector.h:466
#11 ... in main invalid.cpp:8
best は 90 のはずです。普通のビルドは 2 と 5 を表示し、終了コード 0 で、何のヒントも出しません。ASan は読んだ場所(10 行目)、解放した場所(8 行目の push_back)、元の確保場所まで示しました。範囲 for の本体で vector を伸ばしても同じことが起きます。
for (int x : v) {
if (x == 2) v.push_back(20); // ASan: heap-use-after-free rangefor.cpp:5
std::cout << x << ' ';
}
ASan でも捕まらないもの
ループ中の erase ももう 1 つの定番ですが、こちらは ASan が黙っていました。
std::vector<int> w = {1, 2, 2, 3, 4, 4, 5};
for (auto it = w.begin(); it != w.end(); ++it)
if (*it % 2 == 0) w.erase(it); // 'it' is invalid after erase
// printed: 1 2 3 4 5 (one 2 and one 4 survived)
erase は再確保しないのでメモリ自体は有効なままで、ASan には報告することがありません。ループは消した場所に詰めてきた要素を飛ばすだけです。libc++ のデバッグ用ハードニングモードでも、ここでは捕まりませんでした。直し方は erase の戻り値を使うか、C++20 の std::erase_if を使うことです。
for (auto it = w.begin(); it != w.end(); )
it = (*it % 2 == 0) ? w.erase(it) : it + 1;
auto removed = std::erase_if(v, [](int x) { return x % 2 == 0; }); // C++20: "removed 4: 1 3 5"
-std=c++17 では std::erase_if が error: no member named 'erase_if' in namespace 'std' になります。
単純な範囲外アクセスなら、libc++ には ASan より軽い検査があります。3 要素の vector で v[3] を読むと、普通は黙って v[3] = 0 と表示しますが、-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG を付けると終了コード 134 で止まりました。
.../c++/v1/__vector/vector.h:414: libc++ Hardening assertion __n < size() failed: vector[] index out of bounds
v.at(3) は常に検査して std::out_of_range を投げます(libc++ の what() は単に vector)。
関数の中の文字列:find、npos、string_view
std::string::find は位置を返し、「見つからない」は特別な値 std::string::npos です。これを条件式にそのまま使うと論理が逆になります。
bool has_at_sign(const std::string& s) {
if (s.find('@')) return true; // BUG
return false;
}
has_at_sign("user@example.com") = true
has_at_sign("@admin") = false
has_at_sign("nobody") = true
npos = 18446744073709551615
位置 0 は偽、「見つからない」は巨大な真の値です。警告はありません。s.find('@') != std::string::npos と書くか、C++23 なら s.contains('@') を使います。
std::string_view(C++17)は文字列を所有しない軽い「窓」で、ポインタと長さだけを持ちます。引数の型として優秀ですが、見ている文字列より長生きしてはいけません。一時文字列の view を関数から返すと、普通のビルドは空白を表示し、ASan は表示の行で heap-use-after-free を報告しました。clang が警告するのは単純な場合だけで、std::string_view v = std::string("..."); なら warning: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl] が出ますが、関数呼び出しを挟むと何も言いませんでした。
よくある間違い → 実際に起きたこと → 直し方
| 間違い | 実際に見たもの | 直し方 |
|---|---|---|
ある経路で return がない |
-O0 で 1、-O2 で 66 |
-Werror=return-type |
int/double 版がある所へ show(5L) |
call to 'show' is ambiguous |
完全一致の版か明示的キャスト |
int/ポインタ版がある所へ NULL |
曖昧エラー | nullptr |
引数が std::vector<int> v |
1,000 万要素で 2.4 倍遅い | const std::vector<int>& |
push_back をまたいで参照を保持 |
90 のはずが 2 や 5、終了コード 0 | 添字で持つか、先に reserve |
erase(it) の後に ++it |
結果が違う、ASan も無反応 | it = v.erase(it) か std::erase_if |
if (s.find(x)) |
論理が逆 | npos と比較 |
正直な限界・バージョンの注記
- 実行したのは arm64 macOS 26 上の Apple clang 21.0.0 と libc++ 22 だけです。libstdc++(GCC)も 2 倍、MSVC は 1.5 倍で伸ばすと理解していますが、どちらもここでは試していないので、正確な capacity の並びは載せていません。
std::string_viewは C++17、vector 用std::erase_ifは C++20、std::string::containsは C++23。範囲 for とnullptrは C++11。_LIBCPP_HARDENING_MODEは libc++ の機能(LLVM 18 以降)です。libstdc++ には代わりに_GLIBCXX_ASSERTIONSがあります(未実行)。- 時間は 1 台のマシンでの値です。見るべきは比率で、
reserveの表のとおり、測り方ひとつで答えが 3 倍変わることもあります。
演習
1. 上の show(int) と show(double) があるとき、次の呼び出しはどちらを選ぶか、それともエラーか。show('x')、show(2.5f)、show(10L)
2. libc++ で、空の vector に push_back を 5 回したとき、毎回の capacity はいくつですか。
3. 次のループは 3 2 1 と表示するつもりです。実際には何が起き、どう書けば正しいですか。
std::vector<int> v = {1, 2, 3};
for (std::size_t i = v.size() - 1; i >= 0; --i)
std::cout << v[i] << ' ';
解答
1. show('x') は show(int)(昇格。120 と表示)。show(2.5f) は show(double)(昇格)。show(10L) は曖昧エラー。long はどちらへも標準変換が必要です。
2. 実際の出力は 1 2 4 4 8。
3. 符号なし型で i >= 0 は常に真です。v[0] の次に --i が 18446744073709551615 に回り込み、v[i] は vector のはるか外を読みます。-Wall -Wextra は何も言いませんでした。-Wtautological-unsigned-zero-compare を付けると result of comparison of unsigned expression >= 0 is always true、ASan は heap-buffer-overflow ... in main rev.cpp:6 で止めます。正しい書き方を 3 つ。どれも 3 2 1 と表示します。
for (std::size_t i = v.size(); i-- > 0; ) std::cout << v[i] << ' ';
for (auto it = v.rbegin(); it != v.rend(); ++it) std::cout << *it << ' ';
for (int x : v | std::views::reverse) std::cout << x << ' '; // C++20, <ranges>
次の講座
講座 3 はクラスです。コンストラクタとデストラクタ、ファイルとロックの RAII、浅いコピーが二重解放で終わる様子、そしてそれを防ぐ 0・3・5 のルールを扱います。
- 前へ:C++講座 1/6:最初のプログラム・型・参照
- 次へ:C++講座 3/6:クラス・RAII・5 のルール
- この先:スマートポインタとムーブ、テンプレートと STL アルゴリズム、性能と並行処理
- 関連:C で
reallocを使った同じ伸ばし方の実測は C言語講座 3。JVM で同じ問いを立てたのが Java コレクションの性能 - C++ の位置づけ:プログラミング学習ロードマップ
よくある質問
C++ のオーバーロード呼び出しが ambiguous になるのはなぜ?
完全一致する関数がなく、2 つの候補が同じ順位の変換を必要とすると、コンパイラは選ぶのを拒否します。show(int) と show(double) があるところへ show(5L) や show(5u) を渡すと、clang 21 は「error: call to 'show' is ambiguous」を出しました。long→int も long→double も同じ「標準変換」だからです。完全一致の版を足すか、show(static_cast<int>(n)) のように自分で変換します。
std::vector を値渡しするとどれくらいコストがかかる?
呼ぶたびに丸ごとコピーされます。int 100 万個の vector を合計する関数は値渡しで 1 回 0.116 ms、const 参照で 0.047 ms。1,000 万個では 1.642 ms と 0.698 ms でした(7 回の中央値、-O2、Apple Silicon M1 Pro)。要素を 1 つ読むだけの関数でもコピーに約 124 マイクロ秒かかりました。大きな型は const& で渡します。
push_back の後で vector 要素への参照が壊れるのはなぜ?
size が capacity に達すると、push_back は大きな領域を確保して要素を移し、古い領域を解放します。古い領域を指す参照・ポインタ・イテレータはすべてぶら下がります。手元では 90 のはずの値が -O0 で 2、-O2 で 5 と表示され、AddressSanitizer はその行を heap-use-after-free と報告しました。添字で持つか、先に reserve() します。