Lesson 6 of 6
Everything in Part II comes together here. You are going to build a growable array that owns its memory, copies and moves correctly, destroys cleanly, and passes AddressSanitizer and UndefinedBehaviorSanitizer with zero findings.
That last requirement is the point. Anyone can write code that appears to work.
The specification
A class holding ints with:
| | |
|---|---|
| push_back(int) | append, growing when full |
| pop_back() | remove the last element |
| operator[] | unchecked access, like std::vector |
| at(i) | checked access, throws std::out_of_range |
| size(), capacity(), empty() | observers |
| the Rule of Five | destructor, copy, move — all correct |
Constraints: new[] and delete[] only, no std::vector inside. Must build clean under
-Wall -Wextra -Wpedantic -Werror and run clean under both sanitizers.
Note it is not a template — that is Module 12, which turns this exact class into Vector<T>.
Growth
The one design decision worth thinking about. When the array is full, allocate a bigger one, copy across, free the old.
Double the capacity rather than adding a fixed amount. Doubling makes push_back amortised
constant — n pushes cost O(n) total, because reallocations become exponentially rarer. Growing by
one makes every push a reallocation and n pushes cost O(n²).
This is why capacity and size are different numbers, and why std::vector has both.
The implementation
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <utility>
class IntVector {
int* items;
std::size_t count;
std::size_t capacity;
void grow_to(std::size_t new_capacity) {
int* fresh = new int[new_capacity];
for (std::size_t i = 0; i < count; ++i) {
fresh[i] = items[i];
}
delete[] items;
items = fresh;
capacity = new_capacity;
}
public:
IntVector() : items(nullptr), count(0), capacity(0) {}
explicit IntVector(std::size_t initial)
: items(new int[initial]), count(0), capacity(initial) {}
~IntVector() { delete[] items; }
// Copy: allocate our own buffer, then copy the elements.
IntVector(const IntVector& other)
: items(new int[other.capacity]), count(other.count), capacity(other.capacity) {
for (std::size_t i = 0; i < count; ++i) {
items[i] = other.items[i];
}
}
// Move: take the buffer, leave the source empty but valid.
IntVector(IntVector&& other) noexcept
: items(other.items), count(other.count), capacity(other.capacity) {
other.items = nullptr;
other.count = 0;
other.capacity = 0;
}
// Copy-and-swap: one operator serves both copy and move assignment.
IntVector& operator=(IntVector other) noexcept {
std::swap(items, other.items);
std::swap(count, other.count);
std::swap(capacity, other.capacity);
return *this;
}
void push_back(int value) {
if (count == capacity) {
grow_to(capacity == 0 ? 1 : capacity * 2);
}
items[count] = value;
++count;
}
void pop_back() {
if (count > 0) {
--count;
}
}
int& operator[](std::size_t i) { return items[i]; }
const int& operator[](std::size_t i) const { return items[i]; }
int& at(std::size_t i) {
if (i >= count) {
throw std::out_of_range("IntVector::at index out of range");
}
return items[i];
}
std::size_t size() const { return count; }
std::size_t get_capacity() const { return capacity; }
bool empty() const { return count == 0; }
};
int main() {
IntVector v;
for (int i = 1; i <= 5; ++i) {
v.push_back(i * 10);
}
std::cout << "size " << v.size() << " capacity " << v.get_capacity() << "\n";
for (std::size_t i = 0; i < v.size(); ++i) {
std::cout << v[i] << " ";
}
std::cout << "\n";
IntVector copy = v;
copy[0] = 999;
std::cout << "original " << v[0] << " copy " << copy[0] << "\n";
IntVector moved = std::move(copy);
std::cout << "moved size " << moved.size() << " moved-from size " << copy.size() << "\n";
IntVector assigned;
assigned = v;
std::cout << "assigned size " << assigned.size() << "\n";
v = v;
std::cout << "after self-assignment size " << v.size() << "\n";
try {
v.at(99);
} catch (const std::out_of_range& e) {
std::cout << "caught: " << e.what() << "\n";
}
return 0;
}
size 5 capacity 8
10 20 30 40 50
original 10 copy 999
moved size 5 moved-from size 0
assigned size 5
after self-assignment size 5
caught: IntVector::at index out of range
Read the output against the specification. Capacity is 8 after five pushes — it went 0, 1, 2, 4, 8,
doubling each time. The copy is independent. The moved-from object reports size 0 and is still
safe to destroy. Self-assignment survived. at(99) threw.
Three details worth explaining
One operator= for both copy and move assignment. The parameter is taken by value, so the
caller's argument decides: an lvalue is copied in, an rvalue is moved in. Then we swap. That is the
copy-and-swap idiom from lesson 9.8, and it means self-assignment needs no check — the copy is made
before anything is released.
Why noexcept is honest there. The parameter is constructed by the caller, before the
function is entered, so a throwing copy throws outside this function. The body only swaps, which
cannot throw. The noexcept describes the body, and it is accurate.
grow_to frees only after copying. If new int[new_capacity] throws, we have not yet touched
items, so the object is unchanged and still valid. Freeing first would leave a dangling pointer
on the failure path — the exception-safety trap from lesson 9.8.
Verifying it
Clean under the strictest warnings:
$ g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror intvector.cpp -o iv
$ ./iv
size 5 capacity 8
...
And clean under both sanitizers, which is the requirement:
$ g++ -std=c++20 -Wall -Wextra -fsanitize=address,undefined -g intvector.cpp -o iv_san
$ ./iv_san
size 5 capacity 8
10 20 30 40 50
original 10 copy 999
moved size 5 moved-from size 0
assigned size 5
after self-assignment size 5
caught: IntVector::at index out of range
No ASan report, no UBSan report, exit code 0. That silence is the deliverable. A version with
a missing copy constructor would produce a double free here; one with a leaked buffer in grow_to
would be caught by LeakSanitizer at exit.
Break it deliberately
The most useful thing you can do with a working implementation is damage it and watch the tools react. Try each of these and read the report:
| Change | What the tools say |
|---|---|
| Delete the copy constructor | ASan: attempting double-free |
| Remove delete[] items; from grow_to | LeakSanitizer at exit |
| Remove the destructor's delete[] | LeakSanitizer at exit |
| push_back writes before growing | ASan: heap-buffer-overflow |
| at uses > instead of >= | ASan: overflow on the last index |
| Move constructor leaves other.items unchanged | ASan: double free |
Each is one character or one line, and each produces a precise report naming the file and line. That exercise teaches more about the tools than reading about them.
What you have built
std::vector does considerably more — it works for any type, uses an allocator, constructs
elements in place with emplace_back, provides iterators, and grows without requiring elements to
be copyable. But the core is what you just wrote: a pointer, a size, a capacity, and a doubling
growth strategy.
Use std::vector in real code. Having written this once means you now know what it costs, why
reserve exists, and why holding a pointer into a vector across a push_back is a bug
(lesson 8.5).
Try it
Extend IntVector with reserve(n), clear(), front(), back(), and an insert(index, value). Keep it clean under -Werror and both sanitizers. Then measure how many reallocations
occur pushing 1000 elements with and without reserve.
Checkpoint
- A dynamic array is a pointer, a size, a capacity, and a growth strategy.
- Doubling makes
push_backamortised constant; growing by one makes it quadratic. - The Rule of Five applies in full, because the class owns a raw buffer.
- Copy-and-swap gives one
operator=for both copy and move, and survives self-assignment. - Allocate the new buffer before freeing the old, so a throw leaves the object intact.
- Passing ASan and UBSan with zero findings is the actual deliverable.
- Break it deliberately and read the reports — that is how the tools become familiar.
Questions about this lesson
Log in to ask a question.