C++ For C# Developers: Part 52 – Idioms and Best Practices
As a very large language used for a very wide range of purposes over many decades, C++ can be written in a lot of different ways. Today we’ll look at some of the norms for “modern” C++ to get a sense of how code is normally written.
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 Wrap-up
- 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: Destructuring 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
Guides
There are several existing, popular guides that aim to impose programming standards on C++ codebases for a variety of reasons. These reasons range from trivialities such as formatting to standardization of error-handling and outright bans on certain language features. Additionally, many teams and organizations will create their own in-house rules and possibly enforce them with tools like ClangFormat.
Here are some of the most popular public guides:
- C++ Core Guidelines: maintained by the original creator of C++ and a top C++ standards committee member for general-purpose code rather than a particular industry, domain, or product. The Guidelines Support Library (GSL) by Microsoft and related tools in Visual Studio support these guidelines.
- Google C++ Style Guide: used by Google for their massive C++ codebase spanning the web, mobile devices, and more.
- Unreal Engine Coding Standard: a much more brief guide focused specifically on C++ written for Unreal Engine games
There are strong disagreements between these guides and even their scopes vary widely. This article is mostly about areas that have some semblance of agreement in the broader community of C++ developers. It’s not an attempt to create a new guide.
Use macros extremely rarely
Macros are evaluated at an early stage of compilation and essentially operate as scope-unaware text-replacers that aren’t fully compatible with either variables or functions. They’re notoriously difficult to debug and even understand. While they’re essential to implement assertions, there are very few other cases where they are preferable to regular C++ code:
// Avoid #define PI 3.14 // Encourage const float PI = 3.14;
// Avoid #define SQUARE(x) x * x // Encourage float Square(float x) { return x * x; }
Add include guards to every header
As of this writing, modules are not yet in common usage. While still using the classic header file-based build system, every header file should have “include guards” to prevent redundant definitions by multiple #include
directives:
// Avoid struct Point2 { float X; float Y; }; // Encourage #ifndef POINT2_HPP #define POINT2_HPP struct Point2 { float X; float Y; }; #endif // Encourage (non-standard but widely-supported alternative) #pragma once struct Point2 { float X; float Y; };
Include dependencies directly instead of relying on indirect includes
When one file uses code in another file, it should #include
the file declaring that code directly rather than relying on another header to #include
the desired code. This prevents compilation errors if the middle header removes its #include
.
//////// // Avoid //////// // a.h struct A {}; // b.h #include "a.h" struct B : A {}; // c.h #include "b.h" A a; // Not in b.h //////////// // Encourage //////////// // a.h struct A {}; // b.h #include "a.h" struct B : A {}; // c.h #include "a.h" A a;
Don’t call virtual functions in constructors
Virtual functions rely on a table that’s initialized by the constructors of the classes in an inheritance hierarchy. Calling virtual
functions before these are set up can result in crashes or calling the wrong version of the function:
// Avoid struct Parent { Parent() { Foo(); } virtual void Foo() { DebugLog("Parent"); } }; struct Child : Parent { virtual void Foo() override { DebugLog("Child"); } }; Child c; // Prints "Parent" not "Child"! // Encourage designs that do not require such calls
Don’t use variadic functions
Variadic functions aren’t type-safe, rely on error-prone macros, are difficult to optimize, and often result in error-prone APIs. They should be avoided in favor of techniques such as fold expressions or the use of container types:
// Avoid void DebugLog(int count, ...) { va_list args; va_start(args, count); for (int i = 0; i < count; ++i) { const char* log = va_arg(args, const char*); std::cout << log << ", "; } va_end(args); } DebugLog(4, "foo", "bar", "baz"); // Whoops! 4 reads beyond the last arg! // Encourage void DebugLog() { std::cout << '\n'; } template <typename T> void DebugLog(const T& val) { std::cout << val << '\n'; } template <typename TFirst, typename TSecond, typename ...TRemain> void DebugLog(const TFirst& first, const TSecond& second, TRemain... remain) { std::cout << first << ", "; DebugLog(second, remain...); } DebugLog("foo", "bar", "baz"); // No need for a count. Can't get it wrong.
No naked new and delete
The new
and delete
operators to dynamically allocate memory should be a rare sight. Instead, “owning” types such as containers and smart pointers should call new
in their constructors and delete
in their destructors to ensure that memory is always cleaned up:
// Avoid struct Game { Stats* stats; Game() : stats{new Stats()} { } ~Game() { delete stats; } }; // Encourage struct Game { std::unique_ptr<Stats> stats; Game() : stats{std::make_unique<Stats>()} { } };
// Avoid struct FloatBuffer { float* floats; FloatBuffer(int32_t count) : floats{new float[count]} { } ~FloatBuffer() { delete [] floats; } }; // Encourage struct FloatBuffer { std::vector<float> floats; FloatBuffer(int32_t count) : floats{count} { } };
Prefer range-based loops
The most common loop is from the beginning to the end of a collection. To avoid mistakes and make this more terse, use a range-based) for
loop instead of the three-part for
loop, a while
loop, or a do-while
loop:
// Avoid for ( std::vector<float>::iterator it = floats.begin(); it != floats.end(); ++i) { DebugLog(*it); } // Encourage for (float f : floats) { DebugLog(f); }
Use scoped enums instead of unscoped enums
To avoid adding all the enumerators of an unscoped enum
to the surrounding scope, use a scoped enumeration:
// Avoid enum Colors { Red, Green, Blue }; uint32_t Red = 0x00ff00ff; // Error: redefinition because Red escaped the enum // Encourage enum class Colors { Red, Green, Blue }; uint32_t Red = 0x00ff00ff; // OK
Don’t breach namespaces in headers
When commonly using the members of a namespace, it can be convenient to pull them out with using namespace
. When this is done in a header, the files that #include
it have this decision forced on them. This can lead to namespace collisions and confusion, so it should be avoided.
// Avoid using namespace std; struct Name { string First; string Last; }; // Encourage struct Name { std::string First; std::string Last; };
Make single-parameter constructors explicit
Constructors default to allowing implicit conversion, which can be surprising and expensive. Use the explicit
keyword to disallow this behavior:
// Avoid struct Buffer { std::vector<float> floats; Buffer(int x) : floats{x} { } }; void DoStuff(Buffer b) { } DoStuff(1'000'000); // Allocates a Buffer of one million floats! // Encourage struct Buffer { std::vector<float> floats; explicit Buffer(int x) : floats{x} { } }; void DoStuff(Buffer b) { } DoStuff(1'000'000); // Compiler error
Don’t use C casts
C-style casts may just change the type but also might perform value conversion. They’re hard to search for as they blend in with other parentheses. Instead, use a named C++ cast for better control and easier searching:
// Avoid const float val = 5.5; int x = (int)val; // Changes type, truncates, and removes constness! DebugLog(x); // Encourage const float val = 5.5; int x = const_cast<int>(val); // Compiler error DebugLog(x);
Use specific integer sizes
All the way back in the second article of the series, we learned that the size guarantees for primitive types like long
are very weak. They should be avoided in favor of guaranteed sizes in <cstdint>
:
// Avoid long GetFileSize(const char* path) { // Does this support files larger than 4 GB? // Depends on whether long is 32-bit or 64-bit } // Encourage int64_t GetFileSize(const char* path) { // Definitely supports large files }
Use nullptr
NULL
is an implementation-defined variable-like macro that even requires a #include
to use. It’s a null pointer constant, but of unknown type and erroneously usable in arithmetic. In contrast nullptr
is not a macro or an integer, requires no header, and even has its own type which can be used in overload resolution. It should be used to represent null pointers:
// Avoid int* p = NULL; // Encourage int* p = nullptr;
Follow the Rule of Zero
Most classes shouldn’t need any copy constructors, move constructors, destructors, copy assignment operators, or move assignment operators. Instead, their data members should take care of these functions. This is called the “rule of zero” because no special functions need to be added. It’s the simplest approach and the hardest to implement incorrectly:
// Avoid struct Player { std::string Name; int32_t Score; Player(std::string name, int32_t score) : Name{ name } , Score{ score } { } Player(const Player& other) : Name{ other.Name } , Score{ other.Score } { } Player(Player&& other) noexcept : Name{ std::move(other.Name) } , Score{ std::move(other.Score) } { } virtual ~Player() { } Player& operator=(const Player& other) { Name = other.Name; Score = other.Score; return *this; } Player& operator==(Player&& other) { Name = std::move(other.Name); Score = std::move(other.Score); return *this; } }; Player p{ "Jackson", 1000 }; // Encourage struct Player { std::string Name; int32_t Score; }; Player p{ "Jackson", 1000 };
Follow the Rule of Five
In cases where the Rule of Zero can’t be followed and a special function needs to be added, add all five of them to handle all the ways that objects can be copied, moved, and destroyed:
// Avoid struct File { std::string Path; const char* Mode; FILE* Handle; File(std::string path, const char* mode) : Path{ path } , Mode{ mode } , Handle(fopen(path.c_str(), mode)) { } File(const File& other) : Path{ other.Path } , Mode{ other.Mode } , Handle(fopen(other.Path.c_str(), other.Mode)) { } virtual ~File() { if (Handle) { fclose(Handle); } } File& operator=(const File& other) { Path = other.Path; Mode = other.Mode; Handle = fopen(other.Path.c_str(), other.Mode); return *this; } // No move constructor or move assignment operator // Expensive copies will be required: more file open and close operations! }; // Encourage struct File { std::string Path; const char* Mode; FILE* Handle; File(std::string path, const char* mode) : Path{ path } , Mode{ mode } , Handle(fopen(path.c_str(), mode)) { } File(const File& other) : Path{ other.Path } , Mode{ other.Mode } , Handle(fopen(other.Path.c_str(), other.Mode)) { } File(File&& other) noexcept : Path{ std::move(other.Path) } , Mode{ other.Mode } , Handle(other.Handle) { other.Handle = nullptr; } virtual ~File() { if (Handle) { fclose(Handle); } } File& operator=(const File& other) { Path = other.Path; Mode = other.Mode; Handle = fopen(other.Path.c_str(), other.Mode); return *this; } File& operator==(File&& other) { Path = std::move(other.Path); Mode = other.Mode; Handle = other.Handle; other.Handle = nullptr; return *this; } };
Avoid raw loops
Hand-implemented algorithms are error-prone and difficult to read. Many common algorithms, and even parallelized versions, are implemented in the algorithms library and ranges library for us and can be used with a broad number of types. Readers of such code encounter just a named algorithm which they’re likely already familiar with rather than needing to interpret that from a possibly-complex loop.
// Avoid struct Player { const char* Name; int NumPoints; }; void Avoid(const std::vector<Player>& players) { using It = std::reverse_iterator<std::vector<Player>::const_iterator>; for (It it = players.rbegin(); it != players.rend(); ++it) { const Player& player = *it; if (player.NumPoints > 25) { Player copy = player; copy.NumPoints--; DebugLog(copy.Name, copy.NumPoints); } } } // Encourage struct Player { const char* Name; int NumPoints; }; void Encourage(const std::vector<Player>& players) { using namespace std::ranges::views; auto result = players | filter([](Player p) { return p.NumPoints > 25; }) | transform([](Player p) { p.NumPoints--; return p; }) | reverse; for (const Player& p : result) { DebugLog(p.Name, p.NumPoints); } }
Add restrictions
When using objects in a read-only way, make them const
. When code can usefully run at compile time, make it constexpr
. When a function can’t throw any exceptions, make it noexcept
. When fields don’t need to be used outside of a class, make them protected
or private
. When derivation or overriding are undesirable, make classes and member functions final
. All of these restrictions will add compiler-enforced rules that prevent misuse such as field access or enable new uses such as compile-time code execution.
// Avoid: requires writable strings, can't be used at compile time, might throw int32_t GetTotalCharacters(std::string& first, std::string& last) { return first.size() + last.size(); } // Encourage constexpr int32_t GetTotalCharacters( const std::string& first, const std::string& last) noexcept { return first.size() + last.size(); }
Use braced initialization
Braced initialization (x{}
or x = {}
) is always clearly initialization. Other forms of initialization such as with parentheses (x()
) or nothing (x
) are much more ambiguous. They can be mistaken for declarations, function calls, and function-style casts. Prefer braced initialization to ensure that initialization occurs:
// Avoid template <typename T> T GetDefault() { T t; // Default constructor for classes but nothing for primitives return t; } struct Point2 { float X{ 0 }; float Y{ 0 }; }; std::ostream& operator<<(std::ostream& s, const Point2& p) { s << p.X << ", " << p.Y; return s; } DebugLog(GetDefault<Point2>()); // 0, 0 DebugLog(GetDefault<int>()); // undefined behavior! // Encourage template <typename T> T GetDefault() { T t{}; // Default constructor for classes, primitives are value-initialized return t; } DebugLog(GetDefault<Point2>()); // 0, 0 DebugLog(GetDefault<int>()); // 0
Standardize error-handling
There are two main choices for error-handling in C++: exceptions and error codes. C’s errno
isn’t considered a valid choice due to its reliance on global state which is not part of the call signature and not thread-safe. Codebases should choose one approach or the other to handle errors consistently and safely. For example, introducing exceptions into a codebase that uses error codes is likely to cause uncaught exceptions that crash the program.
If exceptions are chosen, they should be thrown by value and caught by const
reference:
// Avoid try { throw new std::runtime_error{ "Boom!" }; } catch (std::runtime_error* err) { DebugLog(err->what()); // Boom! // ... memory leak here ... } // Encourage try { throw std::runtime_error{ "Boom!" }; } catch (const std::runtime_error& err) { DebugLog(err.what()); // Boom! }
If error codes are chosen, a wrapper type such as std::optional
is encouraged over the use of null pointers to clearly indicate to callers that the operation may not succeed. The use of [[nodiscard]]
is also often warranted to ensure errors are handled.
//////// // Avoid //////// // Caller doesn't know what happens upon error // Caller can ignore error return values FILE* OpenFile(const char* path, const char* mode) { return fopen(path, mode); } FILE* handle = OpenFile("/path/to/file", "rw"); fprintf(handle, "Hello!"); // Crash if null is returned fclose(handle); //////////// // Encourage //////////// // Caller clearly knows this can fail due to the std::optional return value // Caller can't ignore it due to the [[nodiscard]] attribute [[nodiscard]] std::optional<FILE*> OpenFile(const char* path, const char* mode) { FILE* handle = fopen(path, mode); if (!handle) { return {}; } return handle; } // Handling the return value is required by [[nodiscard]] std::optional<FILE*> result = OpenFile("/path/to/file", "rw"); if (!result.has_value()) { DebugLog("Failed to open file"); return; } // Can't directly use the result. Forced to deal with it being optional. // Fewer chances to dereference null and crash FILE* handle = result.value(); fprintf(handle, "Hello!"); fclose(handle);
Mark overridden member functions with override
A virtual
member function that overrides a base class’ member function doesn’t have to be marked that way, but it’s helpful to indicate this. It provides a keyword that readers of the code can look for to know how the function fits into the class design. It also provides the compiler with a way to enforce that the function really overrides a base class version. If the function signatures subtlely don’t match or the base class no longer has such a function, the compiler will catch the mistake instead of creating a new function.
// Avoid struct Animal { virtual void Speak(const char* message, bool loud=false) { // By default, animals can't speak } }; struct Dog : Animal { // Missing "loud" parameter creates a new function virtual void Speak(const char* message) { DebugLog("woof: ", message); } }; std::unique_ptr<Animal> a = std::make_unique<Dog>(); a->Speak("go for a walk?"); // Prints nothing because Dog doesn't override // Encourage struct Animal { virtual void Speak(const char* message, bool loud=false) { // By default, animals can't speak } }; struct Dog : Animal { // Missing "loud" parameter is a compiler error virtual void Speak(const char* message) override { DebugLog("woof: ", message); } }; std::unique_ptr<Animal> a = std::make_unique<Dog>(); a->Speak("go for a walk?"); // Never executed due to compiler error
Use using
, not typedef
C’s typedef
alias is still supported, but using
is a strictly better version of it. The alias and the target are put into the familiar assignment form where the left hand side is assigned to from the right hand side. It also supports being templated, so it fits in better with generic programming.
// Avoid typedef float f32; f32 pi = 3.14f; // Encourage using f32 = float; f32 pi = 3.14f;
// Avoid #define VEC(T) std::vector<T> VEC(float) floats; // Encourage template <typename T> using Vec = std::vector<T>; Vec<float> floats;
Minimize function definitions in header files
Header files are typically compiled many times as many translation units directly or indirectly #include
them. Any changes to the header file will require recompiling all the translation units that #include
it. The linker will eventually de-duplicate these, but the compilation is slow and so build and iteration times suffer. To reduce the time it takes to compile header files, reduce the number of function definitions in them. Instead, declare functions in them and define them in translation units whenever possible.
//////// // Avoid //////// // math.h #pragma once bool IsNearlyZero(float x) { return std::abs(x) < 0.0001f; } //////////// // Encourage //////////// // math.h #pragma once bool IsNearlyZero(float x); // math.cpp #include "math.h" bool IsNearlyZero(float x) { return std::abs(x) < 0.0001f; }
Use internal linkage for file-specific definitions
By default, entities like variables, functions, and classes have external linkage at file scope. This slows down compilation and the linker because they need to consider the possibility that some other translation unit might want to reference those entities. To speed it up, use static
or an unnamed namespace to give those entities internal linkage and remove their candidacy for reference by other translation units.
// Avoid float PI = 3.14f; // Encourage static float PI = 3.14f; // Encourage namespace { float PI = 3.14f; }
Use operator overloading and user-defined literals very sparingly
Overloaded operators don’t really get a name and user-defined literals usually only have a terse one. As such, it’s often hard for readers to understand what they’re doing. Even worse, overloaded operators may appear to have one meaning while the implementation of the overloaded operator does something else. These should generally be avoided except in cases where the meaning is already well-understood. For example, the +
operator on two std::string
objects is clearly concatenation of the left hand operand followed by the right hand operand but the +
operator on two Player
objects is quite a puzzle.
// Avoid struct Player { int32_t Points; Player operator+(const Player& other) { return { Points + other.Points }; } }; Player a{ 100 }; Player b{ 200 }; Player c = a + b; // No conventional meaning for what + does DebugLog(c.Points); // 300 // Encourage struct Vector2 { float X; float Y; Vector2 operator+(const Vector2& other) { return { X + other.X, Y + other.Y }; } }; Vector2 a{ 100, 200 }; Vector2 b{ 300, 400 }; Vector2 c = a + b; // Well-understood mathematical operator DebugLog(c.X, c.Y); // 400, 600
Prefer pre-increment to post-increment
Whether we use the pre-increment operator (++x
) or the post-increment operator (x++
) on a primitive type like int
makes no difference. With classes that have overloaded this operator, especially in the case of iterators, the pre-increment operator can be implemented more efficiently by removing the need to temporarily have two copies. It’s generally preferable to use the pre-increment operator for this reason:
// Avoid: potentially slower than pre-increment for (auto it = floats.begin(); it != floats.end(); it++) { DebugLog(*it); } // Encourage: always the fastest way to increment for (auto it = floats.begin(); it != floats.end(); ++it) { DebugLog(*it); }
Avoid template metaprogramming
The vast majority of code written should steer far clear from advanced features such as template metaprogramming. This umbrella term refers to using templates as the Turing-complete language they are to generate very complex code at compile time. Techniques such as SFINAE, not even covered in this series, should generally be the province of a few expert-level library creators such as those implementing the C++ Standard Library, testing frameworks, serialization libraries, and so forth. Almost all “normal” code should stick to “normal” features.
// Avoid: SFINAE like this and other TMP tricks template< typename T, std::enable_if_t<std::is_integral<T>::value, bool> = true> void PrintKindOfPrimitive(T) { DebugLog("it's an integer"); } template< typename T, std::enable_if_t<std::is_floating_point<T>::value, bool> = true> void PrintKindOfPrimitive(T) { DebugLog("it's a float"); } PrintKindOfPrimitive(123); // it's an integer PrintKindOfPrimitive(3.14); // it's a float // Encourage using libraries that implement this or avoiding the need at all
Use auto
for at least long type names
Some codebases prefer the AAA style: “almost always auto
.” This means the type of most variables is auto
and only in a few cases is an explicit type named. Advantages include terseness and the potential avoidance of type conversion, especially when types are changed in existing code. Other codebases prefer to explicitly name all types. Advantages include readability without the need for IDE tooltips that reveal deduced types, such as when looking at code in a web browser.
Regardless of the decision, and both are popular, both camps tend to agree that very long types are hard to read and often made more clear by the use of auto
:
// Avoid: type name is so long that an alias is required to fit it on one line using Map = std::unordered_map<std::basic_string<char8_t>, Game::Player*>; using It = Map::const_iterator; for (It it = players.begin(); it != players.end(); ++it) { DebugLog(it->first, "has", it->second->Points, "points"); } // Encourage: use auto for at least long types like these for (auto it = players.begin(); it != players.end(); ++it) { DebugLog(it->first, "has", it->second->Points, "points"); }
Use compile-time polymorphism more often
Both compile-time polymorphism with templates and run-time polymorphism with virtual
functions have valid use cases. However, most languages have limited support for compile-time polymorphism. As such, we can often overlook possibilities for performance improvements by shifting run-time work to compile-time. A lot of code that’s knowable at compile-time must be determined at run-time in languages like C# while the opportunity is there in C++ to make the determination at compile-time. Idiomatic C++ tends to prefer these compile-time solutions to improve run-time performance:
// Avoid: run-time polymorphism when compile-time is suitable struct Weapon { virtual void DoDamage(Player& player) = 0; }; struct FoamDart : Weapon { virtual void DoDamage(Player& player) override { player.Health--; } }; struct Bazooka : Weapon { virtual void DoDamage(Player& player) override { player.Health = 0; } }; FoamDart w; w.DoDamage(p); // Run-time decision // Encourage: compile-time polymorphism when suitable struct FoamDart { void DoDamage(Player& player) { player.Health--; } }; struct Bazooka { void DoDamage(Player& player) { player.Health = 0; } }; template <typename TWeapon> void DoDamage(const TWeapon& weapon, Player& player) { weapon.DoDamage(player); } FoamDart w; DoDamage(w, player); // Compile-time decision
Conclusion
None of the above is gospel. There are exceptional cases for all of these guidelines. Nearly all guides will make some different choices than the above. Despite the conflicting guidance, all of the above are common advice in many guides, teams, codebases, or organizations. Still, a lot is surely missing. It’s not feasible to list every best practice or idiom for C++ any more than it’s feasible for a much smaller, newer language like C#.
In the end, we’re not working on some abstract code. We’ll work on particular codebases and it’s important to be aware of the norms of those particular environments. Each will have their own written or unwritten rules, not to mention very subjective thoughts on style such as the placement of curly braces and whether indentation should be done with tabs or spaces. These certainly aren’t C++-specific issues, but in the case of C++ it’s wide use, large size, and long history somewhat increase the challenge.
#1 by Pavel on May 17th, 2021 ·
Excellent entry in an excellent series! Great digest of best practices and guides! I approve!
#2 by Pavel on May 17th, 2021 ·
typo:
concatenation of the left hand operator followed by the right hand operator
should be something like:
concatenation of the left hand side operand followed by the right hand side operand
#3 by jackson on May 17th, 2021 ·
Thanks for pointing out the typo. I’ve updated the article with a fix.
#4 by Mark on July 27th, 2022 ·
Good list, a couple of nitpicks:
“Calling virtual functions before these are set up can result in crashes or calling the wrong version of the function”
This is not accurate – it will never crash, and it always calls the right function according to the language rules. That is, at the point of executing the ctor, the vtable will be initialized to that class, so you’ll get the definition from that class (including any base classes) but not any override from a more derived class.
This is different to the C# behaviour, which is to call the most derived override having initialized members but only run ctors from base to the current one.
Overall I find the C++ behaviour more sensible – it’s equivalent to calling the virtual function as if no further derived classes exist, and at least everything it can see is fully constructed.
That said, either way it’s considered a gotcha as it may not do what you expect, so the advice is ok, even if the reasoning isn’t entirely correct.
“Avoid template metaprogramming”.
Maybe “don’t use template metaprogramming except where necessary” is more reasonable. E.g. the example you give could clearly be an overload, and even if you do need something more, then better to use ‘if constexpr’ (C++17) or concept / requires (C++20) than enable_if.
What’s “normal” is subjective, but as an example if you want to write something which works like C# generics, pattern matching, ‘is’, or ‘where’ then likely you’re going to need that stuff.
#5 by mactyr on July 31st, 2023 ·
Typo: the second example under “override” is labeled both “encourage” and “avoid”.
Thanks for this great resource!
#6 by jackson on August 4th, 2023 ·
Thanks for letting me know. I updated the article with a fix. I’m glad you’re finding the series useful!