Now that we’ve covered the basics of structs, let’s add functions to them! Today we’ll explore member functions and overloaded operators.

Table of Contents

Member Functions

As in C#, structs in C++ may contain functions. These are called “methods” in C# and “member functions” in C++. They look and work essentially the same as in C#:

struct Vector2
{
    float X;
    float Y;
 
    float SqrMagnitude()
    {
        return this->X*this->X + this->Y*this->Y;
    }
};

Member functions are implicitly passed a pointer to the instance of the struct they’re contained in. In this case, it’s type is Vector2*. Other than using this->X or (*this).X instead of just this.X, its usage is the same as in C#. It is also optional, again like C#:

float SqrMagnitude()
{
    return X*X + Y*Y;
}

Unlike C#, but in keeping with other C++ functions and with data member initialization, we can split the function’s declaration and definition. If we do so, we need to place the definition outside the class:

struct Vector2
{
    float X;
    float Y;
 
    // Declaration
    float SqrMagnitude();
};
 
// Definition
float Vector2::SqrMagnitude()
{
    return X*X + Y*Y;
}

Notice that when we do this we need to specify where the function we’re defining is declared. We do this by prefixing Vector2:: to the beginning of the function’s name.

It’s very common to only declare member function declarations in a struct definition. That struct definition is typically put in a header file (e.g. Vector2.h) and the member function definitions are put into a translation unit (e.g. Vector2.cpp). This cuts down compile times by only compiling the member function definitions once while allowing the member functions to be called by any file that #includes the header file with the member function declaration.

Now that we have a member function, let’s call it!

Vector2 v;
v.X = 2;
v.Y = 3;
float sqrMag = v.SqrMagnitude();
DebugLog(sqrMag); // 13

Calling the member function works just like in C# and lines up with how we access data members. If we had a pointer, we’d use -> instead of .:

Vector2* p = &v;
float sqrMag = p->SqrMagnitude();

All the rules that apply to the global functions we’ve seen before apply to member functions. This includes support for overloading:

struct Weapon
{
    int32_t Damage;
};
 
struct Potion
{
    int32_t HealAmount;
};
 
struct Player
{
    int32_t Health;
 
    void Use(Weapon weapon, Player& target)
    {
        target.Health -= weapon.Damage;
    }
 
    void Use(Potion potion)
    {
        Health += potion.HealAmount;
    }
};
 
Player player;
player.Health = 50;
 
Player target;
target.Health = 50;
 
Weapon weapon;
weapon.Damage = 10;
 
player.Use(weapon, target);
DebugLog(target.Health); // 40
 
Potion potion;
potion.HealAmount = 20;
 
player.Use(potion);
DebugLog(player.Health); // 70

Remember that member functions take an implicit this argument. We need to be able to overload based on that argument in addition to all the explicit arguments. It doesn’t make sense to overload on the type of this, but C++ does provide us a way to overload based on whether the member function is being called on an lvalue reference or an rvalue reference:

struct Test
{
    // Only allow calling this on lvalue objects
    void Log() &
    {
        DebugLog("lvalue-only");
    }
 
    // Only allow calling this on rvalue objects
    void Log() &&
    {
        DebugLog("rvalue-only");
    }
 
    // Allow calling this on lvalue or rvalue objects
    // Note: not allowed if either of the above exist
    void Log()
    {
        DebugLog("lvalue or rvalue");
    }
};
 
// Pretend the "lvalue or rvalue" version isn't defined...
 
// 'test' has a name, so it's an lvalue
Test test;
test.Log(); // lvalue-only
 
// 'Test()' doesn't have a name, so it's an rvalue
Test().Log(); // rvalue-only

We’ll go more into initialization of structs soon, but for now Test() is a way to create an instance of a Test struct.

Finally, member functions may be static with similar syntax and meaning to C#:

struct Player
{
    int32_t Health;
 
    static int32_t ComputeNewHealth(int32_t oldHealth, int32_t damage)
    {
        return damage >= oldHealth ? 0 : oldHealth - damage;
    }
};

To call this, we refer to the member function using the struct type rather than an instance of the type. This is just like in C#, except that we use :: instead of . as is normal for referring to the contents of a type in C++:

DebugLog(Player::ComputeNewHealth(100, 15)); // 85
DebugLog(Player::ComputeNewHealth(10, 15)); // 0

Since static member functions don’t operate on a particular struct object, they have no implicit this argument. This makes them compatible with regular function pointers:

// Get a function pointer to the static member function
int32_t (*cnh)(int32_t, int32_t) = Player::ComputeNewHealth;
 
// Call it
DebugLog(cnh(100, 15)); // 85
DebugLog(cnh(10, 15)); // 0
Overloaded Operators

Both C# and C++ allow a lot of operator overloading, but there are also quite a few differences. Let’s start with something basic:

struct Vector2
{
    float X;
    float Y;
 
    Vector2 operator+(Vector2 other)
    {
        Vector2 result;
        result.X = X + other.X;
        result.Y = Y + other.Y;
        return result;
    }
};
 
Vector2 a;
a.X = 2;
a.Y = 3;
 
Vector2 b;
b.X = 10;
b.Y = 20;
 
Vector2 c = a + b;
DebugLog(a.X, a.Y); // 2, 3
DebugLog(b.X, b.Y); // 10, 20
DebugLog(c.X, c.Y); // 12, 23

Here we see an overloaded binary + operator. It looks just like a member function except it’s name is operator+ instead of an identifier like Use. This is different from C# where the overloaded operator would be static and therefore need to take two arguments. If a C#-style static approach is desired, the overloaded operator may be declared outside the struct instead:

struct Vector2
{
    float X;
    float Y;
};
 
Vector2 operator+(Vector2 a, Vector2 b)
{
    Vector2 result;
    result.X = a.X + b.X;
    result.Y = a.Y + b.Y;
    return result;
}
 
// (usage is identical)

Another difference is that the overloaded operator may be called directly by using operator+ in place of a member function name when its defined inside the struct:

Vector2 d = a.operator+(b);
DebugLog(d.X, d.Y);

The following table compares which operators may be overloaded in the two languages:

Operator C++ C#
+x Yes Yes
-x Yes Yes
!x Yes Yes
~x Yes Yes
x++ Yes Yes, but same for x++ and ++x
x-- Yes Yes, but same for x-- and --x
++x Yes Yes, but same for x++ and ++x
--x Yes Yes, but same for x-- and --x
^x Yes No
true N/A Yes
false N/A Yes
x + y Yes Yes
x - y Yes Yes
x * y Yes Yes
x / y Yes Yes
x % y Yes Yes
x ^ y Yes Yes
x && y Yes Yes
x | y Yes Yes
x = y Yes No
x < y Yes Yes, requires > too
x > y Yes Yes, requires < too
x += y Yes No, implicitly uses +
x -= y Yes No, implicitly uses -
x *= y Yes No, implicitly uses *
x /= y Yes No, implicitly uses /
x %= y Yes No, implicitly uses %
x ^= y Yes No, implicitly uses ^
x &= y Yes No, implicitly uses &
x |= y Yes No, implicitly uses |
x << y Yes Yes
x >> y Yes Yes
x >>= y Yes No, implicitly uses >>
x <<= y Yes No, implicitly uses <<
x == y Yes Yes, requires != too
x != y Yes Yes, requires == too
x <= y Yes Yes, requires >= too
x >= y Yes Yes, requires <= too
x <=> y Yes N/A
x && y Yes, without short-circuiting No, implicitly uses true and false
x || y Yes, without short-circuiting No, implicitly uses true and false
x, y Yes, without left-to-right sequencing No
x->y Yes No
x(x) Yes No
x[i] Yes No, indexers instead
x?.[i] N/A No, indexers instead
x.y No No
x?.y N/A No
x::y No No
x ? y : z No No
x ?? y N/A No
x ??= y N/A No
x..y N/A No
=> N/A No
as N/A No
await N/A No
checked N/A No
unchecked N/A No
default N/A No
delegate N/A No
is N/A No
nameof N/A No
new Yes No
sizeof No No
stackalloc N/A No
typeof N/A No

As in C#, the C++ language puts little restriction on the arguments, return values, and functionality of overloaded operators. Instead, both languages rely on conventions. As such, it'd be legal but very strange to implement an overloaded operator like this:

struct Vector2
{
    float X;
    float Y;
 
    int32_t operator++()
    {
        return 123;
    }
};
 
Vector2 a;
a.X = 2;
a.Y = 3;
int32_t res = ++a;
DebugLog(res); // 123

One particularly interesting operator in the above table is x <=> y, introduced in C++20. This is called the "three-way comparison" or "spaceship" operator. This can be used in general, without operator overloading, like so:

auto res = 1 <=> 2;
 
if (res < 0)
{
    DebugLog("1 < 2"); // This gets called
}
else if (res == 0)
{
    DebugLog("1 == 2");
}
else if (res > 0)
{
    DebugLog("1 > 2");
}

This is like most sort comparators where a negative value is returned to indicate that the first argument is less than the second, a positive value to indicate greater, and zero to indicate equality. The exact type returned isn't specified other than that it needs to support these three comparisons.

While it can be used directly like this, it's especially valuable for operator overloading as it implies a canonical implementation of all the other comparison operators: ==, !=, <, <=, >, and >=. That allows us to write code that either uses the three-way comparison operator directly or indirectly:

struct Vector2
{
    float X;
    float Y;
 
    float SqrMagnitude()
    {
        return this->X*this->X + this->Y*this->Y;
    }
 
    float operator<=>(Vector2 other)
    {
        return SqrMagnitude() - other.SqrMagnitude();
    }
};
 
int main()
{
    Vector2 a;
    a.X = 2;
    a.Y = 3;
 
    Vector2 b;
    b.X = 10;
    b.Y = 20;
 
    // Directly use <=>
    float res = a <=> b;
    if (res < 0)
    {
        DebugLog("a < b");
    }
 
    // Indirectly use <=>
    if (a < b)
    {
        DebugLog("a < b");
    }
}
Conclusion

Today we've seen C++'s version of methods, called member functions, and overloaded operators. Member functions are quite similar to their C# counterparts, but do have differences such as an optional declaration-definition split, overloading based on lvalue and rvalue objects, and conversion to function pointers.

Overloaded operators also have their similarities and differences to C#. In C++, they may be placed inside the struct and used like a non-static member function or outside the struct and used like a static one. When inside the struct, they can be called explicitly like with x.operator+(10). Quite a few more operators may be overloaded, and often with finer-grain control. Lastly, the three-way comparison ("spaceship") operator allows for removing a lot of boilerplate when overloading comparisons.

Coming up next, we'll discuss the lifecycle of structs from construction to destruction. Stay tuned!