Rvalue reference pitfalls
Tuesday, March 6th, 2012I just finished adding initial C++11 support to ODB (will write more about that in a separate post) and, besides other things, this involved using rvalue references, primarily to implement move constructors and assignment operators. While I talked about some of the tricky aspects of rvalue references in the “Rvalue references: the basics” post back in 2008 (it is also a good general introduction to the subject), the experience of writing real-world code that uses this feature brought a whole new realization of the potential pitfalls.
Probably the most important thing to always keep in mind when working with rvalue references is this: once an rvalue reference is given a name, it becomes an lvalue reference. Consider this function:
void f (int&& x) { }
What is the type of the argument in this function? It is an rvalue reference to int
(i.e., int&&
). And what is the type of the x
variable in f()
’s body? It is a normal (lvalue) reference to int
(i.e., int&
). This takes some getting used to.
Let’s see what happens when we forget about this rule. Here is a naive implementation of a move constructor in a simple class:
struct base { base (const base&); base (base&&); }; struct object: base { object (object&& obj) : base (obj), nums (obj.nums) { } private: std::vector<int> nums; };
Here, instead of calling move constructors for base
and nums
we call their copy constructors! Why? Because obj
is an lvalue reference, not an rvalue one. The really bad part to this story is this: if you make such a mistake, there will be no compilation or runtime error. It will only manifest itself as sub-optimal performance which can easily go unnoticed for a long time.
So how do we fix this? The fix is to always remember to convert the lvalue reference back to rvalue with the help of std::move()
:
object (object&& obj) : base (std::move (obj)), nums (std::move (obj.nums)) { }
What if one of the members doesn’t provide a move constructor? In this case the copy constructor will silently be called instead. This can also be sub-optimal or even plain wrong, for example, in case of a raw pointer. If the member’s type provides swap()
, then this can be a good backup plan:
object (object&& obj) : base (std::move (obj)) { nums.swap (obj.nums); }
Ok, that was a warmup. Ready for some heavy lifting? Let’s start with this simple code fragment:
typedef int& rint; typedef rint& rrint;
What is rrint
? Right, it is still a reference to int
. The same logic holds for rvalue references:
typedef int&& rint; typedef rint&& rrint;
Here rrint
is still an rvalue reference to int
. Things become more interesting when we try to mix rvalue and lvalue references:
typedef int&& rint; typedef rint& rrint;
What is rrint
? Is it an rvalue, lvalue, or some other kind of reference (lrvalue reference, anyone)? The correct answer is it’s an lvalue reference to int
. The general rule is that as soon as we have an lvalue reference anywhere in the mix, the resulting type will always be an lvalue reference.
You may be wondering why on earth would anyone create an lvalue reference to rvalue reference or an rvalue reference to lvalue reference. While you probably won’t do it directly, this can happen more often than one would think in template code. And I don’t think the resulting interactions with other C++ mechanisms, such as automatic template argument deductions, are well understood yet.
Here is a concrete example from my work on C++11 support in ODB. But first a bit of context. For standard smart pointers, such as std::shared_ptr
, ODB provides lazy versions, such as odb::lazy_shared_ptr
. In a nutshell, when an object that contains lazy pointers to other objects is loaded from the database, these other objects are not loaded right away (which would be the case for normal, eager pointers). Instead, just the object ids are loaded and the objects themselves can be loaded later, when and if required.
A lazy pointer can be initialized with an actual pointer to a persistent object, in which case the pointer is said to be loaded. Or we can initialize it with an object id, in which case the pointer is unloaded. Here are the signatures of the two constructors in question:
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>&); };
Seeing that we now have rvalue references, I’ve decided to go ahead and add move versions for these two constructors. Here is my first attempt:
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>&&);
Let’s now see what happens when we try to create a loaded lazy pointer:
shared_ptr<object> p (db.load<object> (...)); lazy_shared_ptr<object> lp (db, p);
One would expect that the third constructor will be used in this fragment but that’s not what happens. Let’s see how the overload resolution and template argument deduction work here. The type of the second argument in the lazy_shared_ptr constructor call is shared_ptr<object>&
and here are the signatures that we get:
lazy_shared_ptr (database&, const shared_ptr<object>&); lazy_shared_ptr (database&, (shared_ptr<object>&)&&); lazy_shared_ptr (database&, const shared_ptr<object>&); lazy_shared_ptr (database&, shared_ptr<object>&&);
Take a closer look at the second signature. Here the template argument is an lvalue reference. On top of that we add an rvalue reference. But, as we now know, this is still just an lvalue reference. So in effect our candidate list is as follows and, unlike our expectations, the second constructor is selected:
lazy_shared_ptr (database&, const shared_ptr<object>&); lazy_shared_ptr (database&, shared_ptr<object>&); lazy_shared_ptr (database&, const shared_ptr<object>&); lazy_shared_ptr (database&, shared_ptr<object>&&);
In other words, the second constructor, which was supposed to take an rvalue reference was transformed to a constructor that takes an lvalue reference. This is some profound stuff. Just think about it: given its likely implementation, this constructor can now silently “gut” an lvalue without us ever indicating this desire with an explicit std::move()
call.
So how can we fix this? My next attempt was to strip the lvalue reference from the template argument, just like std::move()
does for its return value:
template <class ID> lazy_shared_ptr ( database&, typename std::remove_reference<ID>::type&&);
But this inhibits template argument deduction and there is no way (nor desire, in my case) to specify template arguments explicitly for constructor templates. So, in effect, the above constructor becomes uncallable.
So what did I do in the end? Nothing. There doesn’t seem to be a way to provide such a move constructor. The more general conclusion seems to be this: function templates with arguments of rvalue references that are formed directly from template arguments can be transformed, with unpredictable results, to functions that have lvalue references instead. Preventing this using std::remove_reference
will inhibit template argument deduction.
Update: I have written a follow up to this post that discusses some of the suggestions left in the comments as well as presents a solution for the above problem.