Is the pass-by-value-and-then-move construct a bad idiom?

19,373

Solution 1

Expensive-to-move types are rare in modern C++ usage. If you are concerned about the cost of the move, write both overloads:

void set_a(const A& a) { _a = a; }
void set_a(A&& a) { _a = std::move(a); }

or a perfect-forwarding setter:

template <typename T>
void set_a(T&& a) { _a = std::forward<T>(a); }

that will accept lvalues, rvalues, and anything else implicitly convertible to decltype(_a) without requiring extra copies or moves.

Despite requiring an extra move when setting from an lvalue, the idiom is not bad since (a) the vast majority of types provide constant-time moves and (b) copy-and-swap provides exception safety and near-optimal performance in a single line of code.

Solution 2

But what happens if a is an lvalue? It seems there will be a copy construction and then a move assignment (assuming A has a proper move assignment operator). Move assignments can be costly if the object has too many member variables.

Problem well spotted. I wouldn't go as far as to say that the pass-by-value-and-then-move construct is a bad idiom but it definitely has its potential pitfalls.

If your type is expensive to move and / or moving it is essentially just a copy, then the pass-by-value approach is suboptimal. Examples of such types would include types with a fixed size array as a member: It may be relatively expensive to move and a move is just a copy. See also

in this context.

The pass-by-value approach has the advantage that you only need to maintain one function but you pay for this with performance. It depends on your application whether this maintenance advantage outweighs the loss in performance.

The pass by lvalue and rvalue reference approach can lead to maintenance headaches quickly if you have multiple arguments. Consider this:

#include <vector>
using namespace std;

struct A { vector<int> v; };
struct B { vector<int> v; };

struct C {
  A a;
  B b;
  C(const A&  a, const B&  b) : a(a), b(b) { }
  C(const A&  a,       B&& b) : a(a), b(move(b)) { }
  C(      A&& a, const B&  b) : a(move(a)), b(b) { }
  C(      A&& a,       B&& b) : a(move(a)), b(move(b)) { }  
};

If you have multiple arguments, you will have a permutation problem. In this very simple example, it is probably still not that bad to maintain these 4 constructors. However, already in this simple case, I would seriously consider using the pass-by-value approach with a single function

C(A a, B b) : a(move(a)), b(move(b)) { }

instead of the above 4 constructors.

So long story short, neither approach is without drawbacks. Make your decisions based on actual profiling information, instead of optimizing prematurely.

Solution 3

The current answers are quite incomplete. Instead, I will try to conclude based on the lists of pros and cons I find.

Short answer

In short, it may be OK, but sometimes bad.

This idiom, namely the unifying interface, has better clarity (both in conceptual design and implementation) compared to forwarding templates or different overloads. It is sometimes used with copy-and-swap (actually, as well as move-and-swap in this case).

Detailed analysis

The pros are:

  • It needs only one function for each parameter list.
    • It needs indeed only one, not multiple ordinary overloads (or even 2n overloads when you have n parameters when each one can be unqualified or const-qualified).
    • Like within a forwarding template, parameters passed by value are compatible with not only const, but volatile, which reduce even more ordinary overloads.
      • Combined with the bullet above, you don't need 4n overloads to serve to {unqulified, const, const, const volatile} combinations for n parameters.
    • Compared to a forwarding template, it can be a non-templated function as long as the parameters are not needed to be generic (parameterized through template type parameters). This allows out-of-line definitions instead of template definitions needed to be instantiated for each instance in each translation unit, which can make significant improvement to translation-time performance (typically, during both compiling and linking).
    • It also makes other overloads (if any) easier to implement.
      • If you have a forwarding template for a parameter object type T, it may still clash with overloads having a parameter const T& in the same position, because the argument can be a lvalue of type T and the template instantiated with type T& (rather than const T&) for it can be more preferred by the overloading rule when there is no other way to differentiate which is the best overloading candidate. This inconsistency may be quite surprising.
        • In particular, consider you have forwarding template constructor with one parameter of type P&& in a class C. How many time will you forget to excluded the instance of P&& away from possibly cv-qualified C by SFINAE (e.g. by adding typename = enable_if_t<!is_same<C, decay_t<P>> to the template-parameter-list), to ensure it does not clash with copy/move constructors (even when the latter are explicitly user-provided)?
  • Since the parameter is passed by value of a non-reference type, it can force the argument be passed as a prvalue. This can make a difference when the argument is of a class literal type. Consider there is such a class with a static constexpr data member declared in some class without an out-of-class definition, when it is used as an argument to a parameter of lvalue reference type, it may eventually fail to link, because it is odr-used and there is no definition of it.

The cons are:

  • A unifying interface can not replace copy and move constructors where the parameter object type is identical to the class. Otherwise, copy-initialization of the parameter would be infinite recursion, because it will call the unifying constructor, and the constructor then call itself.
  • As mentioned by other answers, if the cost of copy is not ignorable (cheap and predictable enough), this means you will almost always have the degeneration of performance in the calls when the copy is not needed, because copy-initialization of a unifying passed-by-value parameter unconditionally introduce a copy (either copied-to or moved-to) of the argument unless elided.
    • Even with mandatory elision since C++17, copy-initialization of a parameter object is still hardly free to be removed away - unless the implementation try very hard to prove the behavior not changed according to as-if rules instead of the dedicated copy elision rules applicable here, which might be sometimes impossible without a whole program analysis.
    • Likewise, the cost of destruction may not be ignorable as well, particularly when non-trivial subobjects are taken into account (e.g. in cases of containers). The difference is that, it does not only apply to the copy-initialization introduced by the copy construction, but also by the move construction. Making move cheaper than copy in constructors can not improve the situation. The more cost of copy-initialization, the more cost of destruction you have to afford.
  • A minor shortcoming is that there is no way to tweak the interface in different ways as plural overloads, for example, specifying different noexcept-specifiers for parameters of const& and && qualified types.
    • OTOH, in this example, unifying interface will usually provide you with noexcept(false) copy + noexcept move if you specifies noexcept, or always noexcept(false) when you specify nothing (or explicit noexcept(false)). (Note in the former case, noexcept does not prevent throwing during copy because that will only occur during evaluation of arguments, which is out of the function body.) There is no further chance to tune them separately.
    • This is considered minor because it is not frequently needed in reality.
    • Even if such overloads are used, they are probably confusing by nature: different specifiers may hide subtle but important behavioral differences which are difficult to reason about. Why not different names instead of overloads?
    • Note the example of noexcept may be particularly problematic since C++17 because noexcept-specification now affect the function type. (Some unexpected compatibility issues can be diagnosed by Clang++ warning.)

Sometimes the unconditional copy is actually useful. Because composition of operations with strong-exception guarantee does not hold the guarantee in nature, a copy can be used as a transactional state holder when the strong-exception guarantee is required and the operation cannot be broken down as sequence of operations with no less strict (no-exception or strong) exception guarantee. (This includes the copy-and-swap idiom, although assignments are not recommended to be unified for other reasons in general, see below.) However, this does not mean the copy is otherwise unacceptable. If the intention of the interface is always to create some object of type T, and the cost of moving T is ignorable, the copy can be moved to the target without unwanted overhead.

Conclusions

So for some given operations, here are suggestions about whether using a unifying interface to replace them:

  1. If not all of the parameter types match the unifying interface, or if there is behavioral difference other than the cost of new copies among operations being unified, there cannot be a unifying interface.
  2. If the following conditions are failed to be fit for all parameters, there cannot be a unifying interface. (But it can still be broken down to different named-functions, delegating one call to another.)
  3. For any parameter of type T, if a copy of each argument is needed for all operations, use unifying.
  4. If both copy and move construction of T have ignorable cost, use unifying.
  5. If the intention of the interface is always to create some object of type T, and the cost of the move construction of T is ignorable, use unifying.
  6. Otherwise, avoid unifying.

Here are some examples need to avoid unifying:

  1. Assignment operations (including assignment to the subobjects thereof, typically with copy-and-swap idiom) for T without ignorable cost in copy and move constructions does not meet the criteria of unifying, because the intention of assignment is not to create (but to replace the content of) the object. The copied object will eventually be destructed, which incurs unnecessary overhead. This is even more obvious for cases of self-assignment.
  2. Insertion of values to a container does not meet the criteria, unless both the copy-initialization and destruction have ignorable cost. If the operation fails (due to the allocation failure, duplicate values or so on) after copy-initialization, the parameters have to be destructed, which incurs unnecessary overhead.
  3. Conditionally creation of object based on parameters will incur the overhead when it does not actually create the object (e.g. std::map::insert_or_assign-like container insertion even in spite of the failure above).

Note the accurate limit of "ignorable" cost is somewhat subjective because it eventually depends on how much cost can be tolerated by the developers and/or the users, and it may vary case by case.

Practically, I (conservatively) assume any trivially copyable and trivailly destructible type whose size is not more than one machine word (like a pointer) qualifying the criteria of ignorable cost in general - if the resulted code actually cost too much in such case, it suggests either a wrong configuration of the build tool is used, or the toolchain is not ready for production.

Do profile if there is any further doubt on performance.

Additional case study

There are some other well-known types preferred to be passed by value or not, depending on the conventions:

  • Types need to preserve reference values by convention should not be passed by value.
    • A canonical example is the argument forwarding call wrapper defined in ISO C++, which requires to forward references. Note in the caller position it may also preserve the reference respecting to the ref-qualifier.
    • An instance of this example is std::bind. See also the resolution of LWG 817.
  • Some generic code may directly copy some parameters. It may be even without std::move, because the cost of the copy is assumed to be ignorable and a move does not necessarily make it better.
    • Such parameters include iterators and function objects (except the case of argument forwarding caller wrappers discussed above).
    • Note the constructor template of std::function (but not the assignment operator template) also uses the pass-by-value functor parameter.
  • Types presumably having the cost comparable to pass-by-value parameter types with ignorable cost are also preferred to be pass-by-value. (Sometimes they are used as dedicated alternatives.) For example, instances of std::initializer_list and std::basic_string_view are more or less two pointers or a pointer plus a size. This fact makes them cheap enough to be directly passed without using references.
  • Some types should be better avoided passed by value unless you do need a copy. There are different reasons.
    • Avoid copy by default, because the copy may be quite expensive, or at least it is not easy to guarantee the copy is cheap without some inspection of the runtime properties of the value being copied. Containers are typical examples in this sort.
      • Without statically knowing how many elements in a container, it is generally not safe (in the sense of a DoS attack, for example) to be copied.
      • A nested container (of other containers) will easily make the performance problem of copying worse.
      • Even empty containers are not guaranteed cheap to be copied. (Strictly speaking, this depends on the concrete implementation of the container, e.g. the existence of the "sentinel" element for some node-based containers... But no, keep it simple, just avoid copying by default.)
    • Avoid copy by default, even when the performance is totally uninterested, because there can be some unexpected side effects.
      • In particular, allocator-awared containers and some other types with similar treatment to allocators ("container semantics", in David Krauss' word), should not be passed by value - allocator propagation is just another big semantic worm can.
  • A few other types conventionally depend. For example, see GotW #91 for shared_ptr instances. (However, not all smart pointers are like that; observer_ptr are more like raw pointers.)

Solution 4

For the general case where the value will be stored, the pass-by-value only is a good compromise-

For the case where you know that only lvalues will be passed (some tightly coupled code) it's unreasonable, unsmart.

For the case where one suspects a speed improvement by providing both, first THINK TWICE, and if that didn't help, MEASURE.

Where the value will not be stored I prefer the pass by reference, because that prevents umpteen needless copy operations.

Finally, if programming could be reduced to unthinking application of rules, we could leave it to robots. So IMHO it's not a good idea to focus so much on rules. Better to focus on what the advantages and costs are, for different situations. Costs include not only speed, but also e.g. code size and clarity. Rules can't generally handle such conflicts of interest.

Solution 5

Pass by value, then move is actually a good idiom for objects that you know are movable.

As you mentioned, if an rvalue is passed, it'll either elide the copy, or be moved, then within the constructor it will be moved.

You could overload the copy constructor and move constructor explicitly, however it gets more complicated if you have more than one parameter.

Consider the example,

class Obj {
  public:

  Obj(std::vector<int> x, std::vector<int> y)
      : X(std::move(x)), Y(std::move(y)) {}

  private:

  /* Our internal data. */
  std::vector<int> X, Y;

};  // Obj

Suppose if you wanted to provide explicit versions, you end up with 4 constructors like so:

class Obj {
  public:

  Obj(std::vector<int> &&x, std::vector<int> &&y)
      : X(std::move(x)), Y(std::move(y)) {}

  Obj(std::vector<int> &&x, const std::vector<int> &y)
      : X(std::move(x)), Y(y) {}

  Obj(const std::vector<int> &x, std::vector<int> &&y)
      : X(x), Y(std::move(y)) {}

  Obj(const std::vector<int> &x, const std::vector<int> &y)
      : X(x), Y(y) {}

  private:

  /* Our internal data. */
  std::vector<int> X, Y;

};  // Obj

As you can see, as you increase the number of parameters, the number of necessary constructors grow in permutations.

If you don't have a concrete type but have a templatized constructor, you can use perfect-forwarding like so:

class Obj {
  public:

  template <typename T, typename U>
  Obj(T &&x, U &&y)
      : X(std::forward<T>(x)), Y(std::forward<U>(y)) {}

  private:

  std::vector<int> X, Y;

};   // Obj

References:

  1. Want Speed? Pass by Value
  2. C++ Seasoning
Share:
19,373

Related videos on Youtube

jbgs
Author by

jbgs

Hi there! :)

Updated on August 02, 2022

Comments

  • jbgs
    jbgs almost 2 years

    Since we have move semantics in C++, nowadays it is usual to do

    void set_a(A a) { _a = std::move(a); }
    

    The reasoning is that if a is an rvalue, the copy will be elided and there will be just one move.

    But what happens if a is an lvalue? It seems there will be a copy construction and then a move assignment (assuming A has a proper move assignment operator). Move assignments can be costly if the object has too many member variables.

    On the other hand, if we do

    void set_a(const A& a) { _a = a; }
    

    There will be just one copy assignment. Can we say this way is preferred over the pass-by-value idiom if we will pass lvalues?

    • Casey
      Casey over 10 years
      Calling std::move on a const& returns a const&& that cannot be moved from.
    • jbgs
      jbgs over 10 years
      You are right, I edited it.
    • Andy Prowl
      Andy Prowl over 10 years
    • KindDragon
      KindDragon about 6 years
      C++ Core Guidelines have the rule F.15 (advanced) for this case isocpp.github.io/CppCoreGuidelines/…
    • johannes
      johannes over 5 years
      Related is this talk by Nicolai Josuttis which discusses some options: youtube.com/watch?v=PNRju6_yn3o
    • Holt
      Holt over 5 years
      Some good reading: stackoverflow.com/questions/26261007/…, check also the mentioned slides and CppCon 2014 talk by Herb Sutter.
    • jaques-sam
      jaques-sam over 4 years
      Why would a move assignment be more costly than a copy construction?
    • Davis Herring
      Davis Herring over 4 years
      Note that the single copy-assignment may itself be a copy-and-swap (of the same expense!) for exception safety.
  • jbgs
    jbgs over 10 years
    Right, but I don't think that expensive-to-move types are so rare. Actually a class that consists only of PODs is as expensive-to-move as expensive-to-copy. The pass-by-value-and-then move would be as expensive as two copies when passing lvalues. That's why it seems a bad idiom to me.
  • Casey
    Casey over 10 years
    @jbgs Programmers with modern C++11 style avoid creating classes that consist primarily of PODs for exactly that reason. The prevalence of constant-time movable types actually discourages the creation of non-constant-time movable types, at least in interfaces.
  • jbgs
    jbgs over 10 years
    I agree that in normal circumstances it shouldn't be too costly. Well, at least it isn't too costly according to a particular C++11 style. But I still feel uneasy about this "moves are cheap" (I don't mean they aren't anyway).
  • Casey
    Casey over 10 years
    @jbgs Then by all means, write perfect-forwarding setters and constructors. (But beware the fiasco of the single-argument perfect-forwarding constructor)
  • jbgs
    jbgs over 10 years
    It makes me wonder why perfect-forwarding isn't always preferred over this idiom.
  • Casey
    Casey over 10 years
    @jbgs Error messages tend to be messy since the code to properly constrain such a perfect-forwarding setter is longer than the rest of the function entire: template <typename T> auto set_a(T&& a) -> decltype((void)(_a = std::forward<T>(a))) { _a = std::forward<T>(a); }. Constrained or not, error messages tend to be confusing.
  • Yakk - Adam Nevraumont
    Yakk - Adam Nevraumont over 10 years
    @jbgs perfect forwarding requires implementation exposure as well.
  • jbgs
    jbgs over 10 years
    That's the issue. Is it fair to assume that fixed size arrays are "rare"? I think we can find too many cases where pass-by-value-and-move is suboptimal. Of course we can write overloads to improve it... but it means getting rid of this idiom. That's why it is "bad" :)
  • Ali
    Ali over 10 years
    @jbgs I wouldn't say fixed size arrays are rare, especially because of the small string optimization. Fixed size arrays can be quite useful: You save a dynamic memory allocation which, in my experience, is quite slow on Windows. If you are doing linear algebra in low dimensions or some 3D animation, or you use some specialized small string, you application will be full of fixed size arrays.
  • jbgs
    jbgs over 10 years
    I fully agree. That's precisely what I mean. PODs (and arrays in particular) aren't rare at all.
  • Casey
    Casey over 10 years
    You're missing (C) 2 overloads/perfect forwarding (1 move, 1 copy, 1 move). I would also analyze the 3 cases (temporary, lvalue, std::move(rvalue)) separately to avoid making any kind of assumptions about relative distribution.
  • jbgs
    jbgs over 10 years
    I didn't miss it. I didn't include it because it is obviously the optimal solution (in terms of moves/copies, but not in other terms). I just wanted to compare this idiom and the usual pre-C++11 setter.
  • Potatoswatter
    Potatoswatter over 10 years
    @jbgs I doubt the intuition about expense would be borne out by experiment. An intermediate POD object is likely to be eliminated by compiler optimizations if the function is inlined. If inlining isn't applicable, then perfect forwarding generally isn't either.
  • Potatoswatter
    Potatoswatter over 10 years
    Where's the measurement here?
  • Ali
    Ali over 10 years
    @Potatoswatter I am not sure what you mean. My point is: jbgs should do measurements to see which option is better for him, and whether the extra move is expensive in his application. Since I don't have his code, I cannot measure it myself. My silly code only shows that move can be expensive if you have, for example, a fixed size stack allocated array as member. I didn't claim having done any measurements; I just gave an example, that's all.
  • Potatoswatter
    Potatoswatter over 10 years
    Because nothing is measured in the example, it shows nothing. Also confusing is the quote right before it.
  • Ali
    Ali over 10 years
    @Potatoswatter OK, thanks, now I see the problem. I wanted to update this answer anyway, there is also another issue that I forgot to mention. Please give me some time, I will fix these things.
  • Ali
    Ali over 10 years
    @Potatoswatter I greatly appreciate you leaving a comment instead of downvoting and giving me the opportunity to improve my answer. (Unfortunately, from time to time, I get anonymous downvotes, which is both upsetting and no help; I would happily fix / delete the answer if I knew my mistake.) So thanks! And hopefully the revised answer is less confusing now.
  • NathanOliver
    NathanOliver over 5 years
    You may want to note that if T is something that could be constructed by a std::initializer_list, this won't allow you to use a list in the call. set_a({1,2,3}) would hae to become set_a(A{1,2,3}) since braced-init-list's don't have a type.
  • skytree
    skytree over 5 years
    Can we write void set_a(const A& a) { _a = std::move(a); }; void set_a(A&& a) { _a = std::move(a); }; ? I add std::move(a) in the first statement.
  • Matthias
    Matthias about 5 years
    So what about PODs and fixed-size (std::)arrays which are more relatively expensive to move than to copy as you mention? So instead of using pass-by-value + move assign should one pass-by-value without move assign or pass by const ref as a rule of thumb (e.g., simple setter)?
  • Ali
    Ali about 5 years
    @Matthias It depends (1) on your PODs or fixed-size arrays, and (2) on your goals. I can't give you a simple rule without knowing your context. As for me, I just pass by const ref whenever I can, and then profile. I haven't had a single issue with this approach so far.
  • Matthias
    Matthias about 5 years
    @Ali Thanks for the advise (similar to Herb Suttor's rule of thumb to start with). Most of my use cases are rendering/physics/mathematics related and involve mathematical vectors of up to 4x float/double used in both performance critical and non-critical game engine code.
  • Ali
    Ali about 5 years
    @Matthias Cool, I didn't know Herb Sutter was advocating this rule of thumb too. However, I think we are missing the gorilla here. How you pass the argument should not matter too much for small things (and 4 doubles are still small enough) as long as the work you do in that function is substantial: You amortize the costs of the argument passing over the time spent in that function. If that is not the case, then the function should be inlined which in turn renders this whole discussion about argument passing moot.
  • Ali
    Ali about 5 years
    In other words: I would worry about proper function inlining. Luckily, the compiler gets it right most of the time (except when it doesn't, see for example here), and you can find those cases where it messed up by profiling your application. Hope this helps!
  • Matthias
    Matthias about 5 years
    @Ali Herb Suttor: "Back to the Basics", CppCon 2014. You're right about the profiling and in-lining optimization, was mostly just concerned with using a consistent approach for simple setters that would be inlined anyway.
  • Lightness Races in Orbit
    Lightness Races in Orbit almost 5 years
    Except when you explicitly signal you want to yield ownership using std::move, which is kind of the point.
  • jaques-sam
    jaques-sam over 4 years
    Why is'nt the answer: doing the std::move before calling the pass-by-value function?