returning a std::string from a variant which can hold std::string or double

Viewed 331

I have the following code:

#include <variant>
#include <string>
#include <iostream>

using Variant = std::variant<double, std::string>;

// helper type for the visitor
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
// explicit deduction guide (not needed as of C++20)
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;


std::string string_from(const Variant& v)
{
    return std::visit(overloaded {
        [](const double arg) { return std::to_string(arg); },
        [](const std::string& arg) { return arg; },
        }, v);
}

int main()
{
    Variant v1 {"Hello"};
    Variant v2 {1.23};
    
    std::cout << string_from(v1) << '\n';
    std::cout << string_from(v2) << '\n';

    return 0;
}

I have a function called string_from() which takes a variant and converts its inner value to a string.

The variant can hold either a std::string or a double.

In case of a std::string, I just return it.

In case of a double, I create a std::string from the double and then return it.

The problem is, I don't like the fact that I'm returning a copy of the std::string in case of a string-variant. Ideally, I would return a std::string_view or another kind of string observer.

However, I cannot return a std::string_view because in case of a double-variant I need to create a new temporary std::string and std::string_view is non-owning.

I cannot return a std::string& for the same reason.

I'm wondering if there's a way to optimize the code so that I can avoid the copy in case of a string-variant.

Note in my actual use case, I obtain strings from string-variants very frequently, but very rarely from double-variants.

But I still want to be able to obtain a std::string from a double-variant.

Also, in my actual use case, I usually just observe the string, so I don't really need the copy every time. std::string_view or some other string-observer would be perfect in this case, but it is impossible due to the reasons above.

I've considered several possible solutions, but I don't like any of them:

  1. return a char* instead of a std::string and allocate the c-string somewhere on the heap in case of a double. In this case, I would also need to wrap the whole thing in a class which owns the heap-allocated strings to avoid memory leaks.

  2. return a std::unique_ptr<std::string> with a custom deleter which would cleanup the heap-allocated strings, but would do nothing in case the string resides in the variant. Not sure how this custom deleter would be implemented.

  3. Change the variant so it holds a std::shared_ptr<std::string> instead. Then when I need a string from the string-variant I just return a copy of the shared_ptr and when I need a string from the double-variant I call std::make_shared().

The third solution has an inherent problem: the std::string no longer resides in the variant, which means chasing pointers and losing performance.

Can you propose any other solutions to this problem? Something which performs better than copying a std::string every time I call the function.

4 Answers

You can return a proxy object. (this is like your unique_ptr method)

struct view_as_string{
    view_as_string(const std::variant<double, std::string>& v){
        auto s = std::get_if<std::string>(&v);
        if(s) ref = s;
        else temp = std::to_string(std::get<double>(v));
    }
    const std::string& data(){return ref?*ref:temp;}
    const std::string* ref = nullptr;
    std::string temp;
};

Use

int main()
{
    std::variant<double, std::string> v1 {"Hello"};
    std::variant<double, std::string> v2 {1.23};
    
    std::cout << view_as_string(v1).data() << '\n';
    
    view_as_string v2s(v2);
    std::cout << v2s.data() << '\n';
}

The problem is, a variant holds different types, but you're trying to find a way to represent all of them in a single type. A string representation is useful for generic logging, but it has the downsides you describe.

For variants, I don't like trying to consolidate the values back into a single common thing, because if that was easily possible then there would be no need for the variant in the first place.

Better, I think, is to defer the conversion as late as possible, and keep forwarding it on to other functions that make use of the value as it is, or convert and forward until it's used--rather than trying to extract a single value and trying to use that.

A fairly generic function might look like this:

template <typename Variant, typename Handler>
auto with_string_view(Variant const & variant, Handler && handler) {
   return std::visit(overloaded{
       [&](auto const & obj) {
           using std::to_string;
           return handler(to_string(obj));
       },
       [&](std::string const & str) {return handler(str); },
       [&](std::string_view str) { return handler(str); },
       [&](char const * str) { return handler(str); }
   }, variant);
}

Since the temporary created in the generic version outlives the call to the handler, this is safe and efficient. It also shows the "forward it on" technique that I've found to be very useful with variants (and visiting in general, even for non-variants.)

Also, I don't explicitly convert to string_view, but the function could add requirements that the handler accepts string views (if that helps document the usage.)

With the above helper function you might use it like this:

using V = std::variant<std::string, double>;

V v1{4.567};
V v2{"foo"};

auto print = [](std::string_view sv) { std::cout << sv << "\n";};
with_string_view(v1, print);
with_string_view(v2, print);

Here's a full live example, expanded out a little too: https://godbolt.org/z/n7KhEW7vY

If thread safety is not an issue, you could simply use a static std::string as the backing storage when returning a double value. Then you would be able to return a std::string_view, eg:

std::string_view string_from(const Variant& v)
{
    static std::string buffer;
    return std::visit(overloaded {
        [&buffer](const double arg) -> std::string_view { buffer = std::to_string(arg); return buffer; },
        [](const std::string& arg) -> std::string_view { return arg; },
        }, v);
}

Online Demo

I've come up with my own solution inspired by apple apple's solution with the view_as_string class. Here it is:

class owning_string_view : public std::string_view
{
public:
    explicit owning_string_view(const char* str) : std::string_view{str}, m_string_buffer{} {}
    explicit owning_string_view(const std::string& str) : std::string_view{str}, m_string_buffer{} {}
    explicit owning_string_view(std::string&& str) : std::string_view{}, m_string_buffer{std::move(str)}
    {
        static_cast<std::string_view&>(*this) = m_string_buffer;
    }

private:
    std::string m_string_buffer;
};

Instead of taking a Variant I made it more generic and it takes strings instead. For lvalue strings it just creates a std::string_view of the string. For rvalue strings it moves the string into the buffer.

It extends from std::string_view so it can be used in std::string_view contexts seamlessly.

Of course you have to be careful not no slice off the std::string_view part from the object when creating an rvalue owning_string_view but this is true for std::string_view as well. You have to be careful not to take a std::string_view from an rvalue std::string.

Passing a owning_string_view as a std::string_view parameter to a function is safe for the same reason it is safe to pass an rvalue std::string as a std::string_view parameter to a function. The rvalue lives during the function call.

I also realized a deeper problem when returning a string_view from my Variantclass. If I try to extract a std::string_view or a owning_string_view from an rvalue Variant I'm still going do end up with a dangling string_view, so I added 2 functions for taking a string from the Variant:

  • one accepts lvalue variants only and it returns owning_string_view.
  • the other accepts rvalue variants only and it returns a std::string, which is moved from the variant (since the variant is an rvalue).

One more observation: Ideally, I would make the first 2 constructors of owning_string_view constexpr but I can't because the default constructor of std::string is not constexpr. I hope this is changed in the future.

Related