Compile-Time Programming: 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 types1, 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::stringand other non-literal types is that they must deallocate their memory in aconstexprcontext. If their values need to leave theconstexprcontext, they must be copied into aliteral type1.
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
constexprcontext, meaning insideconstant expressions. This includes:- Built-in types such as
int,char,double,bool, andnullptr_t - Enumerations (
enumandenum class) Pointertypes to literal types, includingconstandnullptr_tpointersPointers to membersof literal typesLiteral classes2
- Built-in types such as
-
Requirements for a class to be a
literal class- All
non-staticmembers must be literals. - The class must have at least one user-defined
constexprconstructor, or allnon-staticmembers must be initializedin-class. In-classinitializations fornon-staticmembers ofbuilt-intypes must beconstant expressions.In-classinitializations fornon-staticmembers of class types must either use a user-definedconstexprconstructor or have no initializer. If no initializer is given, the default constructor of the class must beconstexpr. Alternatively, allnon-staticmembers of the class must be initializedin-class.- A
constexprconstructor 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-definedconstexprdestructors in literal classes are often of limited use because literal classes do not manage dynamic resources. Innon-literalclasses, however, they can be important, especially for properly deallocating dynamic resources in aconstexprcontext. Virtualfunctions are allowed, butpure virtualfunctions are not.Privateandprotectedmember functions are allowed.Privateandprotectedinheritance are allowed, butvirtualinheritance is not.Aggregate classes3 with only literalnon-staticmembers are also considered literal classes. This applies to all aggregate classes without a base class or if the base class is a literal class.Staticmember variables and functions are allowed if they areconstexprand of a literal type.Friendfunctions 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:
PublicmembersUser-declareddestructorUser-declaredcopy and move assignment operators- Members can be
not literals PublicinheritanceProtectedorprivatestatic members
- What is not allowed:
Protectedandprivatenon-static membersUser-declaredconstructorsVirtualdestructorVirtualmember functionsProtected/privateorvirtualinheritance- Inherited constructors (by
usingdeclaration)
- Restrictions for the base class:
- Only
publicnon-static members allowed OR Publicconstructor required fornon-publicnon-static members
- Only
- What is allowed:
