Mental Model for Compile-Time Programming

My mental model for compile-time programming.

These views do not in any way represent those of NVIDIA or any other organization or institution that I am professionally associated with. These views are entirely my own.

Compile-Time Programming

The most powerful and compelling features of modern C++ revolve around compile-time programming in my opinion.

Consider the following code snippet (godbolt):

bool enable_bar = true;

int foo(int i) {
  return i + 1;
}

int bar(int i) {
  if (enable_bar)
    return i + 1;
  return i;
}

int main() {
  int value = 42;
  return bar(foo(value));
}

Let the following graph depict the program above, where blue boxes represent runtime code and arrows depict dependencies. This graph is a simplified depiction of the dependencies between elements in the abstract syntax tree of the program.

graph LR A(Return Value) --> B(bar) B --> C(foo) B --> D(enable_bar) C --> E(value)

At the end of the day, the program really only depends on enable_bar and value, and we know the values of both of these at compile-time. There are no runtime dependencies of this program, yet the compiler will still emit runtime instructions (even with -O3) because it doesn’t know for a fact that the dependencies can be resolved at compile time.

Consider this altered version of the code snippet above (godbolt):

bool enable_bar = true;

consteval int foo(int i) {
  return i + 1;
}

int bar(int i) {
  if (enable_bar)
    return i + 1;
  return i;
}

int main() {
  constexpr int value = 42;
  return bar(foo(value));
}

The graphical depiction of this program may now look like this, with the addition that green boxes represent compile-time code:

graph LR ret(Return Value) --> B(bar) B --> F(foo) B --> E(enable_bar) F --> V(value) style V fill:#66ff99,stroke:#00cc66 style F fill:#66ff99,stroke:#00cc66

The dependency-tree representation of the program now contains several nodes that do not require any runtime instructions because we’ve used the consteval and constexpr specifiers to inform the compiler that we know everything we need to know in order to call those functions/use those variables at compile-time.

We can take this further:

constexpr bool enable_bar = true;

consteval int foo(int i) {
  return i + 1;
}

consteval int bar(int i) {
  if constexpr (enable_bar)
    return i + 1;
  return i;
}

int main() {
  constexpr int value = 42;
  return bar(foo(value));
}

The graph now looks like this:

graph LR ret(Return Value) --> B(bar) B --> F(foo) B --> E(enable_bar) F --> V(value) style V fill:#66ff99,stroke:#00cc66 style F fill:#66ff99,stroke:#00cc66 style ret fill:#66ff99,stroke:#00cc66 style B fill:#66ff99,stroke:#00cc66 style E fill:#66ff99,stroke:#00cc66

Because we’ve informed the compiler that our code can all be called at compile-time, the instructions emitted by the compiler are now very few; there are only three instructions and one label emitted by the compiler:

main:
 mov $0x2c,%eax
 retq 
 nopw %cs:0x0(%rax,%rax,1)

Of course, the assembly emitted by Compiler Explorer is cleaned up and the true assembly is not three instructions long, but hopefully you get the point.

When you do introduce a runtime dependency into your codebase, it’s helpful to keep this in mind; shift as much of the computation in your program as is (reasonably) possible to compile-time code, and you can potentially remove a huge number of instructions that would otherwise be executed every time you run your program.


Compile-time programming is one of the most compelling reasons to adopt the newest possible C++ standard you are able to. You’ll notice that in our code snippets, we used the consteval specifier on our functions bar and foo. This means the result of the function must be a compile-time constant; or, the node in the syntax tree must be green.

In C++ standards older than C++20, consteval was not available, and constexpr was the only option (besides preprocessor code). Before C++17, constexpr was weaker, and could not perform as much work.

constexpr only tells the compiler that it is possible to evaluate the value of the function or variable at compile time, but the compiler is not mandated to do so. Assuming we only have constexpr available to us, our graph could only really look like the following, where orange boxes represent potentially compile-time code:

graph LR R(Return Value) --> B(bar) B --> F(foo) B --> E(enable_bar) F --> V(value) style V fill:#66ff99,stroke:#00cc66 style E fill:#66ff99,stroke:#00cc66 style F fill:#ff9933,stroke:#cc660 style R fill:#ff9933,stroke:#cc660 style B fill:#ff9933,stroke:#cc660

Imagine one of your coworkers isn’t as privy to compile-time constructs in C++, and adds a potentially runtime-dependent value into this graph. The majority of your code may fall back to runtime code simply because one potentially-runtime value was introduced!

graph LR R(Return Value) --> B(bar) B --> F(foo) B --> E(enable_bar) F --> V(value) F --> Ba(baz) style V fill:#66ff99,stroke:#00cc66 style E fill:#66ff99,stroke:#00cc66

Links

These views do not in any way represent those of NVIDIA or any other organization or institution that I am professionally associated with. These views are entirely my own.

Written on Feb 6th, 2022