C++ Functions, Overloading, std::vector and Strings

C++ lecture 2 on Apple clang 21: overload ambiguity errors, default arguments, the measured cost of passing a vector by value, push_back growth, ASan.

Series: C++ lecture 2/6 (beginner). For readers who can compile a C++ program and know references and const (lecture 1). After this lecture you can write and overload functions without ambiguity errors, choose how to pass arguments with a measured reason, use std::vector and std::string safely, and recognise the bug where a reference into a vector dies.

Most code you write in the first months at a C++ job is functions that take strings and vectors and return results. Three bugs keep showing up in reviews of that code: a call that becomes ambiguous after someone adds an overload, a large vector copied on every call without anyone noticing, and an iterator or reference used after the vector moved its storage. The last one does not crash reliably, which is why it survives. Everything here was compiled and run with Apple clang 21.0.0 and libc++ on an Apple Silicon Mac (M1 Pro), macOS 26, using clang++ -std=c++23 -Wall -Wextra -pedantic.

Functions: declare before use, and always return

A C++ compiler reads top to bottom. Calling a function before it is declared is an error:

decl.cpp:2:27: error: use of undeclared identifier 'square'
    2 | int main() { std::cout << square(4) << '\n'; }

Either move the definition up or add a declaration (the first line plus ;, usually in a header file): int square(int x);.

Forgetting a return on one path is only a warning, and the result is undefined:

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]

We ran it anyway. At -O0 the exit code was 1; at -O2 it was 66, which is 'B': the optimizer assumed the end is unreachable and dropped the second check. With -fsanitize=undefined: runtime error: execution reached the end of a value-returning function without returning a value. Treat -Wreturn-type as an error (-Werror=return-type).

Overloading and the ambiguity error

Overloading means several functions share a name and differ in parameter types. The compiler picks the best match:

#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' printed 65 because a char is promoted to int. Now pass a long or an 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);

The ranking in short: exact match, then promotion (char→int, float→double), then standard conversion (long→int, long→double, int→double), then user-defined conversions. long to int and long to double are both standard conversions, so there is a tie, and C++ refuses to guess.

The same rule explains why nullptr exists. With find(int) and find(const char*):

find(0)        -> find(int) 0
find(nullptr)  -> find(const char*) (null)
find(NULL)     -> error: call to 'find' is ambiguous

NULL is an integer-like constant on this platform, so it matches both. Always write nullptr for "no pointer".

Default arguments

A default argument fills in a value the caller left out:

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]"

Two traps, both real errors:

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

The first comes from repeating = 8 in the definition: write defaults once, in the declaration. The second comes from void log(const char*, int level = 0); next to void log(const char*);: a call with one argument matches both. Pick either an overload or a default, not both.

Pass by value, reference or const reference: measured

Passing by value copies the argument. For an int that is free. For a std::vector it allocates memory and copies every element. We summed a vector in two functions that differ only in the parameter:

#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]] stops the optimizer from merging the function into the caller, which could hide the copy. Mean of 100 calls, median of 7 runs, -O2:

Elements By value By const&
1,000,000 (4 MB) 0.116 ms 0.047 ms
10,000,000 (40 MB) 1.642 ms 0.698 ms
Bar chart. With one million elements, passing by value took 0.116 milliseconds per call and passing by const reference 0.047. With ten million elements, by value took 1.642 milliseconds and by const reference 0.698. The copy more than doubles the cost of the call.
One call that sums a std::vector<int>, by value versus by const reference. Mean of 100 calls, median of 7 runs, -O2, fixed seed, Apple Silicon Mac (M1 Pro).

The copy more than doubled the cost of a call that already touches every element. For a function that only reads v[0] and v.size(), by value cost 124.5 µs per call against effectively zero for const& (1,000 calls, 1,000,000 elements). Below 1,000 elements the difference was under our timer's resolution.

Parameter type When
T (by value) small types (int, double, pointers), or when the function needs its own copy anyway
const T& large types you only read: std::string, std::vector, your classes
T& the function must change the caller's object
std::string_view read-only text from either std::string or a literal (C++17)

std::vector: growth, capacity and reserve

std::vector<T> is a growable array: elements sit next to each other in one heap block. size() is how many elements you have; capacity() is how many fit before the vector must allocate a bigger block. We printed every capacity change during 1,000,000 push_back calls (middle rows trimmed by the program):

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++ doubles the capacity, so 1,000,000 elements need 21 reallocations and copy about 1,048,575 elements in total along the way: roughly one extra copy per element. reserve(n) allocates once up front.

Flow diagram. push_back(x) asks whether size is less than capacity. If yes, x is placed in the next free slot and nothing moves. If no, the vector allocates a new block about twice as large, moves or copies every element into it, and frees the old block. After that, every reference, pointer and iterator that pointed into the old block is dangling, which AddressSanitizer reports as heap-use-after-free.
What one push_back does. The lower path runs only when size equals capacity; with libc++ that happened 21 times for 1,000,000 push_back calls, and every reference into the old block dies each time.

How much does reserve save? It depends on how you measure, and our first attempt was wrong. Running both variants in one process, reserve looked 3x faster (14.55 ms vs 4.61 ms for 10,000,000 ints). But the second run reused memory the first one had already touched. In separate processes, 7 runs each, median:

10,000,000 push_back Median
no reserve 31.85 ms
reserve(N) first 24.83 ms

In a fresh process most of the time goes to the operating system handing out 40 MB of new pages, which both versions pay. reserve is still worth it when you know the size, but its bigger benefit is the next section: no reallocation means no dangling references.

Iterator invalidation: the bug ASan names

An iterator is a position in a container (v.begin(), v.end()); range-for uses them behind the scenes. When the vector reallocates, every iterator, pointer and reference into the old block becomes invalid:

#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!
}

No warning. Three builds, three behaviours:

-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 should be 90. The plain builds printed 2 and 5, exited with code 0 and gave no hint. ASan named the read (line 10), the free (the push_back on line 8) and the original allocation. The same happens in range-for if the body grows the vector:

for (int x : v) {
    if (x == 2) v.push_back(20);     // ASan: heap-use-after-free rangefor.cpp:5
    std::cout << x << ' ';
}

What ASan does not catch

Erasing inside a loop is the other classic, and here ASan stayed silent:

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 does not reallocate, so the memory is still valid and ASan has nothing to report; the loop just skips the element that slid into the erased slot. Neither libc++'s debug hardening mode nor ASan caught it here. The fix is to use what erase returns, or C++20's 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"

With -std=c++17, std::erase_if fails with error: no member named 'erase_if' in namespace 'std'.

For plain out-of-range indexing, libc++ has a cheaper check than ASan. v[3] on a 3-element vector printed v[3] = 0 silently, but compiled with -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG it stopped with exit code 134:

.../c++/v1/__vector/vector.h:414: libc++ Hardening assertion __n < size() failed: vector[] index out of bounds

v.at(3) always checks and throws std::out_of_range (its what() on libc++ is just vector).

Strings in functions: find, npos and string_view

std::string::find returns a position, and "not found" is the special value std::string::npos. Using it as a condition gets the logic backwards:

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

Position 0 is false and "not found" is a huge true number. No warning. Write s.find('@') != std::string::npos, or s.contains('@') in C++23.

std::string_view (C++17) is a cheap, non-owning view of text: a pointer plus a length. It is a good parameter type, but it must not outlive the string it looks at. Returning a view of a temporary printed a blank line in the plain build, and ASan reported heap-use-after-free at the print. clang warns only in the simple case: std::string_view v = std::string("..."); gives warning: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl]; through a function call it said nothing.

Common mistake → what actually happens → fix

Mistake What we saw Fix
Missing return on one path exit 1 at -O0, 66 at -O2 -Werror=return-type
show(5L) with int/double overloads call to 'show' is ambiguous exact overload or explicit cast
NULL with int/pointer overloads ambiguous nullptr
std::vector<int> v parameter 2.4x slower call at 10M ints const std::vector<int>&
Reference kept across push_back 2 or 5 instead of 90, exit 0 keep an index, or reserve first
erase(it) then ++it wrong result, no ASan report it = v.erase(it) or std::erase_if
if (s.find(x)) logic inverted compare with npos

Honest limits / version notes

  • Compiled and run only with Apple clang 21.0.0 and libc++ 22 on arm64 macOS 26. libstdc++ (GCC) grows vectors by 2x as well and MSVC by 1.5x, as far as we know; neither was run here, so their exact capacity sequence is not shown.
  • std::string_view: C++17. std::erase_if for vectors: C++20. std::string::contains: C++23. Range-for and nullptr: C++11.
  • _LIBCPP_HARDENING_MODE is a libc++ feature (LLVM 18 and later); libstdc++ has _GLIBCXX_ASSERTIONS instead (not run here).
  • Timings are from one machine. The ratios matter, and the reserve table shows that how you measure can change the answer by 3x.

Exercises

1. With show(int) and show(double) from above, which function does each call pick, or does it fail? show('x'), show(2.5f), show(10L)

2. On libc++, what are the capacities after each of 5 push_back calls on an empty vector?

3. This loop should print 3 2 1. What happens instead, and how do you write it correctly?

std::vector<int> v = {1, 2, 3};
for (std::size_t i = v.size() - 1; i >= 0; --i)
    std::cout << v[i] << ' ';

Answers

1. show('x') calls show(int) (promotion, prints 120). show(2.5f) calls show(double) (promotion). show(10L) is ambiguous: long needs a standard conversion to either.

2. Real output: 1 2 4 4 8.

3. i >= 0 is always true for an unsigned type. After v[0], --i wraps to 18446744073709551615 and v[i] reads far outside the vector. -Wall -Wextra said nothing; -Wtautological-unsigned-zero-compare reports result of comparison of unsigned expression >= 0 is always true, and ASan stops it with heap-buffer-overflow ... in main rev.cpp:6. Three correct versions, all printing 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>

Next

Lecture 3 builds classes: constructors and destructors, RAII for files and locks, and the shallow copy that ends in a double free, plus how the rule of zero, three and five prevents it.

FAQ

Why is my C++ overloaded function call ambiguous?

When no overload matches exactly and two of them need conversions of the same rank, the compiler refuses to pick. With show(int) and show(double), calling show(5L) or show(5u) gave "error: call to 'show' is ambiguous" on clang 21, because long to int and long to double are both standard conversions. Add an exact overload or convert the argument yourself, e.g. show(static_cast<int>(n)).

How much does passing a std::vector by value cost?

A full copy every call. Summing a 1,000,000-int vector took 0.116 ms per call by value and 0.047 ms by const reference; at 10,000,000 ints it was 1.642 ms versus 0.698 ms (median of 7 runs, -O2, Apple Silicon M1 Pro). A function that only read one element still paid about 124 microseconds for the copy. Pass large objects as const& unless you need your own copy.

Why does my reference to a vector element become invalid after push_back?

When size reaches capacity, push_back allocates a bigger block, moves the elements and frees the old one, so every reference, pointer and iterator into the old block dangles. In our run the stale reference printed 2 at -O0 and 5 at -O2 instead of 90, and AddressSanitizer reported heap-use-after-free at the exact line. Keep an index instead, or reserve() first.

  • #cpp
  • #beginner
  • #overloading
  • #std-vector
  • #iterator-invalidation
  • #addresssanitizer

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑