A blog post about pointers and references? What kind of newbie am I? Well, apparently the kind with 20+ years of programming experience who is not afraid to admit his ignorance.
On the other hand:
- maybe it’s not that easy?
- maybe I wasn’t paying that much attention when it was explained to me?
- maybe things have changed over time?
- or maybe I just haven’t spent enough time thinking about the topic?
I think it might be all of the above.
How can we remember our ignorance, which our growth requires, when we are using our knowledge all the time? — Henry David Thoreau
So what do we have? Let’s look at some history first
First came pointers, the first of their kind in C++, inherited from C in 1979. Pointers:
- can be left uninitialized
- can be assigned null
- can be reassigned
- do not express ownership
- allow pointer arithmetic
- can be copied and moved
- do not manage object lifetime
Then there were references. They were introduced in 1985 for operator overloading, but they have other benefits too. They are, by their very nature, always initialized, because it is not possible to declare a reference without also initializing it. Their most important defining attribute, aside from their slick syntax: they cannot be reassigned. References basically are pointers, but with only a subset of the features and a more convenient syntax. This makes them hard(er) to use incorrectly.
So references:
- are always initialized
- can be assigned null (syntactically at least, but doing so causes undefined behaviour, so never do that)
- cannot be reassigned
- explicitly express the absence of ownership
- don’t allow arithmetic
- can be copied and moved
- do not manage object lifetime (obviously, since the absence of ownership is explicitly expressed)
Then came smart pointers. They had a rough start: std::auto_ptr had its problems and is best forgotten. The reference-counted shared_ptr appeared in Boost around 2001, and C++11 added std::shared_ptr and std::unique_ptr. std::atomic<std::shared_ptr<T>> was added to the standard library in C++20.
Smart pointers are called smart because they manage the lifetime of objects in some way. shared_ptr does this by reference counting, so the last instance of a shared_ptr releases the memory of the object. By contrast, unique_ptr explicitly expresses exclusive ownership and releases the memory when it goes out of scope.
shared_ptr<foo>
- cannot be left uninitialized
- can be assigned null
- can be reassigned
- expresses shared ownership
- can be copied and moved
- automatically deletes the object when the scope of its last copy ends
unique_ptr<foo>
- cannot be left uninitialized
- can be assigned null
- can be reassigned
- explicitly expresses unique ownership
- can be moved but cannot be copied
- automatically deletes the object when its scope ends
Enter the GSL in 2015, which includes, among many other things, the not_null<> and owner<> templates:
not_null<Foo*>
- cannot be left uninitialized
- cannot be assigned null
- can be reassigned
- does not express ownership
- can be copied and moved
They can also be combined, for example:
not_null<shared_ptr<foo>>
- cannot be left uninitialized
- cannot be assigned null
- can be reassigned
- expresses shared ownership
- can be copied and moved
Similar, albeit slightly different in philosophy, is the nn<> type from Dropbox.
Throughout this post, when I talk about ‘pointers’ I mean all kinds: raw, smart, atomic, and also references (even though references are, strictly speaking, not pointers). This is certainly not an exhaustive list of existing (smart) pointers, but let’s compare the specimens we have here.
Different kinds of pointers with different use cases
Pointers, or what we now like to call ‘raw pointers’ to contrast them with ‘smart’ pointers, have a kind of folklore about them. They are attributed qualities like ‘bad style’ or even ‘evil’. In my opinion pointers are useful and harmless in themselves, but like anything, they can be misused in ways that become problematic.
“Warning: towels can be harmful if swallowed in large quantities.” — Douglas Adams
That being said, I think pointers should in general be considered a last resort, not a default. Or to put it another way: use a reference if you can, use a pointer if you have to.
As a rule of thumb, I:
- prefer pointers that express ownership
- prefer pointers that cannot be left uninitialized
- prefer pointers that cannot be null
- prefer pointers that cannot be copied
This basically orders my preference for pointers as:
- Foo&
- unique_ptr<Foo>
- not_null<shared_ptr<Foo>>
- shared_ptr<Foo>
- gsl::owner<Foo*>
- gsl::not_null<Foo*>
- Foo*gsl::not_null<Foo*>
gsl::owner<Foo*>
Depending on the other attributes you need, you may have the luxury of choice; by default, however, references and unique_ptr are probably your friends.
If you want to express that an argument is optional and that the callee does not get ownership, a raw pointer fits the job. However, it does not express that intent explicitly, so what are the alternatives?
For example:
void Func(Foo* foo, int x)
{
if (foo == nullptr)
{
// throw / report error
}
foo->Func(x);
}might be more clearly expressed as:
void Func(gsl::not_null<Foo> foo, int x)
{
foo->Func(x);
}As a side note, the GSL has three options for handling contract violations:
GSL_TERMINATE_ON_CONTRACT_VIOLATION:std::terminatewill be called (default)GSL_THROW_ON_CONTRACT_VIOLATION: agsl::fail_fastexception will be thrownGSL_UNENFORCED_ON_CONTRACT_VIOLATION: nothing happens
The gsl::not_null<foo> above has two advantages: first, it makes explicit that the argument is not optional, and second, GSL-aware static analysis tools can use the type information to give better diagnostics. However, there is still no expression of ownership, so we might write:
void Func(Foo& foo, int x)
{
// do X
foo.Func(x);
}Here we use a reference to express explicitly that the foo argument is not optional and that the callee does not receive ownership. So if references are so great, why not always use them? Consider this data structure:
struct Time {}
struct Task
{
Time& time;
};
Time t;
Task task = { t };Here a Task can be created, but never rescheduled: to reschedule it, you would have to create a new Task. This can lead to very impractical structures, so instead it could be expressed as:
struct Task
{
not_null<Time*> time;
};
Time t;
Task task = { &t };Here a Task must be given a time at construction; the time can be changed later, but only by assigning another Time object.
There is another catch: references can still end up referring to null, and to make matters worse, there is no way to check when that happens.
Time * t = nullptr;
Task task = { *t }; // please never null assign a reference!Then, to stack up more potential problems:
void dowork(Foo& foo)
{
}
struct Bad
{
void set(Foo* f) { foo = f; }
void use() { dowork(*f); }
Foo* foo;
}If this code is used by more than one thread at once, and one thread calls use() while another calls set() to set foo to nullptr, no amount of checking in dowork() can prevent a potential crash. Of course, protecting access to foo with a mutex could solve that, and there are also better ways, but solutions are not what this part of the post is about. We’re digging into the problems first.
Note that the reference is not causing this problem, and it is not even responsible for hiding it. The problem is that calling this code from two threads was unsafe to begin with, no matter what kind of pointer you use.
This does not mean that references or raw pointers (or any kind of pointer, for that matter) are bad. The problem here is that the author did not think about who controls the lifetime of the object foo. You might think that using a unique_ptr or shared_ptr would improve this. Let’s see about that:
void dowork(std::shared_ptr<Foo> foo)
{
}
struct Bad
{
void set(std::shared_ptr<Foo> f) { foo = f; }
void use() { dowork(f); }
std::shared_ptr<Foo> foo;
}This code is just as bad when used from two threads, because foo is potentially copied and reassigned at the same time. For more information, see my other blog posts:
To summarize, some guidelines I like to use:
- Consciously assign the ownership of every object you create.
- Use references to express that no ownership is passed; they are by far the easiest to read and understand.
- Never assign the value of a pointer to a reference if the pointer was optional.
gsl::not_null<>can help make legacy code more readable without actually changing its meaning. If desired, it can fail fast by enabling the exception orstd::terminatebehaviour.gsl::owner<>can turn null and implicitly converted pointer assignments into compiler errors, and expresses ownership explicitly where raw pointers are passed.
Leave a Reply