Enhance readability with operator overloading in C++

In this article, we will look at why operator overloading is providing nice syntactic sugar for our code and how you would write your code differently in C++ compared to C, where operator overloading is not available. This helps us to appreciate the simplicity overloaded operator bring to our code, by not only ensuring we have to write less code, but also by making our code much more intention-revealing. We look at some useful examples that you may encounter in CFD applications, as well as how OpenFOAM makes heavy use of them to provide their nice and easy-to-read equation syntax.

In this series

In this article

Operator overloading: A quest for clean code

Operator overloading is something we could do completely without, but adding it to our code just makes it that much more readable and, more importantly, signals intent. A lot of the time, C++ allows us to customise the language to a degree where the code becomes like reading plain English text. Other times, though, some features may make the code a lot less readable, like templates. Operator overloading definitely falls in the first category, i.e. they make our code that much more readable.

How to do things the boring C way

Let’s start again with an example and see how we would do things in C, and then translate them to C++. Let’s assume we want to write code where we have a Vector containing an x, y, and z component, and we want to add two vectors together. This vector could, for example, represent the velocity vector for a single computational cell (and then we would have one such vector for each computational cell). In C, we may store the vector itself in a struct and provide a function to add vectors.

#include <iostream>

struct Vector {
  double x = 0.0;
  double y = 0.0;
  double z = 0.0;
};

Vector add(const Vector &a, const Vector &b) {
  Vector temp;
  temp.x = a.x + b.x;
  temp.y = a.y + b.y;
  temp.z = a.z + b.z;
  return temp;
}

int main()
{
  Vector a, b;
  a.x = 1.0; a.y = 2.0; a.z = 3.0;
  b.x = 5.0; b.y = 3.0; b.z = 0.5;
  Vector c = add(a, b);
  std::cout << c.x << ", " << c.y << ", " << c.z << std::endl;
  return 0;
}

On lines 3-7, we have the struct definition. Remember, by default everything inside a struct is public, so we don’t need to change any access modifier here (not sure what I am talking about? Then have a look at the article on classes and object-oriented programming). We also call our variable x, y, z, and not _x, _y, _z, because these are now public, not private, which again signals intent (anyone can access these values and modify them). On lines 9-15, we specify the add function, which takes 2 const Vectors as arguments. We have not looked into const before, but that essentially just makes the variables passed into the function constant, i.e. within the add() function, we are not able to modify the content of a and b. There is no intention to modify these values here, and this may be clear to you, but not necessarily for someone else reading your code, (especially for more complicated functions and classes). Think of the const keyword as read-only as an analogy, we can read its content but not write to it.

Within the add() function, we are simply adding together the content of the two vectors passed into the function and store the content in a new Vector called temp. We return a copy of that vector at the end of the function, i.e. line 14. Then, we can instantiate two Vectors on line 19, fill them on line 20-21, add them on line 20 and then, finally, print their content on line 23.

The part which is painful here, and which does not really promote clean code design, is happening on line 19-23. Why? We now ask everyone who wants to use our code to access the low-level state of the vector themselves, add these together with the add function and then print the content manually themselves. This may not seem like a lot, but you want to hide as much of this as possible to promote clean code, and I hope by the end of this article when we compare this implementation with C++, you will agree.

Making our code clean and intention-revealing with C++

So then, let’s look at operator overloading. In C++, we can specify several operators, and there are quite a few. A list of all possible overloadable operators can be found on cppreference.com. An operator is, in its simple form, just a mathematical +, -, *, or /. But there are many more that we can define, and you can find an in-depth discussion over at LearnCPP. In our case, we would want to create a vector class and then overload the + operator for this class. I should mention that operator overloading can only be done within classes, so we need a Vector class in order for operator overloading to be applicable. So let’s flesh out this class first as we would do normally:

#include <iostream>

class Vector {
public:
  Vector() : _x(0.0), _y(0.0), _z(0.0) { }
  void set(double x, double y, double z) {
    _x = x;
    _y = y;
    _z = z;
  }
private:
  double _x;
  double _y;
  double _z;
};

int main()
{
  Vector a, b;
  a.set(1.0, 2.0, 3.0);
  b.set(5.0, 3.0, 0.5);
  return 0;
}

At least the initialisation step of the vectors now has become much cleaner, i.e. we are calling a function called set which signals much clearer intent than having to access low-level variables and modify them yourself in the code.

If we want to add functionality now to our class that allows us to add two Vectors together, we can introduce a special function called operator+(). Let’s give the definition first and then discuss it:

Vector operator+(const Vector &rhs) {
  Vector temp;
  temp._x = _x + rhs._x;
  temp._y = _y + rhs._y;
  temp._z = _z + rhs._z;
  return temp;
}

We would need to add this code after line 10 of the previously given class definition. This definition now is very similar to our previously defined add() function that we came up with for our C code. But look at the function itself, do you spot something peculiar? We are accessing private variables here, e.g. _x! From within the class, this is OK, e.g. we should have access to _x, but why are we able to access also temp._x and rhs._x?

Well, there is an exception to the rule with access modifiers, and that is that you are allowed to access private variables even from outside the class, as long as you do so from within another class that has the same type, e.g. Vector in the example above. So because the operator+() function lives inside the Vector class, any variable that we define within this function that is of the same type (Vector), we will be able to access its private variables. We are able to access their states, and this is required for operator overloading to work. Just a note, I personally call the function arguments to my overloaded operators typically rhs, which stands for right-hand side, and you will see in a second why.

After we have added the operator+() function to our class, we are now able to make the following call in our main function:

int main()
{
  Vector a, b;
  a.set(1.0, 2.0, 3.0);
  b.set(5.0, 3.0, 0.5);
  Vector c = a.operator+(b);
  return 0;
}

Beautiful, isn’t it? On line 6, we call the operator+() function, as we have defined it. Well, if this was the use case, then I would be 0discouraged from using overloaded operators because this is no better than having an add() function. The function names would be different, but I’d argue that add() looks better than operator+(). No, overloaded operators, as the name suggests, allow us to provide a different meaning to the operator itself, so we can replace line 6 here with the following call:

Vector c = a + b;

Now that is much more intention-revealing than the a.operator+(b) mess. Remember, both a and b are of type Vector, i.e. a class that we have just created. You can also see if you compare the above code to the one given before that when we encounter the instruction a + b;, it is the operator+() function being called from the a object. Within the operator+() class, we defined the function argument as const Vector &rhs, i.e. the right-hand side, and the vector b in this case becomes the argument, which is to the right-hand side of vector a, hence the name rhs.

Using operator overloading allows us quickly to provide logical operators to manipulate the states (variables) of these classes. And, something which I find rather nice, is the sheer number of operators we can overload. For example, we have added the function for adding two Vectors together, but what if we actually use this vector now and store an array of millions of Vector entries in it, what if we want to add two velocity vectors together? Perhaps we just want to add the content of one vector to the other, and not create a completely new one. Well, just overload the operator+=() in this case:

void operator+=(const Vector &rhs) {
  _x += rhs._x;
  _y += rhs._y;
  _z += rhs._z; 
}

Within the main function, we can now make calls such as a += b;, which will add the content of b directly to a, no need for a third vector (and thus, you’ll notice that the return type of the operator+= is void, not Vector as before).

There is one final piece I want to introduce, but essentially up to this point, you have already learned all that is required for operator overloading, just find an operator you want to overload and then provide the implementation for it.

This website exists to create a community of like-minded CFD enthusiasts and I’d love to start a discussion with you. If you would like to be part of it, sign up using the link below and you will receive my OpenFOAM quick reference guide, as well as my guide on Tools every CFD developer needs for free.

Join now

Easy debugging: Providing a way to print the content of a class

There is one final piece I want to introduce, but essentially up to this point, you have already learned all that is required for operator overloading, just find an operator you want to overload and then provide the implementation for it. I am talking about a special operator that we can overload, which you may find convenient to use, especially for logging information. This is particularly useful if you already engage in caveman debugging, i.e. your entire debugging strategy consists of printing variables to the console. There are better ways to debug (i.e. using a debugger), and we’ll get around eventually at looking how to use your debugger properly. But let’s return back to our special operator.

What should we do if we wanted to see the state of the class? We could ask for each individual component (e.g. by providing functions such as getX(), etc. and then printing the value they return), but if we just want to print the state to the screen, then really, this is something the class should do for us, rather than the caller, because if you think about it, the class should always implement any logic related to its own state, we shouldn’t be requesting the variables one by one and then doing additional work with them, the whole point of classes is to abstract the implementation away from the caller.

So, naively, we may add another function to the class, which may look similar to the one below:

void print() {
  std::cout << _x << ", " << _y << ", " << _z << std::endl;
}

This would work fine, and we could call, for example, a.print() on our vector a. But with our newfound knowledge of operator overloading, we can make things a lot more readable. Unfortunately, the overloaded operator code becomes a bit more complicated, see the following:

friend std::ostream& operator<<(std::ostream& out, const Vector& rhs)
{
  return out << rhs._x << ", " << rhs._y << ", " << rhs._z;
}

The good news is, you don’t really have to understand all the details here, the only important part here is that the second argument to the operator<< is the class itself, i.e. here the Vector class, and then we simply return return out << and follow this code with whatever we want the class to print. We want to print the content of the rhs vector, so we just prefix the variables _x, _y, and _z with rhs. and we have access to the underlying values. With that overloaded operator, we can now make a call to:

Vector c = a + b;
std::cout << "The new vector c is: " << c << std::endl;

This integrates well with printing values via std::cout, and makes our code, again, much cleaner and intention-revealing. But let’s go back to the overloaded function definition and let’s try to understand it, at least in spirit. The first new thing you’ll notice is the keyword friend. This allows our Vector class now to access private data and functions of whichever class is given, i.e. in this case the std::ostream class. This, in turn, allows us to make calls to out <<, which could have been also written as out.operator<<() and this requires access to private data in order to work correctly, hence we need to declare our overloaded operator as a friend of the std::ostream class. We return with a type of std::ostream from this function, which in turn allows us to use the std::cout << syntax to print out our Vector class in the way we have specified. You can read up more on friend functions over at LearnCpp.

For completeness, the code that we have developed above is reproduced below in full for reference, so you can see how all of these functions work together, and where they are defined.

#include <iostream>

class Vector {
public:
  Vector() : _x(0.0), _y(0.0), _z(0.0) { }
  void set(double x, double y, double z) {
    _x = x;
    _y = y;
    _z = z;
  }
  
  Vector operator+(const Vector &rhs) {
    Vector temp;
    temp._x = _x + rhs._x; temp._y = _y + rhs._y; temp._z = _z + rhs._z;
    return temp;
  }
  
  void operator+=(const Vector &rhs) {
    _x += rhs._x; _y += rhs._y; _z += rhs._z; 
  }
  
  friend std::ostream& operator<<(std::ostream& out, const Vector& rhs)
  {
    return out << rhs._x << ", " << rhs._y << ", " << rhs._z;
  }

private:
  double _x;
  double _y;
  double _z;
};

int main()
{
  Vector a, b;
  a.set(1.0, 2.0, 3.0);
  b.set(5.0, 3.0, 0.5);
  
  Vector c = a + b;
  a += b;
  
  std::cout << a << std::endl; // prints 6, 5, 3.5
  std::cout << c << std::endl; // prints 6, 5, 3.5
  
  return 0;
}

Example use-cases for operator overloading

Before finishing this section, let’s think of some good examples where we can use this operator overloading. As I mentioned before, there are several operators we can overload (just as a reminder, you can look all of them up at cppreference.com).

Making solving linear systems of equations a piece of cake

A good example is again found in linear algebra. We looked at adding vectors, but we could extend that to subtraction and multiplication, both piecewise and in the form of a dot product. We could add support to multiply matrices and vectors, as well as matrices with matrices. But I always find operators to work best if they actually take a lot of heavy lifting off your shoulders while providing very clear intent with the function itself.

For example, we may define a matrix called A and a vector b. If I wanted to solve the equation Ax=b for x, then mathematically we could write x=A/b. From a C++ point of view, we could also write this as x = A.operator/(b), or, as we know by now, x = A/b. In this case, the Matrix class would need to provide an operator overloading for the operator/(), but with that in place, we can write code as x=A/b which makes your code just insanely readable, rather than doing a lot of matrix-vector operations yourself by hand. Hide all of that in the operator/() implementation, and you get clear and intention-revealing code. The basic outline is shown below:

#include <iostream>

class Vector {
  // define vector class here
};

class Matrix {
  // define matrix class here
public:
  Vector operator/(const Vector &rhs) {
    // implement iterative matrix solver here, e.g. gauss-seidel, conjugate gradient, GMRES, etc.
  }
};


int main()
{
  Matrix A;
  Vector x;
  Vector b;

  // fill A, b, x here ...

  // now compute solution
  x = A/b;
  
  return 0;
}

The magic is happening on line 25, where we can make use of our freshly defined overloaded operator in lines 10-12.

Accessing data without get() and set()

Another common and useful operator overload you should know about is the operator[] and operator(). Both can be used to access a specific element in typically a custom container. We have not looked at containers yet in this series, but we will introduce them when we talk about the standard template library. In short, a container is C++ terminology for a data structure, something like a vector, an array, a binary tree, etc.

Let’s define a new class called ScalarField. This class stores a single scalar value for each point on a computational mesh. For example, if we wanted to store the pressure for each grid point, then we would create a new ScalarField instance of it. The problem arises if we want to access the actual values of the pressure (in this example), how would we do that? Naively (and the way done in other programming languages as well), it is customary to define a get() and set() function, which will be used to, well, get and set a value in that class. This would look, for example, like the following example

#include <iostream>
#include <vector>

class ScalarField {
public:
  ScalarField(int size) {
    _data.resize(size);
  }
  
  void set(int index, double value) {
    _data[index] = value;
  }

  double get(int index) {
    return _data[index];
  }

private:
  std::vector<double> _data;
};

int main()
{
  ScalarField p(5);
  p.set(0, 101325);
  std::cout << p.get(0) << std::endl; // prints 101325
  return 0;
}

We define a set() and get() function on lines 10-12 and 14-16, respectively. Then, we can use these functions as shown in the main function on lines 25 and 26. This works and is common practice, but we can do better by providing the operator[] in this case. The code above is now changed and we replace the get() and set() function for the operator[] instead:

#include <iostream>
#include <vector>

class ScalarField {
public:
  ScalarField(int size) {
    _data.resize(size);
  }

  double& operator[](int index) {
    return _data[index];
  }

private:
  std::vector<double> _data;
};

int main()
{
  ScalarField p(5);
  p[1] = 80600;
  std::cout << p[1] << std::endl; // prints 80600
  return 0;
}

We only need one overloaded operator to get both setting and accessing capabilities. You may want to have more cases, i.e. you can always specify more than one definition for the same operator, but in our case one definition is sufficient. With the definition given on lines 10-12, we are able to set data as shown on line 21, as well as retrieve data and read it as shown on line 22. We used less code, and provided clearer intent, a win-win situation!

Operator overloading in OpenFOAM: The dot product

Finally, let’s look again at an example straight from OpenFOAM to see operating overloading in practice. Whenever we want to compute fluxes through a face, we compute [math]flux = \phi_f \cdot n S_f[/math]. Here, [math]\phi_f[/math] is the velocity vector interpolated to the face connecting two cells and [math]n S_f[/math] is the normal vector of that face multiplied by the area of that face.

In OpenFOAM, this is implemented as fvc::flux(phi), though in some other implementations, you may find this being replaced by fvc::interpolate(U) & mesh.Sf(). Here, fvc::interpolate(U), well, interpolates the velocity vector to the face, i.e. this part represents [math]\phi_f[/math] from our equation above. The part mesh.Sf() contains the term [math]n S_f[/math], i.e. it is already the surface area of the face multiplied by the normal vector, and thus this quantity itself is a vector.

The last thing for us to do is to calculate the dot product and this is indicated by the & operator. This is, perhaps, not a clear representation for a dot product, but the creators of OpenFOAM really wanted to provide an overloaded operator for dot products and had to choose from the list of available operators, and so the & represents the final ingredients in our puzzle. Of course, we could have also written fvc::interpolate(U).operator&(mesh.Sf()), just to indicate that this is indeed just an overloaded version of the operator&, but the first definition just reads more natural, that is, if we know that the & is indeed the dot operator here.

If you want to get an idea which other operators are in use and overloaded in OpenFOAM, you can look at the programming guide on page 22 (Section 1.4.1), it details which operators are available in OpenFOAM, and what they represent in a mathematical sense.

Summary

This is all there is to operator overloading, really. We saw that using operator overloading signals clear intent, i.e. if you are adding mathematical quantities together, use the operator+(). Use similar definitions for subtraction, multiplication, and division. But we are not limited to applying this concept to only mathematical operations, for example, using a custom string class, the operator+() could be used to combine two strings.

We also saw that we can borrow the operator<<() from the std::cout class to print content to the console. I find this extremely useful for logging information, but also, for the caller of the class to get a quick idea of what this class is and what data it holds. For small classes, this may be overkill, but once your classes reach thousands of lines of code (they really shouldn’t, but if you look around in open-source projects, such as SU2, their classes can become rather complex and convoluted) it may be useful to provide some form of quick documentation that allows anyone working with the class for the first time to get some basic information about the class and its current state (data). Other programming languages, such as Python, provide dedicated functions to both users and programmers to show what the class currently holds.

I hope you can see the value now of overloaded operators and that you will apply them liberally in your code from now on. Once you know they exist, you should see use cases for them everywhere in your (and other people’s) code.


Tom-Robin Teschner is a senior lecturer in computational fluid dynamics and course director for the MSc in computational fluid dynamics and the MSc in aerospace computational engineering at Cranfield University.