The C++ logo, by Jeremy Kratz, licensed under CC0 1.0 Universal Public Domain Dedication

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:

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.