Error Handling and std::optional
In this article, we'll review some of the options to handle errors (or disappointments) in our C++ code, and see where the std::optional wrapper fits in.
Join the DZone community and get the full member experience.
Join For FreeIn my last two posts in the C++17 STL series, I covered how to use std::optional
. This wrapper type (also called "vocabulary type") is handy when you'd like to express that something is 'nullable' and might be 'empty.' For example, you can return std::nullopt
to indicate that the code generated an error... but it this the best choice?
The article comes from a blog - bfilipek.com - weekly articles about modern C++.
What's the Problem?
Let's see an example:
struct SelectionData
{
bool anyCivilUnits { false };
bool anyCombatUnits { false };
int numAnimating { 0 };
};
std::optional<SelectionData>
CheckSelection(const ObjSelection &objList)
{
if (!objList.IsValid())
return { };
SelectionData out;
// scan...
return {out};
}
This code comes from my older post about refactoring with std::optional
.
The basic idea is that if the selection is valid, you can perform a scan and look for "civil units," "combat units," or a number of animating objects. Once the scan is complete, we can build an object SelectionData
and wrap it with std::optional
. If the selection is not ready, then we return nullopt
- empty optional.
While the code looks nice, you might ask one question: what about error handling?
The problem with std::optional
is that we lose information about errors. The function returns a value or something empty, so you cannot tell what went wrong. In the case of this function, we only had one way to exit earlier - if the selection is not valid. But in a more complicated example, there might be a few reasons.
What do you think? Is this a legitimate use of std::optional
?
Let's try to find the answer.
Error Handling
As you might already know there are a lot of ways to handle errors. And what's even more complicated is that we have different kinds of errors.
In C++, we can do two things:
- Use some error code/special value.
- Throw an exception.
Of course, with a few variations:
- Return some error code and return a computed value as an output parameter.
- Return a unique value for the computed result to indicate an error (like -1,
npos
). - Throw an exception - since exceptions are considered "heavy" and add some overhead a lot of projects use them sparingly.
- Return a pair
<value, error_code>
- Return a variant/discriminated union
<value, error>
- Set some special global error object (like
errno
forfopen
) - often in C style API. - Others... ?
In a few papers and articles, I've seen a nice term, "disappointment," that relates to all kind of errors and "problems" that code might generate.
We might have a few types of disappointments:
- System/OS
- Serious
- Major
- Normal
- Minor
- Expected/probable
Furthermore, we can see the error handling in terms of performance. We'd like it to be fast and using some additional machinery to facilitate errors might not be an option (like in the embedded world). Thus, for example, exceptions are considered "heavy" and usually not used in low-level code.
Where does std::optional
fit?
I think, with std::optional
we simply got another tool that can enhance the code.
std::optional
Version
As I noted several times, std::optional
should be mainly used in the context of nullable types.
From the boost::optional
documentation: When to use Optional
It is recommended to use
optional<T>
in situations where there is exactly one, clear (to all parties) reason for having no value of typeT
, and where the lack of value is as natural as having any regular value ofT
.
I can also argue that since optional adds a "null" value to our type, it's close to using pointers and nullptr
. For example, I've seen a lot of code where a valid pointer was returned in the case of the success and nullptr
in the case of an error.
TreeNode* FindNode(TheTree* pTree, string_view key)
{
// find...
if (found)
return pNode;
return nullptr;
}
Or if we go to some C-level functions:
FILE * pFile = nullptr;
pFile = fopen ("temp.txt","w");
if (pFile != NULL)
{
fputs ("fopen example",pFile);
fclose (pFile);
}
And even in C++ STL we return in the case of failed string searches. So rather than nullptr
it uses a special value to indicate an error (maybe not a failure but a probable situation that we failed to find something).
std::string s = "test";
if(s.find('a') == std::string::npos)
std::cout << "no 'a' in 'test'\n";
I think that in the above example - with npos
, we could safely rewrite it to optional. And every time you have a function that computes something and the result might be empty - then std::optional
is a way to go.
When another developer sees a declaration like:
std::optional<Object> PrepareData(inputs...);
It's clear that Object
might sometimes not be computed and it's much better than:
// returns nullptr if failed! check for that!
Object* PrepareData(inputs...);
While the version with optional might look nicer, the error handling is still quite "weak."
How About Other Ways?
Alternatively, if you'd like to transfer more information about the 'disappointments,' you can think about std::variant<Result, Error_Code>
or a new proposal Expected<T, E>
that wraps the expected value with an error code. At the caller site, you can examine the reason for the failure:
// imaginary example for std::expected
std::expected<Object, error_code> PrepareData(inputs...);
// call:
auto data = PrepareData(...);
if (data)
use(*data);
else
showError(data.error());
When you have optionals, then you have to check if the value is there or not. I like the functional style ideas from Simon Brand where you can change code like:
std::optional<image_view> get_cute_cat (image_view img) {
auto cropped = find_cat(img);
if (!cropped) {
return std::nullopt;
}
auto with_sparkles = make_eyes_sparkle(*with_tie);
if (!with_sparkles) {
return std::nullopt;
}
return add_rainbow(make_smaller(*with_sparkles));
}
Into:
tl::optional<image_view> get_cute_cat (image_view img) {
return find_cat(img)
.and_then(make_eyes_sparkle)
.map(make_smaller)
.map(add_rainbow);
}
More in his post: Functional exceptionless error-handling with optional and expected
New Proposal
When I was writing the article, Herb Sutter published a brand new paper on a similar topic:
PDF P0709 R0 - Zero - overhead deterministic exceptions: Throwing values.
It will be discussed in the next C++ ISO Meeting in Rapperswil at the beginning of June.
Herb Sutter discusses what the current options for error handling are and what their pros and cons are. But the main things is the proposal of throws
, a new version of exception handling mechanism.
This proposal aims to marry the best of exceptions and error codes: to allow a function to declare that it
throws values of a statically known type, which can then be implemented exactly as efficiently as a return value.
Throwing such values behaves as if the function returned union{R;E;}+bool where on success the function returns the normal return value R and on err or the function returns the error value type E, both in the same return channel including using the same registers. The discriminant can use an unused CPU flag or a register.
For example:
string func() throws // new keyword! not "throw"
{
if (flip_a_coin()) throw
arithmetic_error::something;
return “xyzzy”s + “plover”; // any dynamic exception
// is translated to error
}
int main() {
try {
auto result = func();
cout << “success, result is: ” << result;
}
catch(error err) { // catch by value is fine
cout << “failed, error is: ” << err.error();
}
}
In general, the proposal aims to have an exception-style syntax, while keeping the zero-overhead and type safety.
Consistency and Simplicity
I believe that while we have a lot of options and variations on error handling, the key here is "the consistency."
If you have a single project that uses 10 ways of error handling it might be hard to write new parts as programmers will be confused as to what to use.
It's probably not possible to stick to the single version: in some critical performance code exceptions are not an option, or even wrapper types (like optional, variant, expected) are adding some overhead. Keeping the minimum of the right tools is the ideal path.
Another thought on this matter is how your code is clear and straightforward. Because if you have relatively short functions that do only one thing, then it's easy to represent disappointments - as there are just a few options. But if your method is long, with a few responsibilities, then you might get a whole new complexity of errors.
Keeping code simple will help the caller to handle the outcome in a clear meaner.
Wrapping Up
In this article, I reviewed some of the options to handle errors (or disappointments) in our C++ code. We even looked at the future when I mentioned new Herb Sutter's proposal about "Zero-overhead deterministic exceptions."
Where does std::optional
fit?
It allows you to express nullable types. So if you have a code that returns some special value to indicate the result of the computation failure, then you can think about wrapping it with optional. The key thing is that optional doesn't convey the reason for the failure, so you still have to use some other mechanisms.
With optional you have a new tool to express your ideas. And the key here, as always, is to be consistent and write simple code, so it doesn't confuse other developers.
What's your opinion about using optional for error handling? Do you use it that way in your code?
See previous post in the series: Using C++17 std::optional
More from the Author:
Bartek recently published a book - "C++17 In Detail"- rather than reading the papers and C++ specification drafts, you can use this book to learn the new Standard in an efficient and practical way.
Published at DZone with permission of Bartłomiej Filipek, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments