1. Mini performance hygiene helps triage
When you hear the word “performance”, a beginner’s brain often imagines: someone in a black turtleneck is optimizing bubble sort to make it “almost fast”. In memory-bug investigations things are more down-to-earth: performance matters exactly to the extent it affects stability of behavior. The more accidental copies and moves in memory — the more “noise” around bugs, and the harder it is to distinguish root cause from side effects.
Imagine you have a lifetime bug: you stored a pointer to an element of a std::vector, then did a push_back and got a reallocation. If reallocation happens “sometimes” (depending on the current capacity), the bug will appear “sometimes”. If during the investigation you temporarily add a reserve, the move may disappear — and you will either simplify tracing or, conversely, realize the problem is not the reallocation but another part of the code. In both cases you win: the signal becomes controllable.
Keep the main principle in mind: correctness first, optimization later. We are not going to “tune” the program for speed. We will make small changes that (a) usually speed up the code, (b) reduce the number of accidental events (copies, moves), and thereby (c) simplify investigation and make sanitizer reports more straightforward.
2. std::vector: reserve, reallocation and how it differs from resize
std::vector is a workhorse container, but it has one personality trait: it stores elements in a contiguous memory block. While there is space — great. When space runs out, the vector allocates a new buffer (usually larger), moves or copies elements there and frees the old buffer. That is reallocation. After it, all pointers/references/iterators/std::span to elements of the old buffer become invalid.
Here is a simple scheme of what happens:
flowchart LR
A[vector buffer #1
capacity=3] -->|push_back 4th| B[allocate buffer #2
capacity=6]
B --> C[move/copy elements]
C --> D[free buffer #1]
The key word in bug investigation is “sometimes”. Sometimes the capacity is enough, sometimes it is not. Sometimes push_back causes a move, sometimes it does not. So the same wrong pointer can “accidentally” still point to data or it can point to nowhere. The sanitizer in such moments looks like a detective who arrived at the crime scene a week later: there are traces, but half of them have been washed away by rain.
This is where reserve becomes less of an “optimization” and more of an experiment stabilizer. If you roughly know the upper bound on the size, you can ask the vector to allocate enough memory in advance and thus reduce the number of moves.
Small example where reserve makes behavior more predictable:
#include <vector>
#include <string>
int main() {
std::vector<std::string> lines;
lines.reserve(1000); // fewer reallocations -> fewer accidental moves
lines.push_back("one");
lines.push_back("two");
}
Note a practical detail: in conversations and text it is easy to say “reserve()”, but in real code you call reserve(size_type), so you always specify a concrete size. In debugging this is useful to keep in mind literally: “reserve this many”.
reserve and resize — similar words, different consequences
These two methods are often confused because both look like they are about size. But semantically they come from different worlds: reserve is about capacity (capacity), while resize is about logical size (size) and even about creating/removing elements. In triage this is critical: mix them up — you get extra elements, unexpected values, sometimes different logic branches, and now you are investigating a different bug than intended.
Put in plain terms. reserve(n) says: “dear vector, please prepare space for at least n elements, but do not change what I consider actually stored elements”. resize(n) says: “dear vector, you should now really have n elements; if there were fewer — create new ones, if there were more — remove the extras”.
Mini demonstration in a learning context (we read user commands and store history lines):
#include <iostream>
#include <vector>
#include <string>
int main() {
std::vector<std::string> history;
history.reserve(3);
std::cout << history.size() << '\n'; // 0
history.push_back("add task");
std::cout << history.size() << '\n'; // 1
}
And now the same with resize to feel the difference:
#include <iostream>
#include <vector>
#include <string>
int main() {
std::vector<std::string> history;
history.resize(3); // size became 3, three empty strings were added
std::cout << history.size() << '\n'; // 3
std::cout << '"' << history[0] << '"' << '\n'; // ""
}
In triage this is especially important: if you “for optimization” accidentally used resize, you changed program behavior. Investigation should be about minimal changes. So remember: reserve — usually a safe “stabilizer”, resize — this is already changing data and logic.
3. Fewer copies and honest signatures
Copying in C++ is a deceptive thing. A beginner often treats it as “so what, I passed a string”. Then it turns out the string contains a heap buffer, and copying means allocations, data moves, possible reallocations. Even if everything is correct, it creates background noise: more allocations, more memory movement, more places where a lifetime bug can appear or be masked.
From the point of view of memory-bug investigation, extra copies hurt in two ways. First, they change the allocation picture: the sanitizer shows allocation/free traces, and you see more events than necessary. Second, copies can accidentally extend the lifetime of data (for example, if you made a copy of a string — suddenly std::string_view “works”, although it actually referred to a temporary). This is especially nasty: the bug becomes “non‑reproducible” because you accidentally covered it with a copy.
So the mini‑hygiene here is simple: pass by value only when you really need a copy or ownership, and in all other cases — by const& or via view types (if their lifetimes are appropriate).
Small cheat‑sheet for orientation (without fanaticism — we are not writing a dissertation):
| What you pass | Parameter type | When appropriate | What to remember |
|---|---|---|---|
| Small and cheap (int, bool) | |
Almost always | Simple and clear |
| Large owning (std::string, std::vector) | |
Read, don't copy | Data must live with the caller |
| String for temporary parsing | |
Parsing/checks inside the call | Do not store the view for later |
| Contiguous data | |
Process array/vector without copies | Source must not move |
Honest signatures illustrated with a console task manager
To ground the topic, let's tie it to one application: a simple console “task manager”. We have a model:
#include <string>
struct Task {
int id = 0;
std::string text;
bool done = false;
};
And a storage:
#include <vector>
struct TaskStore {
std::vector<Task> tasks;
};
Now look at function signatures. Suppose we want to add a task. The naive variant often looks like this:
#include <string>
#include <vector>
void add_task(std::vector<Task>& tasks, std::string text) {
tasks.push_back(Task{static_cast<int>(tasks.size()) + 1, text, false});
}
Here text is passed by value, so it is copied at the call. Sometimes this is fine, but often we want to accept “string‑like” things without extra copies, and then store a std::string inside because the task must own its text.
Careful variant for our case:
#include <string>
#include <string_view>
#include <vector>
void add_task(std::vector<Task>& tasks, std::string_view text) {
Task t;
t.id = static_cast<int>(tasks.size()) + 1;
t.text = std::string{text}; // make the copy exactly once: into storage
t.done = false;
tasks.push_back(t);
}
The idea here is subtle but practical: the function does not require the caller to pre-create a std::string. It can accept a std::string, a string literal, or a substring. But in Task we store an owning string, not a std::string_view, to avoid dangling.
Printing is similar. If we print tasks, passing the container by value is a classic extra copy (extra memory and extra moves):
#include <iostream>
#include <vector>
void print_tasks(const std::vector<Task>& tasks) {
for (const Task& t : tasks) {
std::cout << t.id << ". " << t.text << '\n';
}
}
This is not a “super‑optimization”. It's basic honesty: printing should not take ownership of the task list. It should only read it.
string_view and span — convenient view types, but with strict lifetime rules
View types are like borrowing data to “look closer” without taking it home. Convenient, fast, but if you accidentally take the view (store it in a struct field, return a reference to a temporary), you get the classic “it worked yesterday”.
In triage views are useful because they reduce copies, hence reduce allocations and moves. But you pay with lifetime discipline.
Consider a typical user-command parsing function. We read a string line and want to check whether it starts with "add ":
#include <string_view>
bool starts_with_add(std::string_view s) {
return s.starts_with("add ");
}
Usage:
#include <iostream>
#include <string>
int main() {
std::string line = "add buy milk";
std::cout << std::boolalpha << starts_with_add(line) << '\n'; // true
}
This is safe: line lives longer than the call. But trouble begins when you try to return a std::string_view that refers to a local string:
#include <string>
#include <string_view>
std::string_view bad() {
std::string tmp = "add buy milk";
return tmp; // tmp dies -> view becomes dangling
}
This trap was already discussed in the lifetime topic, and here it matters as “hygiene”: sometimes an extra copy hides a problem, and an honest signature helps reveal it. So the rule is simple: std::string_view and std::span are great as parameters, but dangerous as stored data without owner guarantees.
reserve plus fewer copies: stabilization in a live code snippet
Let's assemble a small app snippet that illustrates both ideas at once: we read commands, add tasks, sometimes print the list. During investigation it's important that the task vector does not move every two operations, and strings are not copied three times “just because”.
Simple skeleton (no complex architecture, just the principle):
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<Task> tasks;
tasks.reserve(100); // stabilize element addresses during debugging
std::string line;
while (std::getline(std::cin, line)) {
if (line == "list") {
print_tasks(tasks);
} else if (line.starts_with("add ")) {
add_task(tasks, std::string_view(line).substr(4));
}
}
}
Here the key is that add_task accepts a std::string_view, and we pass a view to the substring of line. This is safe because add_task creates a std::string inside and stores it in the Task. In other words, we do not store the view — we use it temporarily.
And tasks.reserve(100) is not a promise “we will have exactly 100 tasks”; it's a practical move: “while we investigate, let's make the std::vector not hop around memory too often”. When the bug is found and fixed, the value can be revisited or removed if it is semantically unnecessary.
I emphasize: reserve does not give an absolute guarantee of “never moving”. If tasks become 101 — it will move. But in triage we often need exactly this: make behavior controllable. Want to — set reserve(1000) and test one hypothesis; want to — remove it and see if the symptom changes.
4. Common mistakes
Mistake #1: turning reserve into a magical talisman against all bugs.
Sometimes after a couple of successful cases you get the idea: “Oh, I added reserve and crashes stopped — so it's fixed.” No. reserve may remove the manifestation of use‑after‑realloc, but the root cause (storing a pointer/reference to a std::vector element) remains. The bug just becomes more devious: it will wait until there is more data.
Mistake #2: confusing reserve with resize, then investigating a new problem.
If you wanted to reduce reallocations but accidentally called resize, you changed the container data. As a result you may get “empty” elements, logic will work differently, and you will fix symptoms that were not present originally. In triage this is especially painful because you lose connection to the original bug.
Mistake #3: “optimizing” signatures by keeping std::string_view inside the model.
A very common scenario: a student sees that std::string_view removes copies and decides to store it in Task to “avoid copying strings entirely”. Then it turns out the original string was temporary or reused, and all tasks suddenly “point” to the same buffer. The data model almost always should own what it needs for its lifetime.
Mistake #4: passing large objects by value “for simplicity”, then being surprised by instability.
Passing std::vector<Task> or std::string by value is not always evil, but if a function shouldn't own the data, it adds hidden copies. These copies create extra allocations and moves, and in diagnostics extra events can blur the picture: an ASan report becomes longer and behavior less reproducible.
Mistake #5: using view types where an owning result is needed, and trying to “fix” it with reserve.
Sometimes you think: “I'll return std::string_view because it's faster.” But if the return value must live independently of an internal temporary string, a view is conceptually wrong. reserve won't help: it's about containers, not about the lifetime of local variables. The correct solution in such places is often trivial: return a std::string by value and sleep peacefully.
GO TO FULL VERSION