What the machine actually does with your object

You can point at any object in a function and say where it lives, when it dies, and how many trips to the allocator it costs.

Where an object lives

C++ gives you two places to put things, and the difference between them is the difference between free and expensive.

A local variable lives in the stack frame. Making one costs the compiler an adjustment to a register. There is no bookkeeping, no free list, no search, no lock. Destroying it costs the same adjustment in reverse. The size has to be known when the function is compiled, and the object dies at the closing brace.

Everything else lives on the heap, and the heap is a data structure with a program attached. Asking for memory means calling into the allocator, which walks its own structures, may take a lock shared with every other thread in the process, and may go to the kernel for more pages. Giving it back runs the same machinery again.

#include <array>
#include <string>
#include <vector>

void demo() {
    float block[128];                 // stack. The frame pointer moves. That is all.
    std::array<float, 128> same{};    // stack too, with the size in the type.

    std::vector<float> heap(128);     // one call to the allocator.
    std::string small = "gain";       // no allocation: it fits inside the object.
    std::string big =
        "a parameter name longer than the small buffer";   // one allocation.

    (void) block; (void) same; (void) heap; (void) small; (void) big;
}

The `std::string` lines are the interesting pair. A string object is not a pointer to characters. It is a small struct, usually 24 or 32 bytes, that carries a size, a capacity, and either a pointer to a heap block or a handful of characters stored inline. That inline buffer is the small string optimisation. Its capacity is around 15 characters on libstdc++ and around 22 on libc++. Below the threshold a string copy is a struct copy. Above it, a string copy is an allocation.

So the same line of code has two different costs depending on how long the text is. Nothing in the source tells you which one you got.

Lifetime is something the compiler tracks

Every object has a construction point and a destruction point, and in C++ they are both places in the code rather than events at runtime. The compiler inserts the destructor call for you at the end of the enclosing scope, in reverse order of construction, including on the path where an exception is thrown.

This is the whole mechanism behind RAII. A type that acquires something in its constructor and releases it in its destructor is released correctly on every exit path, because the compiler writes those exits.

It also means that anything referring to an object has to die first. This function is wrong, and it compiles without a warning on most settings:

#include <string>
#include <string_view>

std::string_view nameFor(int id) {
    std::string name = "param" + std::to_string(id);
    return name;   // name is destroyed on this line. The view outlives it.
}

The returned view points at memory the allocator has already taken back. It will usually work in a debug build and on the developer's machine, because nothing has reused the block yet. It will stop working under load, which is the worst possible time for it to start being noticed.

The fix is to decide who owns the characters. Either return the `std::string` by value and accept the copy, or hand back a reference to something that lives longer than the caller.

#include <string>
#include <vector>

struct Parameters {
    std::vector<std::string> names;
};

const std::string& nameFor(const Parameters& p, int id) {
    return p.names[static_cast<std::size_t>(id)];   // lives as long as p does.
}
The question to ask

For every reference, pointer, view or span in your code, there is one question: what object does this refer to, and is that object guaranteed to still be alive here? If you cannot answer it from the signature, the signature is wrong.

The copy you did not write

C++ copies by default. Assignment copies, passing by value copies, returning a member copies, and a conversion from `const char*` to `std::string` constructs a whole new string. None of it is visible at the call site.

#include <string>
#include <vector>

struct Parameter { std::string name; float value; };

// Two allocations per call, minimum, on any name past the small buffer.
float lookup(std::vector<Parameter> params, std::string name) {
    for (const Parameter& p : params)
        if (p.name == name) return p.value;
    return 0.0f;
}

void caller(const std::vector<Parameter>& params) {
    float drive = lookup(params, "drive amount in decibels");
    (void) drive;
}

`params` is taken by value, so the vector and every string inside it is copied on entry and destroyed on exit. `name` is taken by value, and the caller passed a string literal, so a `std::string` is constructed at the call site from characters the program already had sitting in read-only memory. Changing both parameters to `const&` removes all of it and changes nothing else about the behaviour.

Why this is a correctness problem

In most code, an unnecessary allocation is waste. On a thread with a deadline it is a bug, because an allocation is not a fixed cost.

The audio callback is the clearest case. At 48 kHz with a block of 128 samples, the callback runs about 375 times a second and has 2.67 milliseconds to return. Miss it and the driver plays whatever is in the buffer, which the listener hears as a click. There is no retry and no degraded mode.

A call to `operator new` usually returns in tens of nanoseconds. Usually is the problem. The allocator holds a lock that a lower priority thread may currently own, and the audio thread will wait for it while that thread is descheduled. The block may not be in the free list, so the allocator asks the kernel, and the kernel may fault in pages. Under memory pressure the same call can take milliseconds. It can also throw `std::bad_alloc`, which means the function you thought was simple has an exit path you never considered.

OperationTypicalWorst case that actually happens
Stack localone register adjustmentone register adjustment
`operator new` fast pathtens of nanosecondsblocks on the allocator lock
`operator new` needing pagesmicrosecondspage fault, kernel, milliseconds
`std::string` copy under the SSO limita struct copya struct copy
`std::string` copy over itone `new` plus one `delete`both of the above

So the string copy in a hot loop is not slow code that could be tidied later. It is code whose worst case is unbounded, running somewhere with a hard limit. The average time tells you nothing here, because you are judged on the tail.

Seeing it on Compiler Explorer

Stop guessing which version allocates and read the instructions. Paste both versions into godbolt.org, set the compiler to a recent GCC or Clang, and pass `-O2`. Optimisation matters: at `-O0` everything looks terrible and nothing is informative.

#include <string>

bool byValue(std::string name);
bool byRef(const std::string& name);

bool callsByValue(const std::string& s) { return byValue(s); }
bool callsByRef(const std::string& s)   { return byRef(s); }

Turn on the demangler and read the two callers. `callsByRef` is a single jump into `byRef` and nothing else. `callsByValue` calls the string copy constructor, then `byValue`, then `operator delete`, which appears under its mangled name `_ZdlPv`. The allocation is inside that copy constructor, showing up either as an inlined call to `operator new` (`_Znwm`) on libstdc++ or as a call into the string's own copy routine on libc++.

That is worth sitting with, because it says where the cost lands. A by-value parameter is built by the caller and destroyed by the caller. The function you are reading looks cheap, and the price is paid at every call site, in code you may not have open.

The same trick answers most micro-level questions faster than arguing about them. Does this lambda allocate when it is stored in a `std::function`? Does this `substr` copy? Write the two lines, compile at `-O2`, count the calls.

Counting allocations at runtime

Disassembly tells you about one function. For a whole subsystem, replace the global allocator and count. This is legal C++: you are allowed to define `operator new` and `operator delete` yourself, and yours wins over the standard library's across the entire program.

#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <new>

std::atomic<int> g_allocations{0};

void* operator new(std::size_t size) {
    g_allocations.fetch_add(1, std::memory_order_relaxed);
    if (void* p = std::malloc(size)) return p;
    throw std::bad_alloc();
}

void operator delete(void* p) noexcept          { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

Now put a scope guard around the code that is not allowed to allocate, and let it fail loudly.

#include <atomic>
#include <cstdio>
#include <cstdlib>

extern std::atomic<int> g_allocations;

struct NoAllocations {
    const char* what;
    int start = g_allocations.load(std::memory_order_relaxed);

    explicit NoAllocations(const char* label) : what(label) {}

    ~NoAllocations() {
        const int n = g_allocations.load(std::memory_order_relaxed) - start;
        if (n != 0) {
            std::fprintf(stderr, "%s allocated %d time(s)\n", what, n);
            std::abort();
        }
    }
};

void processBlock(float* samples, int count);

void audioCallback(float* samples, int count) {
    NoAllocations guard{"audioCallback"};
    processBlock(samples, count);
}

Wrap the processing call in a test, run the suite, and the first allocation on that path stops the build rather than producing an occasional click on someone else's machine. The Quilio audio engine runs a version of this across its test suite, which is how a promise about the processing path stays true while people keep editing it.

Keep this in test builds

Aborting inside a destructor is fine in a harness and wrong in shipped code. Guard the whole file behind a build flag, and make the release build use the normal allocator.

What to do with this

Three habits follow from everything above, and they are cheap once they are automatic.

Pass things you only read as `const&` or, for text you will not keep, as `std::string_view`. Give containers their size up front with `reserve` so the growth happens once, before the deadline. Keep the allocations at the edges of the program, in setup and teardown, where a millisecond costs nothing.

Then verify instead of believing. Pick one function in code you already own, read it at `-O2` on Compiler Explorer, and count the calls to `operator new`. Most people find at least one they did not know about. That is the whole point of the exercise, and the rest of this module is about removing them without breaking anything.

Back to C++ that survives contact with a deadline