A Default Value to Dereference Null Pointers
With C++17, modern C++ has acquired a nullable object: std::optional
. optional
has a pretty rich interface, in particular when it comes to handling null optionals.
On the other hand, the oldest nullable type in C++, pointers, doesn’t have any helper to make the handling of its nullity more expressive.
Let’s see what we can do about it, to make our code using pointers, smart or raw, easier to read.
Handling std::nullopt
An optional<T>
is an object that can have all the values that T
can have, plus one: std::nullopt
.
This allows to express the fact that a value can be “not set”, without resorting to sacrificing one possible value of T
, such as 0, -1, or an empty string.
This allows in turn a function to manage errors by returning an optional. The semantics of this kind of interface is that the function should normally return a T
, but it may fail to do so. In that case it returns nothing, or said differently in the language of optionals, it returns a std::nullopt
:
std::optional<int> f() { if (thereIsAnError) return std::nullopt; // happy path now, that returns an int }
On the call site, the caller that gets an optional expects to find a value in it, unless it is a std::nullopt
.
If the caller would like to access the value, it needs to check first if the optional returned by the function is not a std::nullopt
. Otherwise, dereferncing a std::nullopt
is undefined behaviour.
The most basic way to test for the nullity of the optional is to use its conversion to bool
:
auto result = f(); if (result) { std::cout << *result << '\n'; } else { std::cout << 42 << '\n'; // fallback value is 42 }
We can shorten this code by using the ternary operator:
auto result = f(); std::cout << result ? *result : 42 << '\n';
Except that in this particular case the code doesn’t compile, because of operator precedence. We need to add parentheses to clarify our meaning to the compiler:
auto result = f(); std::cout << (result ? *result : 42) << '\n';
This code is pretty clear, but there is a simpler way to express the simple idea of getting the value or falling back on 42.
To achieve that, optional
provide the value_or
member function, that allows to pack it into this:
std::cout << f().value_or(42) << '\n';
This has the same effect as the code above, but it is higher in terms of levels of abstraction, and more expressive.
Handling null pointers
Although they don’t have the same semantics at all, optional and pointers have one thing in common: they are both nullable.
So we would have expected a common interface when it comes to handling null objects. And indeed, we can test and deference pointers with the same syntax as optionals:
int* result = g(); if (result) { std::cout << *result << '\n'; } else { std::cout << 42 << '\n'; }
Or, with the ternary operator:
int result = g(); std::cout << (result ? *result : 42) << '\n';
But we can’t write the nice one-liner for pointers:
std::cout << g().value_or(42) << '\n';
That’s a shame. So let’s write it ourselves!
Writing value_or
with pointers
Until C++ has the uniform function call syntax that has been talked about for years (even decades), we can’t add a member function syntax to pointers, to get the exact same syntax as the one of optional
.
But we can get pretty close with a free function, which we can write this way:
template<typename T, typename U> decltype(auto) value_or(T* pointer, U&& defaultValue) { return pointer ? *pointer : std::forward<U>(defaultValue); }
We can then write our code dealing with null pointers like this:
std::cout << value_or(g(), 42) << '\n';
lvalues, rvalues? The devil is in the details
What should value_or
return? In the above code, I’ve chosen to make it return decltype(auto)
. This makes the return type be exactly the same as the type on the return statement. Indeed, note that a simple auto
would not have returned a reference, but rather a copy.
Now what is the type of the return statement? *pointer
is an lvalue. The type returned by value_or
depends on the type of defaultValue
.
The general principle for the value category returned by the ternary operator is the following:
condition ? lvalue : lvalue // lvalue condition ? lvalue : rvalue // rvalue condition ? rvalue : lvalue // rvalue condition ? rvalue : rvalue // rvalue
If defaultValue
is an lvalue reference (which means that the argument it received was an lvalue), then std::forward<U>(defaultValue)
is an lvalue, and so is the call expression ofvalue_or
.
And if defaultValue
is an rvalue reference (which means that the argument it received was an rvalue), then std::forward<U>(defaultValue)
is an rvalue, and so is the call expression of value_or
.
Do you find that value_or
makes sense for pointers? How do you handle null pointer in your code?
You will also like
- Pointers, References and Optional References in C++
- Clearer interfaces with optional<T>
- Why Optional References Didn’t Make It In C++17
- Get monthly ebooks on C++
Share this post!