Conceptual ownership vs temporary shared access with `unique_ptr` / `shared_ptr`

121 Views Asked by At

I've spent a decade away from C++, and now I'm confused about unique_ptr.

tl;dr I find that in some cases I conceptually want unique ownership, but I'm forced to use shared_ptr for technical reasons. Worse still, I'm forced to use shared_ptr because of unforeseeable client needs, which kinda leads to always using shared instead of unique. And that feels wrong.

Assume we have a Car class, where each Car owns some Wheels. In our model this is full, unique ownership; the Wheels have no way to outlive the Car, when the Car is destructed, all of its Wheels are destructed too. At least conceptually. On the surface it seems like a clear-cut case for unique_ptr.

However, we would like to be able to construct a list of Wheels (owner either by Cars or other wheeled things), and pass that list to a function that computes something about Wheels. What would be the type of that list? It can't be weak_ptrs, because, well, we can't get a weak_ptr out of a unique_ptr, but also, we'd then need to lock() that weak_ptr, practically creating a second owner, violating unique ownership (temporarily).

Technicalities aside, in a more general sense, how do we pass around objects owned by a unique_ptr? The receiver might want to lock the object (eg. because it's running on a different thread), but that sort of violates unique ownership, at least temporarily. But this second "ownership" isn't real ownership, it's more like just "hey, I don't own this thing, but I'm working with it, so please don't destruct it until I'm finished". From a technical point of view, sure, this is shared ownership, but conceptually it's a very different thing. How do we express this difference in C++? Do we? Or do we just go with the technical and call it shared, even if it really isn't (or only in a very limited sense), and with the words of my poetic buddy ChatGPT "conceptually muddle the ownership semantics"?

Even more importantly, when I add a new member to a class, it's pretty easy to tell if it's conceptually a uniquely owned subobject or not; but how do I know if clients of my class will ever need to assume temporary technical ownership of it?

At this point it seems to me that the safe bet is just to always use shared_ptr instead of unique_ptr for class members (possibly with the exception of private members). Maybe add an alias to mark unique ownership (only shared temporarily with workers). (Though it would be real nice if I could also specialize some templates differently for the case of "unique ownership, just using shared_ptr because of potential workers".)

So anyway, what are the best practices around unique_ptr as class member and passing around things owned by unique_ptrs? What books / articles should I go and read?

1

There are 1 best solutions below

0
imre On

I'll try to summarize the comments and links provided in comments (thanks everyone!). Note I may still be wrong, further comments are welcome.

While there are ways to get a shared_ptr to an object owned by a unique_ptr (see shared_ptr's aliasing constructor), generally it's better to pass such objects to functions as T* or T& parameters (most importantly see https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rf-smart).

This is good advice, though it might be (was for me) a bit non-trivial to see why it's safe.

My initial reaction to this advice was "how would this work in a multithreaded environment"? It's very hard to guarantee that while a function running on one thread is working with an object, that object won't get destructed on another thread. This seems to mean that the function does need to extend the object's lifetime (though I'm still reluctant to call this "ownership").

However, the function does not need to extend the object's lifetime if someone up the call chain already has ownership of the object -- which is almost always the case, with the exception of passing the object across threads. In all other cases passing T* or T& is the way to go.

When calling across threads, we do need to pass shared_ptr. (Of course we can't do that if we only have a T*, so a second case of a function needing to take a shared_ptr param is a function that itself may later need to pass the object across threads.) In the case of passing a uniquely owned subobject across threads, we can either pass a shared_ptr to the parent object, or use shared_ptr's aliasing constructor (creating a shared_ptr that points at the subobject, but extends the lifetime of the parent).

One case to consider is passing a subobject s of a parent p from a function f() to another function g() on a thread that is different from the one that conceptually owns p. Passing as T* or T& is still fine, because when we passed p across threads (eg. when calling f()), we passed it as shared_ptr. If f() has a shared_ptr to p, then it can safely pass s to g() as T* or T&, f()'s shared_ptr will guarantee that s won't be destructed while g() (more precisely while f()) is running, regardless of what the conceptual owner thread of p is doing.