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

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 = 100;
    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.