C++ For C# Developers: Part 14 – Inheritance
Now that we know how to initialize structs and other types in C++, we can take a look at inheritance and learn how to make structs derive from each other. There’s a lot of extended functionality here compared to C# class inheritance. Read on to learn the basics as well as advanced features like multiple inheritance and virtual inheritance!
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
Base Structs
The basic syntax for inheritance, also called derivation, looks the same in C++ for structs as it does in C# for classes. We just add : BaseType
after the name of the struct:
struct GameEntity { static const int32_t MaxHealth = 100; int32_t Health = MaxHealth; float GetHealthPercent() { return ((float)Health) / MaxHealth; } }; struct MovableGameEntity : GameEntity { float Speed = 0; };
When declaring, not defining, a struct that inherits from another struct, we omit the base struct name:
struct MovableGameEntity; // No base struct name struct MovableGameEntity : GameEntity { float Speed = 0; };
The meaning of this inheritance is the same in C++ as in C#: MovableGameEntity
“is a” GameEntity
. That means all the data members and member functions of GameEntity
are made part of MovableGameEntity
as a sub-object. We can write code to use the contents of both the parent and the child struct:
MovableGameEntity mge{}; mge.Health = 50; DebugLog(mge.Health, mge.Speed, mge.GetHealthPercent()); // 50, 0, 0.5
Normally, any object must have a size of at least one byte. One exception to this rule is when a base struct has no non-static
data members. In that case, it may add zero bytes to the size of the structs that derive from it. An exception to this exception is if the first non-static
data member is also the base struct type.
// Has no non-static data members struct Empty { void SayHello() { DebugLog("hello"); } }; // Size not increased by deriving from Empty struct Vector2 : Empty { float X; float Y; }; // Size increased because first non-static data member is also an Empty struct ExceptionToException : Empty { Empty E; int32_t X; }; void Foo() { Vector2 vec{}; DebugLog(sizeof(vec)); // 8 (size not increased) vec.SayHello(); // "hello" DebugLog(sizeof(ExceptionToException)); // 8 (size increased from 4!) }
The “is a” relationship continues in that pointers and references to a MovableGameEntity
are compatible with pointers and references to an GameEntity
:
MovableGameEntity mge{}; GameEntity* pge = &mge; // Pointers are compatible GameEntity& rge = mge; // References are compatible DebugLog(mge.Health, pge->Health, rge.Health); // 100, 100, 100
Derived structs can have members with the same names as their base structs. For example, ArmoredGameEntity
might get extra health from its armor:
struct ArmoredGameEntity : GameEntity { static const int32_t MaxHealth = 50; int32_t Health = MaxHealth; };
This introduces ambiguity when referring to the Health
member of an ArmoredGameEntity
: is it the data member declared in GameEntity
or ArmoredGameEntity
? The compiler chooses the member of the type being referred to:
// Referring to ArmoredGameEntity, so use Health in ArmoredGameEntity ArmoredGameEntity age{}; DebugLog(age.Health); // 50 // Referring to GameEntity, so use Health in GameEntity GameEntity& ge = age; DebugLog(ge->Health); // 100
To resolve this ambiguity, we can explicitly refer to the members of a particular sub-object in the inheritance hierarchy using the scope resolution operator: StructType::Member
.
struct ArmoredGameEntity : GameEntity { static const int32_t MaxHealth = 50; int32_t Health = MaxHealth; void Die() { Health = 0; // Health in ArmoredGameEntity GameEntity::Health = 0; // Health in GameEntity } }; ArmoredGameEntity age{}; GameEntity& ge = age; DebugLog(age.Health, ge.Health); // 50, 100 age.Die(); DebugLog(age.Health, ge.Health); // 0, 0
Although it’s uncommon to do so, we can also refer to specific structs from outside the struct hierarchy:
ArmoredGameEntity age{}; ArmoredGameEntity* page = &age; // Refer to Health in GameEntity with age.GameEntity::Health DebugLog(age.Health, age.GameEntity::Health); // 50, 100 age.Die(); // Refer to Health in GameEntity via a pointer with page->GameEntity::Health DebugLog(age.Health, page->GameEntity::Health); // 0, 0
This is sort of like using base
in C#, except that we can refer to any struct in the hierarchy rather than just the immedate base class type.
struct MagicArmoredGameEntity : ArmoredGameEntity { static const int32_t MaxHealth = 20; int32_t Health = MaxHealth; }; MagicArmoredGameEntity mage{}; DebugLog(mage.Health); // 20 DebugLog(mage.ArmoredGameEntity::Health); // 50 DebugLog(mage.GameEntity::Health); // 100
Constructors and Destructors
As in C#, constructors are called from the top of the hierarchy to the bottom. Unlike C#’s non-deterministic destructors/finalizers, C++ destructors are called in the same order as constructors. Recall that data members are constructed in declaration order and destructed in the reverse order. These two properties combine to give the following deterministic order:
struct LogLifecycle { const char* str; LogLifecycle(const char* str) : str(str) { DebugLog(str, "constructor"); } ~LogLifecycle() { DebugLog(str, "destructor"); } }; struct GameEntity { LogLifecycle a{"GameEntity::a"}; LogLifecycle b{"GameEntity::b"}; GameEntity() { DebugLog("GameEntity constructor"); } ~GameEntity() { DebugLog("GameEntity destructor"); } }; struct ArmoredGameEntity : GameEntity { LogLifecycle a{"ArmoredGameEntity::a"}; LogLifecycle b{"ArmoredGameEntity::b"}; ArmoredGameEntity() { DebugLog("ArmoredGameEntity constructor"); } ~ArmoredGameEntity() { DebugLog("ArmoredGameEntity destructor"); } }; void Foo() { ArmoredGameEntity age{}; DebugLog("--after variable declaration--"); } // Note: destructor of 'age' called here // Logs printed: // GameEntity::a, constructor // GameEntity::b, constructor // GameEntity constructor // ArmoredGameEntity::a, constructor // ArmoredGameEntity::b, constructor // ArmoredGameEntity constructor // --after variable declaration-- // ArmoredGameEntity destructor // ArmoredGameEntity::b, destructor // ArmoredGameEntity::a, destructor // GameEntity destructor // GameEntity::b, destructor // GameEntity::a, destructor
As we saw above, the default constructor of base structs is called implicitly. However, if there’s no default constructor then it must be called explicitly. The syntax looks like it does in C# except the base struct type is used instead of the keyword base
:
struct GameEntity { static const int32_t MaxHealth = 100; int32_t Health; GameEntity(int32_t health) { Health = health; } }; struct ArmoredGameEntity : GameEntity { static const int32_t MaxArmor = 100; int32_t Armor = 0; ArmoredGameEntity() : GameEntity(MaxHealth) { } };
This is actually just part of the initializer list that we use to initialize data members:
struct ArmoredGameEntity : GameEntity { static const int32_t MaxArmor = 100; int32_t Armor = 0; ArmoredGameEntity() : GameEntity(MaxHealth), Armor(MaxArmor) { } };
Since the order of initialization is always as above, it doesn’t matter what order we put the initializers in. This is true for both data members and base structs:
struct ArmoredGameEntity : GameEntity { static const int32_t MaxArmor = 100; int32_t Armor = 0; ArmoredGameEntity() // Order doesn't matter // GameEntity is still initialized before Armor : Armor(MaxArmor), GameEntity(MaxHealth) { } };
Multiple Inheritance
Unlike C#, C++ supports structs that derive from multiple structs:
struct HasHealth { static const int32_t MaxHealth = 100; int32_t Health = MaxHealth; }; struct HasArmor { static const int32_t MaxArmor = 20; int32_t Armor = MaxArmor; }; // Player derives from both HasHealth and HasArmor struct Player : HasHealth, HasArmor { static const int32_t MaxLives = 3; int32_t NumLives = MaxLives; };
This means HasHealth
and HasArmor
are sub-objects of Player
and Player
“is a” HasHealth
and “is a” HasArmor
. We use it the same way that we use single-inheritance:
// Members of both base structs are accessible Player p{}; DebugLog(p.Health, p.Armor, p.NumLives); // 100, 20, 3 // Can get a reference to a base struct HasHealth& hh = p; DebugLog(hh.Health); // 100 // Can get a pointer to a base struct HasArmor* ha = &p; DebugLog(ha->Armor); // 20
This explains why there is no base
keyword in C++: there may be multiple bases. When explicitly referencing a member of a sub-object, we always use its name:
Player p{}; // Access members in the HasHealth sub-object DebugLog(p.HasHealth::Health); // 100 // Access members in the HasArmor sub-object DebugLog(p.HasArmor::Armor); // 20 // Access members in the Player sub-object DebugLog(p.Player::NumLives); // 3
Now let’s re-introduce the LogLifecycle
utility struct and see how multiple inheritance impacts the order of constructors and destructors:
struct LogLifecycle { const char* str; LogLifecycle(const char* str) : str(str) { DebugLog(str, "constructor"); } ~LogLifecycle() { DebugLog(str, "destructor"); } }; struct HasHealth { LogLifecycle a{"HasHealth::a"}; LogLifecycle b{"HasHealth::b"}; static const int32_t MaxHealth = 100; int32_t Health = MaxHealth; HasHealth() { DebugLog("HasHealth constructor"); } ~HasHealth() { DebugLog("HasHealth destructor"); } }; struct HasArmor { LogLifecycle a{"HasArmor::a"}; LogLifecycle b{"HasArmor::b"}; static const int32_t MaxArmor = 20; int32_t Armor = MaxArmor; HasArmor() { DebugLog("HasArmor constructor"); } ~HasArmor() { DebugLog("HasArmor destructor"); } }; struct Player : HasHealth, HasArmor { LogLifecycle a{"Player::a"}; LogLifecycle b{"Player::b"}; static const int32_t MaxLives = 3; int32_t NumLives = MaxLives; Player() { DebugLog("Player constructor"); } ~Player() { DebugLog("Player destructor"); } }; void Foo() { Player p{}; DebugLog("--after variable declaration--"); } // Note: destructor of 'p' called here // Logs printed: // HasHealth::a, constructor // HasHealth::b, constructor // HasHealth constructor // HasArmor::a, constructor // HasArmor::b, constructor // HasArmor constructor // Player::a, constructor // Player::b, constructor // Player constructor // --after variable declaration-- // Player destructor // Player::b, destructor // Player::a, destructor // HasArmor destructor // HasArmor::b, destructor // HasArmor::a, destructor // HasHealth destructor // HasHealth::b, destructor // HasHealth::a, destructor
We see here that base structs’ constructors are called in the order that they’re derived from: HasHealth
then HasArmor
in this example. Their destructors are called in the reverse order. This is analogous to data members, which are constructed in declaration order and destructed in the reverse order.
Multiple inheritance can introduce an ambiguity known as the “diamond problem,” referring to the shape of the inheritance hierarchy. For example, consider these structs:
struct Top { const char* Id; Top(const char* id) : Id(id) { } }; struct Left : Top { const char* Id = "Left"; Left() : Top("Top of Left") { } }; struct Right : Top { const char* Id = "Right"; Right() : Top("Top of Right") { } }; struct Bottom : Left, Right { const char* Id = "Bottom"; };
Given a Bottom
, it’s easy to refer to its Id
and the Id
of the Left
and Right
sub-objects:
Bottom b{}; DebugLog(b.Id); // Bottom DebugLog(b.Left::Id); // Left DebugLog(b.Right::Id); // Right
However, both the Left
and Right
sub-objects have a Top
sub-object. This is therefore ambiguous and causes a compiler error:
Bottom b{}; // Compiler error: ambiguous. Top sub-object of Left or Right? DebugLog(b.Top::Id);
To disambiguate, we need to explicitly refer to either the Left
or Right
sub-object. One way is to take a reference to these sub-objects:
Bottom b{}; Left& left = b; DebugLog(left.Top::Id); // Top of Left Right& right = b; DebugLog(right.Top::Id); // Top of Right
Virtual Inheritance
If we don’t want our Bottom
object to include two Top
sub-objects then we can use “virtual inheritance” instead. This is just like normal inheritance, except that the compiler will generate only one sub-object for a common base struct. We enable it by adding the keyword virtual
before the name of the struct we’re deriving from:
struct Top { const char* Id = "Top Default"; }; struct Left : virtual Top { const char* Id = "Left"; }; struct Right : virtual Top { const char* Id = "Right"; }; struct Bottom : virtual Left, virtual Right { const char* Id = "Bottom"; }; // Top refers to the same sub-object in Bottom, Left, and Right Bottom b{}; Left& left = b; Right& right = b; DebugLog(b.Top::Id); // Top Default DebugLog(left.Top::Id); // Top Default DebugLog(right.Top::Id); // Top Default // Changing Left's Top changes the one and only Top sub-object left.Top::Id = "New Top of Left"; DebugLog(b.Top::Id); // New Top of Left DebugLog(left.Top::Id); // New Top of Left DebugLog(right.Top::Id); // New Top of Left // Same with Right's Top right.Top::Id = "New Top of Right"; DebugLog(b.Top::Id); // New Top of Right DebugLog(left.Top::Id); // New Top of Right DebugLog(right.Top::Id); // New Top of Right
Note that virtual inheritance and regular inheritance can be mixed. In this we get one sub-object for all the common virtual
base structs and one sub-object each for all the non-virtual
base structs. For example, this Bottom
has two Top
sub-objects: one for the virtual
inhertiance via Left
and Right
and one for the non-virtual
inheritance via Middle
:
struct Top { const char* Id = "Top Default"; }; struct Left : virtual Top { const char* Id = "Left"; }; struct Middle : Top { const char* Id = "Middle"; }; struct Right : virtual Top { const char* Id = "Right"; }; struct Bottom : virtual Left, Middle, virtual Right { const char* Id = "Bottom"; }; // Top refers to the same sub-object in Bottom, Left, and Right // It does not refer to Middle's Top sub-object Bottom b{}; Left& left = b; Middle& middle = b; Right& right = b; DebugLog(left.Top::Id); // Top Default DebugLog(middle.Top::Id); // Top Default DebugLog(right.Top::Id); // Top Default // Changing Left's Top changes the virtual Top sub-object left.Top::Id = "New Top of Left"; DebugLog(left.Top::Id); // New Top of Left DebugLog(middle.Top::Id); // Top Default (note: not changed) DebugLog(right.Top::Id); // New Top of Left // Same with Right's Top right.Top::Id = "New Top of Right"; DebugLog(left.Top::Id); // New Top of Right DebugLog(middle.Top::Id); // Top Default (note: not changed) DebugLog(right.Top::Id); // New Top of Right // Changing Middle's Top changes the non-virtual Top sub-object middle.Top::Id = "New Top of Middle"; DebugLog(left.Top::Id); // New Top of Right (note: not changed) DebugLog(middle.Top::Id); // New Top of Middle DebugLog(right.Top::Id); // New Top of Right (note: not changed)
Virtual Functions
Member functions in C++ may be virtual. This is directly analogous to virtual methods in C#. Here’s how a base Weapon
struct’s Attack
member function can be overridden by a derived Bow
struct’s Attack
member function:
struct Enemy { int32_t Health = 100; }; struct Weapon { int32_t Damage = 0; explicit Weapon(int32_t damage) { Damage = damage; } virtual void Attack(Enemy& enemy) { enemy.Health -= Damage; } }; struct Bow : Weapon { Bow(int32_t damage) : Weapon(damage) { } virtual void Attack(Enemy& enemy) { enemy.Health -= Damage; } }; Enemy enemy{}; DebugLog(enemy.Health); // 100 Weapon weapon{10}; weapon.Attack(enemy); DebugLog(enemy.Health); // 90 Bow bow{20}; bow.Attack(enemy); DebugLog(enemy.Health); // 70 Weapon& weaponRef = bow; weaponRef.Attack(enemy); DebugLog(enemy.Health); // 50 Weapon* weaponPointer = &bow; weaponPointer->Attack(enemy); DebugLog(enemy.Health); // 30
Notice that the default behavior in C++ is for a member function is to override a base struct’s virtual
function. This differs from C# where the default is to create a new function with the same name, similar to if the new
keyword were used to declare the method.
To override in C#, we must use the override
keyword. In C++, this is optional and mainly used so they compiler will generate an error as a reminder in case our overriding member function no longer matches with a virtual function in a base struct. The override
keyword comes after the function signature, not before as in C#:
struct Bow : Weapon { Bow(int32_t damage) : Weapon(damage) { } virtual void Attack(Enemy& enemy) override { enemy.Health -= Damage; } // Compiler error: no base struct has this virtual function to override virtual float SimulateAttack(Enemy& enemy) override { return enemy.Health - Damage; } };
C++ also has a replacement for C#’s abstract
keyword for when we don’t want to provide an implementation of a member function at all. These member functions are called “pure virtual” and have an = 0
after them instead of a body:
struct Weapon { int32_t Damage = 0; explicit Weapon(int32_t damage) { Damage = damage; } virtual void Attack(Enemy& enemy) = 0; };
Like in C# where the presence of any abstract
methods means we must tag the class itself abstract
, a C++ class with any pure virtual member functions is implicitly abstract and therefore can’t be instantiated:
// Compiler error: Weapon is an abstract struct and can't be instantiated Weapon weapon{10};
Because, unlike C#, overloaded operators are non-static
, they too may be virtual
. In this example, the +=
operator levels up a Weapon
:
struct Weapon { int32_t Damage = 0; explicit Weapon(int32_t damage) { Damage = damage; } virtual void operator+=(int32_t numLevels) { Damage += numLevels; } }; struct Bow : Weapon { int32_t Range; Bow(int32_t damage, int32_t range) : Weapon(damage), Range(range) { } virtual void operator+=(int32_t numLevels) override { // Explicitly call base struct's overloaded operator Weapon::operator+=(numLevels); Range += numLevels; } }; Bow bow{20, 10}; DebugLog(bow.Damage, bow.Range); // 20, 10 bow += 5; DebugLog(bow.Damage, bow.Range); // 25, 15 Weapon& weaponRef = bow; weaponRef += 5; DebugLog(bow.Damage, bow.Range); // 30, 20
Also unlike C#, user-defined conversion operators are non-static
so they too may be virtual
:
struct Weapon { int32_t Damage = 0; explicit Weapon(int32_t damage) { Damage = damage; } virtual operator int32_t() { return Damage; } }; struct Bow : Weapon { int32_t Range; Bow(int32_t damage, int32_t range) : Weapon(damage), Range(range) { } virtual operator int32_t() override { // Explicitly call base struct's user-defined conversion operator return Weapon::operator int32_t() + Range; } }; Bow bow{20, 10}; Weapon& weaponRef = bow; int32_t bowVal = bow; int32_t weaponRefVal = weaponRef; DebugLog(bowVal, weaponRefVal); // 30, 30
Finally, destructors may be virtual
. This is very common as it ensures that derived struct destructors will always be called:
struct ReadOnlyFile { FILE* ReadFileHandle; ReadOnlyFile(const char* path) { ReadFileHandle = fopen(path, "r"); } virtual ~ReadOnlyFile() { fclose(ReadFileHandle); ReadFileHandle = nullptr } }; struct FileCopier : ReadOnlyFile { FILE* WriteFileHandle; FileCopier(const char* path, const char* writeFilePath) : ReadOnlyFile(path) { WriteFileHandle = fopen(writeFilePath, "w"); } virtual ~FileCopier() { fclose(WriteFileHandle); WriteFileHandle = nullptr; } }; FileCopier copier("/path/to/input/file", "/path/to/output/file"); // Calling a virtual destructor on a base struct // Calls the derived struct's destructor // If this was non-virtual, only the ReadOnlyFile destructor would be called ReadOnlyFile& rof = copier; rof.~ReadOnlyFile();
A pure virtual destructor is also a common way of marking a struct as abstract when no other members are good candidates to be made pure virtual. This is like marking a class abstract
in C# without marking any of its contents abstract
.
Stopping Inheritance and Overrides
C# has the sealed
keyword that can be applied to a class to stop other classes from deriving it. C++ has the final
keyword for this purpose. It’s placed after the struct name and before any base struct names:
// OK to derive from Vector1 struct Vector1 { float X; }; // Compiler error if deriving from Vector2 struct Vector2 final : Vector1 { float Y; }; // Compiler error: Vector2 is final struct Vector3 : Vector2 { float Z; };
The sealed
keyword in C# can also be applied to methods and properties that override
to stop further overriding. The final
keyword in C++ also serves this purpose. Like the override
keyword, it’s placed after the member function signature. The two keywords can be used together as they have different, but related meanings.
struct Vector1 { float X; // Allows overriding virtual void DrawPixel(float r, float g, float b) { GraphicsLibrary::DrawPixel(X, 0, r, g, b); } }; struct Vector2 : Vector1 { float Y; // Overrides DrawPixel in Vector1 // Stops overriding in derived structs virtual void DrawPixel(float r, float g, float b) override final { GraphicsLibrary::DrawPixel(X, Y, r, g, b); } }; struct Vector3 : Vector2 { float Z; // Compiler error: DrawPixel in base struct (Vector2) is final virtual void DrawPixel(float r, float g, float b) override { GraphicsLibrary::DrawPixel(X/Z, Y/Z, r, g, b); } };
C# Equivalency
We’ve already seen the C++ equivalents for C# concepts like abstract
and sealed
classes as well as abstract
, virtual
, override
, and sealed
methods. C# has several other features that have no explicit C++ equivalent. Instead, these are idiomatically implemented with general struct features.
First, C# has a dedicated interface
concept. It’s like a base class that can’t have fields, can’t have non-abstract methods, is abstract, and can be multiply inherited by classes. In C++, we always have multiple inheritance so all we need to do is not add any data members or non-abstract member functions:
// Like an interface: // * Has no data members // * Has no non-abstract member functions // * Is abstract (due to Log being pure virtual) // * Enables multiple inheritance (always enabled in C++) struct ILoggable { virtual void Log() = 0; }; // To "implement" an "interface," just derive and override all member functions struct Vector2 : ILoggable { float X; float Y; Vector2(float x, float y) : X(x), Y(y) { } virtual void Log() override { DebugLog(X, Y); } }; // Use an "interface," not a "concrete class" void LogTwice(ILoggable& loggable) { loggable.Log(); loggable.Log(); } Vector2 vec{2, 4}; LogTwice(vec); // 2, 4 then 2, 4
Next we have partial
classes in C#. These allow us to split the contents of a class across multiple files. We can mimic this in C++ in at least two ways. First, and far more commonly, we put the struct’s definition in a header file (e.g. .h
) and split its member definitions across multiple translation unit (e.g. .cpp
) files.
// In Player.h struct Player { const static int32_t MaxHealth = 100; const static int32_t MaxLives = 3; int32_t Health = MaxHealth; int32_t NumLives = MaxLives; float PosX = 0; float PosY = 0; float DirX = 0; float DirY = 0; float Speed = 0; void TakeDamage(int32_t amount); void Move(float time); }; // In PlayerCombat.cpp #include "Player.h" void Player::TakeDamage(int32_t amount) { Health -= amount; } // In PlayerMovement.cpp #include "Player.h" void Player::Move(float time) { float distance = Speed * time; PosX += DirX * distance; PosY += DirY * distance; } // In Game.cpp #include "Player.h" Player player; player.DirX = 1; player.Speed = 1; player.TakeDamage(10); DebugLog(player.Health); // 90 player.Move(5); DebugLog(player.PosX, player.PosY); // 5, 0
Another, much less common, approach is to use virtual inheritance to compose multiple structs into one:
// In PlayerShared.h struct PlayerShared { const static int32_t MaxHealth = 100; const static int32_t MaxLives = 3; int32_t Health = MaxHealth; int32_t NumLives = MaxLives; float PosX = 0; float PosY = 0; float DirX = 0; float DirY = 0; float Speed = 0; }; // In PlayerCombat.h #include "PlayerShared.h" struct PlayerCombat : virtual PlayerShared { void TakeDamage(int32_t amount) { Health -= amount; } }; // In PlayerMovement.h #include "PlayerShared.h" struct PlayerMovement : virtual PlayerShared { void Move(float time) { float distance = Speed * time; PosX += DirX * distance; PosY += DirY * distance; } }; // In Player.h #include "PlayerCombat.h" #include "PlayerMovement.h" struct Player : virtual PlayerCombat, virtual PlayerMovement { }; // In Game.cpp #include "Player.h" Player player; player.DirX = 1; player.Speed = 1; player.TakeDamage(10); DebugLog(player.Health); // 90 player.Move(5); DebugLog(player.PosX, player.PosY); // 5, 0
This approach allows the parts (PlayerCombat
and PlayerMovement
) to be re-composed to form other types, such as a StationaryPlayer
that can fight but not move:
// In StationaryPlayer.h #include "PlayerCombat.h" struct StationaryPlayer : virtual PlayerCombat { }; // In Game.cpp #include "StationaryPlayer.h" StationaryPlayer stationary; stationary.TakeDamage(10); // OK, Health now 90 stationary.Move(5); // Compiler error: Move isn't a member function
Finally, all C# classes have System.Object
as their ultimate base class. C# structs, and other value types, are implicitly “boxed” to System.Object
when used in particular ways such as calling the GetHashCode
method. C++ has no ultimate base struct like this, but one can be created and all other structs can derive from it.
// In Object.h // Ultimate base struct struct Object { virtual int32_t GetHashCode() { return HashBytes((char*)this, sizeof(*this)); } }; // In Player.h #include "Object.h" // Derives from ultimate base struct: Object struct Player : Object { const static int32_t MaxHealth = 100; const static int32_t MaxLives = 3; int32_t Health = MaxHealth; int32_t NumLives = MaxLives; float PosX = 0; float PosY = 0; float DirX = 0; float DirY = 0; float Speed = 0; void TakeDamage(int32_t amount); void Move(float time); // Can override if desired, like in C# virtual int32_t GetHashCode() override { return 123; } }; // In Vector2.h #include "Object.h" // Derives from ultimate base struct: Object struct Vector2 : Object { float X; float Y; // Can NOT override if desired, like in C# // virtual int32_t GetHashCode() override }; // Can pass any struct to this // Because we made every struct derive from Object void LogHashCode(Object& obj) { DebugLog(obj.GetHashCode()); } // Can pass a Player because it derives from Object Player player; LogHashCode(player); // Can pass a Vector2 because it derives from Object Vector2 vector; LogHashCode(vector);
Generic solutions for boxing are likewise possible and we’ll cover those techniques later in the series.
Conclusion
C++ struct inheritance is in many ways a superset of C# class inheritance. It goes above and beyond with support for multiple inheritance, virtual inheritance, virtual overloaded operators, virtual user-defined conversion functions, and skip-level sub-object specifications like Level1::X
from within Level3
.
In other ways, C++ inheritance is more stripped down than C# inheritance. It doesn’t have dedicated support for interfaces or partial
classes and it doesn’t mandate an ultimate base struct like Object
in C#. To recover these features, we rely on the extended feature set’s increased flexiblity to essentially build our own interfaces, partial
classes, and Object
type.
#1 by Andrew on June 26th, 2022 ·
Thanks for this series!
I think you meant 50 instead of 100 in this code block based on the text and code blocks that follow.
#2 by jackson on June 26th, 2022 ·
Well spotted! Thanks for letting me know! I’ve updated the article with a fix.
#3 by Mark on August 8th, 2022 ·
Errata:
> Unlike C#’s non-deterministic destructors/finalizers, C++ destructors are called in the same order as constructors.
Destructors are called in the reverse order to constructors (the log in the example shows this clearly).
> ReadOnlyFile& rof = copier;
> rof.~ReadOnlyFile();
Destructors should never[*] be called explicitly like this. This will result in the dtor being called twice, as it’ll be invoked again when ‘copier’ goes out of scope. This will call ‘fclose(nullptr)’ which is perfectly entitled to crash.
[*] explicit dtor calls have a single purpose and that’s opposite a placement new, and even in that case prefer std::destroy_at().