Compile-Time Staging Strategy (CTSS): constexpr Conversion of 'int to std::string_view'
A detailed introduction to the Compile-Time Staging Strategy (CTSS) for converting an int to a std::string_view at compile time using C++20 and C++23.
Introduction
With the latest constexpr
extensions in C++, we can perform more and more computations at compile time. However, in practice, we repeatedly encounter a specific problem: What do we do when a non-constexpr
variable suddenly needs to be constexpr
later in the program?
A typical example: The size
member of a std::string
is used to instantiate a std::array
of the corresponding size. The compiler rejects this because the size is not a constant expression.
This is where the Compile-Time Staging Strategy (CTSS) comes into play. It allows us to make values constexpr
in multiple stages.
In this article, we will take a detailed look at how CTSS works, using the example of converting an int
to a std::string_view
at compile time.
How Non-constexpr
Values Can Become a Roadblock
When converting an integer value to a string representation, we face a problem: The number of required characters depends on the value itself. It is impossible to predict the number of digits in advance when the number is the result of a complex computation. Therefore, we need to create an oversized array and trim it to the exact required size.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace rng = std::ranges;
constexpr auto calculation(int init) { return (init % 17) * 42 + 5; }
template <auto buffer_size, auto int_builder>
consteval auto int_to_string_view() {
std::array<char, buffer_size> oversized_buffer{};
auto const result = std::to_chars(rng::begin(oversized_buffer),
rng::end(oversized_buffer),
int_builder());
auto const right_size = rng::distance(rng::cbegin(oversized_buffer),
result.ptr);
std::array<char, right_size + 1> rightsize_buffer{}; // Error
...
}
constexpr auto str_view = int_to_string_view<32, [] {
return calculation(42);
}>();
However, this step fails because the determined size (in our case, the variable
right_size
) is not a constant expression.
The Compile-Time Staging Strategy (CTSS) provides an elegant solution to this problem. It allows us to transform intermediate results into constexpr
values in multiple stages, ultimately enabling the creation of a valid std::string_view
.
The Compile-Time Staging Strategy (CTSS)
The first step is done: We have identified the problem. In our example, the rightsize_buffer
array expects right_size
to be a constant expression – but it isn’t.
To resolve this issue, we encapsulate the entire code up to the error inside a constexpr
lambda and return all values that need to be constexpr
. In our case, this is right_size
. Additionally, we need to return the oversized array oversized_buffer
so we can later trim it to the exact required size.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace rng = std::ranges;
template <auto buffer_size, auto int_builder>
consteval auto int_to_string_view() {
constexpr auto intermediate_result = [] {
std::array<char, buffer_size> oversized_buffer{};
auto const result = std::to_chars(rng::begin(oversized_buffer),
rng::end(oversized_buffer),
int_builder());
auto const right_size = rng::distance(rng::cbegin(oversized_buffer),
result.ptr);
return std::pair{oversized_buffer, right_size};
}();
...
}
Important Considerations:
-
All return values must be of
literal types
1, as only these can be used to initializeconstexpr
variables (such asintermediate_result
). -
The lambda must not capture any non-
constexpr
values from its surrounding scope, as that would make it non-evaluatable at compile time. Although this is not an issue in our example, it is a common source of errors in compile-time programming and should always be kept in mind.
With this, the first staging step is complete: We now have the relevant values in a constexpr
-compatible form and can proceed to the next step — adjusting the array size.
Adjusting the Array Size
Creating an array with the exact required size is no longer an issue since we now have the precise size available as a constexpr
value. We reserve an extra byte for the ‘null terminator’ and copy all characters from the oversized array into the appropriately sized rightsize_buffer
array.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace rng = std::ranges;
template <auto value> consteval auto& to_static() { return value; }
template <auto buffer_size, auto int_builder>
consteval auto int_to_string_view() {
constexpr auto intermediate_result = [] {
std::array<char, buffer_size> oversized_buffer{};
auto const result = std::to_chars(rng::begin(oversized_buffer),
rng::end(oversized_buffer),
int_builder());
auto const right_size = rng::distance(rng::cbegin(oversized_buffer),
result.ptr);
return std::pair{oversized_buffer, right_size};
}();
std::array<char, intermediate_result.second + 1> rightsize_buffer{};
rng::copy_n(rng::cbegin(intermediate_result.first),
intermediate_result.second,
rng::begin(rightsize_buffer));
rightsize_buffer[intermediate_result.second] = '\0';
return std::string_view{to_static<rightsize_buffer>()}; // Error
}
At this point, we face another problem: How do we return a
std::string_view
pointing to an array when that array is a local variable? Simply declaring the variable asstatic
is not allowed in aconstexpr
context.
Two Possible Solutions:
-
Declare the
rightsize_buffer
array asstatic constexpr
2. -
Declare
rightsize_buffer
asconstexpr
and make the array indirectlystatic
by passing it to the helper functionto_static
. By passing theconstexpr
array as a Non-Type Template Parameter (NTTP), the array is placed instatic
storage, andto_static
simply returns a reference to this memory.
In both cases, the rightsize_buffer
array must be declared as constexpr
. However, directly declaring it as constexpr
is not possible, as the subsequent copy operation would otherwise fail.
Once again, we face the challenge of a non-constexpr
variable that needs to become constexpr
later in the program.
Final Staging
At this point, we apply the Compile-Time Staging Strategy one last time. After identifying the error, we again encapsulate the relevant code in a constexpr
lambda and return all values that need to be constexpr
. In this case, it is only the rightsize_buffer
array itself.
Finally, we pass the array as an NTTP to our helper function to_static
. The returned reference to the statically stored array is then used to create and return a std::string_view
instance.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
namespace rng = std::ranges;
template <auto value> consteval auto& to_static() { return value; }
template <auto buffer_size, auto int_builder>
consteval auto int_to_string_view() {
constexpr auto intermediate_result = [] {
std::array<char, buffer_size> oversized_buffer{};
auto const result = std::to_chars(rng::begin(oversized_buffer),
rng::end(oversized_buffer),
int_builder());
auto const right_size = rng::distance(rng::cbegin(oversized_buffer),
result.ptr);
return std::pair{oversized_buffer, right_size};
}();
constexpr auto rightsize_buffer = [&intermediate_result] {
std::array<char, intermediate_result.second + 1> rightsize_buffer{};
rng::copy_n(rng::cbegin(intermediate_result.first),
intermediate_result.second,
rng::begin(rightsize_buffer));
rightsize_buffer[intermediate_result.second] = '\0';
return rightsize_buffer;
}();
return std::string_view{to_static<rightsize_buffer>()};
}
auto main() -> int {
constexpr auto str_view = int_to_string_view<32, [] {
return calculation(42);
}>();
...
}
Unlike the first staging step, this time we capture an external variable inside the lambda. However, this is completely safe because it only captures the constexpr
variable intermediate_result
. This ensures that the lambda remains constexpr
-evaluatable, avoiding the previously mentioned pitfall.
With this last step, the conversion is complete. We have successfully transformed an int
into a constexpr
-evaluatable std::string_view
while ensuring that all required values are truly constexpr
.
Summary of the Compile-Time Staging Strategy (CTSS)
-
Identify the error – Analyze which variable is not
constexpr
but needs to be. -
Encapsulate code – Extract the affected code into a
constexpr
lambda that returns the necessary values. -
Watch for pitfalls – Ensure that no non-
constexpr
values are captured and that onlyliteral types
are returned. -
Store values in
constexpr
variables – Use the returned values to initializeconstexpr
variables. -
Final processing – Use the now
constexpr
-compatible values for the actual computation, such as creating astd::string_view
.
Conclusion
The Compile-Time Staging Strategy is a useful technique for many scenarios where constexpr
constraints in C++ seem to pose a challenge. It allows us to solve complex problems and unlocks new possibilities for optimized, efficient programs. With the continuous improvements in C++20
and C++23
, compile-time programming is becoming increasingly powerful—and strategies like CTSS help us make the most of it.
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
3
- Built-in types such as
-
Since C++23, it is allowed to declare variables as
static constexpr
in aconstexpr
context. However, we do not use this approach because the Clang compiler currently has issues handlingstatic constexpr
insideconsteval
functions. ↩︎ -
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
4 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: