A safe pointer in C++ that protects against use after free and updates when the pointee is moved
Sometimes some object A needs to interact with another object B, e.g., A calls one of B’s methods.
In a memory-unsafe language like C++, it is left to the programmer to assure that B outlives A; if B happens to be already destructed, this would be a use-after-free bug. Managing object lifetimes can be tricky, especially with asynchronous code.
Perhaps unneeded, but here is a simple example of the problem:
struct Foo {
void foo();
};
struct Bar {
Foo* f;
void call_foo() { f->foo(); }
};
int main(){
Bar A;
{
Foo B;
A.f = &B;
} // B destructed
A.call_foo(); // Oops
return 0;
}
You could of course argue that one should use a more modern, memory-safe language such as Rust to rule out such bugs by construction. But sunken cost in an existing codebase, or the more mature library ecosystem of an older language like C++ can easily render such a rewrite economically infeasible.
By the way, (and please correct me in case my understanding here is incorrect as a very inexperienced Rust programmer), getting Rust’s borrow checker to understand and agree with object lifetimes in your asynchronous code without having to sprinkle 'static
in many places can vary from challenging to currently impossible.
Also, C++ compilers are improving, so by enabling all warnings and by using features like Clang’s lifetimebound attribute you will already catch trivial instances like the one above, but there is currently no guarantee that more opaque instances of the same bug pattern, possibly spread over multiple translation units, will be caught by the compiler.
One way to avoid lifetime problems like the one sketched above in C++ (at least C++11, that is) is for A to manage the lifetime of B, say via a std::unique_ptr
or std::shared_ptr
.
struct BarOwnsFoo {
std::unique_ptr<Foo> f;
void call_foo() { f->foo(); }
};
int main(){
BarOwnsFoo A;
{
auto B_ptr = std::make_unique<Foo>();
A.f = std::move(B_ptr);
} // B kept alive inside A
A.call_foo(); // Okay
return 0;
}
Safe interactions between separately lifetime-managed objects
Sometimes, however, it may be undesirable to have an ownership relation as in the above code example. Especially in case of shared_ptr
, reasoning about when some object (which might be holding various expensive resources like memory) actually gets destructed in your program becomes harder. So, instead of always coupling lifetime to ownership, we would like to have a pointer-like primitive that enables safe interactions between separately lifetime-managed objects. By safe, we mean triggering some well-defined action like an assertion failure or throwing a std::logic_error
in case the pointee has already been destructed, instead of triggering Undefined Behavior (UB).
Move semantics and pointer invalidation
Another problem, which we have not discussed yet, is the scenario in which we take a pointer or reference to some object, and subsequently, we move that object. The move operation invalidates the pointer or reference; dereferencing the pointer (or using the reference) after the move operation (but before the moved-from object is destructed) would be a use-after-move bug. Besides, it would be a logical bug, in that we would still like to interact with the moved object, not with the ‘empty’ moved-from object.
int main(){
Bar A;
Foo B;
A.f = &B;
Foo C = std::move(B);
A.call_foo(); // Oops again
// ...
}
Instead of having to remember that we may not move an object after having taken a pointer or reference, or forbid moving the object by make the object type non-movable, we would like our pointer-like primitive to be automatically updated.
Proposed primitive: safe_ptr<T>
We propose a design for a safe pointer to an object of type T
that is weak in that it does not have ownership semantics, and gets notified in case the pointee is either destructed or moved.
We will pay a price at runtime for these extra guarantees in terms of a small heap-allocated state, a double pointer indirection when accessing the pointee (comparable to a virtual
function call), and a check against nullptr
.
Note that our use case is in a single-threaded context. Hence, the word safe should not be interpreted as ‘thread-safe.’ Single-threadedness greatly simplifies the design; we need not reason about race conditions such as one where an object is simultaneously moved and accessed on different threads. Extending the design to a thread-safe one is left as an exercise to the reader.
The main idea of our design is to internally use a std::shared_ptr<T*>
to share ownership of a (heap-allocated) pointer T*
. The ownership is shared by the pointee and all safe_ptr<T>
instances.
The pointee type T
must derive from the base class safe_ptr_factory
(templated on T
, using the Curiously Recurring Template Pattern) with a destructor that automatically notifies the shared state about destruction by setting T*
to nullptr
, and with a move constructor and move assignment operator that update T*
to the new this
pointer (which we must properly cast with static_cast<T*>
).
The double indirection thus comes from having to dereference the shared_ptr
to obtain an ordinary pointer T*
, after which you must dereference once more to access the pointee.
You might recognize an instance of Gang-of-Four’s observer pattern in our design.
Below you find an example of how safe_ptr
protects against UB in case of use-after-free:
class Baz : public safe_ptr_factory<Baz> {};
safe_ptr<Baz> sp;
{
Baz b;
sp = b.safe_ptr_from_this();
} // b destructs here but sp is notified
*sp; // throws std::logic_error exception
and how safe_ptr
remains valid after a moving the pointee:
class Fubar : public safe_ptr_factory<Fubar> {
public:
void foo();
};
safe_ptr<Fubar> sp;
Fubar b;
sp = b.safe_ptr_from_this();
Fubar c = std::move(b); // b is moved but sp is notified
sp->foo(); // Okay!
Full implementation
Below, we give the full implementation, which also specifies the behavior in case the pointee type is copied. You can also interact with the code in Matt Godbolt’s Compiler Explorer.
#include <memory>
#include <stdexcept>
//! A non-owning pointer that provides extra safety guarantuees:
/*
* - automatically updated in case the pointee is moved;
* - supports checking whether the pointee is still alive;
* - throws an exception when trying to use the pointee after it has been destructed;
* - throws an exception when trying to dereference the pointer when it is nullptr.
*
* Usage: publicly derive the pointee class T from `safe_ptr_factory<T>`, which provides a public method
* named `.safe_ptr_from_this()` to get a safe_ptr to an object of T.
*/
template <typename T>
class safe_ptr {
std::shared_ptr<T*> pointee_; // note that this is a 'shared_ptr to a pointer to the pointee'
template <typename U>
friend class safe_ptr_factory;
explicit safe_ptr(std::shared_ptr<T*> ptr) : pointee_(std::move(ptr))
{
}
void check() const
{
if (!valid()) {
throw std::logic_error("attempt to dereference nullptr");
}
}
public:
//! Default constructor
safe_ptr() = default;
//! Dereference the pointer. Throws std::logic_error if the pointee is already destructed.
T& operator*() const
{
check();
return **pointee_;
}
//! Gets a pointer p to the object. Returns nullptr if safe_ptr is unitialized or the pointee is dead.
T* get() const noexcept
{
if (pointee_) {
return *pointee_;
}
else {
return nullptr;
}
}
//! Gets a pointer p to the object. Returns nullptr if the pointee is dead.
explicit operator T*() const noexcept
{
return get();
}
//! Arrow-operator. Throws std::logic_error if the pointee is already destructed.
T* operator->() const
{
check();
return get();
}
//! Check whether the pointee is still alive
bool valid() const noexcept
{
return pointee_ && (*pointee_ != nullptr);
}
//! 'operator bool'-overload to check whether the pointee is still alive
explicit operator bool() const noexcept
{
return valid();
}
};
template <typename T>
class safe_ptr_factory {
std::shared_ptr<T*> ptr_;
protected:
//! Default constructor
/*!
* Behavior: constructs a fresh uninitialized shared state.
*/
safe_ptr_factory() = default;
//! Copy constructor
/*!
* Behavior: the shared state will remain associated to the original pointee, not to its copy, which will have an
* fresh uninitialized shared state.
*/
safe_ptr_factory(safe_ptr_factory const& other) : safe_ptr_factory()
{
}
//! Move constructor (delegates to move assignment)
safe_ptr_factory(safe_ptr_factory&& other) noexcept
{
*this = std::move(other);
}
//! Copy assignment
/*!
* Behavior: the shared state will remain associated to the original pointee, not to its copy, which will have an
* fresh uninitialized shared state.
*/
safe_ptr_factory& operator=(safe_ptr_factory const& other)
{
if (this != &other) {
*this = safe_ptr_factory{};
}
return *this;
}
//! Move assignment
/*!
* Behavior: updates the shared state (if non-nullptr) with the new location of the moved pointee.
*/
safe_ptr_factory& operator=(safe_ptr_factory&& other) noexcept
{
if ((this != &other) && other.ptr_) {
ptr_ = std::move(other.ptr_);
// notify shared_state that the 'this' pointer has changed
*ptr_ = static_cast<T*>(this);
}
return *this;
}
~safe_ptr_factory() noexcept
{
if (ptr_) {
// notify destruction to shared_state
//
// Please note that there is a crucial difference between 'ptr_ = nullptr' and '*ptr_ = nullptr', which are
// both valid but have totally different meanings:
// - 'ptr_ = nullptr' resets the shared_ptr and disassociates it from any shared state;
// - '*ptr_ = nullptr' writes a nullptr in the shared state, indicating that the pointee has been
// destructed.
*ptr_ = nullptr;
}
}
public:
//! Get a safe pointer to the pointed object
/*!
* Pointer remains valid if the pointee is moved,
* and throws exception in case of nullptr-dereference or use-after-free.
*/
safe_ptr<T> safe_ptr_from_this()
{
if (!ptr_) {
ptr_ = std::make_shared<T*>(static_cast<T*>(this));
}
return safe_ptr<T>{ptr_};
}
};