Logo

Blog


C++20 Dynamic Allocations 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 post, 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 (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.

1
2
3
4
5
6
7
8
constexpr auto div(int a, int b)
{
  return a / b;
}

constexpr auto x = div(4, 2);  A 
auto           y = div(4, 0);  B 
// constexpr auto z = div(4, 0); C 

This simple function div is marked constexpr. Subsequently, div is used to initialize three variables. In A, 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, B 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 C. Because div gets evaluated at compile-time now, and the error is on the constexpr path, the compilation is terminated with an error like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<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

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
string GetHome()
{
  return getenv("HOME");  A 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
constexpr string GetHome()
{
  if(std::is_constant_evaluated()) {
    return {};  A 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 the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
constexpr string GetHome()
{
#ifdef TEST
  return "/home/cpp";
#else
  if(std::is_constant_evaluated()) {
    return {};  A What to do here?
  } else {
    return getenv("HOME");
  }
#endif
}

constexpr string GetDocumentsDir()
{
  auto home = GetHome();
  home += "/Documents";

  return home;
}

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

1
2
3
4
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. In my post C++20: A neat trick with consteval, I showed a solution with a consteval function called as_constant. If you like using as_constant here, the test can look like this:

1
2
3
4
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.

1
2
3
4
5
6
7
8
#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 pitty 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 post);
  • 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 a bit out of the box. 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 learned something today. If you have other techniques or feedback, please contact me on Twitter or via email.

Andreas