C++: What are constexpr and constinit ?

Introduction to Constexpr - C++11

C++11 introduced a new specifier called constexpr. The reason for introducing constexpr was that many operations which were being performed at runtime, could be done at compile time - i.e. baked into code. This would improve program performance since operations are no longer being computed on the fly, during runtime. Here is an example of a simple program where a function is being executed at runtime :

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 is 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. However, if we look at the x86 assembly code generated by the compiler it would look similar to:

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
        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

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 :

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. This can be seen in the 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 generated:

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

Constinit

The constinit specifier was introduced in C++ 20. What this specifier does is it asserts that a variable has constant initialization - 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 we 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'. The 'Static Initialization order Fiasco' was discussed in an earlier article. If we use constinit, the compiler is giving the programmer it's word that the constinit variable will be constant initialized - so that's before any other static variables are constructed. 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 the constexpr and constinit specifiers in C++. It looked at how they are useful. In particular, how constinit can help solve the Static Initialization Order Fiasco in C++ conveniently!

I hope you enjoyed the article!

2
Subscribe to my newsletter

Read articles from Jayant Chowdhary directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jayant Chowdhary
Jayant Chowdhary

I am a software engineer working on Android OS Camera Software.