foonathan::blog()

Thoughts from a C++ library developer.

Let's Talk about std::optional<T&> 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:

  1. Is std::optional<T&> the same as a pointer?
  2. Do we need std::optional<T&>?
  3. Should the assignment operator rebind or assign through?
  4. 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 return std::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 need std::optional<T&> or a narrow contract.

  • Using std::optional<T&> in a for loop only really makes sense if the iterators return std::optional<T&> already. This implies that the value type of the container is std::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.

This post was made possible by my Patreon supporters. If you'd like to support me as well, please head over to my Patreon and do so! One dollar per month can make all the difference.