Access methods for individual elements of a matrix

We have the freedom to access the contents of the data array directly but it appears more comfortable to provide access methods that permit to read or update an element of a matrix. This allows to keep the address arithmetic within the struct and we no longer need to clutter our code with constructs like i*incRow + j*incCol whenever we access an element.

Such methods could be implemented as follows:

struct Matrix {
   /* ... */

   double get(std::size_t i, std::size_t j) const {
      return data[i*incRow + j*incCol];
   }

   void set(std::size_t i, std::size_t j, double value) {
      data[i*incRow + j*incCol] = value;
   }

   /* ... */
};

The const keyword following the parameter list of get tells that the method supposedly does not change the state of the matrix. This means that this method may be invoked even if we have just read-only access for a Matrix object like a Matrix that has been passed as const reference, i.e. as const Matrix&.

These access methods can be used as in following example:

if (A.get(2, 3) > 0) {
   A.set(2, 3, -1);
}

This, however, can be done far more elegantly in C++ using references. Not just parameters can be passed per reference but return types can be reference types as well. This has the consequence that the result of such a method call can be used on the left side or the right side of an assignment, i.e. as a so-called lvalue or rvalue. When an lvalue is needed, the actual address is taken, and when an rvalue is required, that means the actual value is needed, the pointer of the reference is implicitly dereferenced. The good thing about references as return values is that we can use them in any way we like, the compiler does the right thing in dependence of how we use it.

struct Matrix {
   /* ... */

   double& access(std::size_t i, std::size_t j) {
      return data[i*incRow + j*incCol];
   }

   /* ... */
};

Example for using the access method:

if (A.access(2, 3) > 0) { /* using the returned value as rvalue */
   A.access(2, 3) = -1;   /* using the returned value as lvalue */
}

This allows us to use A.access(i, j) like any other variable.

In C++, this is usually further simplified by avoiding the method name access and using A(i, j) instead of A.access(i, j). This is possible by overloading the function call operator ().

Overloading in C++ means that the same name or operator is used for different types of operands. We are used that + can be used for int and double and know that the operators works differently for these two types. In C++ you have the freedom to extend this overloading to self-defined types like Matrix. All operators in C++ can be overloaded including the function call operator (). Overloaded operators can be defined like normal functions or methods, just instead of a method name we write operator followed by the symbol of the operator. Following example shows how to replace the access method by an overloaded function call operator:

struct Matrix {
   /* ... */

   double& operator()(std::size_t i, std::size_t j) {
      return data[i*incRow + j*incCol];
   }

   /* ... */
};

This operator can be used as follows:

if (A(2, 3) > 0) {
   A(2, 3) = -1;
}

If we want to support matrices with read-only access as well, we have two provide this operator in two variants:

struct Matrix {
   /* ... */

   const double& operator()(std::size_t i, std::size_t j) const {
      return data[i*incRow + j*incCol];
   }

   double& operator()(std::size_t i, std::size_t j) {
      return data[i*incRow + j*incCol];
   }

   /* ... */
};

The compiler selects automatically the first method if the matrix is read-only, otherwise the other method is taken. The first method returns a const double& and thereby inhibits any modifications of the returned matrix element.

Neither C nor C++ check array indices automatically. Accesses through out-of-range array indices can cause very hard to track errors. These access methods, however, allow us to add such a range check at a central location without cluttering the rest of the application with such checks. For this we use the assert function which is available through #include <cassert>:

#include <cassert>
/* ... */
struct Matrix {
   /* ... */

   const double& operator()(std::size_t i, std::size_t j) const {
      assert(i < m && j < n);
      return data[i*incRow + j*incCol];
   }

   double& operator()(std::size_t i, std::size_t j) {
      assert(i < m && j < n);
      return data[i*incRow + j*incCol];
   }

   /* ... */
};

Exercise

Extend the following example by adding a copy function with following signature. This copy function shall use the above defined access methods:

void copy_matrix(const Matrix& A, Matrix& B) {
   /* copy A to B */
}

Consider and check which of the access methods is invoked for which matrix.

Example

#include <cassert> /* needed for assert */
#include <cstddef> /* needed for std::size_t and std::ptrdiff_t */
#include <printf.hpp> /* needed for fmt::printf */

enum class StorageOrder {ColMajor, RowMajor};

struct Matrix {
   const std::size_t m; /* number of rows */
   const std::size_t n; /* number of columns */
   const std::ptrdiff_t incRow;
   const std::ptrdiff_t incCol;
   double* data;

   Matrix(std::size_t m, std::size_t n, StorageOrder order) :
         m(m), n(n),
         incRow(order == StorageOrder::ColMajor? 1: n),
         incCol(order == StorageOrder::RowMajor? 1: m),
         data(new double[m*n]) {
   }

   const double& operator()(std::size_t i, std::size_t j) const {
      assert(i < m && j < n);
      return data[i*incRow + j*incCol];
   }
   
   double& operator()(std::size_t i, std::size_t j) {
      assert(i < m && j < n);
      return data[i*incRow + j*incCol];
   }

   void init() {
      for (std::size_t i = 0; i < m; ++i) {
         for (std::size_t j = 0; j < n; ++j) {
            data[i*incRow + j*incCol] = j * m + i + 1;
         }
      }
   }

   void print() {
      for (std::size_t i = 0; i < m; ++i) {
         fmt::printf("  ");
         for (std::size_t j = 0; j < n; ++j) {
            fmt::printf(" %4.1lf", data[i*incRow + j*incCol]);
         }
         fmt::printf("\n");
      }
   }
};

int main() {
   Matrix A(7, 8, StorageOrder::ColMajor);
   A.init();
   A(2, 3) = -1;
   fmt::printf("A =\n"); A.print();
   delete[] A.data;
}