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

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!