Today we’ll cover the last major topic of structs in C++: how we control access to them. We’ll talk about access specifiers like private, the “friendship” concept, and finally get around to the details of const.

Table of Contents

Access Specifiers

Like in C#, the members of structs in C++ can have their access level changed by the public, protected, and private access specifiers. It’s written a little differently in C++ though. Instead of being included like a modifier of a single member (e.g. public void Foo() {}), access specifiers in C++ are written like a label (e.g. public:) and apply until the next access specifier:

struct Player
{
// The default access specifier is public, so TakeDamage is public
    void TakeDamage(int32_t amount)
    {
        Health -= amount;
    }
 
// Change the access specifier to private
private:
 
// Health is private
    int32_t Health;
 
// NumLives is private
    int32_t NumLives;
 
// Change the access specifier back to public
public:
 
// Heal is public
    void Heal(int32_t amount)
    {
        Health += amount;
    }
 
// GetExtraLife is public
    void GetExtraLife()
    {
        NumLives++;
    }
};

While uncommon in C++, we can make this feel more like C# by explicitly adding the access specifier before every member:

struct Player
{
    public: void TakeDamage(int32_t amount)
    {
        Health -= amount;
    }
 
    private: int32_t Health;
    private: int32_t NumLives;
 
    public: void Heal(int32_t amount)
    {
        Health += amount;
    }
 
    public: void GetExtraLife()
    {
        NumLives++;
    }
};

The meaning of public, private, and protected are similar to C#:

Access Specifier Member Accessibile From
public Anywhere
protected Only within the struct and in derived structs
private Only within the struct

Unlike C#, access specifiers may also be applied when deriving from structs:

struct PublicPlayer : public Player
{
};
 
struct ProtectedPlayer : protected Player
{
};
 
struct PrivatePlayer : private Player
{
};
 
// Default inheritance access specifier is public
struct DefaultPlayer : Player
{
};

The inheritance access specifier maps the member access levels in the base struct to access levels in the derived struct:

Inherit public Inherit protected Inherit private
Base public public protected private
Base private private private private
Base protected protected protected private

This means that ProtectedPlayer and PrivatePlayer have hidden the public members of Player from outside code:

PublicPlayer pub{};
pub.Heal(10); // OK: Heal is public
 
ProtectedPlayer prot{};
prot.Heal(10); // Compiler error: Heal is protected
 
PrivatePlayer priv{};
priv.Heal(10); // Compiler error: Heal is private
 
DefaultPlayer def{};
def.Heal(10); // OK: Heal is public

When a virtual member function is overridden, it may have a different access level than the member function it overrides. In this case, the access level is always determined at compile time using the type the member function is being called on. This may be different than the runtime type of the object. That means access specifiers don't support runtime polymorphism.

struct Base
{
    virtual void Foo()
    {
        DebugLog("Base Foo");
    }
 
private:
 
    virtual void Goo()
    {
        DebugLog("Base Goo");
    }
};
 
struct Derived : Base
{
private:
 
    virtual void Foo() override
    {
        DebugLog("Derived Foo");
    }
 
public:
 
    virtual void Goo() override
    {
        DebugLog("Derived Goo");
    }
};
 
// These calls use the access specifiers in Base
Base b;
b.Foo(); // "Base Foo"
//b.Goo(); // Compiler error: Goo is private
 
// These calls use the access specifiers in Derived
Derived d;
//d.Foo(); // Compiler error: Foo is private
d.Goo(); // "Derived Goo"
 
// These calls use the access specifiers in Base, even though the runtime object
// is a Derived
Base& dRef = d;
dRef.Foo(); // "Derived Foo"
//dRef.Goo(); // Compiler error: Goo is private in Base

When using virtual inheritance, the most accessible path through the derived classes is used to determine access level:

struct Top
{
    int32_t X = 123;
};
 
// X is private due to private inheritance
struct Left : private virtual Top
{
};
 
// X is public due to public inheritance
struct Right : public virtual Top
{
};
 
// X is public due to public inheritance via Right
// This takes precedence over private inheritance via Left
struct Bottom : virtual Left, virtual Right
{
};
 
Top top{};
DebugLog(top.X); // 123
 
Left left{};
//DebugLog(left.X); // Compiler error: X is private
 
Right right{};
DebugLog(right.X); // 123
 
// Accessing X goes through Right
Bottom bottom{};
DebugLog(bottom.X); // 123

It's important to note that access levels may change the layout of the struct's non-static data members in memory. While the data members are guaranteed to be laid out sequentially, perhaps with padding between them, this is only true of data members of the same access level. For example, the compiler may choose to lay out all the public data members then all the private data members or to mix all the data members regardless of their access level:

struct Mixed
{
    private: int32_t A = 1;
    public: int32_t B = 2;
    private: int32_t C = 3;
    public: int32_t D = 4;
};
 
// Some possible layouts of Mixed:
//   Ignore access level: A, B, C, D
//   Private then public: A, C, B, D
//   Public then private: B, D, A, C

Structs, including all of the examples above, use public as their default access level. That applies to their members and their inheritance. To make private the default, replace the keyword struct with class:

class Player
{
    int32_t Health = 0;
};
 
Player player{};
DebugLog(player.Health); // Compiler error: Health is private

That's right: classes are just structs with a different default access level!

They're so compatible that we can even declare them as struct and define them as class or visa versa:

struct Player;
class Player
{
    int32_t Health = 0;
};
 
class Weapon;
struct Weapon
{
    int32_t Damage = 0;
};

The choice of which to use is mostly up to convention. The struct keyword is typically used when all or the majority of members will be public. The class keyword is typically used when all or the majority of members will be private.

As far as terminology, it's typical to say just "classes" or "structs" rather than "classes and structs" since the two concepts are essentially the same. For example, "structs can have constructors" implies that classes can also have constructors. All of the previous articles about structs in this series apply equally to classes.

Friendship

C++ provides a way for structs to explicitly grant complete access to their members regardless of what the access level would otherwise be. To do so, the struct adds a friend declaration to its definition stating the name of the function or struct that it wants to grant access to:

class Player
{
    // Private due to the default for classes
    int64_t Id = 123;
    int32_t Points = 0;
 
    // Make the PrintId function a friend
    friend void PrintId(const Player& player);
 
    // Make the Stats class a friend
    friend class Stats;
};
 
void PrintId(const Player& player)
{
    // Can access Id and Points members because PrintId is a friend of Player
    DebugLog(player.Id, "has", player.Points, "points");
}
 
// It's OK that Stats is actually a struct, not a class
struct Stats
{
    static int32_t GetTotalPoints(Player* players, int32_t numPlayers)
    {
        int32_t totalPoints = 0;
        for (int32_t i = 0; i < numPlayers; ++i)
        {
            // Can access Points because Stats is a friend of Player
            totalPoints += players[i].Points;
        }
        return totalPoints;
    }
};
 
Player p;
PrintId(p); // 123 has 0 points
 
int32_t totalPoints = Stats::GetTotalPoints(&p, 1);
DebugLog(totalPoints); // 0

It's so common for structs to be friends with inline functions in particular that C++ provides a shortcut to defining the inline function and declaring it as a friend:

class Player
{
    int64_t Id = 123;
    int32_t Points = 0;
 
    // Make the PrintId inline function and make it a friend
    friend void PrintId(const Player& player)
    {
        // Can access Id and Points members
        // because PrintId is a friend of Player
        DebugLog(player.Id, "has", player.Points, "points");
    }
};

Even though the definition of PrintId appears within the definition of Player, like a member function would, it is still a normal function and not a member function. We can see this when calling it:

// Call like a normal function
Player p;
PrintId(p); // 123 has 0 points
 
// Call like a member function
p.PrintId(); // Compiler error: Player doesn't have a PrintId member

Also, note that friendship is not inherited and a friend-of-a-friend is not a friend:

class Player
{
    int32_t Points = 0;
    friend class Stats;
};
 
struct Stats
{
    friend class PointStats;
};
 
struct PointStats : Stats
{
    static int32_t GetTotalPoints(Player* players, int32_t numPlayers)
    {
        int32_t totalPoints = 0;
        for (int32_t i = 0; i < numPlayers; ++i)
        {
            // Compiler error: can't access Points because PointStats is NOT
            // a friend of Player even though it inherits from Stats and is
            // a friend of Stats. Only Stats is a friend.
            totalPoints += players[i].Points;
        }
        return totalPoints;
    }
};
Const and Mutable

As we've seen throughout the series and touched on lightly, types in C++ may be qualified with the const keyword. We can put this qualifier on any use of a type: local variables, global variables, data members, function arguments, return values, pointers, and references.

At its most basic, const means we can't re-assign:

// x is a const int
const int32_t x = 123;
 
// Compiler error: can't assign to a const variable
x = 456;

The const keyword can be placed on the left or right of the type. This is known as "west const" and "east const" and both are in common usage. The placement makes no difference in this case as both result in a const type.

const int32_t x = 123; // "West const" version of a constant int32_t
int32_t const y = 456; // "East const" version of a constant int32_t
DebugLog(x, y); // 123, 456

For even slightly more complicated types, the ordering matters more. Consider a pointer type:

const char* str = "hello";

There are two potential meanings of const char* str:

  1. A non-const pointer to a const char
  2. A const pointer to a non-const char

That is to say, one of these two will be a compiler error:

// Compiler error if (1) because this would change the char
*str = 'H';
 
// Compiler error if (2) because this would change the pointer
str = "goodbye";

The rule is that const modifies what's immediately to its left. If there's nothing to its left, it modifies what's immediately to its right.

Because there's nothing left of const in const char* str, the const modifies the char immediately to its right. That means the char is const and the pointer is non-const:

*str = 'H'; // Compiler error: character is const
str = "goodbye"; // OK: pointer now points to "goodbye"

Using "east const" eliminates the "nothing to its left" case so it's easier to determine what is const:

// 'char' is left of 'const', so 'char' is const
char const * str = "hello";
str = "goodbye"; // OK: pointer is non-const
*str = 'H'; // Compiler error: char is const
 
// '*' is left of 'const', so pointer is const
char * const str = "hello";
str = "goodbye"; // Compiler error: pointer is const
*str = 'H'; // OK: char is non-const
 
// Both 'char' and '*' are left of a 'const', so both are const
char const * const str = "hello";
str = "goodbye"; // Compiler error: pointer is const
*str = 'H';// Compiler error: char is const

Besides assignment, the const keyword also means we can't modify the data members of a const struct:

struct Player
{
    const int64_t Id;
 
    Player(int64_t id)
        : Id(id)
    {
    }
};
 
Player player{123};
 
// Compiler error: can't modify data members of a const struct
//player.Id = 1000;

A reference to a const T has the type const T& and a pointer to a const T has the type const T*. These can't be assigned to non-const references with type T& and non-const pointers with type T* since that would remove the "const-ness" of it:

const int32_t x = 123;
 
// Compiler error: const int32_t& incompatible with int32_t&
int32_t& xRef = x;
 
// Compiler error: const int32_t* incompatible with int32_t*
int32_t* xPtr = &x;

Member functions may also be const by putting the keyword after the function signature, similar to where we'd put override:

class Player
{
    int32_t Health = 100;
 
public:
 
    // GetHealth is a const member function
    int32_t GetHealth() const
    {
        return Health;
    }
};

A const member function of a struct T is implicitly passed a const T* this pointer instead of a (non-const) T* this pointer. This means all the same restrictions apply and it is not allowed to modify the data members of this. This is similar to readonly instance members in C# 8.0.

We're also prohibited from calling non-const member functions on a const struct, either from inside or outside the struct:

class Player
{
    int32_t Health = 100;
 
public:
 
    int32_t GetHealth() const
    {
        // Compiler error: can't call non-const member function from const
        //                 member function because 'this' is a 'const Player*'
        TakeDamage(1);
 
        return Health;
    }
 
    void TakeDamage(int32_t amount)
    {
        Health -= amount;
    }
};
 
Player player{};
const Player& playerRef = player;
 
// Compiler error: can't call non-const TakeDamage on const reference
playerRef.TakeDamage();
 
// OK: GetHealth is const
DebugLog(playerRef.GetHealth()); // 100

To opt-out of this restriction and allow a particular data member to be treated as non-const by a const member function, we use the mutable keyword:

class File
{
    FILE* Handle;
 
    // Can be modified by const member functions
    mutable long Size;
 
public:
    File(const char* path)
    {
        Handle = fopen(path, "r");
        Size = -1;
    }
 
    ~File()
    {
        fclose(Handle);
    }
 
    // A const member function
    long GetSize() const
    {
        if (Size < 0)
        {
            long oldPos = ftell(Handle);
            fseek(Handle, 0, SEEK_END);
            Size = ftell(Handle); // OK: Size is mutable
            fseek(Handle, oldPos, SEEK_SET);
        }
        return Size;
    }
};

The mutable keyword is commonly used with cached values like the above caching of the file's size. For example, it means that the relatively-expensive file I/O operations only need to be performed one time regardless of how many times GetSize is called:

const File f{"/path/to/file"};
for (int32_t i = 0; i < 1000; ++i)
{
    // Can call GetSize on const File because GetSize is const
    // GetSize can update Size because Size is mutable
    // Only the first call does any file I/O: saves 999 file I/O operations
    DebugLog(f.GetSize());
}

The following table contrasts C++'s const keyword with C#'s const and readonly keywords:

Factor C++ const C# const C# readonly
Types Any Numbers, strings, booleans Any
Applicability Everywhere Fields, local variables Fields, references
Can assign to field Only mutable N/A No
Can set field of field Only mutable N/A Structs no, Classes yes
Can set field of reference Only mutable N/A Yes, but doesn't change referenced structs
Can call function of field Only const function N/A Yes, but doesn't change field of structs
Can call function of reference Only const function N/A Yes, but doesn't change referenced structs

Generally, C++'s 'const' provides more consistent and thorough immutability than C#'s readonly, which is its closest equivalent.

C# Equivalency

C# has three more access levels than C++:

  • internal
  • protected internal
  • private protected
Access Specifier Member Accessibile From
internal Within same assembly
protected internal Within same assembly or in derived classes
private protected Within same assembly or in derived classes within same assembly

C++ has no concept of "assemblies" like .NET DLLs, so none of these apply. They can, however, be simulated in C++.

One solution employs a combination of friend classes and withholding some of the library's header files to simulate internal:

// Player.h: distributed with library (e.g. in 'public' directory)
class Player
{
    // "Internal" access is granted via a particular struct
    friend struct PlayerInternal;
 
    // "Internal" data members are private instead
    int64_t Id;
 
public:
 
    // Public library API
    int64_t GetId() const
    {
        return Id;
    }
};
 
// PlayerInternal.h: NOT distributed with library (e.g. in 'internal' directory)
#include "Player.h"
struct PlayerInternal
{
    // Functions of the friend struct provide access to "internal" data members
    static int64_t& GetId(Player& player)
    {
        return player.Id;
    }
};
 
// Library.cpp: code written inside the library
#include "Player.h"
#include "PlayerInternal.h"
Player player{};
PlayerInternal::GetId(player) = 123; // OK
DebugLog(player.GetId()); // OK
 
// User.cpp: code written by the user of the library
#include "Player.h"
Player player{};
PlayerInternal::GetId(player) = 123; // Compiler error: undefined PlayerInternal
DebugLog(player.GetId()); // OK

Variants of this approach can be created to simulate protected internal and private protected.

Conclusion

Once again, the two languages have quite a lot of overlap but also many differences. They both feature public, protected, and private access specifiers with roughly the same meaning. C# also has internal, protected internal, and private protected, which C++ needs to simulate. C++ has inheritance access specifiers, friend functions, and friend classes while C# doesn't.

C++ const serves a similar role to C# readonly and const, but applies much more broadly than either. Allowing it on function arguments and return values as well as arbitrary variables, pointers, and references is a feature that has major impact on how C++ code is designed. A lot of C++ codebases make as many variables, references, and functions as possible const to enforce immutability with compiler errors. The mutable keyword offers flexibility, such as when implementing caches.

The last gigantic difference between the languages is that structs and classes are the same in C++. The only difference is the keyword used and the default access level. In C#, structs and classes are extremely different. For example, C# structs don't support inheritance or default constructors and there are are no readonly class types or unmanaged classes. C++ structs and classes provide almost the combined feature set of C# structs, classes, and interfaces as well as being used to build other language features such as delegates.

Next week we'll wrap up talking about structs!