Skip to content

A safer C++ smart pointer with the semantics of unique_ptr and with the memory safety and rule-of-zero benefits of shared_ptr

License

Notifications You must be signed in to change notification settings

hpenne/owned_ptr

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

47 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

owned_ptr - the missing C++ smart pointer?

Why

C++ has two smart pointer classes, shared_ptr and unique_ptr, both of which are great.

shared_ptr allows shared ownership, and uses internal reference counting to destroy the object only when all "handle" objects that refer to it are destroyed. It also has some support for multiple threads through atomic reference counting. This gives it some runtime overhead. Furthermore, each handle object is the size of two pointers and the internal control block also has two pointers' worth of overhead.

unique_ptr is for exclusive ownership. It has no run time overhead and (mostly) no memory overhead, and it has great ownership semantics because the code makes it very obvious who owns the object and when it will be destroyed.

Some programmers prefer shared_ptr for everything, while others frown upon that and advocate that one should always use unique_ptr where possible, because of the extra overhead of shared_ptr and the superior semantics of unique_ptr.

Both of these are important points, and I tend to agree.

However, there are two problems with unique_ptr that make this a little more complicated.

First, unique_ptr is not "rule of zero" friendly. The destructor of the type contained by the unique_ptr must be known when the destructor of the unique_ptr is invoked. If you have a class "A" with a unique_ptr<B> member and no user defined destructor, then the compiler will generate a default destructor for you. This will happen in the header containing "A", which means that this header must include the header for "B".

If you try to forward declare "B" then compilation will fail. You can fix this by declaring a destructor for "A" in the header for "A" and implementing it in the source file, but that violates "rule of zero" and you will then need to declare/define all the other four special member functions as well.

That is a lot of boilerplate code just for wanting to use unique_ptr. Interestingly, shared_ptr does not have this problem.

You can avoid this by using a custom deleter, but it is a little more complicated (and the handle object size will increase by the size of one pointer). This is kind of what shared_ptr does behind the scenes.

The second, more serious issue is memory safety.

Remember how shared_ptr ensured that the object was not destroyed until every reference to it was gone. unique_ptr has no such features, and any dependencies to the object from other places in your object graph will use a simple pointer or reference. This leaves the door wide open for use-after-free errors, where the object is destroyed while such pointers or references still exist elsewhere in your object graph, and one of these is later dereferenced. This happens more often than one would think, and use-after-free is a big source of security vulnerabilities.

All this makes shared_ptr seem more attractive than first thought, but when you think about it what it actually shows is that there is a need for third kind of smart pointer that sits between the two: One that has better performance than shared_ptr, semantics that are as good as unique_ptr, but the memory safety and "rule of zero" advantages of shared_ptr.

This is a library for such a smart pointer.

The class

This library defines a smart pointer named owned_ptr. This is a handle object (one pointer in size) that points to a block on the heap containing the owned object, a reference count and a deleter function (to make the class "rule of zero" friendly).

From this you can create dep_ptr instances, which are the handle objects used for non-owning users of the object (dependencies). There is also a dep_ptr_const which is created if the owned_ptr is const. The size of these is also one pointer, so the total overhead vs. unique_ptr is never more than two pointers in total regardless of how many handle objects you have.

The destructor of the owned object is called when the owned_ptr handle object is destroyed, but the block (which also contains the reference count) is not deallocated until the last dep_ptr is also destroyed. Any attempt to access the object using a dependency handle object after the owned_ptr was destroyed is trapped, which make uses of these classes safe against use-after-free.

The size of the handle objects is a single pointer, so this is more memory efficient than shared_ptr. It is also much faster, as it does not use atomics for reference counting. It has much better memory safety than unique_ptr, and it allows "rule of zero" with forward declared classes.

Usage

Basics

Creation of objects and dependencies:

auto foo1 = make_owned<string>("foo1");
auto foo2 = owned_ptr<string>{"foo2"s};
auto foo3 = std::move(foo1); // But copy is not supported
auto dep1 = foo3.make_dep(); // Creates dependency pointer
std::cout << *dep1 << " is " << dep1->size() << " chars long\n";
std::cout << *foo2 << " is " << foo2->size() << " chars long\n";

This uses the same principles as make_shared and make_unique, with forwarding of constructor arguments.

Note that because the object and the ref count and deleter are all allocated inside the same contiguous block on the heap, there is no support for creating objects with the following syntax:

auto foo = make_owned<string>{new string{"foo"}}; // Does not compile

Memory safety checks

Use of a dependency pointer whose "parent" owned_ptr has been destroyed will cause an assert by default:

auto foo = std::make_optional(owned_ptr<string>{"foo"});
auto dep = foo.make_dep();
foo = nullopt;
*dep; // Fails at runtime

There no way to access a deleted object, so no use-after-free is possible.

Note that the standard error handling policy is that this only applies in Debug builds (as with assert in general), but this is configurable:

Configuration

The types have a second template parameter that controls error handling. This is defaulted to an error handling policy where usage errors (like use-after-free and used of moved-from pointers) are caught in Debug builds using assert, but not in Release builds.

This can be modified by using a custom policy:

struct my_error_handler {
    static void check_condition(bool condition, const char *reason) {
        MY_ASSERT(condition) << reason;
    }

    static constexpr bool reset_when_moved_from{true};
};

auto foo = owned_ptr<string, my_error_handler>{"foo"};

This is highly recommended for secure code, where asserts should not be disabled in Release builds.

Notice also the reset_when_moved_from flag. Setting this to false will cause moved-from dep_ptr object to still be valid (not nullptr), which is perhaps a little less intuitive for some (but still valid C++), but increases performance because the objects will not need to be checked for nullptr on access.

The default policy sets this flag to true in Debug builds, but false in Release. This will catch use-after-move Debug, while maximizing performance in Release.

About

A safer C++ smart pointer with the semantics of unique_ptr and with the memory safety and rule-of-zero benefits of shared_ptr

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published