Implementing a Dynamic Array Container in Modern C++
A custom vector implementation relies on managing three pointers that track the boundaries of allocated storage and initialized elements. This implementasion explores memory management strategies, proper deep-copy semantics, and iterator stability during reallocation.
Core Architecture
template <typename T>
class DynamicArray {
T* _data_start; // Beginning of allocated heap block
T* _data_finish; // One past the last constructed element
T* _heap_cap; // One past the end of allocated storage
public:
using iterator = T*;
using const_iterator = const T*;
// Constructors, destructor, and member functions...
};
Construction Patterns
The default constructor establishes an empty container by nullifying all pointers:
DynamicArray()
: _data_start(nullptr)
, _data_finish(nullptr)
, _heap_cap(nullptr)
{}
For range-based initialization, traverse the input sequence and append elements individually rather than bulk copying:
template <typename InputIt>
DynamicArray(InputIt first, InputIt last)
: _data_start(nullptr)
, _data_finish(nullptr)
, _heap_cap(nullptr)
{
for (; first != last; ++first) {
push_back(*first);
}
}
The fill constructor pre-allocates storage then populates it:
DynamicArray(size_t count, const T& value)
: _data_start(nullptr)
, _data_finish(nullptr)
, _heap_cap(nullptr)
{
reserve(count);
for (size_t i = 0; i < count; ++i) {
push_back(value);
}
}
Destruction and Cleanup
The destructor releases the contiguous heap block and poisons pointers to prevent use-after-free:
~DynamicArray() {
delete[] _data_start;
_data_start = _data_finish = _heap_cap = nullptr;
}
Deep Copy Semantics
When implementing the copy constructor, avoid std::memcpy for element transfer. While bitwise copying works for primitive types, it creates shallow copies for classes managing dynamic resources (like strings or nested containers), causing double-free errors during destruction.
Incorrect approach:
// Dangerous: performs shallow copy
memcpy(new_buffer, source._data_start, sizeof(T) * source.size());
Correct implementation using element-wise assignment:
DynamicArray(const DynamicArray& other)
: _data_start(nullptr)
, _data_finish(nullptr)
, _heap_cap(nullptr)
{
reserve(other.capacity());
for (const auto& element : other) {
push_back(element);
}
}
Copy-and-Swap Idiom
Modern assignment operators leverage pass-by-value and non-throwing swaps for exception safety and simplicity:
DynamicArray& operator=(DynamicArray rhs) {
swap(rhs);
return *this;
}
void swap(DynamicArray& other) noexcept {
using std::swap;
swap(_data_start, other._data_start);
swap(_data_finish, other._data_finish);
swap(_heap_cap, other._heap_cap);
}
Capacity Management
Query functions calculate logical properties from pointer arithmetic:
size_t size() const {
return static_cast<size_t>(_data_finish - _data_start);
}
size_t capacity() const {
return static_cast<size_t>(_heap_cap - _data_start);
}
bool empty() const {
return _data_start == _data_finish;
}
Storage Reallocation
When expanding capacity, preserve the element count before reallocating, as pointer arithemtic becomes invalid after the heap block moves:
void reserve(size_t new_cap) {
if (new_cap <= capacity()) return;
size_t current_size = size();
T* new_block = new T[new_cap];
if (_data_start) {
for (size_t i = 0; i < current_size; ++i) {
new_block[i] = _data_start[i];
}
delete[] _data_start;
}
_data_start = new_block;
_data_finish = _data_start + current_size;
_heap_cap = _data_start + new_cap;
}
Resizing Operations
resize adjusts the logical element count, truncating or expanding as needed:
void resize(size_t new_size, const T& value = T()) {
if (new_size < size()) {
_data_finish = _data_start + new_size;
} else {
if (new_size > capacity()) {
reserve(new_size);
}
while (_data_finish < _data_start + new_size) {
*_data_finish = value;
++_data_finish;
}
}
}
Stack Operations
Appending requires growth logic that handles the zero-capacity edge case:
void push_back(const T& value) {
if (_data_finish == _heap_cap) {
size_t new_capacity = (capacity() == 0) ? 2 : capacity() * 2;
reserve(new_capacity);
}
*_data_finish = value;
++_data_finish;
}
Removal simply retreats the finish pointer with out deallocating storage:
void pop_back() {
assert(_data_start < _data_finish);
--_data_finish;
}
Arbitrary Insertion and Deletion
Inserting at arbitrary positions invalidates iterators if reallocation occurs. Preserve the logical offset to recover the position pointer after potential buffer relocation:
iterator insert(iterator pos, const T& value) {
size_t offset = static_cast<size_t>(pos - _data_start);
if (_data_finish == _heap_cap) {
size_t new_cap = (capacity() == 0) ? 1 : capacity() * 2;
reserve(new_cap);
pos = _data_start + offset;
}
iterator current = _data_finish;
while (current > pos) {
*current = *(current - 1);
--current;
}
*pos = value;
++_data_finish;
return pos;
}
iterator erase(iterator pos) {
assert(pos < _data_finish);
iterator current = pos;
while (current < _data_finish - 1) {
*current = *(current + 1);
++current;
}
--_data_finish;
return pos;
}
Note that these operations require O(N) element shifts, making them inefficient for frequent modifications at arbitrary positions.
Iterator Interface
Raw pointers serve as random-access iterators:
iterator begin() { return _data_start; }
iterator end() { return _data_finish; }
const_iterator begin() const { return _data_start; }
const_iterator end() const { return _data_finish; }
Element Access
Bounds-checked indexing ensures safe access:
T& operator[](size_t index) {
assert(index < size());
return _data_start[index];
}
const T& operator[](size_t index) const {
assert(index < size());
return _data_start[index];
}