Logo

Blog


constexpr functions: optimization vs guarantee

The feature of constant evaluation is nothing new in 2023. You have constexpr available since C++11. Yet, in many of my classes, I see that people still struggle with constexpr functions. Let me shed some light on them.

What you get is not what you see

One thing, which is a feature, is that constexpr functions can be evaluated at compile-time, but they can run at run-time as well. That evaluation at compile-time requires all values known at compile-time is reasonable. But I often see that the assumption is once all values for a constexpr function are known at compile-time, the function will be evaluated at compile-time.

I can say that I find this assumption reasonable, and discovering the truth isn't easy. Let's consider an example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
constexpr auto Fun(int v)
{
  return 42 / v;  A 
}

int main()
{
  const auto f = Fun(6);  B 

  return f;  C 
}

The constexpr function Fun does divide 42 by a value provided by the parameter v A. In B, I call Fun with the value 6 and assign the result to the variable f.

Last, in C, I return the value of f to prevent the compiler optimizes this program away. If you use Compiler Explorer to look at the resulting assembly, GCC with -O1 brings this down to:

1
2
3
main:
        mov     eax, 7
        ret

As you can see, the compiler has evaluated the result of 42 / 6, which of course, is 7. Aside from the final number, there is also no trace at all of the function Fun.

Now, this is what, in my experience, makes people believe that Fun was evaluated at compile-time thanks to constexpr. Yet this view is incorrect. You are looking at compiler optimization, something different from constexpr functions.

Let's remove the constexpr from the function first:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
auto Fun(int v)
{
  return 42 / v;  A 
}

int main()
{
  const auto f = Fun(6);  B 

  return f;  C 
}

The resulting assembly, again GCC and -O1 is the following:

1
2
3
4
5
6
7
8
Fun(int):
        mov     eax, 42
        mov     edx, 0
        idiv    edi
        ret
main:
        mov     eax, 7
        ret

Okay, that looks more like proof that constexpr helped before. You now can see the function Fun, but the result is still known in main. Why is that?

The reason is that constexpr implies inline! Try for yourself, make Fun inline, and you will see exactly the same assembly output as when the function was constexpr.

Because of the implicit inline, the compiler understands that Fun never escapes the current compilation unit. By knowing that there is no reason to keep the definition around. Then, Fun itself is reasonably simple to the compiler, and the parameter is known at compile-time. An invitation for the optimizer, which it happily accepts.

What you see here is still an optimization. Yes, if you are interested in a small binary footprint, you can be happy. But, constexpr can give you more! You can get guarantees from constexpr. Let's explore that.

Ways to enforce constant evaluation

The current code does not force the compiler into evaluating Fun at compile time. You must force the compiler into a compile context. You have roughly four options for doing so:

  • assign the result of Fun to a constexpr variable;
  • use Fun as a non-type template argument;
  • use Fun as the size of an array;
  • use Fun within another constexpr function that is forced into constant evaluation by one of the three options before.

Here you find the four cases in code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
constexpr auto Other(int v)
{
  return Fun(v);
}

int main()
{
  constexpr auto f{Fun(6)};
  int            data[Fun(6)]{};  // Please prefer the std::array solution
  std::array<int, Fun(6)> data2{};
  constexpr auto          ff{Other(6)};
}

Enforcing constant evaluation

So far, I have done neither of the four variants, time to change this. Let me make the variable f constexpr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
constexpr auto Fun(int v)
{
  return 42 / v;  A 
}

int main()
{
  constexpr auto f = Fun(6);  B 

  return f;  C 
}

Once you look at the resulting assembly, you see ... no change compared to the initial example. Remember that I started by stating that distinguishing optimization from the guarantee is difficult?

My example does now come with the guarantee that Fun is evaluated at compile-time. However, since there is no difference between the former version in the resulting assembly, what is my point?

Well, time to start talking about the guarantee.

What if, and please don't be shocked, replace 6 with 0 in my call to Fun? Urg, yes, that will result in a division by zero. Who, aside from Chuck Norris, can divide by zero? At least, I can't, and neither can any of the compilers I use.

But, the initial example, despite that Fun is constexpr, compiles just fine. Well, this little warning about the division by zero aside. Ah, yes, and the result is, well, potentially the result to expect if one of us could divide by zero.

The guarantee

Make the variable f in B constexpr, or choose another way to force the compiler into constant evaluation. The result? If you make the change, your compile will fail, and the compiler tells you the obvious, a division by zero does not produce a constant value. This is what constexpr functions bring you: an evaluation free of undefined behavior!

Putting constexpr on a function only gives you a small part of constexpr. Only by using a constexpr function in a context requiring constant evaluation will you get the full benefits out of it, no undefined behavior.

I hope this post helps you to understand better what constexpr can bring you and how to distinguish the guarantee from the optimization of a compiler.

Andreas