Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How should I take these arguments to ensure return value optimisation?

I want to mimic how Rust does error handling in C++, and I know that using std::move can inhibit RVO, and possibly (I'm not sure) using r-value references. I was wondering in the following how I should take the constructor arguments to the Result class:

enum ResultEnum : unsigned char { Ok, Err };


template <typename T, typename E = bool>
struct Result
{
private:
    ResultEnum result;
public:
    Result(ResultEnum result, const T& t) /* SHOULD I TAKE THE ARGUMENT BY COPY, REFERENCE OR FORWARDING/UNIVERSAL REFERENCE */
    {
        this->t = t; /* WHAT WOULD BE BEST HERE? std::move???*/
        this->result = result;
    }
    Result(ResultEnum result, const E& err) /* SAME HERE, COPY, REF OR FORWARDING/UNIVERSAL REF? */
    {
        this->e = err; /* WHAT WOULD BE BEST HERE? std::move??? Will std::move prevent RVO?*/

        this->result = result;
    }
    union
    {
        T t;
        E e;
    };

    bool is_ok() const{ return result == ResultEnum::Ok; }
    bool is_err() const{ return result == ResultEnum::Err; }

    
};

struct MyFoo {};

Result<MyFoo, bool> createMyFoo()
{
    MyFoo f;
    return { Ok, f }; /* CAN I EXPECT RVO HERE? WHAT EFFECT WOULD CHANGING THE CONSTRUCTOR ARGUMENTS MAKE, IE., COPY vs REF vs FORWARDING/UNIVERSAL REF */
}

int main()
{
    auto f = createMyFoo();
    if (f.is_err())
    {
        /* SOMETHING */
    }
}

Inside the constructor, assuming RVO is taking place, can I assume that any assigning inside the constructor is assigning to the return value in the calling stack frame directly?

like image 714
Zebrafish Avatar asked Oct 29 '25 20:10

Zebrafish


1 Answers

I've taken some of the comments together to provide an example. I do agree that std::expected or for now boost's result type would be the better choice in production code, but there's certainly no harm in exploring C++ for yourself! So, in the interest of education, I'll also try to explain how std::expected does it and why it actually has 22 constructors rather than just two.

For starters, here is a simple implementation based on your example, but using std::variant:

template<typename T, typename E = bool>
struct Result
{
    template<typename... Args>
    constexpr Result(Args&&... args) requires std::constructible_from<T, Args...>
        : result{std::forward<Args>(args)...}
    {}
    
    template<typename... Args>
    constexpr Result(Args&&... args) requires std::constructible_from<E, Args...>
        : result{std::forward<Args>(args)...}
    {}

    std::variant<T, E> result;

    constexpr bool is_ok() const { return std::holds_alternative<T>(result); }
    constexpr bool is_err() const { return std::holds_alternative<E>(result); }
};

Using this code, you can simply return f; from your function and observe that the Result(MyFoo&&) constructor is selected: f is moved into the result member.

While that's nice to have, this definition is not very well-behaved: the two constructor templates may be ambiguous if no arguments are present so, for example, you can't call Result<MyFoo, bool>{}. As remarked in the comments, the standard library disambiguates this using tag types rather than enum values, in this case both std::in_place_t and std::unexpect_t:

// constructs T from args
template< class... Args >
constexpr explicit expected( std::in_place_t, Args&&... args );

// constructs E from args
template< class... Args >
constexpr explicit expected( std::unexpect_t, Args&&... args );

While these allow you to construct either type directly in place with or without arguments, it would still be nice to support the return f; syntax from before. With std::expected, an expected value can be converted implicitly, while an unexpected value must be returned explicitly as std::unexpected<E>:

template< class U = T >
constexpr explicit(!std::is_convertible_v<U, T>) expected( U&& v );

template< class G >
constexpr explicit(!std::is_convertible_v<const G&, E>)
    expected( const std::unexpected<G>& e );

template< class G >
constexpr explicit(!std::is_convertible_v<G, E>)
    expected( std::unexpected<G>&& e );

Some extra trickery is involved here to allow the use of compatible types other than T and E, as well as the forwarding reference U&&. The unexpected type needs two constructors since a forwarding reference is not possible.

Note that this covers just the most relevant constructors, and that their actual definitions are more complicated, because various prosaic constraints are required to ensure a well-defined overload set without ambiguities. Finally, there is also a partial specialisation for T = void that behaves more like a std::optional<E> instead.

In summary: the easiest way of supporting both copy and move semantics is using forwarding references and std::forward. This is not normally possible in constructors, but can be achieved with constructor templates. In other cases you'll need to provide separate definitions for copying from const&s and moving from &&s.

As for RVO, the rule since C++11 is that for example f in the statement return f; is "move-eligible". It's important that the expression returned is just f or (f), but otherwise if there is a move constructor for the return type, it will be selected.

And to answer the final bit of your question: there is no return value in a constructor and a reference to const cannot be moved from, so as you wrote them they would just copy their arguments. But you should initialise members directly using the colon notation wherever possible.

like image 165
sigma Avatar answered Oct 31 '25 11:10

sigma



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!