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