Constexpr Functions Part 4

Constexpr/Consteval Functions Can Use Non-const Local Variables

Within a constexpr or consteval function, we can use local variables that aren't constexpr, and the value of these variables can be changed.

As a silly example:

#include <iostream>

consteval int transform(int p, int q) // function is consteval
{
    p = p + 5; // we can modify the value of non-const function parameters

    int result { p + q }; // we can instantiate non-const local variables
    if (p > q)
        result = result - 3; // and then modify their values

    return result;
}

int main()
{
    constexpr int value { transform(10, 20) };
    std::cout << value << '\n';

    return 0;
}

When such functions are evaluated at compile-time, the compiler will essentially "execute" the function and return the calculated value.

Constexpr/Consteval Functions Can Use Function Parameters and Local Variables as Arguments in Constexpr Function Calls

Above, we noted, "When a constexpr (or consteval) function is being evaluated at compile-time, any other functions it calls are required to be evaluated at compile-time."

Perhaps surprisingly, a constexpr or consteval function can use its function parameters (which aren't constexpr) or even local variables (which may not be const at all) as arguments in a constexpr function call. When a constexpr or consteval function is being evaluated at compile-time, the value of all function parameters and local variables must be known to the compiler (otherwise it couldn't evaluate them at compile-time). Therefore, in this specific context, C++ allows these values to be used as arguments in a call to a constexpr function, and that constexpr function call can still be evaluated at compile-time.

#include <iostream>

constexpr int processValue(int value) // processValue() is now constexpr
{
    return value;
}

constexpr int transform(int input) // input is not a constant expression within transform()
{
    return processValue(input); // if transform() is resolved at compile-time, then `processValue(input)` can also be resolved at compile-time
}

int main()
{
    std::cout << transform(25);

    return 0;
}

In the above example, transform(25) may or may not be evaluated at compile time. If it is, then the compiler knows that input is 25. And even though input isn't constexpr, the compiler can treat the call to processValue(input) as if it were processValue(25) and evaluate that function call at compile-time. If transform(25) is instead resolved at runtime, then processValue(input) will also be resolved at runtime.

Can a Constexpr Function Call a Non-Constexpr Function?

The answer is yes, but only when the constexpr function is being evaluated in a non-constant context. A non-constexpr function may not be called when a constexpr function is evaluating in a constant context (because then the constexpr function wouldn't be able to produce a compile-time constant value), and doing so will produce a compilation error.

Calling a non-constexpr function is allowed so that a constexpr function can do something like this:

#include <type_traits>

constexpr int performAction()
{
    if (std::is_constant_evaluated()) // if evaluating in constant context
        return compileTimeFunction();
    else
        return runtimeFunction();
}

Now consider this variant:

constexpr int performAction(bool flag)
{
    if (flag)
        return compileTimeFunction();
    else
        return runtimeFunction();
}

This is legal as long as performAction(false) is never called in a constant expression.

As an aside: Prior to C++23, the C++ standard says that a constexpr function must return a constexpr value for at least one set of arguments, otherwise it's technically ill-formed. Calling a non-constexpr function unconditionally in a constexpr function makes the constexpr function ill-formed. However, compilers aren't required to generate errors or warnings for such cases -- therefore, the compiler probably won't complain unless you try to call such a constexpr function in a constant context. In C++23, this requirement was rescinded.

For best results, we'd advise the following:

  1. Avoid calling non-constexpr functions from within a constexpr function if possible.
  2. If your constexpr function requires different behavior for constant and non-constant contexts, conditionalize the behavior with if (std::is_constant_evaluated()) (in C++20) or if consteval (C++23 onward).
  3. Always test your constexpr functions in a constant context, as they may work when called in a non-constant context but fail in a constant context.

When Should I Constexpr a Function?

As a general rule, if a function can be evaluated as part of a required constant expression, it should be made constexpr.

A pure function is a function that meets the following criteria:

  • The function always returns the same return result when given the same arguments
  • The function has no side effects (e.g., it doesn't change the value of static local or global variables, doesn't do input or output, etc.).

Pure functions should generally be made constexpr.

As an aside: Constexpr functions don't always need to be pure. In C++23, constexpr functions can use and modify static local variables. Since the value of a static local persists across function calls, modifying a static local variable is considered a side-effect.

That said, if your program is trivial or a throw-away and you don't constexpr a function, the world isn't going to end. Hopefully.

Best practice: Unless you have a specific reason not to, a function that can be evaluated as part of a constant expression should be made constexpr (even if it isn't currently used that way).

A function that cannot be evaluated as part of a required constant expression should not be marked as constexpr.

Why Not Constexpr Every Function?

There are a few reasons you may not want to constexpr a function:

  1. constexpr signals that a function can be used in a constant expression. If your function cannot be evaluated as part of a constant expression, it should not be marked as constexpr.
  2. constexpr is part of the interface of a function. Once a function is made constexpr, it can be called by other constexpr functions or used in contexts requiring constant expressions. Removing the constexpr later will break such code.
  3. constexpr functions can be harder to debug since you can't breakpoint or step through them in a debugger.

Why Constexpr a Function When It Is Not Actually Evaluated at Compile-Time?

New programmers sometimes ask, "why should I constexpr a function when it's only evaluated at runtime in my program (e.g., because the arguments in the function call are non-const)"?

There are a few reasons:

  1. There's little downside to using constexpr, and it may help the compiler optimize your program to be smaller and faster.
  2. Just because you're not calling the function in a compile-time evaluatable context right now doesn't mean you won't call it in such a context when you modify or extend your program. And if you haven't constexpr'd the function already, you may not think to when you do start to call it in such a context, and then you'll miss out on the performance benefits. Or you may be forced to constexpr it later when you need to use the return value in a context requiring a constant expression somewhere.
  3. Repetition helps ingrain best practices.

On a non-trivial project, it's a good idea to implement your functions with the mindset that they may be reused (or extended) in the future. Any time you modify an existing function, you risk breaking it, and that means it needs to be retested, which takes time and energy. It's often worth spending an extra minute or two "doing it right the first time" so you don't have to redo (and retest) it again later.

Summary

Non-const local variables: Constexpr/consteval functions can use and modify non-const local variables and function parameters. The compiler "executes" the function at compile-time to calculate the result.

Function parameters as arguments: Constexpr/consteval functions can use their (non-constexpr) parameters and local variables as arguments to other constexpr function calls. When evaluating at compile-time, the compiler knows all values and can evaluate nested constexpr calls.

Calling non-constexpr functions: Constexpr functions can call non-constexpr functions, but only when evaluating at runtime (non-constant context). Calling them in constant context produces compilation error.

When to constexpr:

  • Pure functions (same inputs = same outputs, no side effects) should generally be constexpr
  • Functions that can be evaluated as part of constant expressions should be constexpr
  • Enables compile-time evaluation, better performance, and single-source dual-mode functionality

When not to constexpr:

  • Function cannot be evaluated in constant expressions
  • Once constexpr, it becomes part of the function's interface - removing it later breaks code
  • Harder to debug (can't breakpoint/step through compile-time evaluation)

Why constexpr runtime-only calls: Enables compiler optimizations, prepares for future compile-time use, ingrains best practices, and saves refactoring effort later.

Best practice: Make functions constexpr unless there's a specific reason not to. Functions that cannot be evaluated as constant expressions should not be marked constexpr.

Understanding these advanced constexpr capabilities helps you write flexible, efficient code that serves both compile-time and runtime needs effectively.