NTTP Compile-Time Builder Strategy - constexpr Conversion of 'std::vector to std::array'
A deep dive into the 'NTTP Compile-Time Builder Strategy' for constexpr conversion of 'std::vector' to 'std::array' in C++.
Introduction
Non-Type Template Parameters (NTTPs) are a powerful feature of compile-time programming in C++. They allow values to be passed and processed at compile time. However, they come with a significant limitation: only literal types1 are allowed.
This means that many commonly used standard types, such as std::string
or std::vector
, cannot be passed as NTTPs. In many use cases, it would be beneficial to utilize dynamic or complex data types in a similar manner.
The NTTP Compile-Time Builder Strategy provides a way to achieve exactly that.
This article explains how this technique works and in which scenarios it can be effectively used.
Core Concept: The NTTP Compile-Time Builder Strategy
The NTTP Compile-Time Builder Strategy consists of the following steps:
1. Encapsulation of Value Creation in a Builder
Instead of passing the value directly, we define a constexpr
lambda function that generates and returns the desired value. This lambda acts as a builder, describing how the value is created without instantiating it immediately.
1
constexpr auto str_builder = [] { return std::string{"Hello NTTP!"}; };
2. Passing the Builder as an NTTP
Since lambdas in C++ are implicitly constexpr
, they can be passed as NTTPs. This allows non-literal types to be processed at compile time indirectly.
1
2
3
4
5
constexpr auto str_builder = [] { return std::string{"Hello NTTP!"}; };
auto main() -> int {
process_string<str_builder>(); // Passing the Builder as an NTTP
}
3. Generating the Value within the Function
Inside the target function (process_string
), the builder is executed to generate the desired value at compile time.
1
2
3
4
5
template <auto builder>
auto process_string() {
std::string str = builder(); // Generate the value using the builder
// Further processing...
}
After explaining the fundamentals of the NTTP Compile-Time Builder Strategy, let’s look at a concrete use case.
Practical Example: Converting std::vector<int>
to std::array<int, N>
Implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <size_t max_size, auto builder>
constexpr auto to_array() noexcept {
namespace rng = std::ranges;
constexpr auto data = [] {
auto const int_vec = builder();
std::array<int, max_size> result{};
auto const end_pos = rng::copy(int_vec, rng::begin(result)).out;
auto const right_size = rng::distance(rng::begin(result), end_pos);
return std::pair{result, right_size};
}();
std::array<int, data.second> result{};
rng::copy_n(rng::begin(data.first), data.second, rng::begin(result));
return result;
}
How Does the Conversion Work?
The to_array
function applies the NTTP Compile-Time Builder Strategy to transform a std::vector<int>
into a std::array<int, N>
at compile time.
The first NTTP defines the size of the array. This size is not automatically derived but must be explicitly passed at the function call. It must be large enough to hold the entire contents of the std::vector
. The size cannot be inferred from std::vector
because std::array
requires a constant expression for its size.
The second NTTP is the builder, which describes how the std::vector
is created.
Inside to_array
, the builder executes to generate a std::vector
at compile time.
The contents of the vector are copied into an oversized array (std::array<int, max_size>
), and the exact number of copied elements is determined.
These values — the temporary array and its corresponding element count — are returned in a std::pair
.
Why This Intermediate Step?
The key limitation of std::array
is that its size must be known at compile time. We can only create the final array once its exact size is available as a constant expression. To achieve this, the entire process is wrapped in a lambda function, a technique known as Compile-Time Staging Strategy (CTSS).
For more details on CTSS:
Compile-Time Staging Strategy (CTSS): constexpr Conversion of int to std::string_view
In the final step, the temporary oversized array is trimmed to its actual size and returned as a constexpr
value.
Usage Example:
1
2
3
4
5
6
7
8
constexpr auto vector_builder = [] {
std::vector<int> vec{0, 8, 15};
vec.push_back(50);
// Familiar handling of std::vector
return vec;
};
static constexpr auto arr = to_array<42, vector_builder>();
Now, the builder’s result is available as a constexpr
value and can be further processed.
When is such a conversion useful?
Since C++20, many types that were previously restricted to runtime, including std::vector
, can now be used in constexpr
contexts — even though they involve dynamic memory management. This makes it possible to work with std::vector
at compile-time just as flexibly as at runtime.
The advantages are clear:
- Dynamic memory management → Elements can be added or removed dynamically.
- Flexibility → The number of elements does not need to be known in advance.
However, there is a critical limitation:
Dynamically allocated memory must also be deallocated at compile-time. This means that std::vector
cannot retain its values beyond compile-time, as its allocated memory is freed once the constexpr
execution is complete.
To store values permanently in a compile-time data structure, we need to convert std::vector
into an std::array
.
When Is the NTTP Compile-Time Builder Strategy Necessary?
The NTTP Compile-Time Builder Strategy is a powerful tool, but it is not always required. In some cases, a std::vector
can simply be passed as a regular function argument without needing a builder.
However, in this specific example, passing a std::vector
as a function argument would not be possible because the lambda inside to_array
would need to capture it by reference. Since function parameters are never constexpr
, capturing a reference to the std::vector
would create a non-constexpr
reference, which is not allowed inside a constexpr
function.
This approach is particularly beneficial when combined with the Compile-Time Staging Strategy (CTSS). Whenever the generated value needs to be used in a context that initializes a constexpr
variable, the NTTP Compile-Time Builder Strategy fully demonstrates its advantages.
Conclusion
This article has demonstrated how the NTTP Compile-Time Builder Strategy enables the use of non-literal types as NTTPs, allowing dynamic structures like std::vector
to be utilized in pure compile-time processing.
In combination with Compile-Time Staging Strategy (CTSS), this technique provides a flexible and efficient way to manage complex data transformations at compile time, ensuring that dynamically generated values remain valid beyond their immediate execution scope.
Share your feedback
Praise or criticism is appreciated!
Footnote
-
What are Literal Types?
A literal type in C++ is a type that can be used in a
constexpr
context, meaning insideconstant expressions
. This includes:- Built-in types such as
int
,char
,double
,bool
, andnullptr_t
- Enumerations (
enum
andenum class
) Pointer
types to literal types, includingconst
andnullptr_t
pointersPointers to members
of literal typesLiteral classes
2
- Built-in types such as
-
Requirements for a class to be a
literal class
- All
non-static
members must be literals. - The class must have at least one user-defined
constexpr
constructor, or allnon-static
members must be initializedin-class
. In-class
initializations fornon-static
members ofbuilt-in
types must beconstant expressions
.In-class
initializations fornon-static
members of class types must either use a user-definedconstexpr
constructor or have no initializer. If no initializer is given, the default constructor of the class must beconstexpr
. Alternatively, allnon-static
members of the class must be initializedin-class
.- A
constexpr
constructor must initialize every member or at least those that are not initializedin-class
. - Virtual or normal default destructors are allowed, but user-defined destructors with {} are not allowed. User-defined constructors with {} are allowed if they are declared as
constexpr
. However, user-definedconstexpr
destructors in literal classes are often of limited use because literal classes do not manage dynamic resources. Innon-literal
classes, however, they can be important, especially for properly deallocating dynamic resources in aconstexpr
context. Virtual
functions are allowed, butpure virtual
functions are not.Private
andprotected
member functions are allowed.Private
andprotected
inheritance are allowed, butvirtual
inheritance is not.Aggregate classes
3 with only literalnon-static
members are also considered literal classes. This applies to all aggregate classes without a base class or if the base class is a literal class.Static
member variables and functions are allowed if they areconstexpr
and of a literal type.Friend
functions are allowed inside literal classes.- Default arguments for constructors or functions must be
constant expressions
.
A literal type ensures that objects of this type can be evaluated at compile time, as long as all dependent expressions are
constexpr
. - All
-
Requirements for a class to be a
aggregate class
- What is allowed:
Public
membersUser-declared
destructorUser-declared
copy and move assignment operators- Members can be
not literals
Public
inheritanceProtected
orprivate
static members
- What is not allowed:
Protected
andprivate
non-static membersUser-declared
constructorsVirtual
destructorVirtual
member functionsProtected/private
orvirtual
inheritance- Inherited constructors (by
using
declaration)
- Restrictions for the base class:
- Only
public
non-static members allowed OR Public
constructor required fornon-public
non-static members
- Only
- What is allowed: