tags:
- Cpp
Constexpr in C++ (ENG)
The keyword constepxr
introduced in C++11 is one of the most impactful updates in the C++'s evolution. Since C++11 introduced the constexpr
specifier, there's finally a way for us to write faster and more efficient code. constexpr
is const-er than const
, not only promise you a constant value and promise you the value would be known during compilation also.
The feature unlocks compile-time optimization, a technique that shifts computation from runtime to compile time, which significantly improving runtime performance and reducing overload. For variables and simple expressions, constexpr
behaves like what we talked about. But for functions, thing could be getting nuanced, which I'll explain later.
constexpr
objects are const-qualified, and they are guaranteed to be known at compile time. Because their values are determined during compilation, such objects may be placed in the read-only data segment. This feature is particularly beneficial for embedded systems, where memory layout and efficiency are critical. It’s important to note that const
does not guarantee a value is known or initialized at compile time — it only ensures that the value cannot be modified after initialization. You can initializedconst
objects at runtime.
For example: (note here that std::array<>
's size value need to be a constant expression)
int i; // unitialized, unknown at compile time
constexpr auto j = i; // error, i's value unknown
std::array<int, j> myArr; // error, j's value unknown
constexpr auto arrSize = 10; // ok
std::array<int, arrSize> myArr1; // ok
std::array<int, 10> myArr2; // ok
And for const
:
int i;
const auto j = i; // ok
std::array<int, j> myArr; // error
const auto arrSize = 10;
std::array<int, arrSize> myArr1; // error
See, constexpr
variables are const
, but not vice versa. Therefore, when initializing size parameters for certain STL containers—like std::array
—you must use a constant expression, which can only be guaranteed via constexpr
.
constexpr
FunctionsCompared to constexpr
variables, there aren't many restrictions on constexpr
functions. The function context can be known either during compilation or at runtime—both are acceptable. However, only when the function arguments are constexpr
can the context be determined at compile time. Otherwise, it will be resolved at runtime.
For example:
constexpr auto i = 10;
constexpr auto j = 10;
auto k = 10;
constexpr auto add(auto i, auto j) noexcept { return i + j;}
constexpr auto res1 = add(i, j); // ok
constexpr auto res2 = add(i, k); // error
auto res3 = add(i, k); // ok, runtime
And since the constexpr
functions really could not throw any exceptions, we typically annotate it with noexcept
.
And if you make anything wrong (misuse of constexpr
), with the constexpr
specifier, you can catch errors at compile time, making the code even safer. However, there is a trade-off because performing all calculations at compile-time makes the process compiler-dependent. The result may vary with different compilers.
Between C++11 and C++14, the return compile-time results differ. In C++11, a constexpr
function is limited to having only one single return statement, meaning branches are not allowed. But in C++14, this restriction was relaxed, which is a good thing for us.
For example:
// In C++11, you need some branchless tricks:
constexpr bool trueOrFalse(bool cond) noexcept {
return (cond ? true : false);
}
// After C++14:
constexpr bool trueOrFalse(bool cond) noexcept {
if(cond) {
return true;
} else {
return false;
}
}
Although branchless is better, but I am out of energy thinking about it...
Let's first go dive in a real example to see how the compile-time optimization happens:
#include <iostream>
int add(int a, int b){ // No constant expression
return a + b;
}
int main() {
int a = 100;
int b = 200;
int c = add(a, b);
return 0;
}
This example demonstrates a simple function add
that adds two integers. In the main
function, we call add
with a
and b
as arguments. Currently, the function does not use constexpr
, so the addition is performed at runtime.
After compilation:
add(int, int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 100
mov DWORD PTR [rbp-8], 200
mov edx, DWORD PTR [rbp-8]
mov eax, DWORD PTR [rbp-4]
mov esi, edx
mov edi, eax
call add(int, int)
mov DWORD PTR [rbp-12], eax
mov eax, 0
leave
ret
So you can see how many assembly instructions there are needed to be execute in the run-time.
With the using of constexpr
, our C++ code turns into:
#include <iostream>
constexpr int add(int a, int b) noexcept {
return a + b;
}
int main() {
constexpr int a = 100;
constexpr int b = 200;
constexpr int c = add(a, b);
return 0;
}
After compilation without any compiler optimization:
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 100
mov DWORD PTR [rbp-8], 200
mov DWORD PTR [rbp-12], 300
mov eax, 0
pop rbp
ret
You see, we don't have the add()
function symbol here. Why? You may have guessed it, the constexpr
specifier implies inline
semantics.
constexpr
The previous example might not be very intuitive, now let's do another experiment to see how awesome constexpr
can be.
#include <iostream>
int fibonacci(int n) {
return (n <= 1) ? n : (fibonacci(n - 1) + fibonacci(n - 2));
}
int main() {
int fib10 = fibonacci(10);
int fib20 = fibonacci(20);
int fib30 = fibonacci(30);
int fib40 = fibonacci(40);
std::cout << "Fibonacci(10): " << fib10 << std::endl;
std::cout << "Fibonacci(20): " << fib20 << std::endl;
std::cout << "Fibonacci(30): " << fib30 << std::endl;
std::cout << "Fibonacci(40): " << fib40 << std::endl;
return 0;
}
This code calculates the Fibonacci sequence at runtime. The output might look like this:
du@DVM:~/cpp$ time ./proc
Fibonacci(10): 55
Fibonacci(20): 6765
Fibonacci(30): 832040
Fibonacci(40): 102334155
real 0m0.604s
user 0m0.600s
sys 0m0.003s
And with constexpr
, our source code look like this:
#include <iostream>
// constexpr function to calculate Fibonacci numbers at compile-time
constexpr int fibonacci(int n) noexcept {
return (n <= 1) ? n : (fibonacci(n - 1) + fibonacci(n - 2));
}
int main() {
constexpr int fib10 = fibonacci(10);
constexpr int fib20 = fibonacci(20);
constexpr int fib30 = fibonacci(30);
std::cout << "Fibonacci(10): " << fib10 << std::endl;
std::cout << "Fibonacci(20): " << fib20 << std::endl;
std::cout << "Fibonacci(30): " << fib20 << std::endl;
return 0;
}
With the use of constexpr
, the output might look like this:
du@DVM:~/cpp$ time ./proc
Fibonacci(10): 55
Fibonacci(20): 6765
Fibonacci(30): 832040
Fibonacci(40): 102334155
real 0m0.005s
user 0m0.003s
sys 0m0.002s
You can see how crazy the difference is—in user time, the optimized version is 200 times faster! This demonstrates the significant impact of compile-time optimization using constexpr
.
Here, we have some points to keep in mind. You must noted the right-hand side of constexpr
variable declaration must be a constant expression. Because everything should be figured out at compile-time, thus everything should be known at compile-time. Otherwise, an error will occur.
Also, we saw the function declared by constexpr
implies an inline
semantics, and since C++17, the static data member declared by constexpr
would imply inline
semantics as well.
#include <iostream>
class myClass {
public:
constexpr static int a = 50;
// Equivalent to inline const static int a = 50;
constexpr int a_square();
};
constexpr int myClass::a_square(){ // myClass::a_square would be inlined
return myClass::a * myClass::a;
}
int main() {
myClass obj;
constexpr int i = obj.a_square();
return 0;
}
And variable/function template now can be declared constexpr
too.