Part 2 of a 5-part series on implementing a FixedCapacityVector in C++.

  1. Memory Layout
  2. The Rule of Five (this post)
  3. Element Access & Modifiers
  4. Insert & Erase
  5. Getting to constexpr

In the previous post we laid out the raw std::byte buffer that backs the container and the data() helper that views it as typed memory. With the storage in place, we can now bring instances to life.

Rule of Five

With the memory layout in place, we can now shift our focus to implementing the constructors and destructors of the class so that we can actually create instances and make sure that the elements are correctly destructed when the lifetime of the container ends. A C++ class that manages memory or some other resource should adhere to the so-called Rule of Five to make sure that copy and move construction/assignment as well as destruction are correctly implemented.1

Default Construction

The default constructor FixedCapacityVector() is trivial and can be marked as = default. The two member variables will be initialized according to the initialization statements that we have specified during their declaration, i.e. a size m_size of 0 and an uninitialized buffer m_buffer. This is the desired representation of an empty vector.

Destructor

In the destructor, we have to make sure that all constructed elements are correctly destructed. The logic is implemented in a member function called clear() since we also want to be able to manually clear the container. For an empty container, checked using the empty() member function, nothing has to be done. The std::destroy function allows us to conveniently destroy a range of objects, essentially calling the element destructor in a loop. Using our data() method from the last post, we obtain a T* pointer to pass as the iterator range:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
constexpr ~FixedCapacityVector() { clear(); }
constexpr void clear() noexcept {
  if (empty()) {
    return;
  }

  // destruct all constructed objects
  std::destroy(data(), data() + m_size);
  m_size = 0;
}

Copy Construction

Next, we implement a copy constructor to create a copy of an existing container. For the first time, we will have to deal with constructing an instance of ValueType inside the buffer using placement new. Since data() returns a T* pointer, we can use simple pointer arithmetic data() + i to obtain the memory address where each element should be constructed. The copy constructor is then just a matter of looping over the elements of the source container and calling the element copy constructor at the corresponding address in the target buffer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
constexpr FixedCapacityVector(const FixedCapacityVector &other) noexcept(
        std::is_nothrow_copy_constructible_v<ValueType>) {
  try {
    for (SizeType i = 0; i < other.m_size; ++i) {
      new (data() + i) T(other.data()[i]);
      ++m_size;
    }
  } catch (...) {
    clear();
    throw;
  }
}

There are three interesting aspects to note about exceptions in this constructor:

  1. When an exception is thrown inside a constructor, the object is not considered fully constructed and its destructor is never invoked.
  2. Therefore, we catch any exception thrown by a member copy constructor and clean up any elements that were constructored succesfully up to that point.
  3. To that end, we purposefully increment m_size inside the loop, rather than setting it to other.m_size up front so that m_size always reflects the number of successfully constructed elements.

Incrementing <code>m_size</code> only after each successful copy keeps the allows us to manually clean up successfully constructed elements in case of an exception.Incrementing <code>m_size</code> only after each successful copy keeps the allows us to manually clean up successfully constructed elements in case of an exception.

Whether the element copy constructor can throw at all is exactly what we encode in the noexcept specification: the copy constructor is conditionally noexcept, propagating the noexcept-ness of the underlying element copy constructor via std::is_nothrow_copy_constructible_v<ValueType>. We also provide a second version of the copy constructor that allows copying from a FixedCapacityVector of different capacity. If the source contains more elements than the target can hold, a std::length_error is thrown:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
template <SizeType other_capacity>
  requires(other_capacity != capacity_value)
constexpr FixedCapacityVector(
    const FixedCapacityVector<T, other_capacity>
        &other) noexcept(other_capacity <= capacity_value &&
                         std::is_nothrow_copy_constructible_v<ValueType>) {
  if (other.size() > capacity_value) {
    throw std::length_error("FixedCapacityVector: Attempt to copy from a "
                            "vector with more elements than capacity");
  }
  try {
    for (SizeType i = 0; i < other.size(); ++i) {
      new (data() + i) T(other.data()[i]);
      ++m_size;
    }
  } catch (...) {
      clear();
      throw;
  }
}

The requires constraint disambiguates the two versions of the constructor so that this variant only applies when the other vector has a different capacity. Note the conditional noexcept specification: if other_capacity <= capacity_value, the capacity check can never fail and the constructor is noexcept provided the element copy constructor is as well.

There is a subtle detail to address here: this constructor accesses other.m_size, which is a private member. Since FixedCapacityVector<T, 4> and FixedCapacityVector<T, 8> are different types (different template instantiations), they cannot access each other’s private members by default. To enable this, we add a friend declaration granting access to all other instantiations of the same template:

1
2
template <typename U, std::size_t other_capacity>
friend class FixedCapacityVector;

This pattern is needed for all cross-capacity operations (copy/move constructors and assignment operators).

Move Construction

Along the same lines, we can implement the move constructor by calling the element move constructor in a loop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
constexpr FixedCapacityVector(FixedCapacityVector &&other) noexcept(
    std::is_nothrow_move_constructible_v<ValueType> &&
    std::is_nothrow_destructible_v<ValueType>) {
  try {
    for (SizeType i = 0; i < other.m_size; ++i) {
      new (data() + i) T(std::move(other.data()[i]));
      ++m_size;
    }
  } catch (...) {
    clear();
    throw;   
  }
  other.clear();
}

Here, we purposefully clear the other at the very end, so that it remains in a valid state if one of the move-construction steps throws. This is important because if move construction throws, other remains a fully constructed object. Its destructor will eventually run during stack unwinding, so it must still contain exactly the elements indicated by other.m_size. As in the copy constructor, m_size is incremented inside the loop, so that we can clean-up already move-constructed elements in the case of an exception. Similar to the copy constructor, we also provide a move constructor to move from a FixedCapacityVector instance with different capacity, again throwing an exception if not all elements fit in the target container.

Initializer List Constructor

For convenience, we also support construction from a std::initializer_list, allowing the familiar brace-initialization syntax:

1
FixedCapacityVector<int, 4> vec = {1, 2, 3};

The implementation follows the same placement new pattern we have seen in the copy constructor, iterating over the initializer list and copy-constructing each element into the buffer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
constexpr FixedCapacityVector(std::initializer_list<T> init) {
  if (init.size() > capacity_value) {
    throw std::length_error("FixedCapacityVector: Attempt to initialize with "
                            "more elements than capacity");
  }
  try {
    for (const auto &item : init) {
        new (data() + m_size) T(item);
        ++m_size;
    }
  } catch(...) {
      clear();
      throw;
  }
}

Since the number of elements in the initializer list is only known at runtime, we check against the capacity and throw a std::length_error if it is exceeded.

Assignment Operators

The custom copy and move assignment operators follow the same placement new pattern as the corresponding constructors, but they have to deal with the elements that already live in the target container. The cleanest way to handle this is to destroy all existing elements first and then reconstruct the new ones in the freed storage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
constexpr FixedCapacityVector &operator=(const FixedCapacityVector &other) noexcept(
    std::is_nothrow_copy_constructible_v<ValueType>) {
  if (this == std::addressof(other)) {
    return *this;
  }
  clear();
  try {
    for (SizeType i = 0; i < other.m_size; ++i) {
      new (data() + i) T(other.data()[i]);
      ++m_size;
    }
  } catch(...) {
      clear();
      throw;
  }
  return *this;
}

The first thing to take care of is a self-assignment guard. It is important to check whether the object being assigned to is the same as the source object. If this is the case, no action has to be taken. Without this guard, the operator would destroy all existing elements and then attempt to copy from the already-destroyed source:

1
2
3
if (this == std::addressof(other)) {
  return *this;
}

Here we use std::addressof() instead of the plain & operator to obtain the address of other. While we are not going to overload operator& on FixedCapacityVector itself, using std::addressof() is good practice to guarantee the correct address even for types that do overload operator&. In contrast, the placement new and std::destroy_at calls throughout the rest of the implementation operate on T* pointers obtained from data(), where simple pointer arithmetic (data() + i) suffices to obtain the address of a container element, so std::addressof is unnecessary there.

It is worth being honest about the tradeoff of this destroy-then-reconstruct approach: it is not strongly exception-safe. We destroy the existing elements before constructing the new ones, so if a copy constructor throws midway through the loop, the target is left in a valid but partially populated state rather than its original contents. A strongly exception-safe implementation would build the new state off to the side and only swap it in once construction has fully succeeded, but that requires additional storage or a more elaborate dance. For a fixed-capacity container where the element copy constructor is often noexcept anyway, we opted for the simpler approach.


With construction, assignment and destruction handled, the next post covers the functions that allow us to add/remove elements at the end of the container and access arbitrary elements.


  1. Declaring some of these functions as = default/= delete can be a perfectly valid strategy to fulfill the rule of five. It depends on the kind of resource that is managed and how the wrapping class is intended to be used. ↩︎