Compile-time programming
The Evolutionary Timeline
Compile-time programming in C++ wasn't designed all at once; it evolved from a happy accident into a first-class language feature.
| Era | Key Feature | Description |
|---|---|---|
| C++98 | Macros & Template Metaprogramming (TMP) | Macros were crude. TMP was discovered by accident—realizing the template system is a Turing-complete language executed at compile time. It used a horrible |
| C++11/14 | constexpr | The game-changer. Allowed you to write regular-looking C++ functions that could execute at compile time. |
| C++17 | if constexpr | Allowed compile-time conditional branching |
| C++20 | consteval & constinit | Introduced immediate functions (guaranteed compile-time) and formal Concepts to replace messy template constraints. |
Macros
Marcros are a thing of the past now but it is used in many legacy systems and can still have it uses. Marcros are evaluated at compile time based on compiler flags most of the time.
Contants
#define MAX_BUFFER_SIZE 1024
Functions
int square(int x)
{
return x * x;
}
#define SQUARE(x) square(x)
template <typename... Args>
void log(Args&&... args)
{
(std::cout << ... << args);
std::cout << std::endl;
}
#define LOG(...) log(__VA_ARGS__)
Conditional Compilation
This is the most common modern use
#define DEBUG 1
int main() {
std::cout << "Application started." << std::endl;
#if DEBUG
std::cout << "[LOG] Debug mode is ON. Internal states verified." << std::endl;
#else
std::cout << "Running in production mode." << std::endl;
#endif
return 0;
}
Templates
Templates are very powerfull and one of my most favourite feature. They give you the ability to write generic, type-safe code that the compiler customizes for you on the fly.
The compiler uses that blueprint to generate actual functions or classes only when you request them with a specific data type. This process is called template instantiation.
Function Templates
Function templates are used to define generic arguments or return types of the function. When the compiler sees a template being used with a specific datatype, in this case a float or int,
it will generate the actual function and perform it's duty, i.e, validate the datatype and the operations performed on it.
Add two numbers
template <typename T>
T add(T a, T b)
{
return a + b;
}
Add n numbers
template <typename... T>
auto add(T&&... args)
{
return (... + args);
}
Class Templates
Templates aren't just for functions; they allow you to create entire containers or classes that can hold any data type. This is exactly how the C++ Standard Template Library (STL) containers like std::vector or std::list work.
// Added ttl as a uint64_t just as an example and made a static assert.
template <typename T, uint64_t TTL>
class ManagedPtr
{
static_assert(TTL > 0 && (TTL & (TTL - 1)) == 0, "TTL must be a power of two");
private:
T* m_Ptr;
TTL ttl;
public:
template <typename... Args>
ManagedPtr(Args&&... args): m_Ptr(new T(std::forward<Args>(args)...)) {}
ManagedPtr(const ManagedPtr &other) = delete;
ManagedPtr& operator=(const ManagedPtr& other) = delete;
ManagedPtr(ManagedPtr&& other) noexcept: m_Ptr(nullptr) {
std::swap(m_Ptr, other.m_Ptr);
};
ManagedPtr& operator=(ManagedPtr&& other) noexcept {
if(this != &other) {
delete m_Ptr;
m_Ptr = other.m_Ptr;
other.m_Ptr = nullptr;
}
return *this;
};
~ManagedPtr() {
delete m_Ptr;
}
T* get() const { return m_Ptr; }
}
Template Specialization
Sometimes, a generic blueprint doesn't work for a specific type. For example, comparing two numbers is straightforward, but comparing two char* (C-style strings) compares their memory addresses, not their alphabetical value.
Template specialization allows you to write a custom "override" blueprint for a specific type.
#include <iostream>
#include <cstring>
// Generic template
template <typename T>
bool isEqual(T a, T b) {
return a == b;
}
// Explicit specialization for const char*
template <>
bool isEqual<const char*>(const char* a, const char* b) {
return std::strcmp(a, b) == 0; // Correct way to compare C-strings
}
int main() {
// Uses the specialized template because the arguments are const char*
std::cout << isEqual("apple", "apple") << std::endl; // True
return 0;
}
Function Templates with auto (Abbreviated Templates)
Instead of writing out the template <typename T> boilerplate, you can use auto directly in the function signature.
The C++20 auto equivalent:
auto add(auto a, auto b) {
return a + b;
}
auto add(auto&&... args) {
return (... + args);
}
Difference between original example and auto
There is a subtle difference between the original example and auto.
In the original example, we use one template parameter T.
This meant that both parameters had to be the same type.
In the example with auto, C++20 treats each auto as a seperate parameter.
As if we wrote:
template<typename T1, typename T2>
auto add(T1 a, T2 b)
{
return a + b;
}
Note: The return type is auto because T1 and T2 could be different types. int & float, we have to use auto as the return type.
We could force b to be the same as a by using decltype(a):
auto add(auto a, decltype(a) b) {
return a + b;
}
Concepts
Source: Back to Basics: Concepts in C++ - Nicolai Josuttis - CppCon 2024
- Constraints are compile-time boolean values
- Constraints can be combined with
&&- You can also use
||but note that it may add a significant amount of compile-time
- You can also use
- Constraints can be used with
if constexprbecause they are compile-time boolean values
Constraints with Concepts
Constraints are a part of the methods signature. This is why two overloads with the same arguments are allowed.
template<typename Collection>
concept HasPushBack = requires (Collection c, Collection::value_type v) {
c.push_back(v);
};
template<typename Collection, typename T>
requires HasPushBack<Collection>
void add(Collection& c, const T& v)
{
c.push_back(v);
}
template<typename Collection, typename T>
void add(Collection& c, const T& v)
{
c.insert(v);
}
std::vector<int> a;
std::set<int> b;
add(a, 9);
add(b, 9);
Compile-type polymorphism
Overload resolution prefers the more specialized function if one matches. This is why add(a, 9) works because an overload exists that requires
that constraint HasPushBack<Collection> is met.
template<typename Collection>
concept HasPushBack = requires (Collection c, Collection::value_type v) {
c.push_back(v);
};
Two constraints are defined here.
Collection chas a methodpush_back.- The method
push_backaccepts aCollection::value_type vas an argument.
We could have defined another constraint for insert but in this example it was not necessary since overload resolution prefers the more specialized function.
NOTE: Alternative syntax, you can use a concept as a typename. This is the same as requires HasPushBack<Collection>
template<HasPushBack Collection, typename T>
void add(Collection& c, const T& v)
{
c.push_back(v);
}
Testing concepts
How do we write test cases for concepts?
template<typename Collection>
concept HasPushBack = requires (Collection c, Collection::value_type v) {
c.push_back(v);
};
// Asserts true for vectors
static_assert(HasPushBack<std::vector<int>>);
// Asserts false for sets
static_assert(HasPushBack<std::set<int>> == false);
Auto: The C++20 way
template<typename Collection>
concept HasPushBack = requires (Collection c, Collection::value_type v) {
c.push_back(v);
};
void add(HasPushBack auto& c, const auto& v)
{
c.push_back(v);
}
void add(auto& c, const auto& v)
{
c.insert(v);
}
std::vector<int> a;
std::set<int> b;
add(a, 9);
add(b, 9);
Using auto we can simplify the original templated methods.
Notice the usage of the concept here:
void add(HasPushBack auto& c, const auto& v)
This is how we constraint auto.
Alternatively you could also constraint auto like this:
template<typename Collection>
concept HasPushBack = requires (Collection c, Collection::value_type v) {
c.push_back(v);
};
// Invalid, can't use decltype(c) here.
void add(auto& c, const auto& v)
requires HasPushBack<decltype(c)>
{
c.push_back(v);
}
The problem here is that auto& c is an L value reference. In the concept we are using Collection::value_type.
std::vector<int>&::value_type is invalid because references don't have members
Solution: Remove the reference
void add(auto& c, const auto& v)
requires HasPushBack<std::remove_cvref_t<decltype(c)>>
{
c.push_back(v);
}
std::decay_t<T> vs std::remove_cvref_t<T>
You may think that std::decay_t<T> is okay to use instead of std::remove_cvref_t<T> but std::decay_t<T> removes both const, volatile & references.
std::decay_t<T> also converts arrays to pointers. This may not be what you want.
std::remove_cvref_t<T> removes only the reference.
Can we make the concept take care of this so we don't have to have requires HasPushBack<std::remove_cvref_t<decltype(c)>>?
We can, here is how:
template<typename Collection>
concept HasPushBack = requires (Collection c, std::remove_cvref_t<Collection>::value_type v) {
c.push_back(v);
};
Our code now looks like:
template<typename Collection>
concept HasPushBack = requires (Collection c, std::remove_cvref_t<Collection>::value_type v) {
c.push_back(v);
};
// Test case:
static_assert(HasPushBack<std::vector<int>&>); // will assert true
void add(auto& c, const auto& v)
requires HasPushBack<decltype(c)>
{
c.push_back(v);
}
void add(auto& c, const auto& v)
{
c.insert(v);
}
std::vector<int> a;
std::set<int> b;
add(a, 9);
add(b, 9);
Concepts for multiple parameters
A more simple way to do this is to use multiple parameters in our concept:
template<typename Collection, typename T>
concept CanPushBack = requires (Collection c, T v) {
c.push_back(v);
};
void add(auto& c, const auto& v)
requires CanPushBack<decltype(c), decltype(v)>
{
c.push_back(v);
}
// OR
template <typename Collection, typename T>
void add(Collection& c, const T& v)
requires CanPushBack<Collection, T>
{
c.push_back(v);
}
Granularity of concepts
template <typename Collection>
concept SequenceCont =
std::ranges::range<Collection> &&
requires (std::remove_cvref_t<Collection> c, std::ranges::range_value_t<Collection> v) {
typename std::remove_cvref_t<Collection>::value_type; // Must have a value_type
typename std::remove_cvref_t<Collection>::iterator; // Must have an iterator type
typename std::remove_cvref_t<Collection>::const_iterator; // Must have an iterator type
c.push_back(v); // Can I push back?
c.pop_back(); // Can I pop back?
c.insert(c.begin(), v); // Can I insert by passing an iterator and a value?
c.erase(c.begin()); // Can I erase by passing an iterator?
c.clear(); // Can I clear?
std::remove_cvref_t<Collection>{v,v,v}; // Can I call the constructor this way?
c = {v,v,v}; // Can I have an assignment?
{c < c} -> std::convertible_to<bool>; // Can I compair with less than and does it return a bool?
};
std::convertible_to<> & std::ranges::range<> are concepts in the standard library.
Here we were able to create a concept that checks if a type satisfies multiple concepts and requirements.
Requires
require can be used in two different ways, require expression & require clause
Requires expression
template<typename Collection, typename T>
concept CanPushBack = requires (Collection c, T v) {
c.push_back(v);
};
Requires clause
void add(auto& c, const auto& v)
requires CanPushBack<decltype(c), decltype(v)>
{
c.push_back(v);
}
Combining requires expression & clause
This works but it's better not to use it. requires requires [definition]
void add(auto& c, const auto& v)
requires requires { c.push_back(v); }
{
c.push_back(v);
}
Requires & compile-time if
Constraints can be used with if constexpr because they are compile-time boolean values
void add(auto& c, const auto& v)
{
if constexpr (requires { c.push_back(v); })
{
c.push_back(v);
}
else
{
c.insert(v);
}
}
With if constexpr and requires we can do a lot.
- We can use complex concepts we define like
SequenceCont - We can chain requires with
&&since they are compile time booleans
Concepts vs. if constexpr: When to use which?
Since both approaches solve the exact same problem, here is a quick guide for your notes on when to use Constrained Overloads vs. Inline if constexpr:
| Feature | Constrained Overloads (requires clause) | Inline if constexpr + requires |
|---|---|---|
| Readability | Better for architectural separation. Keeps functions short and single-purpose. | Better when the logic is simple and localized inside one function. |
| Error Messages | Excellent. If a constraint fails, the compiler tells you exactly which function signature failed. | Can sometimes lead to longer error messages inside the function body if a branch fails to compile. |
| Extensibility | Open for extension. Other programmers can add new overloads for custom types without touching your original code. | Closed for extension. If a new type needs a brand new edge case, you must modify the original function to add another else if constexpr. |