A vector-like class
A vector is a pointer, a size and a capacity, plus the five operations of lesson 033. Writing the class once shows what the library does: the growth policy, the deep copy, the move that transfers the buffer and the destructor that frees it.
The class below is a small int array with push_back. It starts with the allocation and ends with all five operations, and every step is one the standard vector takes as well, with more care about exceptions and allocators.
The size and the capacity are separate so that push_back can be cheap: the block holds room for elements that do not exist yet, and the size says how many do.
Allocate and release
The class holds a size, a capacity and a pointer to a block of ints. The constructor allocates the block, push_back writes the next element, and the destructor frees the block.
The program stores two values and prints the size and the second element. The capacity is larger than the size, as it is in a vector, because the block is allocated ahead of the elements.
The pointer is private and the destructor releases it, which is the RAII rule of lesson 027. The copy operations are still missing, which is the next section's problem.
#include <iostream>
class int_array
{
public:
explicit int_array(int capacity)
: size_(0), capacity_(capacity), data_(new int[capacity]) {}
~int_array()
{
delete[] data_;
}
void push_back(int value)
{
data_[size_++] = value;
}
int& at(int index)
{
return data_[index];
}
int size() const
{
return size_;
}
private:
int size_;
int capacity_;
int* data_;
};
int main()
{
int_array values{4};
values.push_back(10);
values.push_back(20);
std::cout << values.size() << ' ' << values.at(1) << '\n';
}
g++ -std=c++23 -Wall -Wextra basic.cpp -o basic
./basic
2 20
Grow the block
When the size reaches the capacity, push_back allocates a block twice as large, copies the elements into it and frees the old one. The capacity doubles, which is the growth policy of lesson 019.
The program pushes five values into a block of two and prints the size and the capacity after each one. The capacity goes 2, 4, 8 and the elements are copied at the moment it changes.
The copy loop is the part a vector does with a move when the element type allows it. For an int the copy is a memory copy, which is why the loop is simple here.
#include <iostream>
class int_array
{
public:
explicit int_array(int capacity)
: size_(0), capacity_(capacity), data_(new int[capacity]) {}
~int_array()
{
delete[] data_;
}
void push_back(int value)
{
if (size_ == capacity_) {
grow();
}
data_[size_++] = value;
}
int size() const
{
return size_;
}
int capacity() const
{
return capacity_;
}
private:
void grow()
{
int* fresh = new int[capacity_ * 2];
for (int i = 0; i < size_; ++i) {
fresh[i] = data_[i];
}
delete[] data_;
data_ = fresh;
capacity_ *= 2;
}
int size_;
int capacity_;
int* data_;
};
int main()
{
int_array values{2};
for (int i = 1; i <= 5; ++i) {
values.push_back(i);
std::cout << values.size() << '/' << values.capacity() << ' ';
}
std::cout << '\n';
}
g++ -std=c++23 -Wall -Wextra grow.cpp -o grow
./grow
1/2 2/2 3/4 4/4 5/8
Copy the block
The copy constructor allocates a new block and copies the elements, and the copy assignment frees the old block and does the same. Without them the default copy would copy the pointer, and the two destructors would free one block.
The program copies an array and changes the copy. The original keeps its values, which is the proof that the two own different blocks. The copy prints its size and its first element.
The assignment checks for self-assignment before it frees, as lesson 031 explained. A copy-and-swap would do the same in fewer lines.
#include <iostream>
class int_array
{
public:
explicit int_array(int capacity)
: size_(0), capacity_(capacity), data_(new int[capacity]) {}
int_array(const int_array& other)
: size_(other.size_), capacity_(other.capacity_), data_(new int[other.capacity_])
{
for (int i = 0; i < size_; ++i) {
data_[i] = other.data_[i];
}
}
int_array& operator=(const int_array& other)
{
if (this != &other) {
int* fresh = new int[other.capacity_];
for (int i = 0; i < other.size_; ++i) {
fresh[i] = other.data_[i];
}
delete[] data_;
data_ = fresh;
size_ = other.size_;
capacity_ = other.capacity_;
}
return *this;
}
~int_array()
{
delete[] data_;
}
void push_back(int value)
{
data_[size_++] = value;
}
int& at(int index)
{
return data_[index];
}
int size() const
{
return size_;
}
private:
int size_;
int capacity_;
int* data_;
};
int main()
{
int_array first{4};
first.push_back(10);
first.push_back(20);
int_array second = first;
second.at(0) = 99;
std::cout << first.at(0) << ' ' << second.at(0) << ' ' << second.size() << '\n';
}
g++ -std=c++23 -Wall -Wextra copy.cpp -o copy
./copy
10 99 2
Move the block
The move constructor takes the pointer, the size and the capacity and leaves the source empty. No element is copied, and the moved-from object can be destroyed safely because its pointer is null.
The program moves an array into a new one and prints both sizes and the moved value. The source reports size 0, which is the state a moved-from object should be in.
The move assignment frees the target's block first. Both are marked noexcept, which is what lets a vector of these objects move them during a reallocation, as lesson 080 measured.
#include <iostream>
#include <utility>
class int_array
{
public:
explicit int_array(int capacity)
: size_(0), capacity_(capacity), data_(new int[capacity]) {}
int_array(int_array&& other) noexcept
: size_(other.size_), capacity_(other.capacity_), data_(other.data_)
{
other.size_ = 0;
other.capacity_ = 0;
other.data_ = nullptr;
}
int_array& operator=(int_array&& other) noexcept
{
if (this != &other) {
delete[] data_;
size_ = other.size_;
capacity_ = other.capacity_;
data_ = other.data_;
other.size_ = 0;
other.capacity_ = 0;
other.data_ = nullptr;
}
return *this;
}
~int_array()
{
delete[] data_;
}
void push_back(int value)
{
data_[size_++] = value;
}
int& at(int index)
{
return data_[index];
}
int size() const
{
return size_;
}
private:
int size_;
int capacity_;
int* data_;
};
int main()
{
int_array first{4};
first.push_back(7);
int_array second = std::move(first);
std::cout << second.size() << ' ' << second.at(0) << ' ' << first.size() << '\n';
}
g++ -std=c++23 -Wall -Wextra move.cpp -o move
./move
1 7 0
All five
The full class has the destructor, the copy constructor, the copy assignment, the move constructor and the move assignment. That is the rule of five, and the class is now safe to copy, move and destroy.
The program copies, moves and assigns and prints the sizes. Every operation leaves a valid object, and the destructors free one block per live array.
This is what std::vector does, with two more things: an allocator and the exception guarantees of lesson 078. The shape is the same, which is why reading the vector's interface is easy after writing this one.
#include <iostream>
#include <utility>
class int_array
{
public:
explicit int_array(int capacity)
: size_(0), capacity_(capacity), data_(new int[capacity]) {}
int_array(const int_array& other)
: size_(other.size_), capacity_(other.capacity_), data_(new int[other.capacity_])
{
for (int i = 0; i < size_; ++i) {
data_[i] = other.data_[i];
}
}
int_array& operator=(const int_array& other)
{
if (this != &other) {
int_array copy{other};
swap(copy);
}
return *this;
}
int_array(int_array&& other) noexcept
: size_(other.size_), capacity_(other.capacity_), data_(other.data_)
{
other.size_ = 0;
other.capacity_ = 0;
other.data_ = nullptr;
}
int_array& operator=(int_array&& other) noexcept
{
if (this != &other) {
delete[] data_;
size_ = other.size_;
capacity_ = other.capacity_;
data_ = other.data_;
other.size_ = 0;
other.capacity_ = 0;
other.data_ = nullptr;
}
return *this;
}
~int_array()
{
delete[] data_;
}
void swap(int_array& other) noexcept
{
std::swap(size_, other.size_);
std::swap(capacity_, other.capacity_);
std::swap(data_, other.data_);
}
void push_back(int value)
{
data_[size_++] = value;
}
int& at(int index)
{
return data_[index];
}
int size() const
{
return size_;
}
private:
int size_;
int capacity_;
int* data_;
};
int main()
{
int_array a{2};
a.push_back(1);
int_array b = a;
int_array c = std::move(a);
b = c;
std::cout << a.size() << ' ' << b.size() << ' ' << c.size() << ' ' << b.at(0) << '\n';
}
g++ -std=c++23 -Wall -Wextra five.cpp -o five
./five
0 1 1 1
Exercises
- Add a pop_back that decrements the size and returns nothing, and check that the capacity stays.
- Add an at() that throws std::out_of_range for an index past the size.
- Replace the copy assignment in five.cpp with the copy-and-swap form and check the output does not change.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What three values does a minimal dynamic array store?
Answer
A pointer, a size and a capacity
The pointer names the block, the size counts the elements and the capacity counts the allocated slots.
-
What does the growth in the lesson do when the size reaches the capacity?
Answer
It allocates twice the capacity, copies the elements and frees the old block
The doubling policy and the copy are what make push_back amortised constant time.
-
What does this program print?
int_array v{2}; v.push_back(1); v.push_back(2); v.push_back(3); std::cout << v.size() << '/' << v.capacity();Answer
3/4
The third push grows the block from 2 to 4, so the size is 3 and the capacity 4.
-
Why does the class need a copy constructor at all?
Answer
Because the default copy would copy the pointer, not the block
A member-by-member copy duplicates the pointer, and two destructors would free one block.
-
What does the move constructor leave in the source object?
Answer
A null pointer, size 0 and capacity 0
The source gives up the block and becomes empty but valid, so its destructor is safe.
-
What does this program print?
int_array a{4}; a.push_back(7); int_array b = std::move(a); std::cout << b.size() << ' ' << a.size();Answer
1 0
The move transfers the element, so the target has one and the source none.
-
Why must the move constructor be noexcept for a vector of these objects to move them?
Answer
Because a throwing move could not be undone during a reallocation
The vector falls back to copying when the move might throw, as lesson 080 showed with counters.
-
What does the copy assignment check before it frees the old block?
Answer
That the source and the target are not the same object
Self-assignment would free the block it is about to read, so the check
this != &othercomes first. -
The class has a destructor, a copy constructor, a copy assignment, a move constructor and a move assignment. What is this set called?
Answer
The rule of five
Five special members: the destructor and the two copy and two move operations.
-
Which member does
at()return so thatv.at(0) = 99;changes the element?Answer
An int& reference
A reference lets the caller write through the returned value; by value it would change a copy.
The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev