C++ For C# Developers: Part 15 – Struct and Class Permissions
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
- 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
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
:
- A non-
const
pointer to aconst
char
- 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!
#1 by Baggers on August 26th, 2020 ·
What a resource! Thanks for taking all the time to take to write these. I’d have happily bought this series as a book.
#2 by Muhammed Uraz on September 21st, 2024 ·
Every time i see “const” i was like, “WHY”. Now it’s coming to an end. Thanks million times.