When to overload an operator in C++ as a member of a user-defined data structure and when not? I am completely lost

131 Views Asked by At

For any of you who can help explain to me when and when not to overload an operator as a member of a class or struct for example or not as a member, like global one.

The thing is that I am learning all about vectors and 3D vectors etc for mathematical learning purposes and trying to simulate the common operations on them to better understand their behaviour when doing so.

So, I bought a book which lightly touches on the same aspect as I do but I got to the part of it where the author of the book have written a simple vector struct in C++ and got bunch of overloaded operators that some where declared and defined inside the struct and some weren't.

Let me give you this exact example that left me completely baffled. The following example will show two different overloaded operators by which the two of them do the same "overall" purpose (I think?) which is (vector/scalar multiplication), however, one is being a member of the struct and the other isn't.

struct Vector3D
{
    float x, y, z;

    Vector3D() = default;

    Vector3D(float a, float b, float c)
    {
        x = a;
        y = b;
        z = c;
    }

    Vector3D& operator*=(float s)
    {
        x *= s;
        y *= y;
        z *= z;

        return(*this);
    }

    Vector3D& operator/=(float s)
    {
        s = 1.0f / s;

        x *= s;
        y *= s;
        z *= s;

        return(*this);
    }

    float& operator[](int i)
    {
        return ((&x)[i]);
    }

    const float& operator[](int i) const
    {
        return ((&x)[i]);
    }
};


inline Vector3D operator*(const Vector3D& v, float s)
{
  return (Vector3D(v.x * s, v.y * s, v.z * s));
}

The one that I am specifically asking about is the

  Vector3D& operator*=(float s)
    {
        x *= s;
        y *= s;
        z *= s;

        return(*this);
    }

And

inline Vector3D operator*(const Vector3D& v, float s)
{
  return (Vector3D(v.x * s, v.y * s, v.z * s));
}

Please if anyone has a clear explanation to share it

I have of course tried the above code to see what difference they make and I have found couple of things where they differ:

is when I try to print out to the screen the final result of multiplying a vector with a scalar (any float number) using both methods, only the member overloaded operator will printed out right away. The one that outside the struct can not be printed out unless the return got assigned to another vector instance.

For example:

int main()
{

    float s = 2; //Defining the scalar

    // Invoking the first overloaded operator

    Vector3D vecA(2, 2, 2);

    vecA *= 3;

    for(int i = 0; i < 3; ++i)
    {
        std::cout << vecA[i] << std::endl; // 6, 6, 6 (Worked)
    }

    // Now Invoking the global overloaded operator

    Vector3D vecB(2, 2, 2);

    vecB * s; 

    for(int i = 0; i < 3; ++i)
    {
        std::cout << vecB[i] << std::endl; // 2, 2, 2 (No change)
    }
    
}
1

There are 1 best solutions below

4
tbxfreeware On

One reason to choose a non-member operator is symmetry. By symmetry, I mean that certain operators should be commutative.

Scalar multiplication is a good example. It is reasonable to want both (s * vec) and (vec * s) to be allowable expressions. If you want to use s as the left operand, however, you must use a non-member function. You cannot use a member function, because s is not a Vector3D object. In fact, you must define two operator* functions, one for (s * vec), and the other for (vec * s).

Stream operators are another time when you must use non-member operator functions, because the left operand is not a Vector3D object.

The comments in the following program explain further. It contains several idioms you may find useful, including the hidden friend idiom. In addition, the program fixes the two problems identified by @Pete Becker:

  1. operator* should be implemented by invoking operator*=. Coding the same set of operations in two different functions is error-prone. Sooner or later, the two will get out of sync!
  2. The operator[] functions in the OP invoke undefined behavior, because they make the unwarranted assumption that member variables x, y, and z are stored as if they were the elements of an array. The code below uses an actual array.
// main.cpp
#include <array>
#include <cstddef>
#include <format>
#include <iostream>

class Vector3D
{
    enum : std::size_t { zero, one, two, three };
    using value_type = double;
    std::array<value_type, three> v{};
public:
    value_type& x() noexcept { return v[zero]; }
    value_type& y() noexcept { return v[one]; }
    value_type& z() noexcept { return v[two]; }

    value_type const& x() const noexcept { return v[zero]; }
    value_type const& y() const noexcept { return v[one]; }
    value_type const& z() const noexcept { return v[two]; }

    Vector3D() noexcept
        = default;
    Vector3D(
        value_type const x,
        value_type const y,
        value_type const z) noexcept
    {
        this->x() = x;
        this->y() = y;
        this->z() = z;
    }
    Vector3D& operator*=(value_type const s) noexcept
    {
        // operator*= must be a member function.
        x() *= s;
        y() *= s;
        z() *= s;
        return *this;
    }
    friend Vector3D operator*(Vector3D v, value_type const s) noexcept
    {
        // Note that v is a value parameter that receives 
        // a copy of its argument. Changing v here, will not 
        // change the original argument used when operator* 
        // is invoked.
        //
        // This function could also be implemented as a 
        // member function (with only one parameter, s).
        //
        // It is a common idiom for operator* to be implemented 
        // by calling member function operator*= to do the work.
        // 
        // The following return statement can be broken into pieces:
        // 
        //    1. Parameter v is like a "local" variable. It is a 
        //       Vector3D object, but it is not *this object.
        //
        //    2. Because v is a Vector3D object, we can use 
        //       operator*= to perform scalar multiplication on it.
        //
        //    3. operator*= returns a reference to v, which now 
        //       holds the result of scalar multiplication.
        //
        //    4. The return statement copies the result, and 
        //       sends it back to the calling routine.
        return v *= s;
    }
    friend Vector3D operator*(value_type const s, Vector3D v) noexcept
    {
        // In order to have a symmetric operator*, where s 
        // can appear either first or second, we have to code 
        // a second version of operator*.
        //
        // This function cannot be a member function, because 
        // the first operand, s, is not an object!
        //
        // Note that the two operator* functions use the 
        // "hidden friend" idiom.
        return v *= s;
    }
    Vector3D& operator+=(Vector3D const& that) noexcept
    {
        // operator+= must be a member function.
        x() += that.x();
        y() += that.y();
        z() += that.z();
        return *this;
    }
    friend Vector3D operator+(Vector3D a, Vector3D const& b)
    {
        // operator+ can be implemented either as a 
        // member function (with one parameter), or as 
        // a non-member function (with two parameters).
        //
        // I have chosen to use a non-member function, 
        // so that I can treat the left operand as 
        // a "local" variable.
        //
        // Note that parameter `a` is a value parameter.
        // It receives a copy of its argument.
        //
        // Unlike scalar multiplication, there is no "symmetry" 
        // problem here, so I only need one version of 
        // operator+.
        //
        // You can do an internet search to learn more about 
        // the "hidden friend" idiom used here. I like the 
        // video by Dan Saks:
        // https://www.youtube.com/watch?v=POa_V15je8Y
        return a += b;
    }
    std::string to_string() const
    {
        return std::format("[{}, {}, {}]", x(), y(), z());
    }
    friend std::ostream& operator<< (
        std::ostream& ost,
        Vector3D const& v)
    {
        // Another hidden friend.
        //
        // Once again, this function must be implemented as 
        // a non-member function, because the left operand 
        // is not a Vector3D object.
        ost << v.to_string();
        return ost;
    }
    value_type& operator[](std::size_t const i) noexcept
    {
        // Trap subscripting errors.
        return v.at(i);
    }
    value_type const& operator[](std::size_t const i) const noexcept
    {
        // Trap subscripting errors.
        return v.at(i);
    }
};
int main()
{
    Vector3D a{ 1, 2, 3 };
    a *= 2;
    auto b = a * 3;
    std::cout
        << "a     : " << a
        << "\nb     : " << b
        << "\nb * 2 : " << b * 2
        << "\n2 * b : " << 2 * b
        << "\na + b : " << a + b
        << "\n\n";
    return 0;
}
// end file: main.cpp

Output:

a     : [2, 4, 6]
b     : [6, 12, 18]
b * 2 : [12, 24, 36]
2 * b : [12, 24, 36]
a + b : [8, 16, 24]