More constexpr with variants and view types
Consider a simple class like the following one:
#include <string>
class my_class {
std::string str;
public:
my_class(std::string str) : str(std::move(str)) {}
int get_data() const { return str.empty() ? 1 : str.length(); }
// other member variables
};
As it happens, you might want to define some constants of my_class
, but if you write:
const my_class global = my_class("123");
then global
might be initialized at runtime, with all problems that come with it.
You definitely want to use constexpr
, but the constructor of my_class
is not constexpr
.
A possible alternative is to declare a corresponding view type:
#include <string_view>
class my_class_view {
std::string_view str;
public:
my_class_view(std::string_view str) : str(str) {}
int get_data() const { return str.empty() ? 1 : str.length(); }
// other member variables
};
This approach does not scale well for classes with many methods, as it leads to code duplication, unless you are prepared to do some refactoring.
Plus, if you have functions that take a const my_class&
, you need to convert my_class_view
to my_class
, which in general will allocate. With const my_class global
, there are multiple issues, but at least it will not allocate every time you use global
as a parameter to a function that takes a const myclass1&
.
The singleton pattern fixes more or less all issues; you can define a constant of the correct type, and you do not need to worry about accessing non-initialized data:
const my_class& global () { const static auto v = my_class("123"); return v;}
But it does not help if you want to use the constant data at compile time, for example:
-
for reducing code bloat (on the contrary, the singleton pattern might increase the code bloat)
-
to reduce the amount of used resources; it is a constant, so there should be no need to allocate any memory
I wanted to do better.
Note that you do not need to put everything in header files if you do not want to. You can declare a constant as usual in the header file, and then define it constexpr
in one translation unit:
// . hpp file
extern const int my_constant;
class some_class{
static const int my_constant;
// other things
}
// .cpp file
constexpr int my_constant = 42;
constexpr int some_class::my_constant = 42;
Detect if the class is initialized at runtime or not
Since using different classes has its own set of gotchas, the follow-up idea is to "merge" the owning and non-owning class together, depending on whether the class has been initialized at compile-time or not.
With consteval
and std::variant
, this can be achieved easily:
#include <string_view>
#include <string>
#include <variant>
class my_class2 {
std::variant<std::string_view, std::string> str;
public:
consteval explicit my_class2(std::string_view str_) : str(str_) {}
consteval explicit my_class2(const char* str_) : str(std::string_view(str_)) {}
explicit my_class2(std::string str) : str(std::move(str)) {}
int get_data() const {
auto data = std::visit([](std::string_view arg) { return arg; }, str);
return data.empty() ? 1 : data.length();
}
// other member functions
};
Note that consteval
is available from C++20; if you are working with older standards, marking the function constexpr
might be good enough.
The usage of the class is mostly unchanged, and how consteval
is used and the overloads are defined, can help to prevent some issues related to the life-time of the data, as my_class2
is conditionally owning:
constexpr my_class2 global_1 = my_class2("123"); // works
constexpr my_class2 global_2 = my_class2(std::string_view("123")); // works
const my_class2 global_3 = my_class2("123"); // works
constinit my_class2 global_4 = my_class2(std::string_view("123")); // works
const my_class2 global_5 = my_class2(std::string("123")); // works
void bar(const std::string& str){
my_class2 local_1 = my_class2(str); // works
my_class2 local_2 = my_class2(str.c_str()); // fails to compile, note that it could otherwise dangle
my_class2 local_3 = my_class2("123"); // works
constexpr my_class2 local_4 = my_class2("123"); // works
}
Now it is possible to use my_class2
as constexpr
, unless you use std::string
explicitly, or the data is not available at compile-time.
Initializing my_class2
with std::string_view
or const char*
could create a my_class2
instance with a dangling member variable. consteval
prevents it, as this is only an issue if the data is created at runtime; it’s the main reason why I’m preferring consteval
over constexpr
,
The main piece of code that makes building my_class
at compile-time is a variant over a type, and its corresponding view type:
std::variant<std::string_view, std::string> str;
I’m using std::string
and std::string_view
as examples because string literals have static storage duration, so it is extremely easy to create examples in a couple of lines of code, but the same concept can be applied to other classes.
Note that using std::variant
is really important. Having both std::string
and std::string_view
as member variables, and using one or the other, is not enough, because in general it is not possible to initialize std::string
at compile-time, even if empty, as the standard does not guarantee it, so some standard libraries might not support it 🗄️.
One constructor instead of overloads
Thanks to if consteval
, which requires at least C++23, one can merge all constructors together and still detect if it is executed at compile-time, or runtime.
#include <string_view>
#include <string>
#include <variant>
class my_class3 {
std::variant<std::string_view, std::string> str;
public:
constexpr explicit my_class3(std::string_view str_){
if consteval {
str = str_;
} else {
str = std::string(str_);
}
}
int get_data() const {
auto data = std::visit([](std::string_view arg) { return arg; }, str);
return data.empty() ? 1 : data.length();
}
// other member functions
};
Compared to my_class2
, myclass2
has a "fallback" mechanism instead of a compile-time error when being initialized with runtime parameters:
void bar(const std::string& str){
my_class2 local_2 = my_class2(str.c_str()); // fails to compile
my_class3 local_3 = my_class3(str.c_str()); // works as intended
}
Depending on the situation, this fallback mechanism might be desirable; the biggest downside is that there are unexpected, hidden, and expensive operations.
Further consideration for mutable classes
If my_class
is an immutable class, which is a class where all methods are marked const
, then using std::string
or std::string_view
in the implementation does not make a relevant difference.
But what if the class has methods that modify the std::string
member variable?
Granted, for the constexpr
global variables, those methods can be ignored, because the instance is const
, but what about non-const
instances?
Let’s take the following class as an example:
struct my_mutable_class {
std::string str;
my_mutable_class(std::string str) : str(std::move(str)) {}
int get_data() const { return str.empty() ? 1 : str.length(); }
void mutate() noexcept { if( not str.empty() ){str[0] = 'a';}}
};
In the case of my_mutable_class
, it is not possible to use a std::string_view
in mutate
.
One possible solution is to state the following invariant: my_mutable_class
uses a std::string_view
instead of std::string
only if the instance is constant. Unfortunately, there is no way to set this invariant during construction; the constructor does not know if the instance will be saved in a const
variable or not.
Consider the following implementation with std::variant
:
#include <string_view>
#include <string>
#include <variant>
struct my_mutable_class2 {
std::variant<std::string_view, std::string> str;
consteval explicit my_mutable_class2(std::string_view str_) : str(str_) {}
consteval explicit my_mutable_class2(const char* str_) : str(std::string_view(str_)) {}
explicit my_mutable_class2(std::string str) : str(std::move(str)) {}
int get_data() const {
auto data = std::visit([](std::string_view arg) { return arg; }, str);
return data.empty() ? 1 : data.length();
}
void mutate() noexcept {
auto& v = std::get<std::string>(str);
if( not v.empty() ){v[0] = 'a';}
}
// other member functions
};
constexpr auto global = my_mutable_class2("123");
void bar(){
global.mutate(); // compiler error
auto local = my_mutable_class2("123"); // consteval constructor, but result saved in mutable instance
local.mutate(); // throws std::bad_variant_access
}
I’m not happy with this approach, because the class is unable to set and verify its own invariant!
The correctness of the code relies on the user, and with the current design, this is not acceptable. The behaviour of the class is easy to predict, but it is much easier to overlook if the consteval
constructor has been called or not.
Convert the view to the owning type
A better approach is to convert std::string_view
to std::string
:
#include <string_view>
#include <string>
#include <variant>
class my_mutable_class3 {
std::variant<std::string_view, std::string> str;
public:
consteval explicit my_mutable_class3(std::string_view str_) : str(str_) {}
consteval explicit my_mutable_class3(const char* str_) : my_mutable_class3(std::string_view(str_)) {}
explicit my_mutable_class3(std::string str) : str(std::move(str)) {}
int get_data() const {
auto data = std::visit([](std::string_view arg) { return arg; }, str);
return data.empty() ? 1 : data.length();
}
void mutate() noexcept {
if(auto ptr = std::get_if<std::string_view>(&str); ptr){
str = std::string(*ptr);
}
auto& v = std::get<std::string>(str);
if( not v.empty() ){v[0] = 'a';}
}
// other member functions
};
constexpr auto global = my_mutable_class3("123");
void bar(){
global.mutate(); // compiler error
auto local = global;
local.mutate(); // copies string_view in string, might fail
}
The disadvantage of this approach is that void my_mutable_class3::mutate() noexcept
is not noexcept
as the original void my_mutable_class::mutate() noexcept
!
In the case of my_mutable_class
, the method could never fail.
In the case of my_mutable_class3
, the method can fail, and since it is marked noexcept
, it will terminate the program.
If the methods were not marked noexcept
, then the method of my_mutable_class
would never fail, and the method of my_mutable_class3
can throw std::bad_alloc
.
Considering the context, the allocation in mutate
causing issues is improbable. In general, static strings are relatively short, thus I’m confident that the chances that my_mutable_class3
behaves differently than my_mutable_class
are nearly zero.
But it might not be the case for other types.
Thus, it can make sense to minimize the situations where mutate
must convert between a view and a corresponding owning type.
Custom copy operations
One possible approach is to modify the copy operations to always create a std::string
; not only if the copied (or moved) my_mutable_class
holds a std::string
:
#include <string_view>
#include <string>
#include <variant>
class my_mutable_class4 {
std::variant<std::string_view, std::string> str;
public:
consteval explicit my_mutable_class4(std::string_view str_) : str(str_) {}
consteval explicit my_mutable_class4(const char* str_) : my_mutable_class4(std::string_view(str_)) {}
explicit my_mutable_class4(std::string str) : str(std::move(str)) {}
my_mutable_class4(const my_mutable_class4&) : str(std::string(std::visit([](std::string_view arg) { return arg; }, str))) {}
my_mutable_class4& operator=(const my_mutable_class4&){
this->str = std::string(std::visit([](std::string_view arg) { return arg; }, str));
return *this;
}
my_mutable_class4(my_mutable_class4&&) noexcept = default;
my_mutable_class4& operator=(my_mutable_class4&&) noexcept = default;
int get_data() const {
auto data = std::visit([](std::string_view arg) { return arg; }, str);
return data.empty() ? 1 : data.length();
}
void mutate() noexcept {
if(auto ptr = std::get_if<std::string_view>(&str); ptr){
str = std::string(*ptr);
}
auto& v = std::get<std::string>(str);
if( not v.empty() ){v[0] = 'a';}
}
// other member functions
};
constexpr auto global = my_mutable_class4("123");
void bar(){
auto local1 = global; // converts std::string_view to std::string
local1.mutate(); // no internal conversion necessary
const auto local2 = global; // unnecessary conversion from std::string_view to std::string
constexpr auto local3 = gloabl; // compiler error
auto local4 = my_mutable_class4("123");
local4.mutate(); // internal conversion necessary
}
As shown in the example, modifying the copy constructors is not optimal.
In the case of auto local1 = global;local1.mutate();
, the call to mutate
has the same guarantees of my_mutable_class
. But in the case of const auto local2 = global;
, the allocation is unnecessary, as the instance is const
, there is no way to call the mutate
member function.
Last but not least, there are still situations where mutate
needs to copy the data, like auto local4 = my_mutable_class4("123"); local4.mutate();
.
Thus, this approach can avoid some situations where mutate
does additional work, but not always, and it causes some unnecessary copies when copying or moving my_mutable_class4
.
One could change the move operations similarly to the copy operation, but in my opinion there are more disadvantages than advantages. The move constructor would be less efficient and not noexcept
, which I find troublesome. Since even with the modified move operations there are cases where mutate
needs to copy the data, I believe it is better to keep the move operations efficient.
Tagged constructor
Using a tagged constructor to ensure that std::string_view
is not created "by accident".
But the tagged constructor alone will not help, the custom copy operations are still necessary:
#include <string_view>
#include <string>
#include <variant>
struct contexpr_tag_t{} inline constexpr constexpr_tag;
struct my_mutable_class5 {
std::variant<std::string_view, std::string> str;
consteval explicit my_mutable_class5(contexpr_tag_t, std::string_view str_) : str(str_) {}
explicit my_mutable_class5(std::string str) : str(std::move(str)) {}
my_mutable_class5(const my_mutable_class5&) : str(std::string(std::visit([](std::string_view arg) { return arg; }, str))) {}
my_mutable_class5& operator=(const my_mutable_class5&){
this->str = std::string(std::visit([](std::string_view arg) { return arg; }, str));
return *this;
}
my_mutable_class5(my_mutable_class5&&) : str(std::string(std::visit([](auto&& arg) { return std::string(arg); }, str))) {}
my_mutable_class5& operator=(my_mutable_class5&&){
this->str = std::string(std::visit([](auto&& arg) { return std::string(arg); }, str));
return *this;
}
int get_data() const {
auto data = std::visit([](std::string_view arg) { return arg; }, str);
return data.empty() ? 1 : data.length();
}
void mutate() noexcept {
if(auto ptr = std::get_if<std::string_view>(&str); ptr){
str = std::string(*ptr);
}
auto& v = std::get<std::string>(str);
if( not v.empty() ){v[0] = 'a';}
}
// other member functions
};
constexpr auto global = my_mutable_class5(constexpr_tag, "123");
void bar(){
auto local = global; // copies std::string_view in std::string
local.mutate(); // no allocation
auto local2 = my_mutable_class5("123"); // creates std::string
local2.mutate(); // no allocation
auto local3 = my_mutable_class5(constexpr_tag, "123");
local2.mutate(); // allocates
}
It does not change how the class can be used, but it avoid to create instances by accident where a non-const
variable has a std::string_view
instead of a std::string
.
If the tagged constructor is only used for constexpr
instances, then there is no situation where mutate
needs to copy the data.
Private consteval
constructor
A further improvement of the tagged constructor, is to mark, if possible, the consteval
constructor as private
. By doing so, the author of the class can ensure that it is only used for creating constexpr
instances.
#include <string_view>
#include <string>
#include <variant>
struct contexpr_tag_t{} inline constexpr constexpr_tag;
class my_mutable_class6 {
std::variant<std::string_view, std::string> str;
consteval explicit my_mutable_class6(contexpr_tag_t, std::string_view str_) : str(str_) {}
public:
static const my_mutable_class6 global;
explicit my_mutable_class6(std::string str) : str(std::move(str)) {}
my_mutable_class6(const my_mutable_class6&) : str(std::string(std::visit([](std::string_view arg) { return arg; }, str))) {}
my_mutable_class6& operator=(const my_mutable_class6&){
this->str = std::string(std::visit([](std::string_view arg) { return arg; }, str));
return *this;
}
my_mutable_class6(my_mutable_class6&&) = default;
my_mutable_class6& operator=(my_mutable_class6&&)= default;
int get_data() const {
auto data = std::visit([](std::string_view arg) { return arg; }, str);
return data.empty() ? 1 : data.length();
}
void mutate() noexcept {
// no need to convert to std::string
auto& v = std::get<std::string>(str);
if( not v.empty() ){v[0] = 'a';}
}
// other member functions
};
inline constexpr my_mutable_class6 my_mutable_class6::global = my_mutable_class6(constexpr_tag, "123");
static constexpr auto global2 = my_mutable_class6(constexpr_tag, "123"); // compiler error
static constexpr auto global3 = my_mutable_class6("123"); // compiler error
void bar(){
auto local = my_mutable_class6::global; // copies std::string_view in std::string
local.mutate(); // no allocation
auto local2 = my_mutable_class6("123"); // creates std::string
local2.mutate(); // no allocation
}
Unfortunately, it is not possible to write static constexpr my_mutable_class6 global = my_mutable_class6(constexpr_tag, "123");
directly, but otherwise I believe this approach is the best one, especially if you can use define inline
variables (requires at least C++23), so that everyone can see that my_mutable_class6::global1
is constexpr
, and not only const
.
The first advantage is that mutate
never needs to convert the internal view type to the corresponding owning type. The second, is that there is no disadvantage to have an efficient move constructor. Since only the constexpr
instances use a string_view
, the move operations do not need to convert a std::string_view
to a std::string
, but can simply move the std::variant
as-is.
There might be some unnecessary conversion from std::string_view
to std::string
during the copy, but at this point it cannot be avoided. Also note that the unnecessary is relative; the original my_mutable_class
always copied a std::string
when it was copied!
The only real disadvantage is that all constexpr
instances needs to be written inside my_mutable_class6
, it is not possible to define a new constant in another header file or translation unit, thus this approach might not always be viable.
Conclusion
For immutable classes, using a std::variant
and a corresponding view type can help to store more instances as constexpr
. An additional benefit is that copies of some instances get cheaper, as only views of static data are copied.
If the class is mutable, one needs to decide when a conversion between a view type and its corresponding owning type needs to happen; either during construction, or when the non-const
method is executed.
Granted, there is some code-bloat as now the class needs to handle multiple types through std::variant
.
For example (tested with GCC 15.2, x64),
; int get_data() const {
; auto data = std::visit([](std::string_view arg) { return arg; }, this->str);
; return data.empty() ? 1 : data.length();
; }
cmp BYTE PTR [rdi+32], 0
jne .L2
mov rax, QWORD PTR [rdi]
mov edx, 1
test rax, rax
cmove rax, rdx
ret
.L2:
mov rax, QWORD PTR [rdi+8]
mov edx, 1
test rax, rax
cmove rax, rdx
ret
which is more or less the sum of
; int get_data() const {
; std::string_view data = this->str; // str is of type std::string_view
; return data.empty() ? 1 : data.length();
; }
mov rax, QWORD PTR [rdi]
mov edx, 1
test rax, rax
cmove rax, rdx
ret
and
; int get_data() const {
; std::string_view data = this->str; // str is of type std::string
; return data.empty() ? 1 : data.length();
; }
mov rax, QWORD PTR [rdi+8]
mov edx, 1
test rax, rax
cmove rax, rdx
ret
plus the tagging mechanism.
The overhead will vary between compilers, standard library, and the body of the method.
As a final note, even with the proposed approach to use std::variant
, there are some classes that are not covered. But trying to cover those classes is another can of worms, and these notes are already long enough.
If you have questions, comments, or found typos, the notes are not clear, or there are some errors; then just contact me.