C++20 Dynamic Allocations at Compile-time

C++20 Dynamic Allocations at Compile-time

By Andreas Fertig

Overload, 31(176):20-23, August 2023


People often say constexpr all the things. Andreas Fertig shows where we can use dynamic memory at compile time.

You may already have heard and seen that C++20 brings the ability to allocate dynamic memory at compile-time. This leads to std::vector and std::string being fully constexpr in C++20. In this article, I like to give you a solid idea of where you can use that.

How does dynamic allocation at compile-time work?

First, let’s ensure that we all understand how dynamic allocations at compile-time work. In the early draft of the paper ‘Standard containers and constexpr’ [P0784R1], proposed so-called non-transient allocations. They would have allowed us to allocate memory at compile-time and keep it to run-time. The previously allocated memory would then be promoted to static storage. However, various concerns did lead to allowing only transient allocations. That means what happens at compile-time stays at compile-time. Or in other words, the dynamic memory we allocate at compile-time must be deallocated at compile-time. This restriction makes a lot of the appealing use-cases impossible. I personally think that there are many examples out there that are of only little to no benefit.

The advantages of constexpr

I like to take a few sentences to explain what are the advantages of constexpr.

First, computation at compile-time does increase my local build-time. That is a pain, but it speeds up the application for my customers – a very valuable benefit. In the case where a constexpr function is evaluated only at compile-time, I get a smaller binary footprint. That leads to more potential features in an application. I’m doing a lot of stuff in an embedded environment which is usually a bit more constrained than a PC application, so the size benefit does not apply to everyone.

Second, constexpr functions, which are executed at compile-time, follow the perfect abstract machine. The benefit here is that the compiler tells me about undefined behavior in the compile-time path of a constexpr function. It is important to understand that the compiler only inspects the path taken if the function is evaluated in a constexpr context. Here is an example to illustrate what I mean.

  constexpr auto div(int a, int b)
  {
    return a / b;
  }
  
  constexpr auto x = div(4, 2); 
  auto           y = div(4, 0);  
  // constexpr auto z = div(4, 0); 

This simple function div is marked constexpr. Subsequently, div is used to initialize three variables. In , the result of the call to div is assigned to a constexpr variable. This leads to div being evaluated at compile time. The values are 4 and 2. The next two calls to div divide four by zero. As we all know, only Chuck Norris can divide by zero. Now, assigns the result to a non-constexpr variable. Hence div is executed at run-time. In this case, the compiler does not check for the division by zero despite the fact that the function div is constexpr. This changes as soon as we assign the call to div to a constexpr variable, as done in . Because div gets evaluated at compile-time now, and the error is on the constexpr path, the compilation is terminated with an error like that shown in Figure 1.

<source>:8:16: error: constexpr variable 'z' must 
be initialized by a constant expression
constexpr auto z = div(4, 0);
               ^   ~~~~~~~~~
<source>:3:14: note: division by zero
    return a / b;
             ^
<source>:8:20: note: in call to 'div(4, 0)'
constexpr auto z = div(4, 0);
                   ^
1 error generated.
Compiler returned: 1
Figure 1

Aside from not making it, catching such an error right away is the best thing that can happen.

Dynamic allocations at compile-time

As I stated initially, I think many examples of dynamic allocations at compile-time are with little real-world impact. A lot of the examples look like this:

  constexpr auto sum(const vector<int>& v)
  {
    int ret{};
    for(auto i : v) { ret += i; }
    return ret;
  }
  constexpr auto s = sum({5, 7, 9});

Yes, I think there is a benefit to having sum constexpr. But whether this requires a container with dynamic size or if a variadic template would have been the better choice is often unclear to me. I tend to pick the template solution in favor of reducing the memory allocations.

The main issue I see is that, most often, the dynamically allocated memory must go out of the function. Because this is impossible, it boils down to either summing something up and returning only that value or falling back to, say std:array.

So, where do I think dynamic allocations at compile-time come in handy and are usable in real-world code?

A practical example of dynamic allocations at compile-time for every C++ developer

All right, huge promise in this heading, but I believe it is true.

Here is my example. Say we have an application with a function GetHome that returns the current user’s home directory. Another function GetDocumentsDir, returns, as the name implies, the documents folder within the user’s home directory. In code, this can look like this:

  string GetHome()
  {
    return getenv("HOME"); // assume /home/cpp
  }
  string GetDocumentsDir()
  {
    auto home = GetHome();
    home += "/Documents";
    return home;
  }

Not rocket science, I know. The only hurdle is that the compiler figures out that getenv is never constexpr.

For now, let’s just use std::is_constant_evaluated and return an empty string.

What both functions return is a std::string.

Now that we have a constexpr std::string, we can make these two functions constexpr, as shown next.

  constexpr string GetHome()
  {
    if(std::is_constant_evaluated()) {
      return {}; // What to do here?
    } else {
      return getenv("HOME");
    }
  }
  constexpr string GetDocumentsDir()
  {
    auto home = GetHome();
    home += "/Documents";
    return home;
  }

The issue is that while the code may look nice, the functions are unusable at compile-time due to the restriction of allocations at compile-time. They both return a std::string which contains the result we are interested in. But it must be freed before we leave compile-time. Yet, the user’s home directory is a dynamic thing that is 100% run-time dependent. So absolutely no win here, right?

Well, yes. For your normal program, compile-time allocations do nothing good here. So time to shift our focus to the non-normal program part, which is testing. Because the dynamic home directory makes tests environment-dependent, we change GetHome slightly to return a fixed home directory if TEST is defined. The code then looks like Listing 1.

constexpr string GetHome()
{
#ifdef TEST
  return "/home/cpp";
#else
  if(std::is_constant_evaluated()) {
    return {};  // What to do here?
  } else {
    return getenv("HOME");
  }
#endif
}
constexpr string GetDocumentsDir()
{
  auto home = GetHome();
  home += "/Documents";
  return home;
}
Listing 1

Say we like to write a basic test checking that the result matches our expectations. I use Catch2 here [Catch2]:

  TEST_CASE("Documents Directory")
  {
    CHECK(GetDocumentsDir() 
      == "/home/cpp/Documents");
  }

Still no use at compile-time of GetDocumentsDir or GetHome. Why not? If we look closely, we now have everything in place. Due to the defined test environment, GetHome no longer depends on getenv. For our test case above, we are not really interested in having the string available at run-time. We mostly care about the result of the comparison in CHECK.

How you approach this is now a matter of taste.

A neat trick with consteval

Among the various improvements of C++20 are changes to constexpr, namely a new keyword consteval. In this part of the article, I want to dig into consteval a bit and see what we can do with this new facility.

What consteval does

As the name of the keyword tries to imply, it forces a constant evaluation. In the standard, a function that is marked as consteval is called an immediate function. The keyword can be applied only to functions. Immediate here means that the function is evaluated at the front-end, yielding only a value, which the back-end uses. Such a function never goes into your binary. A consteval-function must be evaluated at compile-time or compilation fails. With that, a consteval-function is a stronger version of constexpr-functions. We have now a choice:

  • Compile-time only (consteval)
  • Compile- or -run-time (constexpr)
  • Run-time (no attribution required)

Figure 2 visualizes the three different variants.

Figure 2

The behavior of consteval is handy in a situation where you want to ensure that a certain function is always evaluated at compile-time.

We already have constexpr

Now, let’s circle back and see what we can do with constexpr and where things get complicated.

A typical pattern I see in my training classes is the following:

  constexpr int Calc(int x)
  { 
    return 4 * x;
  }
  int main()
  {
    auto res = Calc(2); 
  }

In , we have a constexpr-function, so far so good. Then in , this function gets called, and the result is stored in res. The natural expectation is that Calc is evaluated at compile-time. All criteria are met:

  • The function is marked as constexpr;
  • All input values are constants.

However, Calc is evaluated at run-time. Depending on your optimizer and optimization level, things may be different, but Calc is called at run-time from a standards point. What is missing is making the variable res itself constexpr:

  constexpr int Calc(int x)
  {
    return 4 * x;
  }
  int main()
  {
    constexpr auto res = Calc(2); 
  }

In this version, we achieved what we wanted. Calc is called at compile-time because the variable itself is marked as constexpr (). While in a lot of situations, this is okay, there is one where this pattern doesn’t work. You may already know this. Marking a variable as constexpr also makes this variable implicitly const. If you struggle here, use C++ Insights to show you what constexpr brings piggyback.

Now, assume that we like to have that call to Calc happen at compile-time, but res should be writable at run-time. This is where we can use consteval, to force evaluation at compile-time, regardless of the constexpr’ness of the variable:

  consteval int Calc(int x)
  { // consteval now
    return 4 * x;
  }
  int main()
  {
    auto res = Calc(2); // Compile-time due to
                        // consteval
    ++res;              // Modify res at run-time
  }

Your new friend: as_constant

All right, so far, so good. In the version above Calc is now a compile-time only function. Now, what if we like to have both? Calc should be usable at compile- and run-time. But at the same time we would like res to be writable at run-time? Let me introduce you to as_constant, a handy new helper (you have to copy or write yourself):

  consteval auto as_constant(auto value)
  {
    return value;
  }

Yes, as_constant appears to be a very silly function. The function simply returns its input without any modification. I would probably make you remove such a silly function in a code review. But thanks to the consteval modifier, as_constant serves a greater purpose:

  constexpr int Calc(int x)
  { // constexpr again 
    return 4 * x;
  }
  int main()
  {
    // Forcing compile-time with as_constant 
    auto res = as_constant(Calc(2));
    ++res; // Modify res at run-time 
    res = Calc(res); // Run-time use of Calc 
  }

In , Calc is constexpr again. We use as_constant in to force compile-time evaluation of Calc. As before, we can modify res in , but we can now also use Calc at run-time as shows. This is something you cannot achieve with another new compile-time keyword in C++20, constinit, as constinit works only with static initialized data.

Since as_constant is evaluated purely at compile-time, the by-value semantic is okay. No need to care about moving things.

One thing is left to mention, with the approach shown with as_constant the destructor of the type used in the function must be constexpr.

Using as_constant

If you want to use as_constant in the check for the home directory, the test would look like this:

  TEST_CASE("Documents Directory constexpr")
  {
    CHECK(as_constant(GetDocumentsDir() 
      == "/home/cpp/Documents"));
  }

I probably would soon start defining something like DCHECK for dual execution and encapsulate the as_constant call there. This macro then executes the test at compile and run-time. That way, I ensure to get the best out of my test.

  #define DCHECK(expr)                           \
    CHECK(as_constant(expr));                    \
    CHECK(expr)
  
  TEST_CASE("Documents Directory dual")
  {
    DCHECK(GetDocumentsDir() 
      == "/home/cpp/Documents");
  }

In an even better world, I would detect whether a function is evaluable at compile-time and then simply add this step of checking in CHECK. However, the pity here is that such a check must check whether the function is marked as constexpr or consteval but not execute it because once such a function contains UB, the check would fail.

But let’s step back. What happens here, and why does it work?

as_constant enforces a compile-time evaluation of what it gets called with. In our case, we create two temporary std::strings, which are compared, and the result of this comparison is the parameter value of as_constant. The interesting part here is that temporaries in a compile-time-context are compile-time. We forced the comparison of GetDocumentsDir with the expected string to happen at compile-time. We then only promote the boolean value back into run-time.

The huge win you get with that approach is that in this test at compile-time, the compiler will warn you about undefined behavior:

  • like an of-by-one error (which happened to me while I implemented my own constexpr string for the purpose of this article);
  • memory leaks because not all memory gets deallocated;
  • comparisons of pointers of different arrays;
  • and more...

With the large RAM we have today, memory leaks are hard to test at run-time, but not so in a constexpr context. As I said so often, the compiler is our friend. Maybe our best friend when it comes to programming.

Of course, there are other ways. You can make the same comparison as part of a static_assert. The main difference I see is that the test will fail early, leading to a step-by-step failure discovery. Sometimes it is nicer to see all failing tests at once.

Another way is to assign the comparison result to a constexpr variable that saves you from introducing the helper function as_constant.

I hope you agree with my initial promise; the example I showed you is something every programmer can adapt.

Recap

Sometimes it helps to think out of the box a bit. Even with the restrictions of compile-time allocations, there are ways where we can profit from the new abilities.

  • Make functions that use dynamic memory constexpr.
  • Look at which data is already available statically.
  • Check whether the result, like the comparison above, is enough, and the dynamic memory can happily be deallocated at compile-time.

Your advantages are:

  • Use the same code for compile and run-time;
  • Catch bugs for free with the compile-time evaluation;
  • The result can stay in the compile-time context in more complex cases because it is more like in the initial example with sum.
  • Over time, maybe we will get non-transient allocations. Then your code is already ready.

I hope you have learned something today. If you have other techniques or feedback, please contact me.

References

[Catch2] A modern, C++-native, test framework for unit-tests, TDD and BDD: https://github.com/catchorg/Catch2

[P0784R1] Standard containers and constexpr: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0784r1.html

This article was published as two posts on Andreas Fertig’s blog:

Andreas Fertig is a trainer and lecturer on C++11 to C++20, who presents at international conferences. Involved in the C++ standardization committee, he has published articles (for example, in iX) and several textbooks, most recently Programming with C++20. His tool – C++ Insights (https://cppinsights.io) – enables people to look behind the scenes of C++, and better understand constructs.






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.