The C++20 Naughty and Nice List for Game Devs
The goal here is to compile a list of C++20 features I think game devs should probably use (possibly with caveats), and features I think game devs will probably want to avoid. You’re probably curious “why now?” given that it’s 2023 and C++20 was standardized several years ago. Well, mainly it’s because it took me several years of trying and retrying various features to get a sense for what I liked and what I didn’t.
I’m going to start with a disclaimer, because whenever feedback regarding C++ matters is concerned, things can get touchy, but please do read the following before reacting too strongly to anything that follows. The main discalimer is that I’m assuming that the interested reader is a developer with similar codebase requirements:
- Large codebase (10M+ LOC) that is routinely compiled on your machine
- Heavy DLL usage for plugins and general code structure modularization
- Broad platform compatibility
- Sensitivity to disk usage and binary bloat
- Runtime efficiency is of utmost importance (
-O0
speed is also important)
To the extent that your experience as a developer (or game developer) matches the requirements above, you may be more inclined to agree with my naughty-and-nice classification. I think without such constraints, some of the “naughty features” here are honestly quite nice.
The second disclaimer is that even if your requirements seem to match mine, you can still come up with diametrically opposite conclusions, and that’s OK also. Even if codebases “rhyme,” coding culture and practices differ greatly from team to team, so you’ll need to apply the reasoning used to classify features as “naughty” or “nice” to your own use cases to determine what applies and to what extent.
The next disclaimer is that even if a feature is on the nice list, it doesn’t mean the feature can’t be abused, and it doesn’t mean the feature isn’t naughty in some contexts. Similarly, features in the naughty list may not be naughty in all circumstances.
The last disclaimer is that this post is not even remotely exhaustive. There is far too much material to really cover in detail in a single post, so this just covers some of the broad strokes. If I miss your favorite feature (or most hated feature), it’s either because there wasn’t room to cover it, or because I don’t have enough personal experience with the feature to draw a conclusion one way or another.
With that out of the way, let’s start with the nice items, followed immediately by the naughty items.
(Nice) Default comparison and the three-way comparison operator (aka the spaceship: <=>)
New in C++20 are default comparison rules for structured types that perform an automatic lexicographic comparison when requested.
struct Date
{
int year;
int month;
int day;
auto operator<=>(Date const&) const = default;
bool operator==(Date const&) const = default;
};
With the declaration above, instances of Date
are comparable with any of the binary comparison
operators, with the behavior you expect (don’t actually define a Date
class this way of course).
The three-way comparison operator
there defines a single binary operator which then enables a family of binary comparison operators
on a given object. This operator can of course be overloaded.
These features are a win in my book because combined, they cut down on code duplication, reducing the surface area for bugs as code changes. Lexicographic comparison is a great default, because what other behavior would have been a more sensible default?
The main caveat is that I would not use the 3-way comparison operator for custom numeric types
where maximum efficiency is needed, because there may be slight overhead in debug builds,
but chances are, such types are likely small or change infrequently (such that the advantages
of the 3-way comparison operator with respect to maintainability are not felt). As an additional
note to be aware of, in most cases, you probably want to at least implement operator==
, which
may be faster than invoking operator<=>
to do equality checks.
(Nice) Signed integers are 2’s complement and arithmetic shifts
Signed overflow/underflow remain UB (and it’s understandable that changing this behavior this late
in the game would have dramatic consequences), but it’s nice to know that we can at least expect
sign extension when using operator>>
and operator<<
on signed operands now.
(Nice) Coroutines (with heavy caveats)
C++20 coroutines are one of my favorite features. It’s been also wildly criticized for its complexity (or because they aren’t stackful, etc.). However, while I do think the criticisms are understandable, coroutines can work remarkably well as a user, with similar and often better overhead than what we’d see in a typical task-graph implementation.
The main downside is that there’s a heavy upfront setup cost (and possibly maintenance cost) to bootstrap your coroutine-based frontend API for task scheduling. I recommend checking out concurrencpp if you’re in the market for frameworks that provide a coroutine interface, or if you want to see the type of constructs that are possible.
Another downside to coroutines are that the dependency graph is implicit as opposed to explicit, so if you have tooling to visualize the dependency graph and such, this viz is going to have to operate using runtime data.
(Nice) Constraints and Concepts
Sure there is the dreaded requires requires
syntactic oddity that shows up, but on the whole, constraints and concepts
should show up pretty much anywhere you have a template (which is hopefully used in your codebase
only where needed already). In exchange for the effort of writing constraints and concepts (all
of I recommend naming as opposed to writing inline concepts), you are rewarded with nicer error
feedback from the compiler forevermore which seems pretty worth if you ask me. In addition, the
template type signatures, when decorated with constraints, now inform the user what types are usable
as valid type parameters. This is a strict win in my book.
The concepts that ship with the compiler are also immediately usable. I wouldn’t recommend writing these yourself, since you may be missing out on optimizations available through the use of compiler intrinsics.
(Nice) <bit>
The <bit>
header is nice and standardizes a lot
of operations we arguably should have had years ago (like popcount
and bit rotations and such).
The very important addition here is the inclusion of std::bit_cast
which lets us cast unrelated types safely without relying on the memcpy
pattern. In MSVC for example,
this is implemented using the MSVC __builtin_bit_cast
intrinsic.
(Nice) <numbers>
You now have pi
and other constants available for use in a header.
(Nice) New synchronization primitives
The new <barrier>
,
<latch>
, and <semaphore>
primitives have been working for me with the caveat that if you already have well-tested cross-platform
implementations of these primitives, I would keep using those. A potential disadvantage is that using
these primitives requires you to buy into the <chrono>
way of spelling time points and durations,
which I’ve grown accustomed to but isn’t for everyone.
(Nice) <span>
Passing non-owning views is a good idea in general, so it’s nice that the notion of
std::span
is now officially codified. This isn’t going
to convince me to use STL containers anytime soon (for other reasons I won’t get into), but the implementation
of std::span
as a pointer plus size is unobjectionable enough and easy-to-use (interoperability with functions
that take iterator pairs, range for-loops, etc.).
(Nice-ish) Designated initializers
Designated initializers are a new form of initialization that initializes structured variable members by name.
struct Point
{
float x;
float y;
float z;
};
Point origin{.x = 0.f, .y = 0.f, .z = 0.f};
I consider this feature “nice-ish.” On the one hand, the feature allows us to initialize structured variables in a self-documenting manner, and I’m always a fan of optimizing for the reader. However, the main caveat regarding designated initializer usage is that initialized members must appear in declaration order. In other words, this code won’t compile:
struct Point
{
float x;
float y;
float z;
};
// Oops, field order is incorrect.
Point origin{.y = 0.f, .z = 0.f, .x = 0.f};
This behavior makes sense for structured types with non-trivial layout, but feels unnecessarily restrictive for POD types. I often work with types that have dozens of fields or more, so honoring the initialization order to match the original declaration is quite tedious indeed. Furthermore, in game dev, we often move structure fields around in order to optimize layout (coalescing hot data in cache lines, promoting true sharing, avoiding false sharing). This type of optimization can’t be done without cascading into compilation failures throughout the codebase. As a result, designated initializers have the paradoxical effect of making a codebase less maintainable in a sense.
This one is still barely in the nice category because it does make life better for the reader (who we all prioritize over the writer), but I do hope that restriction is relaxed for trivial types some day. Flexibility with ordering was the entire point of the “named parameters” feature in many other languages after all.
(Naughty) char8_t
MSVC added /Zc:char8_t-
to disable this type entirely, and GCC did the same.
The main reason char8_t
was introduced was to provide a dedicated
type for unicode data. Unfortunately, as specified, unicode conversions to char*
were disallowed,
thereby breaking a lot of compatibility with char*
interfaces that expected users to pass u8""
strings.
This famously caused a bit of drama by breaking a number of
Dear ImGui APIs (wayback twitter thread).
I will continue to abide by the guidelines summarized
here for UTF-8 matters (unless I’m forced to live in a TCHAR
world).
That said, the char8_t
proposal in general has my sympathy, since it would be nice to have the unicode
story fully straightened out, but c’est la vie.
(Naughty-ish) [[no_unique_address]]
This is naughty specifically because of MSVC, where you continue to need to use [[msvc::no_unique_address]]
for reasons.
Otherwise, you probably want this for things like stateless allocators that are used as data structure members but shouldn’t occupy actual memory.
(Naughty) Modules
C++20 modules in its current state are
unfortunately a big disappointment. I do hope they eventually get to a better place, but this may be difficult
for as long as the C++ standard continues to ignore shared linkage and DLLs. Here’s a
StackOverflow answer
I wrote some time ago to describe the problem. Essentially, module and DLL linkages are completely orthogonal
to each other, so if you’re in the business of maintaining a codebase where you’d need both (as most game
devs are), expect there to be a lot of build complexity juggling symbol visibilty and linkage types.
While the problematic jmp
indirection mentioned in that StackOverflow answer can be eliminated with full LTO
(and possibly thin LTO), I suspect most game devs find LTO to be a bit too heavy for frequent usage anyways.
(Naughty) <format>
The <format>
header is probably great in many contexts,
but I would not personally use it for a game engine. Once you get in the business of making an API that
encourages custom formatter specification to live in a template, IMO, you’ve already lost. Using std::format
and fmtlib
which the standard is based on, I saw compile times and binary sizes increase dramatically
in a way that does not scale across a gigantic codebase (to my satisfication). Relying on the linker to
drop duplicate symbols is “an approach” but not one I’m a fan of. It’s incredibly wasteful to ask your
compiler to compile copies of your code and write them to disk for every translation unit that requires
a custom formatter, only to then request the linker to read it all back and drop duplicates (not to mention
PDB sizes). Of course, it’s possible to declare custom formatter templates and implement them in a source file,
but again, this isn’t encouraged by the API, and I don’t believe it’s possible to rely on raw discipline to prevent
that type of code bloat from accruing across a large team.
A preferable interface (I use, but also others AFAIK) is to check the type in a template (no choice there), and dispatch the formatting routine to somewhere that lives in a single translation unit.
(Naughty) <ranges>
I tried <ranges>
in a medium-ish codebase and benchmarked
the compiler about a year ago. I wasn’t remotely happy with the results and reverted the changes, YMMV.
An incredible amount of work has gone into this, but it sort of reinforces my belief that for some constructs,
templates are a valuable prototyping tool, but not the endgame. Personally, I find code that leverages
ranges harder to read, not easier, because lambdas inlined in functions introduce new scopes that have a
strong non-linearizing effect on the code. This isn’t a criticism of ranges per se, but certainly is a stylistic
preference.
(Naughty) <source_location>
The new <source_location>
feature lets
you remove a macro which would have historically expanded __FILE__
and __LINE__
directives and replace
it with a std::source_location
which is populated by a default argument (spelled std::source_location::current
).
This isn’t overwhelmingly naughty, but I don’t see it as a dramatic improvement either. Chances are,
your log statements need to remain macros since you need a way to strip those statements at build time
depending on the build type anyways. The main drawback of std::source_location
is that compared to
__FILE__
, where the file length is statically knowable, std::source_location::file_name
returns
a const char*
string with a file length that isn’t statically knowable.
IMO, the new std::source_location
“pattern” makes function signatures a lot uglier, and would be
better served with a separate mechanism for querying PDBs and stack frame data. Luckily,
stacktrace is on the horizon, but I doubt
we’ll get any cross-platform interfaces for querying DWARF/PDB files anytime soon (wouldn’t that be
lovely).
Conclusion
On the whole, I view C++20 as an impactful and positive change, albeit with a few hiccups here and there (understandable given the sheer scale of the standardization effort). It’s also likely the case that some features I view as misses, might be viewed as absolute godsends by other engineers with different requirements or coding practices to my own. Finally, as a reminder, there were plenty of changes in C++20 that weren’t covered here, either because I lack personal experience with the feature, or because it’s a feature unlikely to be relevant to game devs specifically. With that, I encourage readers to take this list with a grain of salt, try things out, and draw your own conclusions.