C++ For C# Developers: Part 11 – Struct Functions
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
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrapup
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
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 #include
s 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 |
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!
#1 by Jan Reitz on March 25th, 2021 ·
Bug: There is no unary hat in non-.net C++, right? “^x”
I was also expecting a warning in this article that e.g. passing a vector to a method by value is okay in C# but really problematic in C++. I possibly I skip-read over that point so far.
#2 by jackson on March 26th, 2021 ·
Thanks for pointing out the
^x
issue. I removed that row from the table since it is indeed a C++/CLI language extension and I’m trying to keep this series to standard C++.As for passing structs by value in C++, that’s not necessarily problematic. It’s a big deal if a copy constructor (covered in the next article) like your
std::vector
example does some very expensive work or the struct is enormous. It’s not a big deal and often preferable for small structs such as aVector2
orstd::basic_string_view
or to intentionally trigger the copy constructor such as with some instances of passing astd::shared_ptr
. It’s mostly important to know the differences between passing by value, by lvalue and rvalue reference, by pointer, and asconst
versions of these for your particular type.#3 by Peter on August 27th, 2022 ·
Very good article, thank you Jackson.