C++ Tutorial – What Are the constexpr and constinit Specifiers?
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
- The
constinit
specifier and its uses - Summary
##Prerequisites
- A basic understanding of C++: For readers not familiar with C++, Learn C++ Programming for Beginners – Free 31-Hour Course is a helpful resource
- A read through of my previous article What is the Static Initialization Order Fiasco in C++ will benefit you with context around
constinit
.
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!
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.