· 6 min read ·

What std::inplace_vector Reveals About the Contract std::vector Never Could Break

Source: isocpp

The exercise of implementing vector<T> from scratch reveals something counterintuitive: the standard library’s version makes design decisions that a rational engineer would not arrive at in isolation. Quasar Chunawala’s walkthrough on isocpp.org is a good entry point to understanding the mechanics, but the more interesting question is why std::vector chose the constraints it did, and what happens when you need different constraints.

C++26 answers part of that question with std::inplace_vector<T, N>, a fixed-capacity container that stores its elements entirely within the object itself, no heap allocation ever. Understanding what it is and why it had to be a separate type reveals the specific commitments that std::vector’s design locked in from the beginning.

The Move Guarantee That Rules Out Inline Storage

When implementing a growable array, inline storage for small sizes seems like an obvious optimization. std::string does it under the small string optimization. LLVM’s SmallVector<T, N> does it. Google’s absl::InlinedVector does it. The idea is straightforward: reserve N elements inline within the container object and fall back to heap allocation only when the count exceeds N.

std::vector cannot do this because of a single guarantee in the standard: move construction is O(1) and noexcept. For a heap-only vector, this is trivial to satisfy. Moving means stealing the three internal pointers from the source and setting them to null in the source. The elements are never touched.

For inline storage, there are no pointers to steal. If the elements reside in the inline buffer, moving the container object requires moving each element individually, which is O(n). The noexcept guarantee also becomes impossible to maintain, because moving elements can throw if the move constructor is not noexcept. Both llvm::SmallVector and absl::InlinedVector accept this trade-off explicitly: their move constructors are O(n) for the inline case and O(1) only when the data has already spilled to the heap.

The standard chose not to accept that trade-off. The O(1) noexcept move was considered more valuable than inline storage, particularly because vectors are frequently stored inside other containers, moved across function calls, and used in generic code where the move guarantee enables optimizations in calling code. This was the right call. But it closed the door on an entire category of optimization.

What inplace_vector Actually Provides

std::inplace_vector<T, N> is not vector<T> with inline storage as a fallback. It is a fundamentally different container: fixed maximum capacity, no heap allocation ever, hard limit at N elements.

std::inplace_vector<int, 8> v;
v.push_back(1);
v.push_back(2);
// v.push_back on a full container throws std::bad_alloc

The interface deliberately mirrors vector where semantics align: push_back, emplace_back, pop_back, begin, end, size, capacity, reserve. But capacity() always returns N, and reserve only succeeds for arguments at most N; it throws otherwise.

The container also provides unchecked_push_back and unchecked_emplace_back. These skip the bounds check entirely, giving undefined behavior if the container is full, in exchange for tighter codegen on hot paths where the caller has already ensured space is available. This is the container acknowledging that in performance-sensitive contexts, a branch to check capacity is sometimes unacceptable.

The use cases are specific and narrow: fixed-size work queues, command buffers whose upper bound is statically known, embedded or real-time code where heap allocation is forbidden, and lookup tables where a small fixed number of entries suffices. It does not replace vector for the general case. It is not intended to.

Trivially Copyable Propagation

The more interesting property of inplace_vector is what it inherits from its element type. For trivially copyable T, inplace_vector<T, N> is itself trivially copyable. The container has no destructor, no copy constructor with custom logic; it copies like a C array.

std::vector can never have this property, because it always owns heap memory. Any vector<T>, regardless of how trivial T is, requires a destructor that frees the buffer. It requires a copy constructor that allocates new memory. The container overhead is always non-trivial.

This has practical consequences for aggregate types. A struct containing an inplace_vector<float, 4> of trivially copyable floats can be passed to a C API, serialized with memcpy, and stored in a buffer alongside other plain data. A struct containing a vector<float> cannot.

This connects to a broader problem in the standard: the lack of trivially relocatable types. P1144 proposes a new type trait, [[trivially_relocatable]], for types where moving and then destroying the source is equivalent to a bitwise copy of the object bytes. For a std::vector<int>, a move followed by destruction of the source is conceptually just copying three pointer-sized integers and zeroing them. A trivially relocatable annotation would allow a containing vector to use memcpy during its own reallocation for elements of that type.

P1144 has not landed in C++26, and the committee debate continues around edge cases involving self-referential types and which types should qualify by default. inplace_vector was designed with the trivially copyable propagation property precisely because it avoids the heap pointer issue that makes trivially relocatable classification difficult for vector. It is the simpler case, and C++26 gets the simpler case right.

The Aliasing Problem Every Custom Implementation Misses

Both containers expose a correctness case that appears in Quasar Chunawala’s implementation exercise if you push far enough: v.push_back(v[0]).

When push_back is called with a reference to an element already inside the vector, and reallocation is triggered, the reference points into the old buffer. A naive implementation proceeds as follows: allocate a new buffer, transfer existing elements into it (which destroys or moves them from the old buffer), then attempt to construct the new element from the reference. At that point, the reference is dangling. The old buffer has been freed or its elements have been moved-from. This is undefined behavior.

The standard requires push_back to handle self-referential arguments correctly. Implementations manage this in different ways. libc++ contains an explicit check before reallocation: if the address of the incoming value falls within [begin_, end_), it makes a local copy of the value before any allocation or transfer occurs. The local copy is then used to construct the new element after the buffer has been swapped.

The case without reallocation is safe by default. If size < capacity, push_back(v[0]) constructs the new element at end_ before any existing element is touched, and then increments end_. No aliasing hazard exists here.

inplace_vector cannot trigger a reallocation, so the hazardous case simply does not arise. push_back on a non-full inplace_vector constructs the new element at the current end position, and the existing element at v[0] is never moved or destroyed as part of the operation. The constraint that eliminates heap allocation also eliminates this particular class of correctness bug.

A homegrown vector that skips the aliasing check will pass almost all tests and fail only when a caller appends an existing element during a reallocation, a pattern uncommon enough that it often goes undetected. The standard library’s implementation handles it. A learning exercise implementation usually does not, and that gap is worth understanding deliberately.

What the Two Containers Together Clarify

The original vector implementation exercise is worthwhile because it forces you through the placement new and destruction protocol, the growth factor arithmetic, the exception safety sequencing, and the noexcept check on move constructors. That foundation is real and it matters.

But the deeper clarification comes from seeing std::vector and std::inplace_vector as separate answers to related requirements. std::vector optimizes for heap-based dynamic arrays where the element count is unknown at compile time, O(1) move is a first-class guarantee, and allocator compatibility is required. std::inplace_vector optimizes for stack-resident fixed-capacity sequences where trivially copyable propagation, no heap allocation, and tight bounds-checked or unchecked append operations are the priorities.

The fact that these had to be two separate types, rather than one type with an optional inline storage parameter, is a direct consequence of the O(1) noexcept move contract that std::vector established. That contract is load-bearing throughout C++. It enables std::move optimizations in containers of containers, it enables reallocation logic in std::vector itself to use std::move_if_noexcept, and it makes reasoning about performance in generic code tractable.

Writing your own vector teaches you why those constraints exist. Understanding inplace_vector teaches you what they cost.

Was this interesting?