C++ Sink Parameter Passing
Join the DZone community and get the full member experience.
Join For FreeC++ is the most complex language I know, and its parameter passing rules are only getting more arcane now that we have rvalue references in C++ 11. In this post I’d like to examine the specific scenario of passing parameters to a sink method, which consumes its parameters.
What do I mean by “consume”? If a parameter is movable and safe to move from (i.e. it’s an rvalue), it should move from it; if a parameter is only copyable, it should copy it. Here are a couple of sink methods from the C++ Standard Library:
- std::vector‘s push_back method will move from movable rvalues and copy from anything else.
- std::promise‘s set_value method will move from movable rvalues and copy from anything else, and it also has an optimization for storing references directly without copies.
There are at least five approaches to the sink parameter passing problem that I can see, which differ on efficiency and complexity. I’d like to discuss them all in some level of detail, and then conclude with recommendations.
1. Ugh, whatever. Just pass by reference to const.
In other words, you don’t really care about efficiency by potentially avoiding copies (and doing moves instead). You opt for this kind of signature:
void sink(widget const& w) { inner_widget = w; }
…and inside the function you make a copy. This approach has the unfortunate disadvantage of not supporting move-only types, such as std::unique_ptr, std::thread, and others. I’m only putting it here for completeness, because that’s probably what you would do in C++ 98.
2. No, no! Just pass by value!
This is pretty clever advice that people have been citing for a few years now. It requires some discipline on both parts, but the resulting code is definitely very simple:
void sink(widget w) { inner_widget = std::move(w); }
Let’s think about what happens here, assuming that widget is movable and copyable. First, what happens if you are passed an rvalue?
sink(widget{ "my great widget"s });
In that case, the parameter w is going to be move-constructed from the temporary, and then inside the function you’re going to move from it. The result is two move operations, which should be fairly cheap.
Now, what happens if you are passed an lvalue?
widget my_w{ "my great widget"s }; sink(my_w);
In this case, the parameter w is going to be copy-constructed from my_w, and then inside the function you’re going to move from it. The result is a copy + move, which is more expensive than just making a copy (if we used option #1 above), but not considerably so.
Now, what happens if widget is actually move-only and doesn’t support copying? In that case, you can only pass in rvalues:
sink(widget{ "my great widget"s }); widget my_w{ "another widget"s }; sink(std::move(my_w));
In the first case, it’s just a plain temporary; in the second case, you can use std::move to force move semantics. After you call sink in that case, my_w can no longer be used, of course (it has been moved from).
3. What’s this deal with copy+move or move+move? I want one move!
You could try to optimize the previous approach by using a signature that forces an rvalue:
void sink(widget&& w) { inner_widget = std::move(w); }
This is another interesting idea. If widget is movable, this will make just one move; if widget is only copyable, it will make a copy, but that copy is unavoidable anyway. The only problem is that this signature forces the caller to pass in an rvalue. When the caller already has a temporary or an object they don’t mind destroying, everything’s peachy:
sink(widget{ "my widget"s }); widget my_w{ "another"s }; sink(std::move(my_w));
This looks innocent enough, and similarly to approach #2, the caller has to be aware of the fact they can’t use my_w after calling sink. But hey, what if the caller has a copyable object and they want us to copy it, not consume its guts and make it unusable? Unfortunately, this is not an option with the signature we have. The caller would have to manually create a temporary for that to work:
widget important_widget{ "important"s }; sink(widget{ important_widget }); // yuck
OK, so it seems that we’re getting somewhere. One signature doesn’t seem to satisfy every requirement — we can’t have efficiency and convenience all at once. Let’s go a bit deeper down the rabbit hole…
4. Write two overloads.
This is exactly what std::vector does with push_back (and a bunch of other containers follow suit). When you don’t know anything about whether the type is movable or copyable, and whether copies are expensive or cheap, you have to assume the worst — and if you also have to be efficient, you need two overloads:
void sink(widget&& w) { inner_widget = std::move(w); } void sink(widget const& w) { inner_widget = w; }
If you’re frowning right now, that’s OK. If you’re cursing C++, that’s also fine by me — but it’s not Bjarne’s fault! Anyway, just think of the benefits here: if we get an lvalue, we make a single copy. If we get an rvalue, we make a single move. It can’t be any better — and the caller is none the wiser, because they can pass in anything and we’re good to use it straight away.
The “only” problem with two overloads is that this approach doesn’t scale for multiple parameters. What if you have three parameters and want every combination from the caller’s side to work perfectly?
void sink(widget&& w, gizmo&& g, thingy&& t); void sink(widget&& w, gizmo&& g, thingy const& t); void sink(widget&& w, gizmo const& g, thingy&& t); void sink(widget&& w, gizmo const& g, thingy const& t); void sink(widget const& w, gizmo&& g, thingy&& t); void sink(widget const& w, gizmo&& g, thingy const& t); void sink(widget const& w, gizmo const& g, thingy&& t); void sink(widget const& w, gizmo const& g, thingy const& t);
Easy-peasy, right? Which brings us to our fifth option, the nuclear weapon:
5. Use universal references.
What are universal references, you ask? I’m just going to turn you over to Scott Meyers, who explains them very well. I’ll wait while you’re at it.
Now that you’re back: we could declare our sink as taking a universal reference, if we make it a template (universal references aren’t available to non-templates):
template <typename Arg1, typename Arg2> void sink(Arg1&& arg1, Arg2&& arg2);
Now, universal references can bind to anything, so the caller can pass in lvalues, rvalues, whatever they like, and our sink can handle them all. It only remains to see that we can 1) efficiently marshal the parameters to avoid a copy if the objects are movable, and 2) produce good errors if the caller passes in something totally fishy that can’t be converted to a widget or a gizmo or what have you.
To marshal the parameters efficiently (moving when possible), we need std::forward. Scott’s article above mentions it, but if you need more, go ahead and read Thomas Becker’s opus on rvalue references. I’ll wait here, again.
Now that you’re back, we can go ahead and implement the sink:
template <typename Arg1, typename Arg2> void sink(Arg1&& arg1, Arg2&& arg2) { inner_widget = std::forward<Arg1>(arg1); inner_gizmo = std::forward<Arg2>(arg2); }
As a result of using std::forward, when passed in rvalues we make one move per parameter; when passed in lvalues, we make one copy per parameter. Again, it can’t be better than that, and we aren’t forced to write zillions of overloads.
However, this solution is not free of its own problems. Just think about it — we are using templates, which aren’t the easiest feature of C++, and combining them with rvalue references — which people are still not quite used to — to produce something even more scary: universal references. Lots of C++ devs I know would look at the signature above and run away in horror. Why is this seemingly innocent function a template?! Why is it taking an rvalue reference? What do you mean “it’s not an rvalue reference”? I quit!
So. Uhm. That’s one problem with the signature above. But there are more. First, consider the compiler errors someone’s going to get if they pass in something that’s not assignable to a gizmo or a widget:
sink(widget{ "awesome widget"s }, 42 /* oops! not a gizmo */); error C2679: binary '=' : no operator found which takes a right-hand operand of type 'int' (or there is no acceptable conversion) source.cpp(38): could be 'gizmo &gizmo::operator =(const gizmo &)' while trying to match the argument list '(gizmo, int)' source.cpp(107) : see reference to function template instantiation 'void sink<widget,int>(Arg1 &&,Arg2 &&)' being compiled with [ Arg1=widget, Arg2=int ]
While that might be reasonable, you could want a cleaner compiler error. That’s where static_assertgenerally comes in:
template <typename Arg1, typename Arg2> void sink(Arg1&& arg1, Arg2&& arg2) { static_assert(std::is_assignable<widget, Arg1>::value, "Arg1 must be assignable to widget"); static_assert(std::is_assignable<gizmo, Arg2>::value, "Arg2 must be assignable to gizmo"); inner_widget = std::forward<Arg1>(arg1); inner_gizmo = std::forward<Arg2>(arg2); }
Which is fine, but you’re still going to get both the static assertion and the previous compiler error, because the function still tries to assign the widget (or the gizmo) with the invalid argument. To get rid of the second error and keep only the assertion, you’re going to need yet more code that performs the static assertion in a facade function and then forwards to a helper. The helper is overloaded — there is a helper for the valid conversion case that does the actual work, and a helper if the conversion is invalid that does nothing:
template <typename Arg1, typename Arg2> void sink2(Arg1&& arg1, Arg2&& arg2) { static_assert(std::is_assignable<widget, Arg1>::value, "widget must be assignable from Arg1"); static_assert(std::is_assignable<gizmo, Arg2>::value, "gizmo must be constructible from Arg2"); sink2_helper( typename std::integral_constant<bool, std::is_assignable<widget, Arg1>::value && std::is_assignable<gizmo, Arg2>::value >::type(), std::forward<Arg1>(arg1), std::forward<Arg2>(arg2)); } template <typename Arg1, typename Arg2> void sink2_helper(std::true_type, Arg1&& arg1, Arg2&& arg2) { inner_widget = std::forward<Arg1>(arg1); inner_gizmo = std::forward<Arg2>(arg2); } template <typename Arg1, typename Arg2> void sink2_helper(std::false_type, Arg1&&, Arg2&&) { // don't initialize }
Now, this crazy amount of machinery looks crazy, but it produces a very clean compiler warning now:
sink(widget{ "awesome widget"s }, 42); error C2338: gizmo must be constructible from Arg2 source(107) : see reference to function template instantiation 'void sink2<widget,int>(Arg1 &&,Arg2 &&)' being compiled with [ Arg1=widget, Arg2=int ]
There is yet another problem here. When using standard signatures (not universal references), you can use the cool C++ 11 uniform initialization syntax. For example, if widget has a constructor taking two ints, you could say:
sink({ 3, 5 });
That doesn’t work with universal references, unfortunately. The parameter type cannot be deduced when passing a braced init list to a function template. And there’s nothing we can do about it. Too bad.
Additional factors
There are a couple of additional things to consider while we’re talking about moves and efficiency optimizations like these.
Moves aren’t always cheap
Some types that are both movable and copyable are still not cheap to move. By “cheap” I usually mean copying a few pointers around, which is the mental picture I have around moving something likestd::unique_ptr (or that contains a couple of std::unique_ptrs). But types like std::string and std::arrayaren’t always cheap to move, even though they are very common, and even though it’s cheaper to move them than copy them. For example, MSVC’s std::string employs the small string optimization (SSO), which makes moving small strings more expensive than moving large strings. Similarly, std::array allocates its storage statically, so it can’t move the storage — although it can move the elements contained within.
The takeaway from this discussion should be that for some types, you should also worry about moves if you’re so worried about performance. And of course nothing can be worse than premature optimization or pessimization — you’ve got to measure.
Copy+move could be more expensive than one copy assignment
Recall our discussion on option #2, “Just pass by value”? It seems that for lvalues, a copy+move isn’t such a horrible thing compared to just a copy. But that’s true for some types, not all. In some cases, it might actually be faster to copy-assign an object than to copy construct it.
Consider std::vector, which manages an internal array of elements. If you copy-assign a vector v1 to an existing vector v2, and v2.size() >= v1.size(), then you don’t have to allocate new storage for v2‘s elements. You just have to destroy the existing elements and construct new ones in-place. However, if you first copy-construct a temporary vector from v1 and then move-assign from that temporary, you pay for allocating new storage for the temporary — an allocation that could have been avoided.
Some conclusions
C++ isn’t an easy language. And with new features come new problems and new guidance. Until C++ 11, we could safely assume that the most efficient way to consume an object is to pass it by reference to const, and then copy it. In C++ 11, there are so many options — with varying degrees of complexity — that there isn’t a single recipe that can fit all.
Personally, my inclination towards any of the variants above would be affected by the following inputs:
- Is the code I’m writing likely to be a performance bottleneck? If not, I wouldn’t bother optimizing, and would stick to passing by value.
- Are the types involved cheap to move, cheap to copy, or perhaps non-copyable? See the discussion above for what works best in each case.
- Am I willing to pay for the maintenance complexity of universal references, templates, and template metaprogramming? The rare cases in which I would answer “yes” probably belong to a very generic library layer that simply can’t make pessimizing assumptions.
At this point, hopefully you have all the information to make your own choices wisely. There are many C++ experts who spent weeks debating this issue, so I don’t presume to offer the best guidance. Any mistakes in the above text are my own.
I am posting short links and updates on Twitter as well as on this blog. You can follow me: @goldshtn
Published at DZone with permission of Sasha Goldshtein, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments