Part 1 of a 5-part series on implementing a
FixedCapacityVectorin C++.
- Memory Layout (this post)
- The Rule of Five (TBA)
- Element Access & Modifiers (TBA)
- Insert & Erase
- Getting to
constexpr(TBA)
Most C++ developers reach for std::vector and std::array every day, yet rarely implement a container from scratch. Doing so turns out to be an excellent exercise: it touches a surprising amount of modern C++, from manual memory management to the lifetime rules that the standard containers usually hide from us.
Recently, I wanted to write a FixedCapacityVector, an object that fills the gap between a std::array (compile-time capacity and size) and std::vector (dynamic capacity and size).
The goal was to create a container that has a known upfront capacity but whose size can change dynamically within that limit.
Like std::vector, it provides contiguous storage and the same complexity guarantees for its core operations:
- Random element access in
- Insertion and deletion at the end in
- Insertion and deletion at an arbitrary position in , where is the distance to the end of the container
This is not really a novel idea (see Boost’s boost::container::static_vector or C++26’s std::inplace_vector) but a good practice ground for container implementation that touches some important concepts and modern C++ techniques:
- The Rule of Five
- Placement New
- Limiting
noexceptw.r.t. the value type - Explicit destruction with C++17’s
std::destroy_at - C++11’s
std::addressofto avoid pitfalls with overloadedoperator& - C++23’s Deducing
this
Across this series I will outline my approach to implementing a FixedCapacityVector class without going through all the lines but by highlighting some of the most interesting aspects.
To take a look at the full implementation, I would like to refer the reader to source file fixed_capacity_vector.hpp and the corresponding unit tests test_fixed_capacity_vector.cpp on GitHub.
The implementation of these operations is spread across the rest of the series; in this first post we lay the foundation: the memory layout used to store the elements and how we access that raw storage as typed memory.
Memory Layout
To lay the foundation for the implementation, we start by defining the class template for our generic container and the memory layout used to store the elements.
The template takes two parameters: the type of the elements T and the capacity capacity_ which is known and fixed at compile-time:
| |
The type alias SizeType allows us to declare the integer type used for everything related to size/capacity/indices in one central place.
The type alias ValueType and the compile-time constant capacity_value allow us to access the template arguments from outside the class, e.g. when inspecting any concrete template specialization.
Throughout the implementation we will require the size of a single element in bytes in many places, so we store it in the compile-time constant element_size.
Next, we declare the member variables used to manage the elements in the container: a fixed capacity memory buffer m_buffer holding the data and a m_size variable storing the current number of elements. We define the default size of our container to be 0 (an empty vector). It should be noted that we purposefully do not initialize the buffer in any way. Otherwise, the compiler would generate code to initialize the memory which would result in an overhead when creating instances of the class, a cost that grows with the container capacity.
It is worthwhile to discuss the definition of the memory buffer in more detail:
The goal is to store up to capacity_value elements of type ValueType each with a size of element_size bytes. Hence, we declare a buffer of size capacity_value * element_size. Employing C++17’s std::byte type highlights our intent to create a raw buffer of bytes.
Specifying the memory alignment of the buffer using alignas() is crucial. std::byte naturally has a size of and the buffer could be placed at an arbitrary memory address. This would result in undefined behavior for element types that are larger and could result in program crashes or data corruption. The alignas() attribute instructs the compiler to align the memory as if it was an array of type ValueType.
The NOLINTBEGIN/NOLINTEND comments surrounding the declaration of the memory buffer stop the clang-tidy linter from recommending that we use std::vector, std::array, or std::span to manage the memory. std::vector obviously does not suit our container, as it dynamically allocates memory. While std::array or std::span would be valid choices and fit our design, we explicitly want to treat the management of the buffer as one of the key aspects of this exercise. Furthermore, we would still have to implement the custom construction/destruction logic for the elements stored in the buffer so there is not much benefit from using std::span or std::array.
Accessing the Buffer as Typed Memory
Since m_buffer is a raw std::byte array, we need a way to access it as typed memory throughout the implementation. Nearly every operation – construction, destruction, element access, iteration – needs a T* pointer into the buffer. Similar to what std::vector and std::array offer, we provide a data() method that performs this conversion:
The NOLINTNEXTLINE comment instructs clang-tidy not to complain about the usage of reinterpret_cast since it is indeed the correct type of cast here: we are reinterpreting a properly aligned byte buffer as an array of T.
It is worth noting that data() cannot be marked as constexpr. The C++ standard forbids reinterpret_cast in constant expressions because, in general, it can introduce undefined behavior when the target pointer type has stricter alignment requirements than the source. While we have ensured correct alignment via alignas(ValueType), the compiler cannot verify this in the general case and therefore disallows reinterpret_cast during constant evaluation altogether.
There is a related but distinct lifetime concern. Even at runtime, casting the raw std::byte buffer to T* and dereferencing it is technically only well-defined once a T object has actually begun its lifetime at that address — which is exactly what the placement new calls do, so by the time data() hands out a pointer the elements already exist. This also points at the deeper reason data() resists constexpr: the fundamental obstacle is the untyped std::byte buffer, since constant evaluation simply cannot reinterpret raw bytes as a different type. One way to make such storage usable in constant expressions is to sidestep the byte buffer entirely in favour of union-based storage, where the element type is named directly and no reinterpretation is required. Reaching full constexpr support — the kind C++26’s std::inplace_vector offers — is the goal of the final post of the series.
With the storage in place, the next post brings the container to life: the Rule of Five — construction, destruction, copying, and moving.