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
棒グラフ。要素 100 万個で、値渡しは 1 回 0.116 ミリ秒、const 参照は 0.047。1,000 万個では値渡し 1.642 ミリ秒、const 参照 0.698。コピーで呼び出しのコストが 2 倍以上になる。
std::vector<int> を合計する関数 1 回あたりの時間。値渡しと const 参照の比較。100 回呼んだ平均を 7 回測った中央値、-O2、固定シード、Apple Silicon Mac(M1 Pro)。

全要素を読む関数でさえ、コピーで 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 回だけ確保します。

流れ図。push_back(x) はまず size が capacity より小さいかを確かめる。小さければ x を次の空きに置くだけで何も動かない。そうでなければ約 2 倍の新しい領域を確保し、全要素をそこへムーブまたはコピーし、古い領域を解放する。その後、古い領域を指していた参照・ポインタ・イテレータはすべてぶら下がり、AddressSanitizer は heap-use-after-free と報告する。
push_back 1 回の中身。下の道は size が capacity に達したときだけ通る。libc++ では 100 万回の push_back で 21 回起き、そのたびに古い領域を指す参照はすべて無効になる。

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++ のオーバーロード呼び出しが 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() します。

  • #C++
  • #初級
  • #オーバーロード
  • #std::vector
  • #イテレータ無効化
  • #AddressSanitizer

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑