Constexpr in C++ (ENG)

Make Compile-Time Great

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.

Const-er Object

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 Functions

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

Fibonacci with 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.

More You Can Do Since C++17

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.