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
- Part 1: Choosing the right programming language for CFD development
- Part 2: Why you should use C++ for CFD development
- Part 3: The complete guide to memory management in C++ for CFD
- Part 4: Object-orientated programming in CFD
- Part 5: How to handle inheritance and class hierarchies in C++
- Part 6: Templates in C++: Boost your CFD solver performance
- Part 7: Enhance readability with operator overloading in C++
- Part 8: The power of the standard template library (STL) in C++
- Part 9: Understanding Lambda expressions and how to use them in C++
- Part 10: Reduce memory bugs with smart pointers in C++
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 Vector
s 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 Vector
s 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 Vector
s 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 Vector
s 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.