Let’s Talk about std::optional and optional references
This should have been part 2 of my comparison series, and I have almost finished it, but due to university stuff I just haven’t found the time to polish it.
But the optional discussion started again, so I just wanted to really quickly share my raw thoughts on the topic.
In case you are lucky and don’t know what I mean:
std::optional<T&>
doesn’t compile right now, because the behavior of assignment wasn’t clear (even though it actually is).
There are basically four questions in the discussion I want to answer:
- Is
std::optional<T&>
the same as a pointer? - Do we need
std::optional<T&>
? - Should the assignment operator rebind or assign through?
- Should it even have an assignment operator?
tl;dr: no, I don’t, rebind, no.
1. Is std::optional<T&>
the same as a pointer?
What does it even mean to have an “optional T&
”?
Well, it is a T&
that can also be nullptr
.
So a pointer, a T*
?
No, not really.
There is a more important difference between T&
and T*
besides the nullability:
A T&
has implicit creation and access, a T*
explicit creation and access.
If you have an object, you can just silently bind a reference to it. And if you have a reference, you can just treat it as if it was the object.
Whereas for pointers you need to explicitly use &obj
and *ptr
.
And this difference is huge:
It means const T&
can be used for function parameters without any additional syntax issues:
void print(const T& obj);
…
T obj = …;
print(obj);
You wouldn’t want to use a const T*
as now the call side has to do extra work, it has to use the unnecessary &obj
.
This is just awkward.
It is a different situation if the function might modify the object through the pointer/reference, or store a pointer/reference in a persistent location.
So naturally, if you want to have an optional argument, you wouldn’t want to use a pointer for the same reason: Why now introduce unnecessary syntactic overhead? It shouldn’t matter to the caller.
So std::optional<T&>
is not the same as T*
:
It would have implicit creation syntax, not explicit.
Not that a nullable type needs to have explicit creation syntax. Otherwise, there is just no way to check whether the type points to an object or not: With implicit syntax, every operation is done on the pointee, not the pointer. So any null checks would also be done on the pointee, not the pointer. This means that an explicit syntax is required, otherwise you’re just checking whether the optional has an object that is null.
A more thorough analysis can be found in the first twenty minutes of my Rethinking Pointers talk at C++Now earlier this year.
2. Do we need std::optional<T&>
?
As std::optional<T&>
isn’t the same as T*
, we need to look at the situations where we use T&
and think about whether we need an optional version there.
Luckily, I did exactly that in my Rethinking Pointers talk.
We want a T&
in the following situations:
Function parameters (only some kinds, see the talk for more)
void print(const T& obj); void sort(Container& cont);
Getter functions that return a value and we want to avoid a copy, so we use
const T&
const std::string& person::name() {…}
Getter functions that return a value and we absolutely need to write through, so we return
T&
T& std::vector::operator[](std::size_t index) {…} T& std::optional<T>::value() {…}
Range-based
for
loops:for (auto& cur : container) …
Lifetime extension when calling a function (experts only):
const std::string& name = p.name(); // use `name` multiple times
And that’s it.
You shouldn’t use a T&
for any other reason.
So for each of those situations, do we actually need an optional version?
Function parameters: There are cases where we want to have an optional parameter and
std::optional<T&>
would be a solution. But in my opinion simply overloading works better.If we have a getter function where the value might not be there, we could simply add a precondition to the function, or throw an exception. Now the caller has to check before calling. For obvious reasons, this is what
std::optional::value()
itself does. If we want to have the optional return type, we could returnstd::optional<T>
and drop the reference. This creates an unnecessary copy, however,std::optional<const T&>
wouldn’t.Similar argument applies to getter functions where we want write-trough. Just now returning
std::optional<T>
is not an option(…al), so we needstd::optional<T&>
or a narrow contract.Using
std::optional<T&>
in afor
loop only really makes sense if the iterators returnstd::optional<T&>
already. This implies that the value type of the container isstd::optional<T>
in some form or another. I’ve discussed that in detail here and here.If you want lifetime extension,
std::optional<const T&>
will (probably) not do the trick, so it can’t be used.
So there is only one situation where there is no good (or better) alternative to std::optional<T&>
:
Functions that return a value which may or may not be there.
If we’re fine with getting a copy, std::optional<T>
is an alternative.
But if we explicitly want write through, we need std::optional<T&>
, right?
Well, no.
You see, the only difference between std::optional<T&>
and T*
is the implicit creation syntax.
And this difference doesn’t apply!
As we would need explicit access syntax anyway, we can just return a T*
and be done with it.
In the write-through-APIs (operator*
, operator[]
, …), it might be appropriate to have a type where every operation is a noop
if the value isn’t there.
So get_obj() = foo
might not do anything if get_obj()
doesn’t have an object.
But for those semantics we don’t actually need std::optional<T&>
and it definitely shouldn’t have those semantics.
So there is no mainstream use case where we absolutely need a std::optional<T&>
.
3. Should the assignment operator rebind or assign through?
Assignment fundamentally is an optimization of copy. It should just do the same thing as “destroy the current object” and “copy a new one over”.
So when we write opt_a = opt_b
, it will modify opt_a
so it is a copy of opt_b
.
This is true for all T
, including T&
:
If opt_b
is a reference to my_obj
, then opt_a
will also be a reference to my_obj
, even it was a reference to other_obj
before.
So the copy assignment operator does a rebind operation.
Now std::optional
also has an assignment operator taking a T
.
This assignment operator is an optimization of the constructor taking a T
.
Read more about assignment as an optimization in this blog post I’ve written some time ago.
As such, it will destroy the current object, if there is any, and then create the new object inside it.
However, as it is an optimization, it will use T::operator=
if the optional has a value already.
The assignment operator of T
might be more efficient than “destroy” followed by “construct”.
But note that it only does that, because it assumes that the assignment operator of T
is an optimization of copy!
If you provide a T
where rocket = launch
means “launch the rocket” this will fail.
But this isn’t optional’s fault, your type is just stupid!
And one such stupid type is T&
:
The assignment operator of T&
is not an optimization of “destroy” followed by “copy”.
This is because references have no assignment operator:
Every operation you do on a reference is actually done on the object it refers to.
This includes assignment, so the assignment operator will assign the value, it assigns through.
Now some people think that having that behavior in the operator=
of optional<T&>
itself is even a possibility they need to consider.
It isn’t.
It absolutely isn’t.
Ignoring any other counter argument, those semantics would lead to confusion as operator=
would do completely different things depending on the state of the optional
!
std::optional<T&> opt = …;
T obj;
opt = obj;
// if opt was empty before, it will now refer to obj
// if opt wasn't empty before, it will now refer to an object with the same value as obj
return opt; // so this is legal only if the optional wasn't empty before
There is no precedent for an assignment operator that behaves like this. Because an assignment operator shouldn’t behave like this.
4. Should it even have an assignment operator?
Whenever we use a T&
we don’t need to modify the reference itself — after all, we can’t.
So when we replace the T&
with a std::optional<T&>
there is no need to mutate the std::optional<T&>
either.
Now the “assign through” people of std::optional<T&>
argue that this behavior is consistent with T&
.
It isn’t, as references aren’t assignable.
Sure, writing ref = obj
compiles, but it is not an assignemnt.
It only works because every operation done on a reference is done on the object it refers to.
Now as I said before, when we have a nullable reference we can’t do that because then we would have no syntax to check for nullability.
So the only way to be truly consistent with T&
would be if std::optional<T&>
would have no modifying operators.
It shouldn’t have an operator=
, an emplace()
function, etc.
After all, T&
is immutable, so std::optional<T&>
should be as well.
If you are in a situation where you need to mutate a std::optional<T&>
, you didn’t want an std::optional<T&>
, you wanted a pointer.
Because then you store the optional in a persistent location and should have used an explicit creation syntax to make it obvious.
Again, watch my talk.
Conclusion
In my opinion we don’t need std::optional<T&>
.
It is a weird type with only very few use cases.
If the committee decides that adding std::optional<T&>
is worth the effort,
it should be an immutable std::optional
, just like references are.
For the actual uses cases of std::optional<T&>
, just like the use cases of T&
, it doesn’t actually matter.
Note that having a std::optional
version that behaves like a T*
, i.e. a nullable pointer-like type with explicit creation, is useful.
After all, a T*
is exactly such a type!
But as a T*
can do a lot of different things, it might be a good idea to add a distinct type that explicitly models just that.
In my type_safe library, for example, I have an ts::optional_ref<T>
, which is like a T*
and not like a nullable T&
.
More details, again, in my Rethinking Pointers talk.
If you've liked this blog post, consider supporting me - a dollar a month can really help me out.
This blog post was written for my old blog design and ported over. If there are any issues, please let me know.