How to Implement std::vector in C++: Rule of Five, Growth
Build a vector, a unique pointer, a chaining hash map and an SSO string by hand in C++: the rule of five, doubling growth and what interviewers listen for.
- Course: C++ study plan
- Module: Interview idioms
- Kind: Lesson
- Reading time: 15 min
- Runtime: Clang 18 · C++20
How do you implement std::vector in C++?
A minimal std::vector is a class owning three members: a pointer to a heap buffer, a size and a capacity. push_back doubles the capacity when the buffer is full, moving the elements into the new one, which makes it amortised O(1). The class then needs the rule of five: a destructor that frees the buffer, a copy that allocates, and a noexcept move that steals the pointer and empties the source.
Lesson
"Implement std::vector" is the question every senior C++ interview asks in some form, because forty lines show whether you understand ownership, can write the rule of five from memory and know why growth doubles. The interviewer is listening for four sentences — it owns a heap buffer and frees it in the destructor; a copy allocates, a move steals the pointer and empties the source; a full push doubles, so push_back is amortised O(1); reallocation invalidates every reference into the old buffer. This lesson builds the four types they ask for: a Vec<T>, a UniquePtr<T>, a chaining hash map and, in outline, a String with the small-string optimisation. Module 9, Copies, moves and the rule of five, wrote the owning buffer; the new skill is producing it under a clock, narrating.
Vec<T>: three members and the growth rule
template <typename T>
class Vec {
T* data_ = nullptr;
size_t size_ = 0;
size_t cap_ = 0;
void grow() {
const size_t next = cap_ == 0 ? 1 : cap_ * 2;
T* fresh = new T[next];
for (size_t i = 0; i < size_; ++i) fresh[i] = std::move(data_[i]);
delete[] data_;
data_ = fresh;
cap_ = next;
}
public:
void push_back(T v) { // by value: see below
if (size_ == cap_) grow();
data_[size_++] = std::move(v);
}
T& operator[](size_t i) { return data_[i]; }
const T& operator[](size_t i) const { return data_[i]; }
size_t size() const { return size_; }
};
The handle is three words, like std::vector's. grow allocates the new buffer, moves the live elements across, frees the old one and only then updates the members, so a throwing allocation leaves the object as it was. Doubling is what makes push_back cheap: over n pushes the moves total 1 + 2 + 4 + … < 2n, a constant per push on average, where growing by a fixed amount would sum to O(n²). Name the simplification aloud: new T[next] default-constructs every slot; the real container allocates raw storage and constructs each element on push.
push_back takes its parameter by value for a reason: v.push_back(v[0]) on a full vector must work, as it does for std::vector. With const T& v the reference points into the old buffer, grow() frees it, and the assignment reads freed memory; by value, the copy is made at the call, before anything is reallocated.
The rule of five, written out
~Vec() { delete[] data_; }
Vec(const Vec& o) : data_(o.size_ ? new T[o.size_] : nullptr), size_(o.size_), cap_(o.size_) {
std::copy(o.data_, o.data_ + o.size_, data_);
}
Vec(Vec&& o) noexcept : data_(o.data_), size_(o.size_), cap_(o.cap_) {
o.data_ = nullptr;
o.size_ = o.cap_ = 0;
}
Vec& operator=(const Vec& o) { Vec tmp(o); swap(tmp); return *this; }
Vec& operator=(Vec&& o) noexcept {
if (this != &o) {
delete[] data_;
data_ = o.data_; size_ = o.size_; cap_ = o.cap_;
o.data_ = nullptr; o.size_ = o.cap_ = 0;
}
return *this;
}
void swap(Vec& o) noexcept; // std::swap the three members
Each of the five has one job. The copy constructor allocates exactly size elements — a copy has no reason to inherit the source's slack. The move constructor steals the three members and zeroes the source, so the source's destructor deletes nullptr; it is noexcept because std::vector<Vec> moves its elements on reallocation only when the move constructor promises not to throw (Module 9 lesson 3). Copy assignment is copy-and-swap: build the copy first, then exchange, so a failed allocation leaves *this untouched (the strong guarantee) and a = a swaps harmlessly. Move assignment cannot use that trick without an allocation, so it guards this != &o — without the guard, a = std::move(a) frees the buffer and then adopts the freed pointer.
Declare any one of these and the compiler stops generating the moves; write none and the defaults copy the pointer, so two Vecs free one buffer — or apply the rule of zero: a std::unique_ptr<T[]> member owns the buffer and only the two copy operations need writing.
UniquePtr<T>
template <typename T>
class UniquePtr {
T* p_ = nullptr;
public:
explicit UniquePtr(T* p = nullptr) : p_(p) {}
~UniquePtr() { delete p_; }
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
UniquePtr(UniquePtr&& o) noexcept : p_(o.release()) {}
UniquePtr& operator=(UniquePtr&& o) noexcept { reset(o.release()); return *this; }
T* release() { T* p = p_; p_ = nullptr; return p; }
void reset(T* p = nullptr) { T* old = p_; p_ = p; delete old; }
T& operator*() const { return *p_; }
explicit operator bool() const { return p_ != nullptr; }
};
Every line is an interview answer. Copying is deleted because two owners would delete twice — the mistake becomes a compile error. The moves are written through release and reset, which makes self-move-assignment safe without a guard: release nulls o first, then reset deletes the old pointer after installing the new one. explicit operator bool allows if (p) but refuses int n = p;. The size is one pointer, the same as a raw T*, which is what "zero overhead" means.
A chaining hash map
class HashMap {
std::vector<std::vector<std::pair<std::string, int>>> buckets_;
size_t size_ = 0;
size_t bucketOf(const std::string& key) const {
size_t h = 0;
for (unsigned char c : key) h = (h * 31 + c) % buckets_.size();
return h;
}
public:
explicit HashMap(size_t buckets) : buckets_(buckets) {}
void put(const std::string& key, int value) {
auto& chain = buckets_[bucketOf(key)];
for (auto& [k, v] : chain) if (k == key) { v = value; return; }
chain.emplace_back(key, value);
++size_;
}
};
A hash map is a vector of buckets plus a function from key to bucket index; with chaining, each bucket is a small vector of pairs and a collision appends. put walks the chain first so an existing key is overwritten in place; get walks it the same way and returns std::optional<int> rather than a sentinel; erase finds the pair, calls chain.erase(it) and returns at once, so the invalidated iterator is never incremented. Reducing modulo B at every step keeps the intermediate below 31 × 63 + 255, and unsigned char stops a high-bit char going negative. The cost is O(1 + n/B) on average — the load factor — and O(n) when everything collides, which is why std::unordered_map rehashes past a load factor of 1.0; the follow-up "how would you add rehashing?" is answered with 2B buckets and a re-put of every pair, amortised like the vector's growth.
String with SSO, in outline
class String {
char* ptr_; // points at local_ or at the heap
size_t size_ = 0;
union { char local_[16]; size_t heapCap_; }; // 15 characters + '\0' inline
bool isLocal() const { return ptr_ == local_; }
};
That is libstdc++'s real layout, and why sizeof(std::string) is 32 on this platform. A string of at most fifteen characters lives inside the object, in the bytes that would otherwise hold the heap capacity; a longer one allocates and ptr_ points at the heap. Every member function first asks which mode is active, and the instructive case is the move: a small string has nothing on the heap to steal, so moving it copies sixteen bytes and empties the source — "moves are free" is not quite true for strings. You will not be asked to write it; you may be asked why short strings never allocate.
Pitfalls
- A copy constructor that copies
cap_allocates slack the copy never uses; a move constructor that forgets to zero the source double-frees. - Move assignment without the
this != &oguard frees its own buffer on self-move. push_back(const T&)plusgrow()dangles onv.push_back(v[0]).eraseinside a range-forover a chain is undefined behaviour; use the iterator form and return.
Key takeaways
Vec<T>is three members plus agrow()that doubles; 1 + 2 + 4 + … < 2n is whypush_backis amortised O(1).- The five: the destructor frees; the copy allocates
size; the move steals and zeroes,noexcept; copy assignment by copy-and-swap; move assignment guards self-assignment. UniquePtrdeletes copying, moves throughrelease/reset, and is one pointer wide.- A chaining hash map is buckets of pairs: overwrite in place, append on a miss,
erasethen return; the cost is the load factor. - SSO keeps fifteen characters inside the object: small strings never allocate, and moving one is a copy.
Common questions
Why is vector push_back amortised O(1)?
When the buffer is full the vector allocates one about twice as large and moves the elements across. Over n pushes the moves total 1 + 2 + 4 + … < 2n, a constant per push on average; growing by a fixed amount instead would total O(n²). Each reallocation invalidates every reference into the old buffer.
How do you implement unique_ptr in C++?
Wrap a raw T*: the destructor calls delete, the copy constructor and copy assignment are = delete so two owners cannot free the pointer twice, and the move operations transfer it through release() and reset(), which also makes self-move-assignment safe. Add operator*, operator-> and an explicit operator bool; the result is one pointer wide.
Why should a move constructor be noexcept?
std::vector moves its elements during reallocation only if their move constructor is noexcept; otherwise it copies them, because a move that threw half-way through would leave both buffers partly moved. A move constructor without noexcept turns every reallocation into a full copy.
How does a hash map work internally?
A hash map is an array of buckets plus a hash function that maps each key to a bucket index. With separate chaining each bucket holds a small list of key–value pairs, and a collision appends to it. Operations cost O(1 + n/B) on average for n keys in B buckets, so the table rehashes into more buckets as the load factor grows; std::unordered_map does so past 1.0 by default.
What is the small string optimisation in C++?
The small string optimisation (SSO) stores a short string's characters inside the std::string object instead of on the heap. In libstdc++ a string of up to 15 characters needs no allocation, which is why sizeof(std::string) is 32 there; moving such a string copies its bytes rather than stealing a pointer.
Exercises
Vec<T> with the rule of five
Implement template <typename T> class Vec — a growable array with a raw T* buffer, size(), capacity(), push_back, pop_back, operator[], and the rule of five written by hand (destructor, copy constructor, copy assignment, move constructor, move assignment). Growth policy: capacity starts at 0; a push into a full vector reallocates to 1 when the capacity is 0 and to double otherwise. A copy allocates exactly the source's size (so its capacity equals its size); a moved-from Vec is left empty with capacity 0; pop_back never shrinks.
Drive a Vec<long long> with n commands:
push x— append;pop— remove the last, or printemptywhen there is noneget i— print elementi, orout of rangestats— printsize=<s> capacity=<c>print— the elements space-separated, oremptycopy— copy-construct a secondVec, push-1onto the copy, then printoriginal: <elements> (size=<s> capacity=<c>)andcopy: <elements> (size=<s> capacity=<c>)move— move-construct a secondVecfrom the current one, printmoved: size=<s> capacity=<c>andsource: size=<s> capacity=<c>; the currentVecstays in its moved-from state for later commands
9
push 1
push 2
push 3
stats
print
get 1
get 3
pop
stats
prints
size=3 capacity=4
1 2 3
2
out of range
size=2 capacity=4A chaining hash map
Implement a hash map from std::string to int with separate chaining: a vector of B buckets, each a vector<pair<string, int>>. The hash is fixed so the buckets are deterministic: start with h = 0 and, for each character c of the key taken as an unsigned char, set h = (h * 31 + c) % B. A new key is appended to the end of its chain; overwriting an existing key keeps its position.
Read B (1 ≤ B ≤ 64), then n, then n commands:
put key value— insert or overwrite; prints nothingget key— print the value, ormissingdel key— printdeletedormissingdump— one line per bucket,<b>: k=v k=v …in chain order or<b>: -when empty, thensize=<number of keys>
4
7
put ab 1
put cd 2
put ab 3
get ab
get zz
dump
del cd
prints
3
missing
0: -
1: ab=3 cd=2
2: -
3: -
size=2
deleted
(ab hashes to ((97 % 4) * 31 + 98) % 4 = 1; so does cd, and it joins the chain behind ab.)
In this module: Interview idioms
- The interview template — the contest idioms and what they cost
- The STL idiom sheet — the two-line answers to the common shapes
- The pitfalls that fail interviews
- Implement the built-in — a vector, a unique_ptr, a hash map and a string by hand (this lesson)
- Writing clean solutions under time pressure
- The C++ theory drill — thirty questions, thirty answers
- Final checkpoint — Interview idioms
← The pitfalls that fail interviews · Writing clean solutions under time pressure →