Matrix Class Using Templates
The integer matrix solved the ownership problem, but image processing and numerical code rarely use only int. Pixels may be unsigned char, convolution kernels may be float, and geometry transforms are often double.
A template lets the same matrix structure work for several value types. For this tutorial, the class is intentionally restricted to arithmetic types using static_assert. A more general production matrix might support custom numeric types, but that adds requirements about construction, multiplication, and formatting that distract from the implementation.
A Complete C++11 Template Matrix
The following class keeps the same row-major storage rule from Page 1. It adds initializer-list construction, addition, subtraction, scalar multiplication, matrix multiplication, transpose, an identity matrix helper, and stream printing.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
#include <cstddef> #include <iomanip> #include <initializer_list> #include <iostream> #include <stdexcept> #include <type_traits> #include <vector> template <typename T> class Matrix { public: static_assert(std::is_arithmetic<T>::value, "Matrix<T> requires an arithmetic value type"); Matrix() : rows_(0), cols_(0), data_() { } Matrix(std::size_t rows, std::size_t cols, const T& initial_value = T()) : rows_(rows), cols_(cols), data_(rows * cols, initial_value) { if (rows == 0 || cols == 0) { throw std::invalid_argument("matrix dimensions must be non-zero"); } } Matrix(std::initializer_list<std::initializer_list<T> > values) : rows_(values.size()), cols_(0), data_() { if (values.size() == 0) { throw std::invalid_argument("matrix must contain at least one row"); } cols_ = values.begin()->size(); if (cols_ == 0) { throw std::invalid_argument("matrix rows must not be empty"); } data_.reserve(rows_ * cols_); for (typename std::initializer_list<std::initializer_list<T> >::const_iterator row = values.begin(); row != values.end(); ++row) { if (row->size() != cols_) { throw std::invalid_argument("all matrix rows must have the same length"); } data_.insert(data_.end(), row->begin(), row->end()); } } std::size_t rows() const { return rows_; } std::size_t cols() const { return cols_; } bool empty() const { return rows_ == 0 || cols_ == 0; } T& operator()(std::size_t row, std::size_t col) { return data_.at(index(row, col)); } const T& operator()(std::size_t row, std::size_t col) const { return data_.at(index(row, col)); } Matrix& operator+=(const Matrix& rhs) { require_same_shape(rhs); for (std::size_t i = 0; i < data_.size(); ++i) { data_[i] += rhs.data_[i]; } return *this; } Matrix& operator-=(const Matrix& rhs) { require_same_shape(rhs); for (std::size_t i = 0; i < data_.size(); ++i) { data_[i] -= rhs.data_[i]; } return *this; } Matrix& operator*=(const T& scalar) { for (std::size_t i = 0; i < data_.size(); ++i) { data_[i] *= scalar; } return *this; } Matrix transposed() const { Matrix result(cols_, rows_); for (std::size_t row = 0; row < rows_; ++row) { for (std::size_t col = 0; col < cols_; ++col) { result(col, row) = (*this)(row, col); } } return result; } static Matrix identity(std::size_t size) { Matrix result(size, size, T()); for (std::size_t i = 0; i < size; ++i) { result(i, i) = T(1); } return result; } friend Matrix operator+(Matrix lhs, const Matrix& rhs) { lhs += rhs; return lhs; } friend Matrix operator-(Matrix lhs, const Matrix& rhs) { lhs -= rhs; return lhs; } friend Matrix operator*(Matrix lhs, const T& scalar) { lhs *= scalar; return lhs; } friend Matrix operator*(const T& scalar, Matrix rhs) { rhs *= scalar; return rhs; } friend Matrix operator*(const Matrix& lhs, const Matrix& rhs) { if (lhs.cols_ != rhs.rows_) { throw std::invalid_argument("matrix multiplication dimensions do not match"); } Matrix result(lhs.rows_, rhs.cols_, T()); for (std::size_t row = 0; row < lhs.rows_; ++row) { for (std::size_t shared = 0; shared < lhs.cols_; ++shared) { for (std::size_t col = 0; col < rhs.cols_; ++col) { result(row, col) += lhs(row, shared) * rhs(shared, col); } } } return result; } friend std::ostream& operator<<(std::ostream& os, const Matrix& matrix) { for (std::size_t row = 0; row < matrix.rows_; ++row) { for (std::size_t col = 0; col < matrix.cols_; ++col) { os << std::setw(6) << matrix(row, col); } os << '\n'; } return os; } private: std::size_t index(std::size_t row, std::size_t col) const { if (row >= rows_ || col >= cols_) { throw std::out_of_range("matrix index out of range"); } return row * cols_ + col; } void require_same_shape(const Matrix& rhs) const { if (rows_ != rhs.rows_ || cols_ != rhs.cols_) { throw std::invalid_argument("matrix dimensions do not match"); } } std::size_t rows_; std::size_t cols_; /* Good: one contiguous buffer keeps ownership and indexing simple. */ std::vector<T> data_; }; |
There are a few deliberate choices in this implementation:
- The class follows the Rule of Zero. It does not define a destructor, copy constructor, move constructor, or assignment operator because
std::vectoralready handles ownership correctly. - The initializer-list constructor checks that every row has the same length. Without that check, a visual matrix literal could silently become a broken rectangular matrix.
- Addition and subtraction reuse compound operators. That keeps the shape check in one place.
- Matrix multiplication checks
left.cols() == right.rows(). This is the most common rule to forget when writing quick test code. - The multiplication loop uses
row,shared, andcolnames instead of single-letter indexes. In real code reviews, names like these make dimension mistakes easier to see.
Using The Template Matrix
Here is a small example that exercises the operations. The matrices are double, but the same class can also be used with int, float, or other arithmetic types.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
int main() { Matrix<double> a = { {1.0, 2.0, 3.0}, {4.0, 5.0, 6.0} }; Matrix<double> b = { {7.0, 8.0}, {9.0, 10.0}, {11.0, 12.0} }; Matrix<double> product = a * b; Matrix<double> scaled = 0.5 * product; Matrix<double> transposed = product.transposed(); std::cout << "product:\n" << product << '\n'; std::cout << "scaled:\n" << scaled << '\n'; std::cout << "transposed:\n" << transposed << '\n'; } |
The output is:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
product: 58 64 139 154 scaled: 29 32 69.5 77 transposed: 58 139 64 154 |
This is a small implementation, but it is already much safer than the pointer-to-pointer version. Invalid dimensions are rejected before the operation runs. Indexing mistakes throw exceptions instead of walking into unrelated memory. Copying a matrix copies a vector, and returning a matrix by value is natural.
Where This Design Is Good Enough
This kind of matrix class is useful for learning, small tools, tests, simple image-processing experiments, and code where clarity matters more than squeezing every cycle. It also gives you a clean place to add project-specific behavior such as clamping, convolution helpers, CSV export, or debug printing.
For serious numerical work, large image pipelines, sparse matrices, SIMD-heavy operations, or GPU processing, use a mature library. Libraries such as Eigen and OpenCV have years of optimization and edge-case handling behind them. The value of writing a matrix class yourself is learning the design constraints and avoiding the old memory-management traps, not replacing a production linear algebra library.
Practical Takeaways
Prefer one contiguous buffer over a pointer-to-pointer matrix unless you have a specific reason not to. Keep the dimensions inside the object so operations can validate their inputs. Put the indexing formula in one helper function. Let std::vector own the memory. Add templates only after the non-template design is clear.
That is the difference between code that merely works in a tutorial and code that still looks reasonable when you return to it months later.
