Setup
Classes as the Unit of Abstraction in Quant Code
In production quant finance, every reusable computation is a class. A term-structure model is a class. A Monte Carlo path generator is a class. A payoff function is a class. A matrix is a class. This is not organizational ceremony — it is the mechanism by which C++ enforces encapsulation, enables RAII (Resource Acquisition Is Initialisation), and provides the compiler with enough information to eliminate redundant copies.
The central concern of this module is object lifetime. When you pass a matrix to a function, does it copy the data? When you return a matrix from a function, does a copy occur? When a path generator holds a std::vector<double> of 100,000 simulated spot prices and it goes out of scope, is the memory freed? The answers to all of these questions are determined by the special member functions: the constructor, copy constructor, copy assignment operator, move constructor, move assignment operator, and destructor.
Understanding these six functions is a prerequisite for writing C++ that is both correct and efficient. The Rule of Five states: if you define (or delete) any one of {destructor, copy constructor, copy assignment, move constructor, move assignment}, you almost certainly need to define all five explicitly. The compiler's defaults are correct only when your class has no resources to manage. Once your class owns heap memory — directly via a raw pointer or indirectly via its own data members — the defaults will produce incorrect or dangerous behaviour.
The running example throughout this module is a Matrix class, as taught in the Gustave Eiffel M2 quantitative finance C++ course.
Theory
1. Class Anatomy: Headers, Access Control, and const Methods
C++ classes are declared in a header file (.h or .hpp) and defined in a source file (.cpp). The header is what consumers of the class #include; it must contain everything needed to use the class without seeing how it is implemented.
// What belongs in the header (.h):
// - The class declaration (member variables, function signatures)
// - Inline function definitions (small functions that benefit from inlining)
// - Template definitions (must be visible at instantiation point)
//
// What belongs in the source (.cpp):
// - Non-trivial function definitions
// - Definitions of static data members
Access specifiers control which code can access which members:
public: accessible from any code that has the object.protected: accessible from the class and its derived classes.private: accessible only from within the class itself.
For a Matrix class, the data (_nrows, _ncols, _data) is private or protected. The interface (nrows(), ncols(), row()) is public. This separation means clients cannot accidentally corrupt the matrix's internal state.
const member functions declare that the function will not modify *this:
size_t nrows() const { return _nrows; } // const: calling on a const Matrix& is valid
A const method can be called on both const and non-const objects. A non-const method can only be called on non-const objects. As a general rule: any accessor (getter) should be const.
static members belong to the class, not to any instance:
static constexpr size_t MAX_DIM = 10000; // shared across all Matrix instances
2. Constructors and the Initializer List
The constructor is called when an object is created. Its job is to put the object into a valid state.
Matrix(size_t nrows, size_t ncols, const std::vector<Vector>& data);
The initializer list (the colon-separated list between the parameter list and the function body) initializes member variables before the body executes:
Matrix::Matrix(size_t nrows, size_t ncols, const std::vector<Vector>& data)
: _nrows(nrows),
_ncols(ncols),
_data(data) // invokes std::vector's copy constructor
{
// Body executes after all members are already initialized
// Validation can go here:
// if (data.size() != nrows || ...) throw std::invalid_argument(...)
}
Why prefer the initializer list over assignment in the body?
Without the initializer list, the compiler first default-initializes each member (calling its default constructor), then runs the body, which assigns to the already-initialized member. That is one unnecessary construction plus one assignment. For std::vector, the default constructor allocates nothing; the assignment then copies. With the initializer list, the vector is copy-constructed directly from data — one operation instead of two.
For const members and reference members, the initializer list is mandatory — they cannot be assigned to after construction.
Members are initialized in declaration order, not in the order they appear in the initializer list. Always write the initializer list in declaration order to avoid subtle bugs where one member is initialized using another that hasn't been initialized yet.
3. Copy Constructor
The copy constructor initializes a new object as a copy of an existing one:
Matrix(const Matrix& other);
It is called when:
- A variable is initialized from another variable:
Matrix B = A; - An object is passed by value to a function:
void f(Matrix m)—mis copy-constructed from the argument. - An object is returned by value (when NRVO/RVO cannot be applied).
For Matrix, the copy constructor copies all three members:
Matrix::Matrix(const Matrix& other)
: _nrows(other._nrows),
_ncols(other._ncols),
_data(other._data) // std::vector's copy constructor: deep copies all elements
{
}
Deep copy vs. shallow copy:
std::vector<double> has its own copy constructor that allocates new memory and copies all elements. So _data(other._data) is a deep copy — the new matrix has its own independent data buffer.
If _data were instead a raw pointer (double* _data), the compiler-generated copy constructor would copy the pointer value — both matrices would point to the same memory. Modifying one would silently modify the other. Deleting one would leave the other with a dangling pointer. This is the shallow copy problem, and it is why any class holding raw heap pointers must provide an explicit copy constructor that deep-copies the data.
4. Copy Assignment Operator
The copy assignment operator replaces the state of an already-existing object:
Matrix& operator=(const Matrix& other);
It is called when you write A = B where A has already been constructed.
Two requirements that are absent from the copy constructor:
- Self-assignment guard:
A = Amust be safe. Without the guard, if your class frees its own resources before readingother's, andotheris*this, you read freed memory. - Free old resources:
Aalready holds data. If it owns heap memory, that memory must be freed before the new data is installed (with raw pointers).
Matrix& Matrix::operator=(const Matrix& other) {
if (this != &other) { // self-assignment guard
_nrows = other._nrows;
_ncols = other._ncols;
_data = other._data; // std::vector's operator= handles deallocation and copy
}
return *this; // enables chaining: A = B = C
}
Why return Matrix& (rather than void)? To support chained assignment: A = B = C is parsed as A = (B = C). The inner B = C must return a reference to B for the outer assignment to work.
5. Move Constructor
The move constructor transfers ownership of resources from a temporary (or explicitly moved) object rather than copying them:
Matrix(Matrix&& other) noexcept;
It is called when:
- An object is initialized from a temporary:
Matrix C = make_matrix(); - An object is initialized from
std::move(other):Matrix C = std::move(B);
After the move, other is in a valid but unspecified state — it is safe to destroy, but its contents are not specified. For std::vector, moving sets the source vector to empty (size() == 0).
Matrix::Matrix(Matrix&& other) noexcept
: _nrows(std::move(other._nrows)), // for POD types (size_t), std::move is a cast — copies the value
_ncols(std::move(other._ncols)),
_data(std::move(other._data)) // std::vector move: O(1) pointer swap; other._data becomes empty
{
}
Performance: moving a Matrix with 1000×1000 elements transfers 3 values (2 size_ts and one internal pointer inside std::vector) — O(1). Copying it copies all 10⁶ doubles — O(n²). This is the performance motivation for move semantics.
noexcept is critical. The C++ standard library — std::vector in particular — uses move operations during reallocation only if the move constructor is noexcept. If it is not, the standard says "fall back to copy" (to maintain exception safety). Without noexcept, a std::vector<Matrix> would copy every element on every reallocation, silently destroying the O(1) benefit of move semantics.
6. Move Assignment Operator
The move assignment operator transfers resources into an already-existing object:
Matrix& operator=(Matrix&& other) noexcept;
Matrix& Matrix::operator=(Matrix&& other) noexcept {
if (this != &other) { // self-assignment guard (unusual for moves, but safe)
_nrows = std::move(other._nrows);
_ncols = std::move(other._ncols);
_data = std::move(other._data);
}
return *this;
}
std::vector's move assignment frees the current buffer, then transfers the source's internal pointer in O(1). No element-by-element copy occurs.
7. Destructor
The destructor is called when an object's lifetime ends (scope exit, delete, container destruction). Its job is to release any resources the object owns.
virtual ~Matrix() = default;
For Matrix (which owns only std::vector members), = default is correct — std::vector's destructor frees the element storage. No explicit body is needed.
For a class with raw pointer members:
~RawMatrix() {
delete[] _data; // must free what the constructor allocated with new[]
}
virtual destructor: any class intended to be used as a base class in a polymorphic hierarchy must have a virtual destructor. Without it, delete base_ptr where base_ptr holds a Derived* calls only the base destructor — the derived class's resources are leaked. This is covered in the inheritance module; Matrix is marked virtual ~Matrix() here for forward compatibility.
8. = default and = delete
= default asks the compiler to generate the standard implementation:
Matrix(const Matrix&) = default; // compiler-generated deep copy (via vector's copy ctor)
Matrix& operator=(const Matrix&) = default;
Use this when the default behaviour is correct and you want to be explicit about it. It also ensures the generated function is constexpr and noexcept when possible.
= delete makes a function unavailable — attempts to call it are compile errors:
Matrix(const Matrix&) = delete; // Matrix cannot be copied
Matrix& operator=(const Matrix&) = delete;
Use this for types that genuinely should not be copied: locks, file handles, unique ownership wrappers. Attempting to copy a = deleted type produces a clear diagnostic ("use of deleted function") rather than silent incorrect behaviour.
Implementation
Matrix.h — Class Declaration
// Matrix.h
// Compile: part of a larger translation unit; see Matrix.cpp and main.cpp
// Standard: C++17. Compile flags: -std=c++17 -Wall -Wextra -Wpedantic -Werror
#pragma once
#include <vector>
#include <string>
#include <cstddef>
#include <stdexcept>
// Alias for a row vector — a single std::vector<double> represents one matrix row
using Vector = std::vector<double>;
class Matrix {
public:
// --- Constructors ---
// Parameter constructor: constructs a matrix from an explicit 2D data vector.
// Pre-conditions: data.size() == nrows; each data[i].size() == ncols.
Matrix(size_t nrows, size_t ncols, const std::vector<Vector>& data);
// Convenience constructor: zero-initialised matrix of given dimensions
Matrix(size_t nrows, size_t ncols);
// Copy constructor: deep copy via std::vector's copy constructor
Matrix(const Matrix& other);
// Move constructor: O(1) transfer of internal buffer ownership
Matrix(Matrix&& other) noexcept;
// Copy assignment: replace this matrix's contents with other's (deep copy)
Matrix& operator=(const Matrix& other);
// Move assignment: replace this matrix's contents by stealing other's buffer
Matrix& operator=(Matrix&& other) noexcept;
// Destructor: = default is correct because _data is a std::vector<Vector>
// (which owns its own memory and cleans up in its destructor)
virtual ~Matrix() = default;
// --- Accessors (all const: callable on const Matrix&) ---
size_t nrows() const { return _nrows; }
size_t ncols() const { return _ncols; }
// Read-only access to row i (zero-indexed)
const Vector& row(size_t i) const;
// Element access
double at(size_t i, size_t j) const;
double& at(size_t i, size_t j);
// Human-readable description
std::string name() const;
protected:
size_t _nrows;
size_t _ncols;
std::vector<Vector> _data; // _data[i][j] = element at row i, column j (row-major)
};
Matrix.cpp — All Five Special Members, Fully Implemented
// Matrix.cpp
#include "Matrix.h"
#include <sstream>
#include <stdexcept>
// ---------------------------------------------------------------------------
// Parameter constructor
// ---------------------------------------------------------------------------
Matrix::Matrix(size_t nrows, size_t ncols, const std::vector<Vector>& data)
: _nrows(nrows),
_ncols(ncols),
_data(data)
{
// Validate dimensions at construction time — fail loudly rather than
// silently operating on a malformed matrix.
if (_data.size() != _nrows)
throw std::invalid_argument("Matrix: data.size() != nrows");
for (size_t i = 0; i < _nrows; ++i) {
if (_data[i].size() != _ncols)
throw std::invalid_argument("Matrix: row " + std::to_string(i) +
" has wrong column count");
}
}
// ---------------------------------------------------------------------------
// Convenience constructor: zero-initialised
// ---------------------------------------------------------------------------
Matrix::Matrix(size_t nrows, size_t ncols)
: _nrows(nrows),
_ncols(ncols),
_data(nrows, Vector(ncols, 0.0))
{
}
// ---------------------------------------------------------------------------
// Copy constructor
// Deep copy: std::vector<Vector>'s copy constructor allocates a new buffer and
// copies every element. After this constructor, *this and other share no memory.
// ---------------------------------------------------------------------------
Matrix::Matrix(const Matrix& other)
: _nrows(other._nrows),
_ncols(other._ncols),
_data(other._data) // std::vector copy ctor: O(nrows * ncols)
{
}
// ---------------------------------------------------------------------------
// Move constructor
// Transfers ownership of other's internal buffer in O(1).
// After this, other._data.empty() == true (and other._nrows, _ncols are zero
// after std::move for size_t — but for POD types, std::move is just a value
// copy; we set them to zero explicitly for clarity).
// noexcept is required so std::vector<Matrix> uses move during reallocation.
// ---------------------------------------------------------------------------
Matrix::Matrix(Matrix&& other) noexcept
: _nrows(other._nrows),
_ncols(other._ncols),
_data(std::move(other._data))
{
// Leave other in a valid (empty) state
other._nrows = 0;
other._ncols = 0;
// other._data is already empty (std::move transferred its buffer)
}
// ---------------------------------------------------------------------------
// Copy assignment operator
// Replaces *this with a deep copy of other.
// Self-assignment guard prevents: freeing *this's memory then reading from it.
// Returns *this by reference to enable A = B = C chaining.
// ---------------------------------------------------------------------------
Matrix& Matrix::operator=(const Matrix& other) {
if (this != &other) {
_nrows = other._nrows;
_ncols = other._ncols;
_data = other._data; // std::vector operator= frees old buffer, copies new data
}
return *this;
}
// ---------------------------------------------------------------------------
// Move assignment operator
// Steals other's buffer in O(1). Leaves other empty.
// noexcept for the same reason as the move constructor.
// ---------------------------------------------------------------------------
Matrix& Matrix::operator=(Matrix&& other) noexcept {
if (this != &other) {
_nrows = other._nrows;
_ncols = other._ncols;
_data = std::move(other._data);
other._nrows = 0;
other._ncols = 0;
}
return *this;
}
// ---------------------------------------------------------------------------
// Accessors
// ---------------------------------------------------------------------------
const Vector& Matrix::row(size_t i) const {
if (i >= _nrows)
throw std::out_of_range("Matrix::row: index " + std::to_string(i) +
" out of range (nrows=" + std::to_string(_nrows) + ")");
return _data[i];
}
double Matrix::at(size_t i, size_t j) const {
if (i >= _nrows || j >= _ncols)
throw std::out_of_range("Matrix::at: index out of range");
return _data[i][j];
}
double& Matrix::at(size_t i, size_t j) {
if (i >= _nrows || j >= _ncols)
throw std::out_of_range("Matrix::at: index out of range");
return _data[i][j];
}
std::string Matrix::name() const {
std::ostringstream oss;
oss << "Matrix(" << _nrows << "x" << _ncols << ")";
return oss.str();
}
main.cpp — Demonstrating All Special Members
// main.cpp
// Compile: g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror -o matrix_demo Matrix.cpp main.cpp
#include "Matrix.h"
#include <iostream>
#include <cassert>
// Helper: build a correlation matrix for n assets (diagonal = 1, off-diagonal = rho)
Matrix make_corr_matrix(size_t n, double rho) {
std::vector<Vector> data(n, Vector(n, rho));
for (size_t i = 0; i < n; ++i)
data[i][i] = 1.0;
return Matrix(n, n, data); // NRVO: the compiler constructs this directly in the caller
}
int main() {
// -----------------------------------------------------------------------
// 1. Construct via parameter constructor
// -----------------------------------------------------------------------
Matrix corr = make_corr_matrix(3, 0.4);
std::cout << "Constructed: " << corr.name() << "\n";
std::cout << "corr[0][1] = " << corr.at(0, 1) << "\n"; // 0.4
// -----------------------------------------------------------------------
// 2. Copy constructor: deep copy — modifying corr does not affect copy
// -----------------------------------------------------------------------
Matrix corr_copy(corr); // copy constructor called
corr.at(0, 1) = 0.99; // modify the original
std::cout << "\nAfter modifying corr[0][1]:\n";
std::cout << " corr[0][1] = " << corr.at(0, 1) << "\n"; // 0.99
std::cout << " corr_copy[0][1] = " << corr_copy.at(0, 1) << "\n"; // 0.4 (unchanged)
assert(corr_copy.at(0, 1) == 0.4 && "Deep copy failed: copy was modified via original");
// -----------------------------------------------------------------------
// 3. Copy assignment operator
// -----------------------------------------------------------------------
Matrix another(2, 2); // convenience constructor: 2x2 zeros
another = corr_copy; // copy assignment
std::cout << "\nCopy assignment:\n";
std::cout << " another[0][0] = " << another.at(0, 0) << "\n"; // 1.0 (diagonal)
std::cout << " another[0][1] = " << another.at(0, 1) << "\n"; // 0.4
// Self-assignment must be safe
another = another;
std::cout << " Self-assignment survived: another[0][0] = " << another.at(0, 0) << "\n";
// -----------------------------------------------------------------------
// 4. Move constructor: corr_copy is left in valid but empty state
// -----------------------------------------------------------------------
Matrix moved(std::move(corr_copy)); // move constructor called
std::cout << "\nAfter move construction:\n";
std::cout << " moved nrows = " << moved.nrows() << "\n"; // 3
std::cout << " corr_copy nrows = " << corr_copy.nrows() << "\n"; // 0 (emptied)
assert(moved.nrows() == 3 && "Move constructor: destination nrows wrong");
assert(corr_copy.nrows() == 0 && "Move constructor: source not emptied");
// -----------------------------------------------------------------------
// 5. Move assignment operator: transfer ownership to a new variable
// -----------------------------------------------------------------------
Matrix target(1, 1); // temporary placeholder
target = std::move(moved);
std::cout << "\nAfter move assignment:\n";
std::cout << " target nrows = " << target.nrows() << "\n"; // 3
std::cout << " moved nrows = " << moved.nrows() << "\n"; // 0 (emptied)
assert(target.nrows() == 3 && "Move assignment: destination nrows wrong");
assert(moved.nrows() == 0 && "Move assignment: source not emptied");
std::cout << "\nAll assertions passed.\n";
return 0;
}
Validation
Deep copy verification: after Matrix corr_copy(corr), modifying corr.at(0,1) must not affect corr_copy.at(0,1). The assertion assert(corr_copy.at(0,1) == 0.4) confirms independence of the two objects' data buffers.
Move semantics verification: after Matrix moved(std::move(corr_copy)):
moved.nrows()must equal 3 (the data was transferred, not lost).corr_copy.nrows()must equal 0 (the source was emptied).
The assert confirms both. The test corr_copy.nrows() == 0 is only valid because the move constructor explicitly sets other._nrows = 0. The C++ standard only guarantees a "valid but unspecified state" after a move — our implementation makes the state specified and testable.
Self-assignment: another = another must leave another unchanged. With std::vector, the self-assignment guard is not strictly necessary (vector handles it), but the guard documents intent and is required for correctness in raw-pointer implementations.
Compilation: compile with -Wall -Wextra -Wpedantic -Werror. Expected: zero warnings, zero errors. The absence of warnings under these flags is a necessary (not sufficient) condition for correctness.
Limitations and Pitfalls
Default-generated copy for raw pointer members is a shallow copy
The compiler-generated copy constructor and copy assignment operator copy each member. For std::vector members, that means invoking std::vector's own copy operations — which are deep. But if your class holds double* _data instead of std::vector<double> _data, the generated copy constructor copies the pointer value, not the data it points to. Two objects then hold the same pointer. The first to be destroyed calls delete[] _data; the second's destructor calls delete[] on an already-freed pointer — a double-free, causing undefined behaviour (typically a crash or heap corruption).
This is the canonical motivation for the Rule of Five: a class with a raw pointer member needs an explicit copy constructor (allocating new memory and copying elements), copy assignment (freeing old memory, allocating new memory, copying elements), and destructor (freeing the memory). The move operations must then be written too.
Self-assignment
A = A is legal C++ and may occur in generic code (e.g., std::swap of two references into the same container). For raw-pointer implementations, the copy assignment pattern "free old memory, copy from other" is catastrophically wrong when this == &other — you free the memory you are about to read. The if (this != &other) guard is mandatory. An alternative that eliminates the concern entirely is the copy-and-swap idiom: implement operator= by taking the argument by value (invoking the copy constructor), then std::swap(this->_data, other._data). The old data is freed when the by-value copy is destroyed at the end of the function, and the entire pattern is exception-safe and self-assignment-safe by construction.
noexcept on move operations
Without noexcept, the standard library's containers cannot use your move constructor during reallocation. std::vector<Matrix> grows by doubling — when it reallocates, it must transfer elements from the old buffer to the new one. If the move constructor might throw, std::vector falls back to copy (to preserve the strong exception guarantee). The result: a push_back on a std::vector<Matrix> performs an O(n) copy of every existing element, not an O(n) move. This is a silent, severe performance regression. Always mark move constructors and move assignment operators noexcept when their operations are genuinely exception-free (which is the case when all members are std::vector with noexcept moves).
Named Return Value Optimisation (NRVO)
make_corr_matrix returns a Matrix by value. A naive reading suggests a copy is made at the return statement. In practice, the compiler applies NRVO: it constructs the return value directly in the caller's variable (corr), eliding the copy entirely. The destructor for the in-function Matrix is never called because no separate object was ever created. NRVO is permitted (and almost universally applied) by all modern compilers under C++17's guaranteed copy elision rules.
You should not rely on NRVO for correctness — write code that is correct whether or not elision occurs. But you should know it explains why your copy/move constructor may not be called when you expect it to be: the profiler shows zero invocations of the copy constructor for make_corr_matrix, and that is correct and expected.
virtual destructor in base classes
Matrix declares virtual ~Matrix() = default. Any class from which you derive — even if you never explicitly hold a derived object through a base pointer — should have a virtual destructor. Without it, delete base_ptr where base_ptr actually points to a DerivedMatrix only calls ~Matrix(), leaking any resources that DerivedMatrix owns. The virtual keyword costs one pointer in the object's layout (the vtable pointer, added once per object, regardless of how many virtual functions the class has). It is always worth paying.
Interview Angle
Level 1 (Junior Quant Developer)
"What is the Rule of Five? Give a concrete example of a class where you need it."
Expected answer: If a class defines (or deletes) any one of {destructor, copy constructor, copy assignment, move constructor, move assignment}, it probably needs to define all five. The canonical example is a class that owns a raw heap pointer — say, a dynamic array wrapper struct Buffer { double* data; int n; }. The compiler-generated copy constructor copies the pointer (shallow copy); both objects then point to the same memory. When the first is destroyed, delete[] data is called. The second's destructor then calls delete[] on an already-freed pointer — undefined behaviour. The class needs an explicit copy constructor (allocate and copy), copy assignment (check self, free old, allocate and copy), destructor (delete), and move operations (steal the pointer, null the source).
Level 2 (Mid-level C++ Quant)
"What is the difference between the copy constructor and the copy assignment operator? Why does the copy assignment operator need a self-assignment guard, but the copy constructor does not?"
Expected answer: The copy constructor initializes a new, not-yet-constructed object from an existing one. The new object has no prior state to worry about — it has never held resources. Copy assignment replaces the state of an already-constructed object. For raw-pointer members, the assignment must free the old buffer before installing the new one. If this == &other (self-assignment), you free the memory you are about to read — UB. The copy constructor is called on construction, never on an object-in-use, so there is no prior state to free and no self-assignment possible.
"Why must move constructors be marked noexcept to get full performance from the standard library?"
Expected answer: std::vector provides the strong exception guarantee: if push_back throws, the vector is unchanged. During reallocation, it must transfer elements. If it moves elements and the move constructor throws midway, some elements are moved-from, others are not — the invariant is broken and the original buffer is gone. So std::vector uses std::is_nothrow_move_constructible to decide: if the move constructor is noexcept, it moves (O(1) per element); if not, it copies (O(n) per element, preserving the original). Without noexcept, a std::vector<Matrix> of 10,000 elements reallocation silently copies all 10,000 matrices.
Level 3 (Senior / Quant Researcher)
"Explain the copy-and-swap idiom. Why is it preferred over the canonical copy-assignment implementation for classes with strong exception safety requirements?"
Expected answer: Implement operator=(Matrix other) — the argument is taken by value, so the copy constructor runs at the call site. Inside the function, std::swap(_nrows, other._nrows); std::swap(_ncols, other._ncols); std::swap(_data, other._data); exchanges *this's resources with the copy's resources. When other goes out of scope at the end of the function, it destroys the old resources. This approach is: (1) self-assignment safe — self-assignment copies then swaps, which is safe; (2) strongly exception-safe — if the copy constructor throws, the exception propagates before any swap occurs, leaving *this unchanged; (3) DRY — it reuses the copy constructor and destructor rather than duplicating deallocation and copy logic. The trade-off is a potential extra allocation vs. the "check-and-copy" approach that could skip allocation on self-assignment.