Friday, March 22, 2013

C++ ownership semantics

I always get a distinct feeling that smart pointers in C++ are seen as a magical way to handle memory automatically. A lot of discussions often end up in why you should pick smart pointers over raw pointers. Even though I think that it's more of a question in WHICH cases you should do this, I also think it's a secondary point to be made. As I interpret it, the primary point to be made is rather the semantics bound to the smart pointers, and the notion of ownership associated with pointers in general. Choosing the correct type of pointer is simply an effect of this. I'll try to describe what I mean in this post.

When I started programming C++ (around 1998-1999 I think) one of the most annoying things were (and in a sense, still is) memory management. It's also one of the hardest things to get right, even today.

At the time, I usually tried to stick to some hard rules, the most important one being: don't allocate things on the heap if you don't have too. Basically, if the following suits your purpose:
// Stack-allocated auto-variable. No need for manual cleanup
MyType t;

... Then don't do this:
// Heap-allocation - Needs to be deleted at some point
MyType* t = new MyType();

However, sometimes (certainly when working with polymorphic types) we actually need to use pointers and initialize them using new. At that point, it becomes a question of whose responsibility it is to delete the actual pointer when we're done. Because yes - someone must have that responsibility if you want to avoid memory leaks.

The stack is awesome in the sense that stack-allocated variables are easily cleaned up at the end of their defining scope. The usual way to utilize this is through smart pointers. The general idea around smart pointers is easy: Encapsulate a raw pointer inside a stack-allocated type and delete the pointer in the destructor.

There's a bunch of smart pointers out there, both in the Boost C++ library and in the standard library. I'm not going to rant about auto_ptr from C++ 03 here - there's plenty of articles describing the faulty behaviour of auto_ptr out there. It's broken, and we all know it. I'm actually not going to rant at all, but rather point out (no pun intended) some ideas around the semantics of  these pointers. The reason is that I've (more than once) come across the misconception that raw pointers should never ever be used under any circumstances at all. Ever. You should only use smart pointers. I get what people are usually saying when they make that claim, but I think that a standpoint such as that one obscures the idea around ownership. I'm somewhat guilty of giving a half-assed view of this myself in a previous blogpost, where I wrote something along the lines of "Bottom line: avoid raw pointers, use smart pointers". It's not entirely wrong, but it's a lot deeper than that.


Determining ownership

It's not that raw pointers are bad, per se. It's just that they make it hard for the programmer to actually tell who the owner is. To illustrate this point, consider the class interfaces below. Since I'm in the midst of writing a 3D-renderer right now, I'll use some rendereresque class examples:


The Device-class presumably creates a VertexBuffer and returns a pointer to the instance through CreateVertexBuffer. This can then be passed to another class (such as the Mesh-class through the constructor). So, we might use these classes in something resembling the following code snippet:


The problem with raw pointers becomes painfully apparent after writing a bunch of code in this style for a while. For instance, who OWNS the VertexBuffer-pointer? Who is responsible for actually cleaning up after it? Is the lifetime of the buffer-variable bound to the lifetime of the device-variable? In other words, will it be removed in the destructor of the Device class? Or is it up to you, the consumer of this instance to actually delete the pointer? At least let's hope that it's not the Mesh-destructor that removes it. That would be REALLY bad since we could actually have passed the same pointer to several Mesh-instances, leaving every other Mesh with a dangling pointer inside.

If you were to assume that the Device class handles this internally and you never delete the VertexBuffer-pointer, as shown below then one of two things will happen

The two things that can happen are:
  1. You were correct in your assumption and the program works as expected.
  2. Your guess was wrong - The pointer was never deleted, and now you have a memory leak.
If, instead, you were to assume that the Device class does NOT handle this internally and were to delete it yourself just to be sure as shown below, one of two things could still happen:

The two things in this case are:
  1. You were correct in your assumption and the program works as expected.
  2. Your guess was wrong - The device class WILL try and delete the pointer. But since you've already done that, it'll try to delete a dangling pointer, and your program would most likely crash.
This is mostly to demonstrate the problem with a non-obvious ownership in C++. And don't get me wrong. This isn't always apparent - Managing memory correctly is HARD. If you just link against the header files and don't have access to the implementation (which you shouldn't need in this case anyway) you have no way of knowing the semantics of any of these classes in terms of how they handle memory.

This is exactly why the semantics of ownership are important - If we can establish ownership, then we at least have a lot of help when managing memory in C++.


Smart pointers

Smart pointers (as described before) help us a lot. There are different semantics tied to each one, determining how/if they are copyable or movable. Since C++ does stack unwinding when an exception is thrown (basically, calls the destructor on stack-allocated objects), it also helps us with cleanup when something goes wrong, just to mention a few perks.

The ones I'm going to focus on are the two widely used unique_ptr and shared_ptr. I tend to use unique_ptr in a lot more places than I do with shared_ptr - I'll try to explain why in a while. A short description of these pointers is in place:
  1. unique_ptr encapsulates a raw pointer and can't be copied. Trying to assign one unique_ptr to another will give a compile error. It basically replaces the deprecated auto_ptr, but which much clearer semantics. A pointer of this type CAN be moved though, meaning that it gives up ownership of its inner pointer to another unique_ptr instance. This has to be done explicitly by the developer by using std::move
  2. As opposed to unique_ptr, shared_ptr can be copied. It involves somewhat more overhead since it relies on reference counting. Everytime a shared_ptr runs out of scope, the reference counter goes down. Or, in the case of 0 references, the pointer is deleted.
I'm not going to describe the functionality in much more detail than that right now. Other articles and blog posts do that. I'm going to focus on how they help with memory management.

If I know for a fact how each of these pointers actually work, then rewriting the Device class from before to the following gives me a lot more information to work with:

I know, without a doubt, that this class gives me a unique_ptr for a VertexBuffer. I know that I'm now the sole owner of this pointer. It's unique, so there can't be a copy of it inside the Device class. Granted, the Device implementation could store the raw pointer as a private member and delete that one. But that's just an extremely sinister thing to do when returning a unique_ptr, so I'll ignore that.

Basically, the Device type is now telling us that WE are the sole owners of the buffer. It's in our control and it's up to us what will happen from this point forward. Now, it's easy to think that this unique_ptr is only useful in one scope only, since it is apparently unique and can't be copied. But I have to pass a VertexBuffer* to the Mesh implementation, and obviously I can't change the signature to take a unique_ptr and pass the unique_ptr I obtained from Device on - That would be a copy operation.

And no - I'm not going to tell you to use a shared_ptr instead, even though it would work. Since we are then doing reference counting instead, we actually can't claim to have "sole ownership" anymore. A shared_ptr more or less means shared ownership. So here is the thing: I don't really think that leaving the Mesh interface as-is is a bad thing in this case, simply because it is not the owner! Let's look at a snippet of code again, assuming that we're now returning a unique_ptr from Device. I'll also use a unique_ptr to create my Device and Mesh (I'll explain my reasons for this later on)

And yes - I am using raw pointers here. Using std::unique_ptr::get() allows you to get the underlying pointer. And the thing is, I don't really think this is a bad thing to do, granted that the class that you're passing the pointer to is soundly written. I know that when I pass a pointer to a constructor in this way, then it's more of a DI-pattern. It wouldn't make sense for the Mesh-class to delete this resource. Also, notice that the parameter is a VertexBuffer* const. The "const" is important, since it stops that class from modifying the pointer itself (it can still mutate the object being pointed at, but not "re-point" at another object).

Finally - There's still the option to use the shared_ptr and its reference counter. It also has a corresponding weak_ptr that lets you model a temporary ownership (and to break circular references of shared_ptr). As I said before, I usually prefer the unique_ptr, and the reason is simply because it's much clearer in its ownership semantics. Using a shared_ptr by default implies a shared ownership of a pointer, which muddles the boundaries of ownership. When talking to people about shared pointers, I sometimes get the distinct feeling that some programmer's uses them to mimic the way they would write code in a garbage collected language, such as C# or Java. However, just a ref-counter does not a GC make, and we shouldn't suddenly pretend that we can use a shared_ptr and do away with memory issues.

Don't get me wrong - There are times when the shared_ptr is just the thing to use. I just think those times are far apart.

Anyway - This is basically my interpretation of the whole RAII-idiom and the usage of smart pointers. I think smart pointers are a great help in memory management, but not ONLY for the simple reason that they "get cleaned up automatically". I think it all comes down to semantics and understanding how your code is partitioned, and not necessarily on "smart pointers vs. raw pointers", which a lot of debates (but not all of them) seem to focus on.

My take on this might of course be flawed, and I'm always up for discussion. So please, do comment ;)

15 comments:

  1. Very useful post! Very useful code!
    Marketing Presentations

    ReplyDelete
  2. Great article.
    Please correct usage-demo Device pointer and Smart Pointers unique_ptr copy

    ReplyDelete
  3. Thank you so much for this post, this topic became really clear to me now.

    ReplyDelete
  4. This comment has been removed by the author.

    ReplyDelete
  5. Great post. Also love the Iron Maiden shirt.

    ReplyDelete
  6. Thanks. great and straight forward explanation.

    ReplyDelete
  7. Thanks a lot for sharing this! Adds a different perspective to memory management and how to choose the right pointer for the job.

    Side note: I think you two typos in your Shared Pointers section: in bullet point 1, when describing the unique_ptr, you refer to it throughout the description as a shared_ptr:

    "unique_ptr ... Trying to assign one *shared_ptr* to another ... it gives up ownership of its inner pointer to another *shared_ptr* instance. ... "

    ReplyDelete
    Replies
    1. Haha! It took someone about 4 years to spot that (completely missed it myself until now).

      I don't really keep this blog updated anymore sadly, but I'll fix that paragraph. Thanks for pointing it out :)

      Delete
  8. Great post. One of the first articles to help me truly understand the idea of ownership in C++.

    ReplyDelete
  9. Wow.. I was very much confused to replace raw pointers with smart pointers like above. Very detailed and structured explanation. This blog removed all questions I had about smart pointers .. Thanks Alot Mr. Eric :-)

    ReplyDelete
  10. Your explanation helped me a lot in understanding the correlation between ownership, move semantics and smart pointers. Thank you very much!

    ReplyDelete