6

Assume I have a class, which contains a smart pointer as its member variable:

class B;

class A {
 public:
  A(const std::shared_ptr<B>& b) : b_(b) {}  // option1: passing by reference
  A(std::shared_ptr<B> b) : b_(b) {}  // option2: passing by value
  std::shared_ptr<B> b_;
};

I have two choices for A's constructor: construct by smart pointer and construct by smart pointer's reference.

What are the advantages and disadvantages of these two methods?

Is copying the smart pointer wasteful?

Boann
  • 48,794
  • 16
  • 117
  • 146
nick
  • 832
  • 3
  • 12
  • 2
    Pass the raw pointer unless the function is actually going to take ownership. – sweenish Aug 30 '22 at 14:25
  • 3
    Did you mean `std::shared_ptr`? – ks1322 Aug 30 '22 at 14:26
  • copying a `shared_ptr` incurs some expense as it has to atomically update the control block. You should avoid that if possible, – NathanOliver Aug 30 '22 at 14:26
  • What is `b_`? Is it `const std::shared_ptr&` or `std::shared_ptr`? Also, in option 2, use `b_(std::move(b))`. – lorro Aug 30 '22 at 14:27
  • Does this answer your question? [Should we pass a shared\_ptr by reference or by value?](https://stackoverflow.com/questions/3310737/should-we-pass-a-shared-ptr-by-reference-or-by-value) – ks1322 Aug 30 '22 at 14:33
  • @ks1322: That question mostly relates to accepting it as an argument to a function (one which is not guaranteed to retain ownership, and in most circumstances, does not). Some of the answers address the specific case of "function (in this case, constructor) that always assumes ownership", but it's not as clear given the emphasis on functions which don't appear to take ownership. Definitely relevant, but I'm not sure it's a good duplicate. – ShadowRanger Aug 30 '22 at 15:06

1 Answers1

9

The best option is option #3:

A(std::shared_pointer<B> b) : b_(std::move(b)) {}  // option3: passing by value and move

Unlike option1, it won't perform an unnecessary copy when the shared_ptr was constructed from a prvalue passed to A's constructor. Unlike option2, it won't perform an unnecessary copy internally.

The costs are:

  1. When passed a prvalue, it performs a single construction and a single move construction (which is more efficient for shared_ptr, as it avoids needing to manipulate the reference count the way you have to if you copy and destroy the source)
  2. When passed any other r-value (e.g. A(std::move(callers_ptr))), it move constructs twice (again, avoiding any refcnt manipulation), but again, no copies
  3. When passed an l-value, it copies once (into the argument, taking ownership before the object is even constructed), then moves into the member cheaply. This is the case where the caller is maintaining ownership while also giving you separate ownership, so the single copy is unavoidable.

So the costs are:

  • prvalue: One move (plus the necessary actual construction of the original shared_ptr)
  • Other r-value: Two moves
  • l-value: One move (plus necessary copy to acquire ownership)

For comparison, option #1 in each scenario requires:

  • prvalue: One copy (plus the necessary construction of the original shared_ptr)
  • Other r-value: One copy (possibly an additional move as well; not 100% on C++ rules for const reference lifetime extension in this scenario; either way, one copy is worse than two moves, given the atomics involved in shared_ptrs)
  • l-value: Just the necessary copy to acquire ownership (an improvement on option #3, which adds a move, but moves are cheap, so saving copies in the other cases is much more important)

Option #2 in each scenario is exactly the same as option #3, but one move from each scenario instead becomes a copy (so option #2 is objectively worse in every scenario); adding std::move to change option #2 to option #3 is a pure win.

So yes, option #1 might be slightly more efficient if callers always retain their own ownership of the shared_ptr while giving the A its own ownership as well, never transferring their ownership to the new A. But each move you're saving is just a couple non-atomic pointer assignments; either way you copy the pointer(s) from source to target, with move adding the NULLing out of the source, while saving a copy means you avoid incrementing the reference count atomically through the pointer to the non-local control block (likely to be an order of magnitude more expensive than local non-atomic pointer assignment).


Note: There is an option #4, as mentioned by Nathan in the comments that is strictly more performant, using a perfect-forwarding constructor to skip a move operation in each case. The downside is the code gets much more complicated, and if the use case is more complex (not just single trivial shared_ptr member), you have the potential problems involved with the (usually not noexcept) construction/copy operations occurring within the constructor, not on the caller side, so the risk of an exception occurring while the object is only partially constructed increases. As long as all non-move operations occur outside the constructor (meaning they're done for the arguments), the constructor itself can often be noexcept and avoid needing to deal with the possibility of a mid-initialization exception.

ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
  • 2
    Wouldn't the best option be perfect forwarding? – NathanOliver Aug 30 '22 at 14:32
  • 2
    @NathanOliver The extra move operation you could save that way is probably not significant. Moving a shared_ptr should be cheap, for example [here](https://github.com/llvm/llvm-project/blob/main/libcxx/include/__memory/shared_ptr.h#L613) for libc++ it costs only copying and zeroing two pointers. Whether pass-by-value vs pass-by-reference will then be more efficient for a non-inlined call would probably depend on the details. But implementing perfect forwarding is less straight-forward requiring SFINAE or multiple definitions. So I would say it is probably not worth it. – user17732522 Aug 30 '22 at 14:42
  • @NathanOliver: Yep, that would save you something. It's more complex (can't avoid templates), and the savings are pretty trivial (copy from source to target, NULL out source), but if it's really the choke-point in your program you could do that. – ShadowRanger Aug 30 '22 at 14:47
  • @NathanOliver: I added notes on that (including a link to a question demonstrating it). I'm generally `noexcept` paranoid, so perfect-forwarding does (if I understand correctly, won't swear to it) mean construction (regular or copy, both of which are often capable of raising exceptions) can end up occurring within the constructor, where having those operations take place in the caller (as it sets up arguments before calling the constructor) and leaving only the (typically `noexcept`) move construction within the constructor does have some advantages (admittedly more important for swap/move). – ShadowRanger Aug 30 '22 at 14:59