Was the raw-pointer constructor of shared_ptr a mistake?
Asked Answered
P

3

8

In hindsight, given make_shared, would shared_ptr have a constructor that takes a raw pointer had it been introduced with C++11?

Are there strong arguments or use cases in favor of this constructor?

It would have avoided the well documented pitfall of exception-safety and the memory allocation/performance advantage of using make_shared.

I believe another advantage of requiring shared_ptr construction via make_shared would be that it could be a single pointer under the hood, lowering its memory use and making things like atomic_compare_exchange a lot simpler (and possibly more efficient). (see presentation from C++Now)

I understand that a shared_ptr that basically is an intrusive_ptr (with the object and the control block coalesced) would lack features the current std::shared_ptr has. Like:

  1. the ability to free the object separately from the control block (which is nice if you have long lived weak_ptrs)

  2. compatibility with libraries that hand you raw pointers and the responsibility to free them

  3. the ability to hold arbitrary resources with custom deleters (or no deleter, for non-owning pointers)

  4. the ability to point to a sub-object (e.g., a member) while keeping the parent object alive.

What I'm suggesting is that these features may not be used commonly enough (or in the case of using it as a RAII-wrapper) may not be the best fit, to warrant the extra cost:

  1. a separate pointer to the control block
  2. (potentially) more complex atomic_compare_exchange logic, may not be worth it.

In a C++98 world (where shared_ptr was introduced) make_shared is less practical and less user friendly (the lack of perfect forwarding requires reference wrappers and the lack of variadic templates makes the implementation clunky).

Pashalik answered 22/5, 2016 at 17:45 Comment(4)
What if you're not in control of object construction? (Say you're managing a resource from a C library.)Perl
make_shared was introduced in C++11.Bascio
@NicolBolas right, and shared_ptr was introduced in C++03Pashalik
@Pashalik no it wasn'tDonella
B
14

The problem with your logic is the belief that the reason why shared_ptr has a distinction between the managed pointer and the get pointer is because make_shared wasn't available. And therefore, if we forced everyone to use make_shared to create shared_ptr, we wouldn't need that distinction.

This is incorrect.

You can implement shared_ptr's pointer-based constructor without that distinction. After all, in the initial creation of a managed shared_ptr, the get pointer and the managed pointer are the same. If you wanted shared_ptr to be the sizeof(T*), you could just have the shared_ptr fetch the get pointer from the managed block. This is regardless of whether the T is embedded within the managed block.

So the distinction really has nothing at all to do with make_shared and its ability to embed the T within the same memory as the managed block. Or rather, the lack thereof.

No, the distinction between the managed pointer and the get pointer was created because it added features to shared_ptr. Important ones. You listed some of them, but you missed others:

  • The ability to have a shared_ptr to a base class. That is:

    shared_ptr<base> p = make_shared<derived>(...);
    

    To do that, you must have a distinction between what a particular instance points to and what the control block controls.

  • static_pointer_cast and dynamic_pointer_cast (and reinterpret_pointer_cast in C++17). These all rely on the distinction between the managed pointer and the get pointer.

    • This also includes enable_shared_from_this within base classes.
  • A shared_ptr that points to a member subobject of a type that itself is managed by a shared_ptr. Again, it requires the managed pointer to not be the same as the get pointer.

You also seem to trivially dismiss the ability to manage pointers not created by you. That's a critical ability, because it allows you to be compatible with other codebases. Internally, you can use shared_ptr to manage things made by a library that was written in 1998.

With your way, you divide code into two epochs: pre-C++11, and post-C++11. Your shared_ptr will do nothing for any code not explicitly written for C++11.

And the thing about wrapping all of these features up into a single type is this:

You don't need another one.

shared_ptr, because it serves so many needs, can be effectively used almost anywhere. It may not be the absolutely-most-efficient-type-possible, but will do the job in virtually every case. And it isn't exactly slow in doing so.

It handles shared ownership with polymorphism. It handles shared ownership of member objects. It handles shared ownership of memory you didn't allocate. It handles shared ownership of memory with special allocation/deallocation needs. And so forth.

If you need shared-ownership semantics, and you need it to work, shared_ptr's got your back every time. With your suggested idea, there would always be limitations, something in your way from getting your work done.

A type that works should be preferred by default over one that doesn't.

Bascio answered 25/5, 2016 at 22:22 Comment(3)
Should they not have had a unique_ptr constructor for those cases instead?Chockablock
@Bwmat: For what case are you talking about?Bascio
Probably for transfering ownership to the shared_ptr. Still, while that additional indirection might hopefully not cost runtime efficiency, it doesn't actually help much either.Mitrewort
E
14

In hindsight, given make_shared, would shared_ptr have a constructor that takes a raw pointer had it been introduced with C++11?

What if you don't control the allocation of the object? What if you need to use a custom deleter? What if you need list-initialization instead of parens?

None of these cases is handled by make_shared.

Additionally, if you're using weak_ptr, a shared_ptr allocated via make_shared won't free any memory until all the weak_ptrs are destroyed as well. So even if you have a normal shared pointer where none of the above apply, it's possible that you may still prefer the raw pointer constructor.

Yet another situation would be if your type provides overloads for operator new and operator delete. These may make it ill-suited for make_shared, since those overloads will not be called - and presumably they exist for a reason.

Emilio answered 22/5, 2016 at 18:5 Comment(8)
I'd recommend the OP reads Scott Myer's Effective Modern C++ for a thorough description of the rationale of the design.Frit
Having said all that, make_shared is probably the normal case, and the raw pointer case is exceptional.Deyoung
@Martin Sure, but there's quite a difference between saying you should prefer make_shared and suggesting the alternative is a design flaw.Emilio
I think we are in furious agreement here!Deyoung
I take it you're saying that the cost paid (twice the storage and possibly more complex lock-free logic) in the common case is worth the versatility.Pashalik
@Pashalik No I'm not saying that (nor is it true - it's not twice the storage and the ref counting logic is the same - they just free memory differently and at different times). There are cases where make_shared is not an option - that is not a cost-related argument.Emilio
@Emilio I understand shared_ptr has two pointers. The hypothetical shared_ptr that can only be created from make_shared would be a single pointer (like intrusive_ptr). The choice to support the non-make_shared cases (as well as being able to free the object separately from the control block) forces shared_ptr to have two pointers (afaict). That's the cost of that choice. If you're not saying these cases are worth the additional cost, what are you saying?Pashalik
@Pashalik Okay, sure. shared_ptr wouldn't nearly as useful if it were only make_shared()-able. But make_shared is an optimization and not the One True Constructor - so using cost as you are makes the reasoning backwards. Sure, the "cost" we're paying is making the optimization not as good as it could potentially be - in return for making the class more useful.Emilio
B
14

The problem with your logic is the belief that the reason why shared_ptr has a distinction between the managed pointer and the get pointer is because make_shared wasn't available. And therefore, if we forced everyone to use make_shared to create shared_ptr, we wouldn't need that distinction.

This is incorrect.

You can implement shared_ptr's pointer-based constructor without that distinction. After all, in the initial creation of a managed shared_ptr, the get pointer and the managed pointer are the same. If you wanted shared_ptr to be the sizeof(T*), you could just have the shared_ptr fetch the get pointer from the managed block. This is regardless of whether the T is embedded within the managed block.

So the distinction really has nothing at all to do with make_shared and its ability to embed the T within the same memory as the managed block. Or rather, the lack thereof.

No, the distinction between the managed pointer and the get pointer was created because it added features to shared_ptr. Important ones. You listed some of them, but you missed others:

  • The ability to have a shared_ptr to a base class. That is:

    shared_ptr<base> p = make_shared<derived>(...);
    

    To do that, you must have a distinction between what a particular instance points to and what the control block controls.

  • static_pointer_cast and dynamic_pointer_cast (and reinterpret_pointer_cast in C++17). These all rely on the distinction between the managed pointer and the get pointer.

    • This also includes enable_shared_from_this within base classes.
  • A shared_ptr that points to a member subobject of a type that itself is managed by a shared_ptr. Again, it requires the managed pointer to not be the same as the get pointer.

You also seem to trivially dismiss the ability to manage pointers not created by you. That's a critical ability, because it allows you to be compatible with other codebases. Internally, you can use shared_ptr to manage things made by a library that was written in 1998.

With your way, you divide code into two epochs: pre-C++11, and post-C++11. Your shared_ptr will do nothing for any code not explicitly written for C++11.

And the thing about wrapping all of these features up into a single type is this:

You don't need another one.

shared_ptr, because it serves so many needs, can be effectively used almost anywhere. It may not be the absolutely-most-efficient-type-possible, but will do the job in virtually every case. And it isn't exactly slow in doing so.

It handles shared ownership with polymorphism. It handles shared ownership of member objects. It handles shared ownership of memory you didn't allocate. It handles shared ownership of memory with special allocation/deallocation needs. And so forth.

If you need shared-ownership semantics, and you need it to work, shared_ptr's got your back every time. With your suggested idea, there would always be limitations, something in your way from getting your work done.

A type that works should be preferred by default over one that doesn't.

Bascio answered 25/5, 2016 at 22:22 Comment(3)
Should they not have had a unique_ptr constructor for those cases instead?Chockablock
@Bwmat: For what case are you talking about?Bascio
Probably for transfering ownership to the shared_ptr. Still, while that additional indirection might hopefully not cost runtime efficiency, it doesn't actually help much either.Mitrewort
S
5

std::shared_ptr does much more than allocate objects on the heap.

Consider its use as an auto-closing shared file handle:

#include <cstdio>
#include <memory>


int main()
{
  auto closer = [](FILE* fp) { std::fclose(fp); };
  auto fp = std::shared_ptr<FILE>(std::fopen("foo.txt", "r"),
                                  closer);
}
Shaven answered 22/5, 2016 at 18:18 Comment(2)
"it's" is short for "it is". It's not the possessive form.Sisely
@LightnessRacesinOrbit mea culpa!Shaven

© 2022 - 2024 — McMap. All rights reserved.