Operator Overloading in Modern C++ (1)

OO (or OOP) has been used to mean “Object-Oriented (Programming)” for several decades, but there is another use of the acronym OO which is: “Operator Overloading”. Simply put this involves creating (concrete) classes (or types) for which some (or rarely, most, or even all) C++ operators are redefined in terms of functionality related to the class. Since its appearance in the early 1990s, the operator keyword in C++ has been used in numerous applications to allow built-in operators such as +, -= and even -> to be overloaded on a per-class basis. This mini-series intends to demonstrate syntax, techniques, and modern best practices for OO in C++; in this article we’ll start to create a class representing a three-dimensional vector and give it state and functionality.

It is important to recognize that even the built-in types are not necessarily compatible with all operators; an int cannot appear on the left-hand side of -> (pointer-to-member), and % (modulo) cannot be used with float, double or long double. Therefore do not be discouraged if only a few operators can be sensibly used with your shiny new type; no default is provided if left undefined (unlike for the special member functions) and attempt to use a non-overloaded (undefined) operator will result in a compile-time error message.

We’re going to be light on theory in this article, and fairly heavy on demonstration code. Let’s start off with thinking about the state of the class (the data it holds) as opposed to its functionality (the interface and operations it provides). A vector in three-dimensional space is simply three signed values which represent coefficients for the unit vectors i, j and k. It is assumed we’ll want to use floating-point values, but the exact type is not known at this stage. OO and polymorphism are unlikely to be used together in most cases (please leave a comment if you disagree with this), however OO and generics are highly complementary. Here is a possible start to the class:

template<typename T>
class Vec3d {
    T x{}, y{}, z{};
// ...

As it stands this would allow instantiations from double, int and even bool, so in Modern C++ we’d probably want to constrain the permitted types in some way, and even provide a default. Also use of a plain (built-in) array of T is a possibility which we’d like to explore, as opposed to named variables for each of the coefficients. We don’t want other classes to inherit from this class, so we’ll declare it final. Here is the design we’re going to use:

template<typename T = double>
requires(!std::is_integral_v<T>)
class Vec3d final {
    T data[3];
public:
// ...

In Modern C++ the use of plain Vec3d as a type specifier would imply Vec3d<double>, and instantiations with int, unsigned long long or other integral types would fail due to the requires statement. (The need for _v in the requires condition is to access the ::value member from having instantiated the template std::is_integral<T>.)

Use of a built-in array opens the door to uninitialized fields, which we would want to avoid. Two constructors can be written to avoid this, these being a customized default constructor and a constructor taking three values of type T (the expression T{0} should always provide a zero value for multi-precision types):

    Vec3d() {
        data[0] = data[1] = data[2] = T{0};
    }
    Vec3d(const T& x, const T& y, const T& z) {
        data[0] = x;
        data[1] = y;
        data[2] = z;
    }

Four special member functions can be defaulted, trusting the compiler to use memberwise assignment or memcpy() as necessary when copying the data array member (note that the last two must be defaulted or customized, otherwise no code is generated for move semantics):

    Vec3d(const Vec3d& rhs) = default;
    Vec3d& operator=(const Vec3d& rhs) = default;
    Vec3d(Vec3d&& rhs) = default;
    Vec3d& operator=(Vec3d&& rhs) = default;

Public read-only access to the fields is provided by three “getters”, both declared and returning const as they do not mutate the object (note that “setters” are not provided, see later for the reason why):

    const T& x() const { return data[0]; }
    const T& y() const { return data[1]; }
    const T& z() const { return data[2]; }

Beginning the OO functionality part of the class definition, we add support for += (lhs = lhs + rhs), -=, and unary - (negation). Note that the signature for operator+= and operator-= is the same for the special member function operator=, that is: taking a parameter of type const Vec3d& and returning a reference to *this. (In these member functions, as with the others, a Vec3d<T> is implied by use of Vec3d&.) In contrast, operator- does not take a parameter (none is needed) and returns a new object (not a reference), and is declared const for this reason:

    Vec3d& operator+=(const Vec3d& rhs) {
        data[0] += rhs.data[0];
        data[1] += rhs.data[1];
        data[2] += rhs.data[2];
        return *this;
    }
    Vec3d& operator-=(const Vec3d& rhs) {
        data[0] -= rhs.data[0];
        data[1] -= rhs.data[1];
        data[2] -= rhs.data[2];
        return *this;
    }
    Vec3d operator-() const {
        return Vec3d(-data[0], -data[1], -data[2]);
    }

It could be asked: “Why not define operator-= in terms of the other two shown here?” The reason is that this would create an unnecessary temporary thus impacting performance slightly. In Modern C++ we would usually favor speed over size, so the code duplication is preferred.

The final two member functions we’re defining are not going to use operator overloading. The reason is simple, they do not represent operations (dot and cross products) for which a suitable operator is available. (Don’t be tempted to be clever and use operator* for dot product and operator/ for cross product, just because they are otherwise unused, you will cause programmer confusion and bugs.) It is perfectly acceptable to mix OO with conventional named member functions, allowing them to operate on the same objects:

    T dot(const Vec3d& rhs) const {
        return std::inner_product(std::begin(data), std::end(data), std::begin(rhs.data), T{0});
    }
    Vec3d cross(const Vec3d& rhs) const {
        return Vec3d(data[1] * rhs.data[2] - data[2] * rhs.data[1],
            data[2] * rhs.data[0] - data[0] * rhs.data[2],
            data[0] * rhs.data[1] - data[1] * rhs.data[0]);
    }
};

Notice that both of these member functions are declared const and return by value, taking a const Vec3d& as parameter, thus mimicking unary minus as already defined. Due to our decision to use an array to hold the dimension values, the standard library std::inner_product (found in header <numeric>) can be utilized by dot() without having to “roll-our-own”. Also, cross() will use RVO (return value optimization) to construct the object it returns in-place, so you shouldn’t worry about it returning by value.

As already mentioned there is no need for “setters” because a new object can be created from (some of) its publicly available functionality. Thus the code:

auto v = Vec3d(1.0, 2.0, 3.0);
// ...
v.setZ(9.0);

Can instead be written as:

auto v = Vec3d(1.0, 2.0, 3.0);
// ...
auto w = Vec3d(v.x(), v.y(), 9.0);

This mimics functional programming languages where all state is immutable, and could conceivably be taken to an extreme where all member functions were declared const, and even have constant member data (in the form const T data[3]).

Finally we can output to streams using a generic overload of operator<<, which does not have to be a friend function due to the availability of public “getters”:

template<typename T>
inline std::ostream& operator<<(std::ostream& os, const Vec3d<T>& obj) {
    return os << '(' << obj.x() << ',' << obj.y() << ',' << obj.z() << ')';
}

Testing this code with the following main program:

int main() { 
   double d = 2.2;
    Vec3d v(1.1, d, 3.3), w;
    w += -v;
    std::cout << v << '\n' << w.dot(v) << '\n';
    Vec3d x(2.0, 4.0, 3.0), y(1.0, 5.0, -2.0), z;
    z = x.cross(y);
    std::cout << z << '\n';
}

Produces the output:

(1.1,2.2,3.3)
-16.94
(-23,7,6)

That’s about all for this article. We’ve looked at the rationale and syntax behind OO (Operator Overloading) and have created a new type which has a number of operations defined for it. Next time we’ll look at global (non-member) mathematical operator functions and how they can allow mixed-mode operations.

Leave a comment