Compile-Time Programming in C++: New Possibilities with C++20 and C++23
A deep dive into compile-time programming in C++20 and C++23, focusing on efficient 'std::string' to 'std::string_view' conversion.
Introduction
Efficiency is a key factor in modern C++ projects. Especially in performance-critical applications, it is beneficial to avoid expensive memory allocations and runtime computations. The new features in C++20
and C++23
greatly expand compile-time programming, allowing even non-literal types like std::string
and std::vector
to be processed at compile time.
In this article, I will show a concrete example of how to efficiently convert std::string
into std::string_view
at compile time. This reduces runtime costs, avoids unnecessary dynamic memory allocations, and enables new optimizations – such as for logging or generated code metadata. In addition to the new language features, I will explain fundamental concepts of compile-time programming and present practical solutions to common challenges.
Challenge: Using std::string
as constexpr
Every call to a constexpr
or consteval
function from a non-constexpr
context requires all function arguments to be literal types
1, meaning their values must be known at compile time.
However, std::string
is not a literal type, even though it has constexpr
constructors since C++20
and can be used in a constexpr
context at compile time.
Let’s consider the following example:
1
2
3
4
5
auto main() -> int { // non-constexpr context
std::string str{"hello world"};
constexpr auto str_view = to_string_view(str);
return 0;
}
Here, the call fails because std::string
cannot be used as an argument in a constexpr
function to initialize a constexpr
variable.
It also does not help to declare the variable str
as constexpr
, as shown in the following example:
1
2
3
4
5
auto main() -> int { // non-constexpr context
constexpr std::string str{"hello world"};
constexpr auto str_view = to_string_view(str);
return 0;
}
1. Passing a prvalue
instead of an lvalue
Instead of passing a variable, we use a temporary value (prvalue
):
1
2
3
4
auto main() -> int { // non-constexpr context
constexpr auto str_view = to_string_view(std::string{"hello world"});
return 0;
}
Since C++17
, prvalues
are no longer objects but pure expressions. The materialization into an object occurs only within the constexpr
function to_string_view
, making the code valid because the temporary std::string
does not need to be constexpr
at the time of the call.
2. Using a Lambda Function
Alternatively, the std::string
can be encapsulated in a lambda:
1
2
3
4
5
6
7
auto main() -> int { // non-constexpr context
constexpr auto str_view = [] {
std::string str{"hello world"};
return to_string_view(str);
}();
return 0;
}
Since lambdas have been implicitly constexpr since C++17
, the call to the constexpr
function takes place from a constexpr
context.
Converting std::string
into std::array
To implement to_string_view
, we first convert std::string
into std::array
.
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace rng = std::ranges;
template <auto max_size>
consteval auto to_string_view(std::string const& str) {
std::array<char, max_size> max_size_array{};
rng::copy(str, max_size_array.begin());
return max_size_array;
}
auto main() -> int { // non-constexpr context
constexpr auto str_view = to_string_view<128>(std::string{"hello world!"});
return 0;
}
The key limitation of
std::string
and other non-literal types is that they must deallocate their memory in aconstexpr
context. If their values need to leave theconstexpr
context, they must be copied into aliteral type
1.
std::array
is therefore an ideal choice for storing the std::string
value. The maximum size of the array is passed as a Non-Type Template Parameter (NTTP) because function parameters in C++ can never be constexpr
and when instantiating the right_size_array
array, max_size
must be a constant expression.
Dynamic Adjustment of Array Size
Since we never know the exact array size in advance, we first create an oversized array and then trim it to the exact size.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace rng = std::ranges;
template <auto max_size>
constexpr auto to_oversized_array(std::string const& str) {
std::array<char, max_size> max_size_array{};
auto const end_pos = rng::copy(str, rng::begin(max_size_array));
auto const right_size = rng::distance(rng::cbegin(max_size_array), end_pos.out);
return std::pair{max_size_array, right_size};
}
template <auto max_size>
consteval auto to_string_view(std::string const& str) {
constexpr auto intermediate_data = to_oversized_array<max_size>(str);
std::array<char, intermediate_data.second> right_size_array{};
rng::copy_n(rng::cbegin(intermediate_data.first), intermediate_data.second,
rng::begin(right_size_array));
return right_size_array;
}
Problem: Function Parameters in C++ Are Never constexpr
The function parameter str
is not constexpr
, yet we pass it as an argument to a constexpr
function that initializes a constexpr
variable. However, the variable intermediate_data
must remain constexpr
because, when instantiating the right_size_array
array (line 14
), the size must be a constant expression.
Solution: Lambda as NTTP
Instead of passing std::string
as a parameter, we encapsulate it in a lambda and pass it as an NTTP.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace rng = std::ranges;
template <auto max_size, auto string_builder>
constexpr auto to_oversized_array() {
std::array<char, max_size> max_size_array{};
auto const end_pos = rng::copy(string_builder(), rng::begin(max_size_array));
auto const right_size = rng::distance(rng::cbegin(max_size_array), end_pos.out);
return std::pair{max_size_array, right_size};
}
template <auto max_size, auto string_builder>
consteval auto to_string_view() {
constexpr auto intermediate_data = to_oversized_array<max_size, string_builder>();
std::array<char, intermediate_data.second> right_size_array{};
rng::copy_n(rng::cbegin(intermediate_data.first), intermediate_data.second,
rng::begin(right_size_array));
return right_size_array;
}
auto main() -> int { // non-constexpr context
constexpr auto str_view = to_string_view<128, [] {
return std::string{"hello world!"}; }>();
return 0;
}
Optimization: Optional
To keep everything in place, we encapsulate the function to_oversized_array
in a lambda.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace rng = std::ranges;
template <auto max_size, auto string_builder>
consteval auto to_string_view() {
constexpr auto intermediate_data = [] {
std::array<char, max_size> max_size_array{};
auto const end_pos = rng::copy(string_builder(), rng::begin(max_size_array));
auto const right_size = rng::distance(rng::cbegin(max_size_array), end_pos.out);
return std::pair{max_size_array, right_size};
}();
std::array<char, intermediate_data.second> right_size_array{};
rng::copy_n(rng::cbegin(intermediate_data.first), intermediate_data.second,
rng::begin(right_size_array));
return right_size_array;
}
Converting std::array
into std::string_view
By marking the array right_size_array
with static constexpr
, we store it in static memory and allow it to be referenced using a std::string_view
instance. This instance is then returned to the caller of the to_string_view
function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace rng = std::ranges;
template <auto max_size, auto string_builder>
consteval auto to_string_view() {
constexpr auto intermediate_data = [] {
std::array<char, max_size> max_size_array{};
auto const end_pos = rng::copy(string_builder(), rng::begin(max_size_array));
auto const right_size = rng::distance(rng::cbegin(max_size_array), end_pos.out);
return std::pair{max_size_array, right_size};
}();
static constexpr auto right_size_array = [&intermediate_data] {
std::array<char, intermediate_data.second> right_size_array{};
rng::copy_n(rng::cbegin(intermediate_data.first), intermediate_data.second,
rng::begin(right_size_array));
return right_size_array;
}();
return std::string_view{right_size_array};
}
Portability: Using to_static
for Clang
Since Clang has issues with static constexpr
in consteval
functions in C++23, the following helper function ensures portability:
1
template <auto value> consteval auto& to_static() { return value; }
We call this function with the array right_size_array
as a Non-Type Template Parameter (NTTP). NTTPs allow values to be stored directly in the static memory area, making them referenceable. This way, we can safely store std::array
data in static memory and return it as std::string_view
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace rng = std::ranges;
template <auto value> consteval auto& to_static() { return value; }
template <auto max_size, auto string_builder>
consteval auto to_string_view() {
constexpr auto intermediate_data = [] {
std::array<char, max_size> max_size_array{};
auto const end_pos = rng::copy(string_builder(), rng::begin(max_size_array));
auto const right_size = rng::distance(rng::cbegin(max_size_array), end_pos.out);
return std::pair{max_size_array, right_size};
}();
constexpr auto right_size_array = [&intermediate_data] {
std::array<char, intermediate_data.second> right_size_array{};
rng::copy_n(rng::cbegin(intermediate_data.first), intermediate_data.second,
rng::begin(right_size_array));
return right_size_array;
}();
return std::string_view{to_static<right_size_array>()};
}
With this, our compile-time conversion from std::string
to std::string_view
is not only complete but also portable and efficient.
Use Case: Compile-Time Generation of Log Tags
A practical example is creating log tags for generic types:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
constexpr auto type_name() { // GCC only
constexpr std::string_view prefix = "constexpr auto type_name() [with T = ";
std::string_view name = __PRETTY_FUNCTION__;
name.remove_prefix(prefix.size());
name.remove_suffix(1);
return name;
}
template <typename T>
constexpr auto log_tag() {
return to_string_view<64, [] { return "Log<" + std::string(type_name<T>()) + ">"; }>();
}
auto main() -> int { // non-constexpr context
static constexpr auto log_string = log_tag<std::vector<std::string>>();
// output: Log<std::vector<std::__cxx11::basic_string<char> >>
return 0;
}
Here, a log tag for a generic type is created at compile time. This reduces runtime costs and avoids unnecessary memory allocations.
Conclusion
The new features in C++20/23
enable powerful compile-time manipulations even for non-literal types. The techniques shown allow efficient conversion of std::string
into std::string_view
, reducing runtime costs.
Especially in performance-critical applications, compile-time programming can provide significant advantages. The ability to process strings efficiently at compile time opens up exciting optimization possibilities – not only for logging but also for many other areas of modern C++ development.
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: