An optional is a nullable value type.
A shared_ptr
is a reference counted reference type that is nullable.
A unique_ptr
is a move-only reference type that is nullable.
What they share in common is that they are nullable -- that they can be "absent".
They are different, in that two are reference types, and the other is a value type.
A value type has a few advantages. First of all, it doesn't require allocation on the heap -- it can be stored along side other data. This removes a possible source of exceptions (memory allocation failure), can be much faster (heaps are slower than stacks), and is more cache friendly (as heaps tend to be relatively randomly arranged).
Reference types have other advantages. Moving a reference type does not require that the source data be moved.
For non-move only reference types, you can have more than one reference to the same data by different names. Two different value types with different names always refer to different data. This can be an advantage or disadvantage either way; but it does make reasoning about a value type much easier.
Reasoning about shared_ptr
is extremely hard. Unless a very strict set of controls is placed on how it is used, it becomes next to impossible to know what the lifetime of the data is. Reasoning about unique_ptr
is much easier, as you just have to track where it is moved around. Reasoning about optional
's lifetime is trivial (well, as trivial as what you embedded it in).
The optional interface has been augmented with a few monadic like methods (like .value_or
), but those methods often could easily be added to any nullable type. Still, at present, they are there for optional
and not for shared_ptr
or unique_ptr
.
Another large benefit for optional is that it is extremely clear you expect it to be nullable sometimes. There is a bad habit in C++ to presume that pointers and smart pointers are not null, because they are used for reasons other than being nullable.
So code assumes some shared or unique ptr is never null. And it works, usually.
In comparison, if you have an optional, the only reason you have it is because there is the possibility it is actually null.
In practice, I'm leery of taking a unique_ptr<enum_flags> = nullptr
as an argument, where I want to say "these flags are optional", because forcing a heap allocation on the caller seems rude. But an optional<enum_flags>
doesn't force this on the caller. The very cheapness of optional
makes me willing to use it in many situations I'd find some other work around if the only nullable type I had was a smart pointer.
This removes much of the temptation for "flag values", like int rows=-1;
. optional<int> rows;
has clearer meaning, and in debug will tell me when I'm using the rows without checking for the "empty" state.
Functions that can reasonably fail or not return anything of interest can avoid flag values or heap allocation, and return optional<R>
. As an example, suppose I have an abandonable thread pool (say, a thread pool that stops processing when the user shuts down the application).
I could return std::future<R>
from the "queue task" function and use exceptions to indicate the thread pool was abandoned. But that means that all use of the thread pool has to be audited for "come from" exception code flow.
Instead, I could return std::future<optional<R>>
, and give the hint to the user that they have to deal with "what happens if the process never happened" in their logic.
"Come from" exceptions can still occur, but they are now exceptional, not part of standard shutdown procedures.
In some of these cases, expected<T,E>
will be a better solution once it is in the standard.