r/cpp_questions • u/bhul_chuk_maaf17 • 3d ago
OPEN Compile time programming learning advice.
Hi all, can you please recommend me some resources that you have followed for compile time programming in modern C++ for eg: constexpr, consteval or template metaprogramming.
Is there some consolidated resource for the same ?
3
u/Illustrious_Try478 3d ago
cppreference has it all, but it's not specific to just this topic.
1
1
u/Seagull78Reddit 2d ago
Does cppreference have like learning section or more structured pathway to learn this stuff? If not I feel like the structure of the textbook is better
2
u/Foxi_Foxa 3d ago
The best exercices I did was coding a compile time json parser on Christmas holidays. There are enough similar projects out there so you can start from somewhere and get inspiration, and then little by little you understand more and more :)
2
u/MarsupialSuch6948 3d ago
Check the book Hands On Design Patterns with C++ by Fedor G. Pikus. It has some real-world practices and design patterns that resolve during compile-time. It's a bit outdated C++17 but you can check the latest ones.
4
u/EpochVanquisher 3d ago
I would start with specific problems that you’re trying to solve, and ask “Is compile-time programming useful here?” And then try to do it, if it breaks try to fix it, search through Google / Stack Overflow / AI to try and understand why something you want to do doesn’t work at compile time, and then at the end of this process you have a better understanding.
As a general rule—I would say that there’s still a limited amount that you can get done at compile time. Compile-time programming is mostly useful for making it so you don’t pay startup penalties for things that could be calculated earlier. It doesn’t replace e.g. code generation.
1
u/Seagull78Reddit 3d ago
C++ Templates: The Complete Guide 2nd edition by Vandevoorde, Josuttis, and Gregor seems to be like a standard resource. You can easily find a copy of this online.
2
u/conundorum 1d ago edited 1d ago
Let's see...
1: constexpr
This is basically the same as normal programming, but you just slap a constexpr on the front of your function. That allows the compiler to call it at compile time if possible, and serves as the basic form of compile-time programming. Meanwhile, if you want to make a variable into a compile-time constant, just declare it constexpr, and make sure to initialise it with an operation or function call that's either constexpr or can be performed at compile time.
More specifically, though, whether a constexpr function can actually be called at compile time depends on what the function looks like inside. Any function which doesn't use runtime-only resources can be used at compile time, and there are sometimes weird gotchas here. (E.g., you can use the possibility of a throw statement to turn a specific branch of a constexpr function into a runtime-only branch. Similarly, std::string variables can sometimes be created at compile time (but it's generally considered an error to do so), and even non-constexpr std::strings are fine inside a constexpr function if they're destroyed before the function exits; the deciding factor is that dynamic storage allocated at compile time must be released in the same evaluation, so std::string is fine inside a constexpr function (which releases its memory before returning), but can only be a constexpr value itself if it doesn't allocate (which requires small string optimisation on the compiler's side, and is only possible for tiny strings).)
(And, of course, constexpr functions can only be compile-time if the parameters are known at compile time, but that kinda goes without saying.)
The easiest rule of thumb is that if your function only uses primitives or other constexpr/consteval functions, and its return value is primitive or created by a constexpr constructor, then it's usually compile-time.
2. consteval
Really, this is just a "must be compile-time, and can never be runtime" keyword. It's just constexpr but more strict; constexpr functions can be called at compile time (but will happily work at runtime, too), while constexpr must be called at compile time (and will only work at compile time). If a constexpr function call can't be evaluated at compile-time, it'll be evaluated at runtime instead; if a consteval function call can't be evaluated at compile-time, it'll give you a compiler error. That's really the only difference.
Use-wise, use consteval when the result must be known at compile-time, when it doesn't make sense to call at runtime, or if the function is unnecessary at runtime and you don't want it to bloat the executable.
3. constinit
This one is an odd one, and not really a major part of compile-time programming, but I figured I should mention it, too. constinit states that a variable with static or thread-local storage (e.g., static int i;, or global bool b, or namespace-scope bool NS::b;, or thread_local char c;) must be initialised at compile time. It can be modified at runtime, but initialisation must be a compile-time operation. The only requirements are that a variable can't be both constinit and constexpr, it can't be dynamically allocated, and it must be constructible at compile time; the restrictions are a lot looser than constexpr.
Ultimately, it's just there to let you pay for a costly initialisation at compile time instead of runtime, and bake the value into the executable. It looks like part of compile-time programming, but it's only an endpoint at most.
(It can also be used to promise the compiler that a thread_local variable is initialised somewhere else, so it doesn't have to waste time on a hidden guard variable or anything like that, but that use case has nothing to do with compile-time programming.)
4. Template metaprogramming.
Ooh, boy... where do we even begin... xD
Hmm... first and foremost, start with something simple, like learning how to use templates to genericise a C array. Make a struct like this, and make sure the array matches the template parameters.
template<typename T, size_t N>
struct Array {
// C array here.
// Member type equal to T.
// Member size_t equal to N.
};
That'll make sure you understand what the two kinds of tparams are, and what they do. (Type parameters are the big one, and allow you to genericise a type. We use them for all kinds of type-manipulation craziness, and variadics are also based on them. Conversely, non-type template parameters (usually called NTTPs because boy, is that a mouthful) allow you to encode a value into type data, as part of a compile-time type. They're useful for a lot of metaprogramming jazz, and as a way to perform calculations as part of compilation.)
Once you understand that, try to figure out how to dismantle a simple function prototype using templates.
// You want to catch this signature, and be able to reassemble it.
// Hint: Function type is int(char), and function pointer type is int(*)(char).
int extend(char);
// Implementation, for reference. Just extends c from char size to int size, with no changes.
int extend(char c) { return c; }
// Helper type, saves you a bit of typing.
using ExtendType = decltype(extend);
// Template declaration. Keep this as-is, you need it as a baseline.
template<typename T>
struct FunctionType;
// Template specialisation. You just need to answer the four questions correctly.
template<What?>
struct FunctionType<Huh?> {
using Return = Que?;
using Param = Nani?;
};
// If done correctly, these will all pass.
#include <type_traits>
static_assert(std::is_same_v<int, typename FunctionType<ExtendType>::Return>);
static_assert(std::is_same_v<char, typename FunctionType<ExtendType>::Param>);
Once you get that right, you'll have a feel for extracting components from a more complex type, which is crucial for metaprogramming. You'll be using similar tricks to match functions and types to each other, and to obtain & use parts of your template structs & functions, after all!
After that, try to figure out how to make a set of compile-time constants using recursion, such that Unsigned<0>::value == 0;, Unsigned<1>::value == 1;, and Unsigned<N>::value == N;. (Change names if desired.) Hint: Define Unsigned<0> as a template specialisation; you'll need it as a base point.
(Note: Do not just assign the template parameter to value, that defeats the purpose. This is about learning how to do math at compile time, not how to assign NTTPs to members.)
template<What?>
struct Unsigned {
static constexpr unsigned value = Que?; // Must be "static constexpr" or an old-school enum constant.
};
// This is useful, but optional.
// This should have the same "What?" as Integer.
template<What?>
constexpr unsigned Unsigned_v = Unsigned<What?>::value;
// And these should pass.
static_assert(Unsigned<0>::value == 0);
static_assert(Unsigned<1>::value == 1);
static_assert(Unsigned<N>::value == N); // Choose any arbitrary value for N here.
Once you get a feel for the math, you can use similar processes in any arbitrary context. Compile-time functional programming is common, for instance; it's useful to have a function that can do math or create std::initializer_lists entirely within its template parameters.
After that, the next place to look would be... variadiacs, I believe. Try to make a function that can take multiple parameters and send them to std::cout one at a time, with a space after each, and then output, "And that's all, folks!\n" after the last one. The base signature will look something like this:
template<typename... Ts>
void print(const Ts&... ts);
This one's odd if you're not used to it, so I'll give you a big hint: You can't work with Ts or ts like you would with normal parameters; you can determine the size of a pack with the sizeof... operator (e.g., sizeof...(Ts)), you can expand it with the ellipsis operator (e.g., std::tuple<Ts...> or printf("format string", ts...)), you can expand an operator over it with a fold expression (e.g., auto sum = (ts + ...); for a pack of numbers), and there are a few contexts where you can use it unexpanded, but that's about it.
[Continued in reply, sorry. Ran out of space.]
Edit: Typo fix. Fold expressions must be parenthesised.
4
u/crowbarous 3d ago edited 2d ago
Compile-time programming is mostly just programming. You write your algorithm, and it runs at compile time. The newer the standard and the better your compiler, the more it is true.
There's basically no language as statically expressive as C++. The point is that it has no trouble going back and forth between the type world (home to semantic processes, such as template instantiation and overload resolution), and the value world (which is the one that knows whether 2+2 is 4). Most other languages have a one-way pipeline here, so it takes getting used to.
C++ compilers today cheat less and you'll have a harder time breaking them with legal compile-time constructs than 20 years ago, so no point in hesitating.
I advise to look at the Boost.Hana library, which is a very impressive implementation of many compile-time programming ideas. Maybe don't exactly use it, because it's implemented in C++14 (which is the impressive part, actually. The implementation of Hana looks nothing like what I show below), and modern C++ allows implementations that are more robust, faster to compile and easier to maintain.
...
There are a few specific tools which are absent from the standard library, but useful for compile-time programming. First, something that maps values to types:
You'll want to equip this with
constant<1> + constant<2>isconstant<3>, not just a constexpr 3operator""_cso you can write3_cand getconstant<3>constant_tconstant<0L>is acceptable in places that wantconstant_t<0>...I believe the proposed
std::constant_wrapperis basically this, but I don't know the status of that. Arguably the origins of this approach are instd::integral_constantand the more completeboost::hana::integral_constant, however both predateautoNTTPs so you have to specify the type manually (which is why hana hasint_,long_, etc).Now you can use values to guide template instantiation and overload resolution.
Now for the inverse transformation, types →values. This is kind of less useful.
...and again you should implement the necessary conversions.
I mostly just use this to avoid having to use the
.template operator()<...>syntax on lambdas, instead just take anautoargument and pass the type normally in the argument list. Also it can be used when you want overload resolution on a type but it would be imprudent or unwieldy to actually construct the object.For a third one, the
overloadyou just saw a is very useful, and you probably have it in your codebase, maybe several times.There are a few ways to complicate this implementation, such as allowing function pointers, but the one above verbatim that's usable with lambdas will get you very far.