How to best define/construct/initialize a std::string wrapper class

Viewed 338

I'm using a wrapper class around std::string, but what's the easiest/cleanest/most complete way to initialize/construct it. I need at least 3 ways

  • From string literals
  • From std::string rvalues, avoiding copy!?
  • From string_view and string (yes, copy)

The naive programmer would just want to auto-delegate any construction to std::string, but that's not a feature.

struct SimpleString
{
    SimpleString() = default;
    
    template<typename T>
    SimpleString( T t ) : Text( t ) { }   // <==== experimental
    
    // Alternative: are these OK
    SimpleString( const char* text ) : Text( text ) { }
    SimpleString( std::string&& text ) : Text( text ) { }
    SimpleString( const std::string_view text ) : Text( text ) { }

    std::string Text;
};

Preemptive note: Yes, I want it and I need it. Use case: call a generic function where SimpleString is treated differently from std::string.

Note regarding inheriting from std::string: Probably a bad idea because implicit conversions will occur at the first opportunity.

2 Answers

If your primary goal is for the string-conversion constructors, you don't need to go through any huge hoops. The simplest and best approach would be to just accept a std::string by-value on the API and std::move it into place.

class SimpleString
{
public:
    SimpleString() = default;
    SimpleString(std::string text) : Text(std::move(text)) { }
    // ... other constructors / assignment ...

private:
    std::string Text;
};

Doing this, we can leverage the fact that std::string is already constructible by:

  • C-strings/literals,
  • std::string_view,
  • Other std::string objects (either lvalue or rvalue), and
  • Any user-defined type that may already be convertible to std::string

Doing this also gives the user the choice to now decide whether they want to construct the SimpleString by moving their current std::string object, or by allowing SimpleString to explicitly copy it. This gives much more flexibility to the caller.

This works out nicely since you are already going to be owning this std::string anyway, and so accepting it by-value and std::moveing the object is a simple way to "seat" this value. Moving a std::string is reasonably cheap, amounting to a few assignments for the pointer, the size, and the allocator -- so this produces negligible performance overhead while also being a simple and maintainable approach.


Modern C++ and move-semantics have made it exactly this easy to accept these kinds of types. You definitely could go and overload all N ways of constructing it (const char*, std::string_view, Ts that are convertible to strings, etc); but it would be much simpler to just accept the type as-is and move it. It's the most flexible approach while also keeping it simple and maintainable.


Here are some benchmarks to compare using a template vs using std::string + std::move. In general, it's much more favorable to keep it simple. Your coworkers and future maintainers will thank you (even if that future maintainer is you, a year from now).

std::string has a lot of constructors and it would be troublesome to have to re-implement all of them in your class. This is why you would be best-suited forwarding to the constructors of std::string like this:

#include <utility>

struct SimpleString
{
    template <typename... Args>
    SimpleString(Args &&... args) : Text(std::forward<Args>(args)...)
    {
    }

    std::string Text;
};

To avoid conflicts with the default and copy/move constructors, we will need to use SFINAE:

template <typename... Args>
constexpr bool is_empty_pack_v = sizeof...(Args) == 0;

template <typename T, typename... Args>
constexpr bool is_copy_or_move_pack_v = sizeof...(Args) == 1 &&
                                                   (... && std::is_same_v<T, std::remove_reference_t<Args>>);

struct SimpleString
{
    SimpleString() = default;
    SimpleString(const SimpleString &) = default;
    SimpleString(SimpleString &&) = default;

    template <typename... Args, std::enable_if_t<
        not is_empty_pack_v<Args...> &&
        not is_copy_or_move_pack_v<SimpleString, Args...> &&
        std::is_constructible<std::string, Args...>, int> = 0>
    SimpleString(Args &&... args) : Text(std::forward<Args>(args)...)
    {
    }

    std::string Text;
};
Related