Rvalue reference pitfalls, an update

My original post about rvalue reference pitfalls from last week was followed by quite a few comments, including some interesting suggestions that are worth discussing.

While most of the discussion centered around the second problem, Jonathan Rogers pointed out the following interesting observation. Consider again the lazy_shared_ptr constructor from the original article that takes shared_ptr:

template <class T>
class lazy_shared_ptr
{
  ...
 
  template <class T1>
  lazy_shared_ptr (database&, const std::shared_ptr<T1>& p)
    : p_ (p)
  {
  }
};

If we want to support efficient initialization (shall we call it move initialization?), then it seems natural to add an rvalue reference overload:

template <class T>
class lazy_shared_ptr
{
  ...
 
  template <class T1>
  lazy_shared_ptr (database&, const std::shared_ptr<T1>& p)
    : p_ (p)
  {
  }
 
  template <class T1>
  lazy_shared_ptr (database&, std::shared_ptr<T1>&& p)
    : p_ (std::move (p))
  {
  }
};

While this works, as Jonathan pointed out, another alternative to provide the same functionality is to just have a single constructor that takes its argument by value:

template <class T>
class lazy_shared_ptr
{
  ...
 
  template <class T1>
  lazy_shared_ptr (database&, std::shared_ptr<T1> p)
    : p_ (std::move (p))
  {
  }
};

Let’s consider what happens when we use both versions to initialize lazy_shared_ptr with lvalues and rvalues. When we use the original implementation with an lvalue, the first constructor (the one taking const lvalue reference) is selected. The value is then copied to p_ using shared_ptr’s copy constructor. Using the second implementation with an lvalue causes that copy constructor to be called right away to create the temporary. This temporary is then passed to the lazy_shared_ptr constructor where it is moved to p_. So in this case the second implementation requires an extra move constructor call.

Let’s now pass an rvalue. In the first implementation the second constructor is selected and the value is passed as an rvalue reference. It is then moved to p_. When the second implementation is used, a temporary is again created but this time using a move instead of a copy constructor. The temporary is then moved to p_. In this case, again, the second implementation requires an extra move constructor call.

Considering that move constructors are normally very cheap, this makes for a good way to keep your code short and concise. But the real advantage of this approach becomes apparent when we have multiple arguments that we want to pass efficiently (this was also a topic of Sumant’s post from a few days ago). If we use the rvalue reference approach, then for n arguments we will need 2^n constructor versions.

Note, however, that the pass by value approach is only a good idea if you know for sure that the argument type provides a move constructor. If that’s not the case, then this approach will perform significantly worse compared to the rvalue reference version. This is the reason, for example, why it is not a good idea to use this technique in std::vector’s push_back().

Ok, let’s now turn to the problem that triggered a lot of comments and suggestions. Here is a quick recap. We have two constructors like these:

template <class T>
class lazy_shared_ptr
{
  ...
 
  template <class ID>
  lazy_shared_ptr (database&, const ID&);
 
  template <class T1>
  lazy_shared_ptr (database&, const std::shared_ptr<T1>&);
};

One initializes a lazy pointer using an object id, creating an unloaded pointer. The other initializes it with the pointer to the actual object, creating a loaded pointer.

Now we want to add move initialization overloads for these two constructors. As it turns out, the straightforward approach doesn’t quite work:

template <class T>
class lazy_shared_ptr
{
  ...
 
  template <class ID>
  lazy_shared_ptr (database&, const ID&);
 
  template <class ID>
  lazy_shared_ptr (database&, ID&&);
 
  template <class T1>
  lazy_shared_ptr (database&, const std::shared_ptr<T1>&);
 
  template <class T1>
  lazy_shared_ptr (database&, std::shared_ptr<T1>&&);
};

As you may recall, we have two problems here. The first manifests itself when we try to initialize lazy_shared_ptr with an lvalue of the shared_ptr type:

shared_ptr<object> p = ...;
lazy_shared_ptr<object> lp (db, p);

Instead of selecting the third constructor, the overload resolution rules select the second because the rvalue reference in its second argument becomes an lvalue reference (see the original post for details on why this happens).

The second problem occurs when we try to initialize a lazy pointer with an object id that is again an lvalue. For example:

string id = ...
lazy_shared_ptr<object> lp (db, id);

In this case, instead of selecting the first constructor, the overload resolution again selects the second constructor which is again transformed to a version that has an lvalue instead of an rvalue reference for its second argument. If you are wondering why this is a problem (after all, the first two constructors accomplish essentially the same), consider that while the first constructor’s hypothetical implementation will use a copy constructor to initialize the id, the second constructor will most likely use the move constructor to accomplish the same. Which means that the state of our lvalue will be transferred without us explicitly asking for it, as we normally do with the std::move() call.

As Thomas noted in the comments and as I should have mentioned explicitly in the original post, the C++ mechanisms that are causing problems here are exactly the same ones that allow for perfect argument forwarding. In fact, rvalue references are primarily used to implement two related but also quite distinct things: the move semantics and perfect forwarding. What happens here is that we are trying to implement the move semantics but are getting perfect forwarding instead. To paraphrase the conclusion of my original post, any time you write a function like this:

template <typename T>
void f (T&&);

You always get perfect forwarding and never move semantics.

If we look closely at the two problematic cases above, we will notice that they both happen when the template argument is an lvalue reference which results in our rvalue reference becoming lvalue. When we pass an rvalue, everything works great. In fact, we never want our move initialization constructor to be called for lvalues since we have other overloads (const lvalue reference) taking care of these cases. So what we want is to disable the move initialization constructor when the template argument is an lvalue reference. As it turns out, this is not that difficult in C++11:

  template <class ID,
            typename std::enable_if<
              !std::is_lvalue_reference<ID>::value,
              int>::type = 0>
  lazy_shared_ptr (database&, ID&&)

To put this in more general terms, it is possible to reduce perfect forwarding back to just move semantics by disabling a function for template arguments that are lvalue references.

7 Responses to “Rvalue reference pitfalls, an update”

  1. haohaolee Says:

    don’t know if this is a typo when you say take its argument by value:

    template
    lazy_shared_ptr (database&, std::shared_ptr& p)
    : p_ (std::move (p))

  2. Boris Kolpackov Says:

    Yes, that was a type. Fixed, thanks!

  3. Anonymous Says:

    I have a feeling you meant ‘fourth’ instead of ’second’ here where you refer to the constructor selected.

    “Instead of selecting the third constructor, the overload resolution rules select the second because the rvalue reference in its second argument becomes an lvalue reference (see the original post for details on why this happens).”

  4. JJ Says:

    As for this: “If you are wondering why this is a problem (after all, the first two constructors accomplish essentially the same), consider that while the first constructor’s hypothetical implementation will use a copy constructor to initialize the id, the second constructor will most likely use the move constructor to accomplish the same.”

    Then, how about having only the second, perfect-forwarding one, and in it using std::forward() instead of std::move()? Wouldn’t this solve the issue better than your solution involving enable_if?

  5. Boris Kolpackov Says:

    Anonymous, no, in this case the second constructor will be selected since its second argument becomes an lvalue reference.

  6. Boris Kolpackov Says:

    JJ, someone asked this question in the reddit’s discussion of this post:

    http://www.reddit.com/r/cpp/comments/qw786/rvalue_reference_pitfalls_an_update/

    I have also provided an answer there.

  7. JJ Says:

    got it. quite tricky indeed. Thx for your answer!