constexpr is a Platform

Let me share a useful insight with you: constexpr is a platform.

Just like you write code that targets Windows or a microcontroller, you write code that targets compile-time execution. In both cases you restrict yourself to the subset of C++ that works on your target platform, use conditional compilation if your code needs to be portable, and execute it on the desired target platform. You can thus view constexpr as another platform you can target; it just so happens to be run by your compiler.

This insight can answer a lot of design questions surrounding constexpr.

What should(n’t) be constexpr?

The capabilities of compile-time programming are expanding with every version of C++ and more and more functions of the standard library are marked constexpr. This raises the question: what shouldn’t be constexpr?

Let’s treat constexpr as a platform and compare it with a microcontroller. What C++ functions can be ported to it? The answer here is a lot more straightforward. For starters, everything that is portable C++ which doesn’t interface the OS just works. And even some OS functionality can be implemented: printing to stdout can be some sort of debug output, we can have networking APIs if the chip has the appropriate hardware, etc. Other APIs can’t be done or don’t make sense, like threading on single-core processors or window creation on systems without a display. So on a platform we can use portable C++ code and everything that can be built on top of the APIs the system provides us.

The same should apply to constexpr: everything that is portable, standard C++ should be usable at compile-time, as well as every functionality built on top of the system APIs. The “system” here is the compiler, which can provide interfaces for issuing diagnostics, reflection of the source code, and potentially debug output. One big difference between the constexpr platform and traditional ones is that constexpr functions can’t interact with global (runtime) state in any way.

So if we’re using a (post C++17/20) C++ library it would be reasonable to expect that all functions without side effects or OS interaction are constexpr.

The library also needs to be header-only or use modules for that assumption to apply.

Of course, whether the library author deemed it necessary to actually make it constexpr is a different question. After all, compile-time programming is currently limited to either simple things or more esoteric libraries, so there is not a lot of demand.

Should it be necessary to mark functions as constexpr?

Currently, you need to explicitly mark a function constexpr if it should be a constexpr function. However, we could imagine a future version of C++ where this isn’t required: if we’re calling a function at compile-time, the compiler tries to execute it at compile-time. If it works, good, otherwise, it issues a diagnostic. That way, we don’t need to manually mark everything as constexpr, which is just unnecessary boilerplate.

Or is it?

Let’s imagine constexpr isn’t needed on functions, and we’re having a library providing a function get_the_answer():

int get_the_answer()
{
    int result;
    /* expensive computation */;
    return result;
}

It just so happens that expensive computation is constexpr, so a user uses it at compile-time.

constexpr int the_answer = lib::get_the_answer();

The library author then wants to optimize get_the_answer() by caching expensive computation:

int get_the_answer_impl() { /* as before */ }

int get_the_answer()
{
    // Lazily compute once.
    static int result = get_the_answer_impl();
    return result;
}

This is a breaking change: a constexpr function cannot contain static variables! The user’s code is broken. This is why we need to explicitly mark constexpr functions with constexpr. By doing so, we document which functions can be used at compile-time and promise it to our users.

But let’s compare constexpr with another platform. Now we’re having a user who uses the initial version of the library on Linux. This works fine, as expensive computation is regular, standard C++ code that is cross-platform.

Again the library author wants to optimize get_the_answer(). This time, they opt to use the built-in Windows support for getting answers:

int get_the_answer()
{
    int result;
    GetTheAnswerEx2(&result, NULL, NULL); // Windows only
    return result;
}

This is also a breaking change: a function calling WinAPIs does not compile on Linux. The user’s code is broken. As such, a library author should explicitly mark functions as linux if they should be available on Linux. By doing so, we document which functions can be used on Linux and promise it to our users.

Except we don’t?

We don’t explicitly mark which functions are available on which platforms using a mandatory keyword in the source code. Instead, library code is assumed to be cross-platform unless explicitly documented otherwise. If a library update breaks code on certain platforms, affecting users file an issue to fix the breaking change.

In our case, the library author changes get_the_answer().

int get_the_answer()
{
    int result;
#ifdef WIN32
    GetTheAnswerEx2(&result, NULL, NULL); // Windows only
#else
    /* expensive computation */
#endif
    return result;
}

So if we don’t have an “OS marker”, why should we keep the annoying constexpr marker?

We could just expect that everything is constexpr that follows the conditions stated in the previous section, unless the library explicitly documents otherwise. If we use something at compile-time that then breaks in a library update, we react the same way as a library that breaks under an OS: we file an issue and the library author fixes it with conditional compilation, in our case using std::is_constant_evaluated():

int get_the_answer_impl() { /* as before */ }

int get_the_answer()
{
    if (std::is_constant_evaluated()) // compile-time platform
    {
        return get_the_answer_impl();
    }
    else // other platform
    {
        // Lazily compute once.
        static int result = get_the_answer_impl();
        return result;
    }
}

Marking functions as constexpr for documentation purposes is as necessary as marking functions as linux or windows.

How to verify that a constexpr function can be run at compile-time?

You might say that another benefit of marking functions constexpr is that the compiler can go ahead and verify that it actually works at compile-time. However, this is only partly true; the following code compiles.

constexpr int f(int i)
{
    if (i == 0)
      return 0;

    return std::getchar();
}

The function is marked constexpr even though it is only constexpr if i is 0; otherwise, it does I/O which obviously can’t work at compile-time. But this is totally fine: a function can be marked constexpr if there is one possible combination of arguments that work at compile-time. This is the case here.

And note that even if there is no combination of arguments that work at compile-time, the compiler isn’t even required to issue a diagnostic!

So how do we check that our function works at compile-time?

Well, we do it the same way we check that our function works under Linux: we write a test that covers all relevant arguments.

constexpr auto result_constexpr = foo(1, 2, 3);
CHECK(result_constexpr == 42);

auto a = 1;
auto result_runtime = foo(a, 2, 3);
CHECK(result_runtime == 42);

Note that we use a local variable to prevent the compiler from invoking the second foo at compile-time as well.

If our function to test doesn’t use std::is_constant_evaluated() to change the implementation depending on the platform it’s running on, the runtime test isn’t necessary as it will execute the same code, just at runtime. It only tests whether the constexpr implementation of the compiler matches your processor, which should be done by compiler writers and not you.

Writing tests where all the results are computed at compile-time and just the verification happens at runtime has some additional benefits:

Is std::is_constant_evaluated() a bad idea?

When std::is_constant_evaluated() was added to C++20 as a way to query whether a function invocations happens at compile-time, some people argued that it was a bad idea. Now it is possible to write code like this, which behaves completely different at compile-time and at runtime:

constexpr int f()
{
    if (std::is_constant_evaluated())
        return 42;
    else
        return 11;
}

Obviously, writing code like this is bad, so we should make it impossible to do it.

However, it is already possible to write something like this:

constexpr int f()
{
#ifdef WIN32
        return 42;
#else
        return 11;
#endif
}

While this particular implementation of f() is bad, conditional compilation is essential for writing cross-platform code. The same applies to std::is_constant_evaluated() and constexpr code. To leverage platform specific APIs, we need a way to query the platform we’re running on and decide accordingly.

Prime examples are the bit functions added in C++20, like std::countl_zero(x). At runtime, you want to use the specialized assembly instructions which aren’t available at compile-time. So you use std::is_constant_evaluated() to switch implementations.

And just like with cross-platform code you need to test both versions to ensure that both work.

Conclusion

constexpr is a platform.

Writing constexpr functions is just like writing portable functions: most code should be constexpr, just like most code is cross-platform; a constexpr marker should be unnecessary, just like a hypothetical linux marker; you need to test constexpr functions at compile-time and runtime, just like you need to do for cross-platform code; and you need a way to perform conditional compilation to pick the best APIs, just like all other portable code.