When we write programs, many operations which are performed at runtime can actually be done at compile time – that is, baked into code.

This improves program performance since operations are no longer being computed on the fly, during runtime.

While these techniques of offloading operations to compile time improve performance, they can also help subtle problems such as a the 'Static initialization Order Fiasco' which I covered in a previous article.  

This tutorial teaches you two ways to make this happen in C++. Here is what we'll cover:

Prerequisites

How to Evaluate Functions at Compile Time Using constexpr

To understand this, let's first take a look at an example of a function which performs a very simple computation:

int add2(int input) {
    return input + 2;
}

int main() {
    int b = add2(3);
    std::cout << "b = " << b;
    return 0;
}

Here, all that the function add2() does is it adds 2 to the input. In main(), add2() was called with input 2. So, it's pretty straightforward for anyone looking at the program to tell what the output of the function is going to be: 5. There is no sort of non-determinism here.

But if we look at the x86 assembly code generated by the compiler it would look similar to the below:

add2(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, DWORD PTR [rbp-4]
        add     eax, 2
        pop     rbp
        ret
.LC0:
        .string "b = "
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     edi, 3
        // Function add2() getting called
        call    add2(int)
        mov     DWORD PTR [rbp-4], eax
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
        mov     rdx, rax
        mov     eax, DWORD PTR [rbp-4]
        mov     esi, eax
        mov     rdi, rdx
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
        mov     eax, 0
        leave
        ret

(Note that the reference assembly codes in this article were generated by using the compiler explorer tool at godbolt.org.)

The function add2() actually gets called at runtime in main() with the call add2(int) line.

Since this function does something that can be completely computed before the program actually executes (2+3 = 5, even in our heads!), wouldn't it be cool if we could have the compiler not create a function for this operation and just fill in the answer directly in assembly code? This is exactly what constexpr does.

If the code was changed to this:

constexpr int add2(int input) {
    return input +2;
}

int main() {
    int b = add2(3);
    std::cout << "b = " << b;
    return 0;
}

the compiler would output the following assembly code:

.LC0:
        .string "b = "
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], 5
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
        mov     rdx, rax
        mov     eax, DWORD PTR [rbp-4]
        mov     esi, eax
        mov     rdi, rdx
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
        mov     eax, 0
        leave
        ret

The function add2() has disappeared and the line mov DWORD PTR [rbp-4], 5 has baked into the program the evaluation of the function add2() at compile time. There is no runtime call to add2().

Mind you, this is possible since we've passed in 3 – an expression that is known at compile time – to the add2() function. If something that couldn't be evaluated at compile time was passed in, the compiler would again generate an add2() function.

You can see what I mean in this snippet:

#include<iostream>
#include <random>

constexpr int add2(int input) {
    return input +2;
}

int main() {
    int rd = std::rand();
    int b = add2(rd);
    std::cout << "b = " << b;
    return 0;
}

The assembly generated again has the add2() function:

add2(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, DWORD PTR [rbp-4]
        add     eax, 2
        pop     rbp
        ret
.LC0:
        .string "b = "
main:
        push    rbp
// Snip

Alright, you've now seen how using the constexpr specifier can help programs move runtime costs to compile time costs in many cases.

Let's now look at another specifier introduced recently, in C++20, which verifies that variables are initialized at compile time.

The constinit Specifier and its Uses

The constinit specifier was introduced in C++ 20. This specifier asserts that a variable has constant initialization – it sets the initial values of the static variables to a compile-time constant. Otherwise, the program is ill-formed and the compiler produces an error. For example:

int add2(int v) {
    return v + 2;
}

//Error: 'constinit' variable 'glob' does not have a constant initializer  
constinit int glob = add2(2);

int main() {

    return 0;
}

The compiler errors out here since the add2() function isn't sure to have constant initialization – which can be determined at compile time. Now if the add2() function is marked constexpr, it will have constant initialization, so the code compiles.

constexpr int add2(int v) {
    return v + 2;
}

//OKAY 'constinit' variable 'glob' does have a constant initializer.  
constinit int glob = add2(2);

int main() {

    return 0;
}

Now you may ask – what's really the use of this specifier?

The answer is it that can be used in certain cases to solve the 'Static Initialization Order Fiasco'.

I talked about the 'Static Initialization order Fiasco' in an earlier article. If we use constinit, the compiler is giving the programmer its word that the constinit variable will be constant initialized – so that's before any other static variables are constructed at runtime. We get rid of the 'Static Initialization / Destruction Order Fiasco'.

Another example where we use strings that are constant initialized illustrates this:

// Parent.h
#pragma once
class Parent {
    public:
       size_t getMoneyCount();
       constexpr Parent(const char *moneyString): mData(moneyString) {};
    private:
        std::string_view mData;     
};
extern Parent everyonesParent;

// Parent.cpp
#include<Producer.h>

constinit static Parent everyonesParent("TheParent");

size_t Parent::getMoneyCount() {
    return mData.size();
}

//Child.cpp
#include<Child.h>

class Child {
    public:
    Child(Parent &parent) : mMoneyCount(parent.getMoneyCount()) {};
    private:
    size_t mMoneyCount;
};

static Child everyonesChild(everyonesParent);

There is no static initialization order problem here, since the static object everyonesParent is guaranteed to be initialized before everyonesChild, since it was marked constinit.

It was okay to mark everyonesParent constinit since it used std::string_view which can be constant initialized – unlike std::string. Also, it had a constexpr constructor. If it didn't use either of these, compilation would have failed!

In closing, something to note about constinit: constinit does not imply const.

constinit values can be modified after construction. Take this example – it is perfectly legal:

#include<iostream>

constinit int i = 42;
int main() {
 i++;
 std::cout << " i is " << i << "\n";
}

Summary

This article covered compile time operations and run time operations. It analyzed how compilers produce code to either generate functions used at runtime or evaluate them at compile time.

You learned about the constexpr and constinit specifiers in C++, and how they are extremely useful.

I hope you enjoyed the article!