C++ / CAPSTONE PROJECTS
Project: a matrix class with move semantics and operators
Build a row-major Matrix that owns its element buffer, follows the rule of five, and returns sums by moving instead of copying.
What you will learn
- Implement the rule of five as one unit: destructor, copy pair, and noexcept move pair
- Steal the buffer pointer and zero the source so exactly one Matrix owns the memory
- Write operator+ as a free function taking Matrix by value, then return that parameter
- Index with operator()(r, c) because two-argument operator[] needs C++23
Understanding Project: a matrix class with move semantics and operators
A Matrix here is three things: a row count, a column count, and one pointer to rows*cols doubles allocated as a single block. Computing the offset as r * cols_ + c keeps the entire matrix in one allocation and one contiguous run of memory, so indexing is a multiply and an add instead of a hop through an array of row pointers. The moment the class holds a raw owning pointer, the compiler-generated copy operations become wrong: they would duplicate the pointer, and two matrices would call delete[] on the same block. Declaring the destructor is what obliges you to write the other four members yourself.
Copying and moving promise different things. A copy must produce a second block holding the same values, so it always allocates; a move only has to hand the pointer over and leave the source safe to destroy and safe to assign to, which is why the move constructor sets other.data_ to nullptr and other.rows_ and other.cols_ to zero. The compiler picks the move overload when the source is an rvalue: a temporary such as the result of a + b, or a named object you deliberately cast with std::move. Mark both move members noexcept, because std::vector checks that trait during reallocation and falls back to copying every element if your move might throw.
Operator placement follows access and asymmetry. operator+= and operator() work on the left operand and need the private members, so they are member functions; operator+ and operator<< are free functions, which keeps the left operand unprivileged and lets operator<< have std::ostream on the left. The pattern Matrix operator+(Matrix lhs, const Matrix& rhs) { lhs += rhs; return lhs; } spends one copy to get scratch space and then gives the result away: return lhs cannot be elided, because parameters are excluded from copy elision, but the language treats the return of a named parameter as an rvalue, so the move constructor runs and no second block is allocated. In a + b + c only a is ever copied; the intermediate temporary flows straight into the next by-value parameter.
<algorithm>
<cstddef>
<iostream>
<stdexcept>
<utility>
class Matrix {
public:
Matrix() = default;
Matrix(std::size_t rows, std::size_t cols)
: rows_(rows), cols_(cols), data_(new double[rows * cols]()) {}
~Matrix() { delete[] data_; }
Matrix(const Matrix& other)
: rows_(other.rows_), cols_(other.cols_),
data_(new double[other.rows_ * other.cols_]) {
std::copy(other.data_, other.data_ + rows_ * cols_, data_);
std::cout << "copy ctor: new buffer\n";
}
Matrix(Matrix&& other) noexcept
: rows_(other.rows_), cols_(other.cols_), data_(other.data_) {
other.rows_ = 0;
other.cols_ = 0;
other.data_ = nullptr;
std::cout << "move ctor: stole buffer\n";
}
Matrix& operator=(const Matrix& other) {
if (this != &other) {
double* fresh = new double[other.rows_ * other.cols_];
std::copy(other.data_, other.data_ + other.rows_ * other.cols_, fresh);
delete[] data_;
data_ = fresh;
rows_ = other.rows_;
cols_ = other.cols_;
}
std::cout << "copy assign: new buffer\n";
return *this;
}
Matrix& operator=(Matrix&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
rows_ = other.rows_;
cols_ = other.cols_;
other.data_ = nullptr;
other.rows_ = 0;
other.cols_ = 0;
}
std::cout << "move assign: stole buffer\n";
return *this;
}
double& operator()(std::size_t r, std::size_t c) { return data_[r * cols_ + c]; }
double operator()(std::size_t r, std::size_t c) const { return data_[r * cols_ + c]; }
Matrix& operator+=(const Matrix& rhs) {
if (rows_ != rhs.rows_ || cols_ != rhs.cols_)
throw std::invalid_argument("shape mismatch");
for (std::size_t i = 0; i < rows_ * cols_; ++i) data_[i] += rhs.data_[i];
return *this;
}
std::size_t rows() const { return rows_; }
std::size_t cols() const { return cols_; }
private:
std::size_t rows_ = 0;
std::size_t cols_ = 0;
double* data_ = nullptr;
};
Matrix operator+(Matrix lhs, const Matrix& rhs) {
lhs += rhs;
return lhs; // lhs is a parameter, so elision is forbidden and this moves
}
std::ostream& operator<<(std::ostream& os, const Matrix& m) {
for (std::size_t r = 0; r < m.rows(); ++r)
for (std::size_t c = 0; c < m.cols(); ++c)
os << m(r, c) << (c + 1 == m.cols() ? '\n' : ' ');
return os;
}
int main() {
Matrix a(2, 2);
a(0, 0) = 1; a(0, 1) = 2; a(1, 0) = 3; a(1, 1) = 4;
Matrix b(2, 2);
b(0, 0) = 10; b(0, 1) = 20; b(1, 0) = 30; b(1, 1) = 40;
std::cout << "-- Matrix sum = a + b --\n";
Matrix sum = a + b;
std::cout << sum;
std::cout << "-- sum = a --\n";
sum = a;
std::cout << sum;
std::cout << "-- sum = a + b --\n";
sum = a + b;
std::cout << sum;
std::cout << "-- Matrix taken = std::move(sum) --\n";
Matrix taken = std::move(sum);
std::cout << "sum is now " << sum.rows() << "x" << sum.cols() << "\n";
std::cout << taken;
}
Each of the five special members answers one question: after this operation, which object owns the element buffer, and which one must not free it?
Worked examples
A destructor silently deletes your move constructor
Declaring a destructor on a matrix-like type stops the compiler from generating a move constructor, so std::move quietly performs a deep copy.
<cstddef>
<iostream>
<utility>
<vector>
struct Cells {
std::vector<double> v;
explicit Cells(std::size_t n) : v(n) {}
Cells(const Cells& o) : v(o.v) { std::cout << " deep copy\n"; }
Cells(Cells&& o) noexcept : v(std::move(o.v)) { std::cout << " cheap move\n"; }
};
struct Good {
Cells cells;
explicit Good(std::size_t n) : cells(n) {}
};
struct Bad {
Cells cells;
explicit Bad(std::size_t n) : cells(n) {}
~Bad() {}
};
int main() {
Good g(9);
Bad b(9);
std::cout << "Good has no destructor:\n";
Good g2 = std::move(g);
std::cout << " g2 holds " << g2.cells.v.size() << " cells\n";
std::cout << "Bad declares ~Bad():\n";
Bad b2 = std::move(b);
std::cout << " b2 holds " << b2.cells.v.size() << " cells, b still holds "
<< b.cells.v.size() << "\n";
}
Example explained
Line 1Good declares no destructor, copy, or move member, so the compiler generates a move constructor that move-constructs cells and the trace shows "cheap move".
Line 2~Bad() {} is enough to suppress the implicit move constructor, so std::move(b) binds to the implicit copy constructor instead.
Line 3b.cells.v.size() is still 9 after the supposed move, proving the 9 elements were duplicated rather than transferred.
Line 4This is exactly the trap in a hand-written Matrix: the delete[] destructor you need also removes the move operations, so you must declare all five.
Counting buffers through a chained sum
A counter incremented only where memory is really allocated shows that a + b + c allocates one extra buffer, not three.
<cstddef>
<iostream>
<utility>
<vector>
struct M {
static int allocs;
std::vector<double> v;
explicit M(std::size_t n) : v(n, 1.0) { ++allocs; }
M(const M& o) : v(o.v) { ++allocs; }
M(M&& o) noexcept : v(std::move(o.v)) {}
M& operator+=(const M& o) {
for (std::size_t i = 0; i < v.size(); ++i) v[i] += o.v[i];
return *this;
}
};
int M::allocs = 0;
M operator+(M lhs, const M& rhs) {
lhs += rhs;
return lhs;
}
int main() {
M a(3), b(3), c(3);
std::cout << "three matrices: " << M::allocs << " buffers\n";
M total = a + b + c;
std::cout << "after a + b + c: " << M::allocs << " buffers\n";
std::cout << "total[0] = " << total.v[0] << "\n";
}
Example explained
Line 1allocs increases only in the sizing constructor and the copy constructor, the two places that ask for new memory.
Line 2In a + b, a is copy-constructed into the by-value parameter lhs, and that copy is the single extra buffer.
Line 3The temporary produced by a + b is a prvalue, so it initializes the next by-value parameter directly with no allocation.
Line 4return lhs picks the move constructor, which takes over the vector's block, so the returned value costs nothing.
Important notes
A moved-from Matrix is emptied, not destroyed; leave it as a valid 0x0 matrix so destroying it or assigning to it later is well defined, and never read its elements.
Using std::vector<double> as the storage member gives correct copy and move behaviour for free, but if you keep rows_ and cols_ as separate members the implicit move copies those numbers, leaving a moved-from matrix that claims a shape whose storage is gone.
Common mistakes
Taking other.data_ in the move constructor but forgetting other.data_ = nullptr: both matrices later call delete[] on the same block, a double free that usually crashes when the scope ends.
Writing copy assignment as delete[] data_; then allocating and copying, with no this != &other guard: m = m frees the very buffer it is about to read, so the elements become garbage.
Ending a factory function with return std::move(result);: the explicit move turns a returnable local into an rvalue and blocks the elision the compiler would have applied, adding a pointless move constructor call that GCC and Clang warn about with -Wpessimizing-move.
Try it yourself
Change, predict, then run
Add operator-= as a member and a free operator- built with the same by-value parameter idiom, then run Matrix d = a - b - a; with the tracing prints and confirm you see exactly one "copy ctor" line.
Open the C++ workspaceCheck your understanding
Given Matrix operator+(Matrix lhs, const Matrix& rhs) { lhs += rhs; return lhs; }, how many element buffers does Matrix c = a + b; allocate?
- One: the by-value parameter copies a, and returning that parameter moves its buffer into c
- None: the compiler elides everything, so c ends up sharing a's buffer
- Two: one for the by-value parameter and one for the object returned by value
- Three: one for the parameter, one for the return value, and one for c
Show answer
Copying a into the by-value parameter is the only allocation. return lhs cannot use copy elision, since parameters are excluded from it, but overload resolution treats the returned named parameter as an rvalue and selects the move constructor, which only transfers the pointer; the resulting prvalue then initializes c directly. "Two" assumes the returned object needs a fresh block, which would only happen if the class had no move constructor and the copy constructor ran instead.