C++ For C# Developers: Part 23 – Compile-Time Programming
The vast majority of the code we write executes at runtime. Today’s article is about the other kind of code, which runs during compilation. C# has very limited support for this. In C++, especially its newer versions, most of the language features are usable at compile-time. Read on to learn how to take advantage of this!
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
Constant Variables
C# has const
fields of classes and structs. They must be immediately initialized by a constant expression, which is one that’s evaluated at compile time. Their type must be a primitive like int
and float
or string
. A const
is implicitly static
and readonly
.
Likewise, C++ has constexpr
variables. They must be “literal types” which include primitives like int
and float
, but also references, classes that meet certain criteria, arrays of “literal types,” or void
. They are implicitly const
, but not static
.
struct MathConstants { // Constant member variable // Implicitly `const` // Not implicitly `static`. Need to add the keyword. static constexpr float PI = 3.14f; }; // Constant global variable constexpr int32_t numGamesPlayed = 0; void Foo() { // Constant local variable constexpr int32_t expansionMultiplier = 3; // Constant reference // Need to add `const` because `numGamesPlayed` is implicitly `const` constexpr int32_t const& ngp = numGamesPlayed; // Constant array // Implicitly `const` with `const` elements constexpr int32_t exponentialBackoffDelays[4] = { 100, 200, 400, 800 }; }
So far we’ve just used primitives, references to primitives, and arrays of primitives. Classes are allowed too, but with a few restrictions. First, they must be either an aggregate, a lambda, or have at least one non-copy, non-move constexpr
constructor. We’ll get into constexpr
functions in the next section.
struct NonLiteralType { // Delete the copy and move constructors NonLiteralType(const NonLiteralType&) = delete; NonLiteralType(const NonLiteralType&&) = delete; private: // Add a private non-static data member so it's not an aggregate int32_t Val; }; // Compiler error: not a "literal type" constexpr NonLiteralType nlt{};
Second, it must have a constexpr
destructor.
struct NonLiteralType { // Destructor isn't constexpr NonLiteralType() { } }; // Compiler error: NonLiteralType doesn't have a constexpr destructor constexpr NonLiteralType nlt{}; struct LiteralTypeA { // Explicitly compiler-generated destructor is constexpr LiteralTypeA() = default; }; struct LiteralTypeB { // Implicitly compiler-generated destructor is constexpr }; // OK constexpr LiteralTypeA lta{}; constexpr LiteralTypeB ltb{};
Third, if it’s a union
then at least one of its non-static
data members must be a “literal type.” If it’s not a union
, all of its non-static
data members and all the data members of its base classes must be “literal types.”
union NonLiteralUnion { NonLiteralType nlt1; NonLiteralType nlt2; }; // Compiler error: all of the union's non-static data members are non-literal constexpr NonLiteralUnion nlu{}; struct NonLiteralStruct { NonLiteralType nlt1; int32_t Val; // Primitives are literal types }; // Compiler error: not all of the struct's non-static data members are literal constexpr NonLiteralStruct nls{};
If we satisfy these requirements, we’re free to make constant class variables. Here’s a simple aggregate:
struct Vector2 { float X; float Y; }; // Constant class instance constexpr Vector2 ORIGIN{0, 0};
One last thing to keep in mind with constexpr
variables: they’re incompatible with constinit
variables. This C++20 keyword is used to require that a variable be constant-initialized:
// Requires `ok` to be constant-initialized constinit const char* ok = "OK"; // Compiler error: can't be both constexpr and constinit constexpr constinit const char* ok2 = "OK"; // Compiler error: not initialized with a constant constinit const char* err = rand() == 0 ? "f" : "t";
Constant Functions
Functions in C++ may also be constexpr
. This means they can be executed at compile time:
constexpr int32_t SumOfFirstN(int32_t n) { int32_t sum = 0; for (int32_t i = 1; i <= n; ++i) { sum += i; } return sum; } // 1 + 2 + 3 DebugLog(SumOfFirstN(3)); // 6
Because 3
is a compile-time constant, the call to SumOfFirstN(3)
will be executed during compilation and replaced by the return value to become:
DebugLog(6); // 6
If the argument to SumOfFirstN
isn’t a compile-time constant, SumOfFirstN
will be compiled and called like normal:
// Read `n` from a file FILE* handle = fopen("/path/to/file", "r"); int32_t n{0}; fread(&n, sizeof(n), 1, handle); // Argument not known at compile time // Depends on the state of the file system // SumOfFirstN call happens at runtime DebugLog(SumOfFirstN(n)); // 6 fclose(handle);
If we don’t want the function to be callable at runtime, such as to avoid accidentally increasing the executable size or performing compile-time computation at runtime, C++20 allows us to replace constexpr
with consteval
. The only difference between constexpr
and consteval
is that we’ll get a compiler error if we try to call a consteval
function at runtime.
consteval int32_t SumOfFirstN(int32_t n) { int32_t sum = 0; for (int32_t i = 1; i <= n; ++i) { sum += i; } return sum; }
In order to be constexpr
, functions must meet certain criteria. Since their introduction in C++11, this criteria has been greatly relaxed in C++14 and C++20. Future versions of the language will likely continue the trend. For now, we’ll just look at the rules for C++20.
First, all of their parameters and their return type must be “literal types.”
// Not a literal type due to non-constexpr destructor struct NonLiteral { ~NonLiteral() { } }; // Compiler error: constexpr function arguments must be literal types constexpr void Foo(NonLiteral nl) { } // Compiler error: constexpr function return value must be a literal type constexpr NonLiteral Goo() { return {}; }
Second, a constexpr
constructor or destructor can’t be in a class that has virtual base classes or non-constexpr
base class constructors or destructors.
struct NonLiteralBase { }; struct NonLiteralCtor : virtual NonLiteralBase { // Compiler error: constructor can't be constexpr with virtual base classes constexpr NonLiteralCtor() { } }; struct NonLiteralDtor : virtual NonLiteralBase { // Compiler error: destructor can't be constexpr with virtual base classes constexpr ~NonLiteralDtor() { } };
Third, the body of the function can’t have any goto
statements or labels except case
and default
in switch
statements:
constexpr int32_t GotoLoop(int32_t n) { int32_t sum = 0; int32_t i = 1; // Compiler error: constexpr function can't have non-case, non-default label beginLoop: if (i > n) { // Compiler error: constexpr function can't have goto goto endLoop; } sum += i; // Compiler error: constexpr function can't have non-case, non-default label endLoop: return sum; }
Fourth, all local variables have to be “literal types:”
constexpr void Foo() { // Compiler error: constexpr function can't have non-literal variables NonLiteral nl{}; }
Fifth, the function can’t have any static
variables:
constexpr int32_t GetNextInt() { // Compiler error: constexpr functions can't have static variables static int32_t next = 0; return ++next; }
Any function that follows all these rules is free to be constexpr
or consteval
.
Constant If
Since C++17, a new form of if
is available: if constexpr (...)
. These are evaluated at compile time regardless of whether they’re in a constexpr
function or not. The compiler then removes the if
in favor of either the code in the if
block or the else
block. They’re very useful for removing code from the executable to reduce its size and to remove the cost of branch instructions at runtime.
For example, say we want to set the minimum severity level that is logged at compile time and not have to check it every time a log message is written. We could use a compiler-defined preprocessor symbol (LOG_LEVEL
), a constexpr
string equality function (IsStrEqual
), and if constexpr
to either log or not log:
// The compiler sets a preprocessor symbol based on its configuration // It's like we wrote this into the C++ code: #define LOG_LEVEL "WARN" // Constant function that compares NUL-terminated strings for exact equality constexpr bool IsStrEqual(const char* str, const char* match) { for (int i = 0; ; ++i) { char s = str[i]; char m = match[i]; if (s) { if (m) { if (s != m) { return false; } } else { return false; } } else { return !m; } } return true; } // Logs debug messages if LOG_LEVEL is DEBUG void LogDebug(const char* msg) { // Compare LOG_LEVEL to "DEBUG" at compile time // If false, the WriteLog call is removed from the executable if constexpr (IsStrEqual(LOG_LEVEL, "DEBUG")) { WriteLog("DEBUG", msg); } } // Logs warning messages if LOG_LEVEL is DEBUG or WARN void LogWarn(const char* msg) { // Compare LOG_LEVEL to "DEBUG" and "WARN" at compile time // If false, the WriteLog call is removed from the executable if constexpr (IsStrEqual(LOG_LEVEL, "DEBUG") || IsStrEqual(LOG_LEVEL, "WARN")) { WriteLog("WARN", msg); } } // Logs warning messages if LOG_LEVEL is DEBUG, WARN, or ERROR void LogError(const char* msg) { // Compare LOG_LEVEL to "DEBUG", "WARN", and "ERROR" at compile time // If false, the WriteLog call is removed from the executable if constexpr (IsStrEqual(LOG_LEVEL, "DEBUG") || IsStrEqual(LOG_LEVEL, "WARN") || IsStrEqual(LOG_LEVEL, "ERROR")) { WriteLog("ERROR", msg); } }
Static Assertions
Similar to if constexpr
, we can write assertions that execute at compile time rather than run time. For example, we could static_assert
to make sure the LOG_LEVEL
was set to a valid value. If it’s not the code won’t compile:
static_assert(IsStrEqual(LOG_LEVEL, "DEBUG") || IsStrEqual(LOG_LEVEL, "WARN") || IsStrEqual(LOG_LEVEL, "ERROR"), "Invalid log level: " LOG_LEVEL);
We could also check to make sure the code is being compiled in a supported build environment:
static_assert(MSC_VER >= 1900, "Only Visual Studio 2015+ is supported"); static_assert(_WIN64, "Only 64-bit Windows is supported");
Or, as is more traditional for assertions, we could make sure that we didn’t make a mistake in our code. For example, it’s common to define a struct
that’s serialized and deserialized when reading from or writing to files or network sockets. We can use static_assert
to make sure it’s the right size and we haven’t inadvertently increased it by adding data members or padding:
struct PlayerUpdatePacket { int32_t PlayerId; float PositionX; float PositionY; float PositionZ; float VelocityX; float VelocityY; float VelocityZ; int32_t NumLives; }; static_assert( sizeof(PlayerUpdatePacket) == 32, "PlayerUpdatePacket serializes to wrong size");
Since C++17, we can omit the error message if we don’t think it’s helpful:
static_assert(sizeof(PlayerUpdatePacket) == 32);
Constant Expressions
Now that we have a wealth of compile-time features in constexpr
variables, constexpr
functions, if constexpr
, and static_assert
, we need to know when we’re allowed to use these and when we’re not. Clearly literals like 32
are constant expressions and calls to rand()
to get a random number are not, but many cases are not so clear-cut.
C++ qualifies an expression as constant as long as it doesn’t match any of a list of criteria. Since C++11, each version of the language has relaxed these restrictions. The language is broadly moving toward being entirely executable at compile time. Until then, we need to know what’s not allowed in a compile-time expression so we know what we can’t use with all these compile-time features. Let’s go through the rules as of C++20.
First, the this
pointer can’t be used, implicitly or explicitly, outside of constexpr
member functions and constexpr
constructors:
struct Test { int32_t Val{123}; int32_t GetVal() { // Explicitly use the this pointer // Compiler error: can't use `this` outside of constexpr member function constexpr int32_t val = this->Val; return val; } };
The same goes for lambdas referencing captured objects, since they are effectively accessed via the this
pointer to the lambda class:
const int32_t outside = 123; auto lambda = [outside] { // Compiler error: can't reference variables outside the lambda constexpr int32_t const* pOutside = &outside; DebugLog(*pOutside); }; lambda();
Second, calls to non-constexpr
functions and constructors aren’t allowed:
struct Test { int32_t Val{123}; // User-defined conversion operator to int32_t operator int32_t() { return Val; } }; // Try to make a Test and convert it to an int32_t // Compiler error: call to Test constructor which isn't constexpr constexpr int32_t val = Test{};
Third, we can’t make calls to constexpr
functions that are only declared but not yet defined:
// Declare constexpr function constexpr int32_t Pow(int32_t x, int32_t y); // Compiler error: Pow isn't defined yet constexpr int32_t eight = Pow(2, 3); // Define constexpr function constexpr int32_t Pow(int32_t x, int32_t y) { int32_t result = x; for (; y > 1; --y) { result *= x; } return result; }
Fourth, calls to constexpr
virtual
functions of classes that aren’t “literal types” and whose lifetimes began before the expression:
struct Base { constexpr virtual int32_t GetVal() { return 1; } }; struct Derived : Base { constexpr virtual int32_t GetVal() override { return 123; } }; // Class begins lifetime before the constant expression Derived d{}; // Compiler error: can't call constexpr virtual function on class that began its // lifetime before the constant expression constexpr int32_t val = d.GetVal(); // OK: Derived object begins lifetime during constant expression constexpr int32_t val2 = Derived{}.GetVal();
Fifth, triggering any form of undefined behavior. This rule adds a safety net to constexpr
code. Undefined behavior simply won’t compile.
// Compiler error: dividing by zero is undefined behavior constexpr float f = 3.14f / 0.0f; // Compiler error: signed integer overflow is undefined behavior constexpr int32_t i = 0x7fffffff + 1;
Sixth, lvalues can’t be used as rvalues if the lvalue isn’t allowed in a constant expression. For example, a non-const
lvalue isn’t allowed but a const
lvalue is:
// i1 can't be used in a constant expression because it's not const int32_t i1 = 123; // i2 can be used in a constant expression because it is const const int32_t i2 = 123; // i3 can't be used in a constant expression because it's not initialized with // a constant expression const int32_t i3 = i1; // Compiler error: i1 can't be used constexpr int32_t i4 = i1; // Compiler error: i3 can't be used constexpr int32_t i5 = i3; // OK: i2 can be used constexpr int32_t i6 = i2;
References are not an allowed workaround since they are just a synonym for an object:
struct HasVal { int32_t Val{123}; }; HasVal hv{}; constexpr HasVal const& rhv{hv}; constexpr int32_t ri{rhv.Val}; constexpr HasVal hv2{}; constexpr HasVal const& rhv2{hv2}; constexpr int32_t ri2{rhv2.Val};
Seventh, only the active member of a union can be used. Accessing non-active members is a form of undefined behavior that’s commonly allowed by compilers in run-time code but disallowed in compile-time code.
union IntFloat { int32_t Int; float Float; constexpr IntFloat() { // Make Int the active member Int = 123; } }; // Call constexpr constructor which makes Int active constexpr IntFloat u{}; // OK: can use Int because it's the active member constexpr int32_t i = u.Int; // Compiler error: can't use Float because it's not the active member constexpr float f = u.Float;
The compiler-generated copy or move constructor or assignment operator of a union
whose active member is mutable
is also disallowed:
union IntFloat { mutable int32_t Int; float Float; constexpr IntFloat() { Int = 123; } }; constexpr IntFloat u{}; constexpr IntFloat u2{u};
Eighth, converting from void*
to any other pointer type:
constexpr int32_t i = 123; constexpr void const* pv = &i; // Compiler error: constant expressions can't convert void* constexpr int32_t const* pi = static_cast<int32_t const*>(pv);
Ninth, any use of reinterpret_cast:
constexpr int32_t i = 123; // Compiler error: constant expressions can't use reinterpret_cast even to cast // to the same type constexpr int32_t i2 = reinterpret_cast<int32_t>(i);
Tenth, modifying an object that’s not a literal type whose lifetime began within the constant expression:
constexpr int Foo(int val) { // val begins its liftime here, before the constant expression starts // Compiler error: constant expression can't modify object whose lifetime // started before constexpr int ret = ++val; return ret; }
Eleventh, new
and delete
unless the allocated memory is deleted by the constant expression:
// Compiler error: memory allocated by `new` not deleted in constant expression constexpr int32_t* pi = new int; constexpr int32_t GetInt() { int32_t* p = new int32_t; *p = 123; int32_t ret = *p; delete p; return ret; } // OK: GetInt() call deletes memory it allocated with the new operator constexpr int32_t i = GetInt();
Twelfth, comparing pointers is technically “unspecified behavior” and not allowed in a constant expression:
int32_t i = 123; int32_t* pi1 = &i; int32_t* pi2 = &i; // Compiler error: can't compare pointers in a constant expression constexpr bool b = pi1 < pi2;
Thirteenth, while we can catch exceptions, we can’t throw them:
constexpr void Explode() { // Compiler error: can't throw in a constant expression throw "boom"; }
dynamic_cast and typeid are also not allowed to throw an exception:
struct Combatant { virtual int32_t GetMaxHealth() = 0; }; struct Enemy : Combatant { virtual int32_t GetMaxHealth() override { return 100; } }; struct TutorialEnemy : Combatant { virtual int32_t GetMaxHealth() override { return 10; } }; Enemy e; Combatant& c{e}; // Compiler error: can't call dynamic_cast in a constant expression if it // will throw an exception constexpr TutorialEnemy& te{dynamic_cast<TutorialEnemy&>(c)}; Enemy* p = nullptr; // Compiler error: can't call typeid in a constant expression if it will // throw an exception constexpr auto name = typeid(p).name();
Fourteenth, and finally, variadic functions using va_start
, va_arg
, and va_end
which are generally not recommended to be used anyways:
constexpr void DebugLogAll(int count, ...) { va_list args; // Compiler error: can't use va_start in constant expressions va_start(args, count); for (int i = 0; i < count; ++i) { const char* msg = va_arg(args, const char*); DebugLog(msg); } va_end(args); }
Conclusion
C++ provides a powerful set of compile-time programming options with constexpr
variables, constexpr
functions, constexpr if
, and static_assert
. Each new version of the language makes more and more of the regular, run-time features available at compile time. While there are still quite a few restrictions, many of them are for esoteric features like variadic functions or to explicitly prevent bugs such as by forbidding undefined behavior.
The general direction of the language is to eventually be fully available at compile time so there’s no need to use another language or write another program to generate source code. The result is a uniform build process and the de-duplication of compile-time and run-time code. By simply adding the constexpr
keyword, we can often move run-time calculations to compile time and increase run-time performance by using those pre-computed results.
In contrast, C# support for compile-time programming is limited to using built-in operators on primitives and string
in const
and default function arguments. These have been available since the first version (in 2002) and no more features have been added or announced since then. Compile-time programming is virtually always done by an external tool and build step that generates .cs
or .dll
files. This seems to represent a philosophical difference between the two languages.
As we progress in the series, we’ll cover even more compile-time programming options in C++: template “metaprogramming” and preprocessor macros. Stay tuned!
#1 by Ökehan on February 19th, 2021 ·
The code from the article produces a compiler error: “C3615 constexpr function ‘GetInt’ cannot result in a constant expression”.Is not it the correct behaviour?
Code:
Btw, I have been enjoying with these great articles, thank you for your effort.
#2 by jackson on February 20th, 2021 ·
I’m glad you’ve been enjoying the articles!
I mentioned before the rules that each version of C++ has removed restrictions, but didn’t say which version is required for each rule. This particular example requires C++20 in order to compile. From your compiler error message it looks like you’re using Microsoft Visual Studio. Unfortunately, that compiler hasn’t yet been updated to support this feature of the recently-standardized C++20. This does compile in GCC and Clang though, so hopefully support will be available soon in Visual Studio.
#3 by TonyH on February 24th, 2022 ·
Is this an error ? should be constexpr instead of consteval ?
// Compiler error: can’t be both constexpr and constinit
consteval constinit const char* ok2 = “OK”;
#4 by jackson on February 24th, 2022 ·
Thanks for letting me know about this. I’ve updated the article to use
constexpr
instead ofconsteval
as was noted in the previous line’s comment but incorrect in the line that followed.#5 by TonyH on February 24th, 2022 ·
Perhaps a line for constexpr and one for consteval