Error reporting in C++
20 August 2024
In this tutorial, we will be talking about errors that occur during the execution of a program and how to report them in C++. We won’t be talking much about program logic errors, but rather “environmental” errors, such as file not found, out of memory, no network etc. Reporting such errors becomes “trickier” when they take place in a function that normally returns a value. What should it return instead?
So, what is the big deal with error reporting?
Recovering from errors is not always possible.
Modern programming languages provide a variety of ways
to convey that something went wrong and effective error reporting increases the correctness of software
and makes it more maintainable.
The goals of error reporting are simple:
- Make correct code easy to write, and incorrect code hard to write.
- Fail quickly if you must, report unignorable and unambiguous errors.
Before we examine these concepts in more detail, this tutorial also exists:
- As a FREE course on Udemy which includes interactive exercises for each error reporting technique.
- As a Youtube playlist where each error reporting technique is explained in a video.
Table of contents
- Unignorable errors 👾
- Fail quickly: Exceptions 💥
- An “empty” value 🪹
- An “invalid” value 🪑
std::optional
🐱bool
🤔- Error codes 🚦
std::pair<bool, std::string>
🔗std::pair<ErrorCode, std::string>
🔗- Custom classes with
bool
and the payload 🔗 - Custom class with
error_code
and the payload 🔗 std::variant
(type-safe union) ⚖️std::expected
🎁- Conclusion 🎉
Unignorable errors 👾
If an error has occurred, then ignoring it is typically a bad idea. What should we do about it? Are we in a valid state?
Should we escalate the error to the caller or a user interface?
From a language syntax perspective, C++17 introduced the [[nodiscard]]
attribute.
nodiscard
induces a compilation error if the return value of a function is ignored.
You should absolutely use [[nodiscard]]
for functions that may
communicate an error state through their return value.
That being said, it is not a silver bullet as we will see soon.
In fact, in all of the examples we will be looking at in this tutorial,
we will assume that [[nodiscard]]
is used.
I have not included nodiscard
in the code snippets to make them shorter and easier to read.
To demonstrate the different error reporting techniques, we will be looking at a relatively simple function that reads a file and returns its contents:
std::string readFile(std::filesystem::path p);
Based on this description, what can go wrong when reading a file?
Well, even in such a simple case, there are multiple things that can go wrong:
- The file may not exist or we may not have the permissions to read it.
- The file could be locked by another process.
- The file could be too big for our data structures or memory, etc.
So how do we communicate these errors?
How do we communicate these errors OR the contents of the file?
How do we stop the caller from accessing the contents of the file if an error has occurred?
In the rest of this tutorial we will be looking at different ways to report errors while addressing these questions.
We will evaluate each technique based on its simplicity, expressiveness and robustness.
Fail quickly: Exceptions 💥
Exceptions allow us to immediately exit a scope and unwind the stack until someone catches the exception. This allows us to keep the API very simple and focus on the “happy path” of the function. If something goes wrong when reading a file, an exception is thrown and we will not return the contents of the file.
struct FileNotFoundException : public std::runtime_error {
FileNotFoundException(const std::string& msg) : std::runtime_error{msg} {}
};
std::string readFile(std::filesystem::path p) {
if (noFile) throw FileNotFoundException{"File does not exist"};
}
On the other hand, handling exceptions may be error prone.
This is partly because exceptions are not part of the function signature.
In other words, unless very clearly documented,
we cannot know which exact exceptions a function may throw,
just by looking at its signature.
To make things even trickier,
exceptions may be thrown by functions that the function we are calling invokes.
This means that if we want to be granular about the exceptions we want to handle,
we need to potentially catch a large number of them.
That being said, exceptions aside,
you should be using patterns like RAII to make sure that resources are always released properly.
Exceptions may also come with a performance penalty if they are thrown frequently.
It’s not uncommon for C++ code to be compiled with exceptions disabled.
Let’s look at an example of how we can use exceptions to report errors.
We can see that readFile
throws an FileNotFoundException
if an error is encountered.
Then, in the caller, we use a try-catch
block to handle the exception.
std::string readFile(std::filesystem::path p) {
if (noFile) throw FileNotFoundException{"File does not exist"};
}
If an exception is thrown, the content
variable is never returned and we end up in the catch block.
As a result, in the try block we can focus on the happy path and not worry about error handling.
Whenever we access the content
we know it is valid.
try {
const auto content = readFile("file.txt");
// Use content
} catch (const FileNotFoundException& e) {
// Handle the error
}
Let’s look at another example. Exceptions may make sense when you have deep call stacks and you want to avoid checking for unrecoverable errors in multiple places and then only propagating them up the stack.
error_t do_subtask() {
if (auto error = do_subsubtask()) { return error; }
// More code
}
error_t do_task() {
if (auto error = do_subtask()) { return error; }
// More code
}
int main() { return do_task(); }
Your code becomes unnecessarily complex. If you can’t do anything about an error, then do consider throwing an exception instead of bloating your code by propagating errors, just to exit “gracefully”.
There are many best practices when it comes to exceptions in the C++ core guidelines:
- E.1: Develop an error-handling strategy early in a design
- E.2: Throw an exception to signal a function can’t perform its task
- E.3: Use exceptions for error handling only
- E.14: Use purpose-designed user-defined types as exceptions
- E.17: Don’t try to catch every exception in every function
- E.18: Minimize the use of explicit try/catch
- E.28: Avoid error handling based on global state (e.g. errno)
- Modern C++ best practices for exceptions and error handling
In my opinion, there are three things to keep in mind:
- First, develop a strategy when it comes to error-handling and exceptions early in the project. One of the worst things you can do is to mix techniques and make it unclear when exceptions may be thrown.
- Second, use exceptions to report exceptional errors. An exception should signal that something “horrible” and unexpected has happened. It should not be used to report errors that are somewhat “common”. For example, exceptions may be thrown for a constructor not being able to construct an object, due to failure to allocate memory. Another example could be a function that tries to get an out-of-bounds element from a container. In these cases you’d want the exception to be thrown and this error to be “escalated”. Throwing exceptions for exceptional errors also means that you should not be handling exceptions in most places in your code either. Catch exceptions only when and if you can do something about them.
- Third, create your own exception types. They are more readable and allow your exceptions to not be confused with others.
So, what happens if we may not or cannot throw exceptions? A value needs to be returned. But what should it be to make sure that wrong code is hard to write? What should the type be to make the error unignorable and unambiguous?
An “empty” value 🪹
This strategy is rather simple, if an error occurs, we return an empty or a default value. If no error occurs, we return the actual value.
std::string readFile(std::filesystem::path p) {
if (error) return "";
}
The advantage of this approach is that it is very simple to implement. Nothing needs to change in the API to support this error reporting technique. On the other hand, there are two issues with it:
- First, there’s no way to distinguish between different errors. If more than one kind of error can occur, there’s no way to represent them.
- Second, the default or empty value could actually be a valid value. For example, if we are reading a file, a file could be empty. In that case there would not be a way to tell apart an empty file from a missing file or a file that we do not have permissions to read.
std::string readFile(std::filesystem::path p) {
// ...
if (error) return "";
}
Handling errors using empty values is very simple. We check whether the returned value is empty and if so, we handle the error. As long as there’s only one kind of error and we can guarantee that a default value is not a valid value for the payload, this approach works fine. Otherwise, we need to look into other techniques.
const auto content = readFile("file.txt");
if (content.empty()) {
// Some kind of error or... an empty file
}
An “invalid” value 🪑
In this occasion, if an error occurs, we return an invalid value. If everything goes well, we return the actual value.
std::string readFile(std::filesystem::path p) {
if (noFile) return "MissingFile";
}
Invalid values are values that you would not expect to be returned by the function.
For example, if we are measuring a distance, a negative value could be an invalid value.
If we are measuring the temperature of a room, a value of 1000 degrees would hopefully be an invalid value.
In the case of reading a file, if the file is missing we are returning a string with a MissingFile
value.
This approach is rather similar to the previous one. To begin with, it’s very simple to implement and understand. The API does not need to change. Then, unlike the previous approach, we can distinguish between different errors.
On the other hand, it shares a disadvantage with the previous approach:
What if MissingFile
is actually the contents of the file?
As long as we can guarantee
that the values we return, when errors occur, can never be valid,
then this approach could work.
Another issue with this approach is that
since the same type is used for both the contents and the errors,
we leave room for programming mistakes.
For example, there’s nothing stopping a typo in the equality comparison,
resulting in MissingFile
being treated as a valid value.
This can be quite a nasty bug to spot.
Here’s an example of how we can use invalid values to report errors. When no file is found, we return a string with the value “MissingFile”. Then, in the caller, we check if the returned value is equal to “MissingFile” and if so, we handle the error. The approach is very simple and as we discussed, allows us to distinguish between different errors.
std::string readFile(std::filesystem::path p) {
if (noFile) return "MissingFile";
}
However it is error prone
since the same type is used for both the valid values and the errors.
If we are not careful,
we could end up treating an error as a valid value.
For example,
if we skipped an “s” in the right-hand side of the first comparison,
we would end up treating MissingFile
as a valid value
and be in trouble.
const auto content = readFile("file.txt");
if (content == "MissingFile") {
// Handle the error
} else {
// Use content
}
std::optional
🐱
Let’s look at std::optional
as the means to report errors.
std::optional
is a type that was introduced in C++17.
It is a type that can either hold a value or not.
If we try to access the value of an optional that does not hold a value, an exception is thrown.
For example, an optional int can either hold an integer or not.
An optional string can either be a string or “nothing”.
Here’s the cool part: An optional string may hold an empty string.
This allows us to distinguish between an empty string and a missing string.
In our readFile
example, we return an empty optional if an error occurs,
otherwise we return the contents of the file.
std::optional<std::string> readFile(std::filesystem::path p) {
if (error) { return std::nullopt; }
}
The main advantage of this approach is while it is still rather simple,
it protects the caller from accidentally accessing the payload
if an error has occurred.
In other words, if the caller tries to access the contents of the file
without checking for an error first and an error has occurred,
an exception will be thrown. Wrong code becomes harder to get away with.
Furthermore, we can now distinguish between an empty file and an error,
which was an issue in one of the previous approaches.
On the other hand, we cannot distinguish between different errors.
We can only express whether an error has occurred or not, but not what kind of error.
This may be OK, if we do not care about the exact error
or if there’s only one kind of error.
Here’s a more elaborate example of how we can use std::optional
to report errors.
std::optional<std::string> readFile(std::filesystem::path p) {
// ...
if (error) { return std::nullopt };
else { return "<your file contents>" };
}
The most interesting part is
how a user of the readFile
function can access the contents of the file.
First, they need to check if the optional holds a value.
Then, if it does, they may access the value using the value()
function.
The use of std::optional
“forces” the caller to check for an error
before accessing the contents of the file.
If the caller tries to access the contents of the file
when an error has occurred, an exception will be thrown.
Remember, the goal is to make wrong code hard to get away with
and std::optional
helps us achieve just that.
const auto content = readFile("file.txt");
if (content) {
// Use content.value()
} else {
// Handle the error, but what kind of error?
// content.value() will throw
}
bool
🤔
In this part we will be looking at using booleans to report errors.
A boolean is a built-in type that can either be true
or false
.
It is very often used to determine whether something has succeeded or not.
In this example we return true
if the file was read successfully and false
otherwise.
bool readFile(std::filesystem::path p, std::string& content) {
if (error) return false;
}
The main advantage of this approach is that it is very simple to implement and understand.
It’s also supported in all C++ standards.
The drawback is that we cannot distinguish between different errors.
This of course may be OK under certain circumstances, if for example there’s only one kind of error.
Moreover, we need to pass the contents of the file as an out parameter.
As a result, the caller cannot handle it as a constant.
To make matters worse, there’s nothing stopping the caller from accidentally accessing the contents of the file
if an error has occurred. The content variable is not “protected” in any way.
Here’s a more complete example of the use case, as well as the issues that arise.
bool readFile(std::filesystem::path p, std::string& content) {
// ...
if (error) return false;
}
content
needs to be passed as a parameter and it cannot be a constant.
At the same time,
it can be accessed even if an error has occurred, which may lead to bugs.
Finally, we cannot distinguish between different errors.
std::string content;
if (readFile("file.txt", content)) {
// Use content, but not const :(
} else {
// Handle the error, but what kind of error?
// May still use content by mistake
}
Error codes 🚦
Next, let’s review using error codes to report errors. Error codes are typically integers with values that represent different errors. You will find them frequently used in C and C++ code.
enum class ReadFileError { NoError, NoFile };
ReadFileError readFile(std::filesystem::path p, std::string& content) {
if (noFile) return ReadFileError::NoFile;
}
They are readily available,
very flexible and can be even used to communicate with different processes,
languages and systems.
You can use them to distinguish between different errors
and they can even be combined with bitwise operations.
On the other hand,
whatever “content” your function returns.
needs to be passed as a parameter.
This is not very neat and you cannot handle the payload as a constant.
Moreover, there’s nothing stopping the caller
from accidentally accessing the contents of the file
even if an error has occurred.
In this example we can see both the usage of error codes
as well as the issues that arise.
The readFile
function returns the NoFile
error code
if the file is missing.
enum class ReadFileError { NoError, NoFile };
ReadFileError readFile(std::filesystem::path p, std::string& content) {
if (noFile) return ReadFileError::NoFile;
}
Then, on the caller side, we need to pass the contents of the file as a parameter and use a switch statement to handle the different error codes. As we discussed, the content variable is not “protected” in any way, and can be accessed even if an error has occurred. It is also not a constant, even if its value will probably not change.
std::string content;
const auto error = readFile("file.txt", content);
switch (error) {
case ReadFileError::NoError:
// Use content
break;
case ReadFileError::NoFile:
// Handle the error
break;
}
std::pair<bool, std::string>
🔗
Let’s discuss about returning a pair, of a boolean value and the contents of the file. The boolean value indicates whether an error has occurred or not.
std::pair<bool, std::string> readFile(std::filesystem::path p) {
if (error) return {false, ""};
}
This is similar to a previous approach
where we used a boolean to indicate the error.
The difference is that we now
also return the contents of the file as part of the return value.
As a result,
we do not need to pass them as a parameter
and we can handle the payload as a constant.
On the other hand, the other drawbacks remain.
We cannot distinguish between different errors
and there’s nothing stopping the caller
from erroneously accessing the contents of the file.
Here’s an example of how to use pairs.
If there’s an error, we return a pair with false
as the first element.
The second element should not matter and we can return an empty string.
std::pair<bool, std::string> readFile(std::filesystem::path p) {
// ...
if (error) return {false, ""};
}
On the caller side,
we check if the first element of the pair is false
and if so,
handle the error.
As you can see, nothing stops us from accidentally accessing the contents of the file
even if an error has occurred.
const auto [success, content] = readFile("file.txt");
if (success) {
// Use content
} else {
// Handle the error, but what kind of error?
// May still use content by mistake
}
std::pair<ErrorCode, std::string>
🔗
In this section we will discuss about returning a pair of an error code and the contents of the file as the means to report errors.
std::pair<ReadFileError, std::string> readFile(std::filesystem::path p) {
if (noFile) return {ReadFileError::NoFile, ""};
}
By following this approach,
we can both distinguish between different errors
and the caller can handle the contents of the file as a constant.
On the downside, the caller can still access the contents of the file
even if an error has occurred.
This is how we can use this technique.
If there’s an error, we return a pair with the error code as the first element.
If there’s no error, we return the “no error” code
and the contents of the file as the second element.
std::pair<ReadFileError, std::string> readFile(std::filesystem::path p) {
// ...
if (noFile) return {ReadFileError::NoFile, ""};
}
The caller may use a switch
statement
to handle the different error codes.
As we can see, the caller may still access the contents by mistake,
even if an error has occurred.
const auto [error, content] = readFile("file.txt");
switch (error) {
case ReadFileError::NoError:
// Use content
break;
case ReadFileError::NoFile:
// Handle the error
break;
}
Custom classes with bool
and the payload 🔗
Let’s take a quick look at an alternative to the pair approach.
In this case, we will be using a custom class that holds a boolean value and the contents of the file.
ReadFileResult
has two data members, a boolean value and a string.
The boolean value indicates whether an error has occurred or not.
struct ReadFileResult {
bool success{};
std::string content{};
};
ReadFileResult readFile(std::filesystem::path p) {
if (error) return {false, ""};
}
This approach is very similar to the pair we discussed earlier,
with the added benefit of being more expressive.
Instead of referring to the first and second element of the pair,
we can refer to the success
and content
data members of the ReadFileResult
class.
The drawbacks are the same,
namely we cannot distinguish between different errors
and there’s nothing stopping the caller
from erroneously accessing the contents of the file.
Here’s an example of how to use custom classes to report errors.
It looks very similar to the pair approach
but now we can refer to the success
and content
data members.
struct ReadFileResult {
bool success{};
std::string content{};
};
ReadFileResult readFile(std::filesystem::path p) {
if (error) return {false, ""};
}
Fun fact, if the class you return has two data members, you can use structured bindings to access them just like you would with a pair. So from the caller’s perspective, getting a pair or a class with two public data members can be treated exactly the same as of C++17.
const auto result = readFile("file.txt"); // Or use structured bindings
if (result.success) {
// Use result.content
} else {
// Handle the error, but what kind of error?
// May still use result.content by mistake
}
Custom class with error_code
and the payload 🔗
Do you know what’s missing? Yet another alternative to the pair approach. I promise this is the last one. This time we are returning a custom class that holds an error code and the contents of the file.
struct ReadFileResult {
ReadFileError error{};
std::string content{};
};
ReadFileResult readFile(std::filesystem::path p) {
if (noFile) return {ReadFileError::FileDoesNotExist, ""};
}
Compared to the equivalent pair approach, this is more expressive. Other than that, it shares the same advantages and drawbacks.
struct ReadFileResult {
ReadFileError error{};
std::string content{};
};
ReadFileResult readFile(std::filesystem::path p) {
if (noFile) return {FileError::FileDoesNotExist, ""};
}
We can now distinguish between different errors and the caller can handle the contents of the file as a constant. However, there’s still room for misuse as the caller can access the contents of the file even if an error has occurred.
const auto result = readFile("file.txt");
if (result.error == ReadFileError::NoError) {
// Use result.content
} else {
// May still use result.content by mistake
}
std::variant
(type-safe union) ⚖️
Enough fooling around, let’s get into… the more interesting stuff.
std::variant
is a C++17 type-safe union.
What does that mean?
It means that it may be one and only one of the types it holds.
For example, if there’s a variant of a string and an error code,
then the variant may hold either a string OR an error code.
We will explain more about variants soon,
but first let’s take a look at how we can use them to report errors.
std::variant<std::string, ReadFileResult> readFile(std::filesystem::path p) {
if (noFile) return ReadFileResult::FileDoesNotExist;
}
By returning a variant of an error code and the contents of the file,
we can now both distinguish between different errors
AND the contents of the file are now protected against accidental access.
This is because the result can either be an error or the contents of the file.
If the caller tries to get the contents of the file when an error has occurred,
an exception will be thrown.
The main drawback of this approach is
that the usage of variants may seem “exotic” to some,
but as soon as you get used to them, they are very powerful and effective.
Let’s go over the basic usage of variants. Assume a variant that can store an integer, a string or a double. First, we initialize it with an integer. Then when we ask to get an integer from it and get the corresponding value. Instead of the type, we may also provide the index of the type we want to get.
std::variant<int, std::string, double> var{10};
std::cout << std::get<int>(var) << "\n"; // 10
std::cout << std::get<0>(var) << "\n"; // 10
var = "Hello";
std::cout << std::get<std::string>(var) << "\n"; // Hello
std::cout << std::holds_alternative<std::string>(var) << "\n"; // true
std::cout << std::holds_alternative<int>(var) << "\n"; // false
std::cout << std::get<double>(var) << "\n"; // 💥 std::bad_variant_access
Similarly, if we set the variant to be a string
and ask to get a string,
we will now get the value of the string we set.
Calling holds_alternative
with a type, or an index,
returns whether the variant holds the specific type or not.
Finally, if we try to get a type
that the variant does not hold, an exception will be thrown.
Now, let’s look further into how we can use variants to report errors.
From the perspective of readFile
things look pretty simple.
If there’s an error, we return the error code.
If there’s no error, we return the contents of the file.
std::variant<std::string, ReadFileResult> readFile(std::filesystem::path p) {
if (noFile) {
return ReadFileResult::FileDoesNotExist;
}
return "(╯°□°)╯︵ ┻━┻"; // File contents
}
On the caller side, we can use holds_alternative
to check if the variant contains an error or not.
If it is an error, we go ahead and handle the error.
Remember that if we try to access the contents of the file
when an error has occurred, an exception will be thrown.
Then, if the variant does indeed hold the contents of the file,
we can access them using get
.
const auto result = readFile("file.txt");
if (std::holds_alternative<std::string>(result)) {
const auto fileContents = std::get<std::string>(result);
// Use fileContents
}
else {
const auto error = std::get<ReadFileResult>(result);
// Handle error
}
std::expected
🎁
Now let’s get serious and take a stub at std::expected
,
a type that was added to the standard library as of C++23.
std::expected
is essentially a specialization of a std::variant
.
Unlike a variant which can hold many types, std::expected
only holds two.
The first type is the “expected” type,
in other words the type we expect to get if no error has occurred.
The second type is the “unexpected” type.
We get this type if something went wrong.
std::expected<std::string, ReadFileResult> readFile(std::filesystem::path p) {
if (noFile) return std::unexpected(ReadFileResult::FileDoesNotExist);
}
std::expected
attempts to address the readability issue of variants,
since it’s specifically designed to be used for error reporting.
We can both distinguish between different errors
and the contents of the file are protected against accidental access.
It also has some cool functional features
that can make it a very powerful tool.
On the downside, as of the beginning of 2024,
the compiler support for std::expected
is rather poor.
std::expected
, as a specialization of a variant,
exhibits a similar behavior but with some sprinkles on top.
Here we have an std::expected
where the expected type is an integer
and the unexpected type is a string.
If we set the value of 10
to it,
then we have set the expected value.
This means that has_value
will return true
and value
will return the value we set.
Instead of has_value
,
we may also use it directly in an expression as a boolean.
Moreover, we can dereference it to get the value.
Next, if we set an unexpected value to it,
then has_value
will return false
.
Note how we use the unexpected
utility class to set the unexpected value.
To get the unexpected value,
we can use the error()
member function.
Trying to access the expected value,
when an error has been set,
results in an exception being thrown.
std::expected<int /* expected_t */, std::string /* error_t */> result{};
result = 10;
std::cout << result.has_value() << "\n";// true
std::cout << result.value() << "\n"; // 10
std::cout << *result << "\n"; // 10
std::cout << result.error() << "\n"; // Undefined behavior
result = std::unexpected{"(ง ͠° ͟ل͜ ͡°)ง"};
std::cout << result.has_value() << "\n";// false
std::cout << result.error() << "\n"; // (ง ͠° ͟ل͜ ͡°)ง
std::cout << *result << "\n"; // Undefined behavior
std::cout << result.value() << "\n"; // 💥 std::bad_expected_access
As we can see, std::expected
has some “protection” mechanisms against accidental access.
Let’s list them:
- First, accessing the expected value via
value
when an error has occurred, results in an exception being thrown. - Accessing the unexpected value via
error
when no error has occurred, results in undefined behavior. - Dereferencing, when an error has occurred, also results in undefined behavior.
std::expected
has some cool functional features. Let’s go over them.
Aside of the value_or
function that returns the expected value
or some other “default” value,
we get some “monadic” operations.
First, we have and_then
.
and_then
is invoked
if the expected value is currently set.
It supplies the expected value, as an argument,
to the provided function
and returns the result of the function.
If the expected value is not set,
and_then
does nothing
and returns the unexpected value.
On the other hand, or_else
is invoked
if the expected value is not set.
The unexpected value is supplied as an argument
to the provided function
and returns the result of the function.
Similarly, if the expected value is set,
or_else
does nothing and returns the expected value.
We may chain these functions together to create a “pipeline” of operations.
std::expected<Tokens, Error> tryParse(std::string text) { /* ... */ }
std::expected<Tokens, Error> handleErrorAndParse(Error error) { /* ... */ }
std::expected<Token, Error> getFirstToken(Tokens token) { /* ... */ }
auto /* std:: expected<Token, Error> */ token
= tryParse(text)
.or_else(handleErrorAndParse)
.and_then(getFirstToken);
std::cout << token.value() << std::endl;
Alright, enough theory, let’s go back to our example.
As far as readFile
is concerned, things look pretty simple.
If there’s an error, we return the error code as an unexpected value.
If everything went well, we return the contents of the file.
std::expected<std::string, ReadFileResult> readFile(std::string) {
if (noFile) {
return std::unexpected{ReadFileResult::FileDoesNotExist};
}
return "◉_◉";
}
On the caller side,
we can use has_value
to check if the expected value is set or not.
Then, we can use value
to access the contents of the file.
In case the result
contains an unexpected value,
we end up on the else
branch of the if
statement.
There we can fetch the error code, using error
,
and handle the error.
const auto result = readFile("file.txt");
if (result) { // or result.has_value()
const auto fileContents = result.value();
// Use fileContents (std::string)
}
else {
const auto error = result.error();
// Handle error (ReadFileResult)
}
Conclusion 🎉
When it comes to error reporting, we have many options.
While exceptions are a choice, yes,
they should not necessarily be your first one.
Reserve them for exceptional errors.
Things you truly do not expect to happen under typical circumstances.
Things that need to be escalated.
If you do use exceptions,
make sure you only catch them when you can do something about them.
One issue with exceptions is that they can make your code harder to follow.
They are also not part of the function signature
and may come with a performance penalty if they are thrown frequently.
Next, depending on whether the function you are creating
returns some kind of value or not, you have different options.
The goal,
is to avoid the caller from accidentally accessing the contents of the file
when an error has occurred and that going unnoticed.
By the way, I am not suggesting you overengineer things either.
If your function does not return anything special
and you are only interested in whether an error has occurred or not,
then you can use a boolean to indicate that.
If you have multiple kinds of errors, then you can use an error code.
On the other hand, if you want to return some kind of “payload”,
let’s say the contents of a file,
then you can use an optional value
if you only have one kind of error.
If you have multiple kinds of errors, then use expected.
To summarize, if there’s no payload, return a boolean or an error code.
If there’s a payload, use an std::optional
or an std::expected
.
If std::expected
is not available,
then you could use a std::variant
instead
or implement your own expected-like class.
- Use exceptions for… exceptional situations
- Think twice before avoiding them
- Don’t overuse them, can be verbose, keep things simple
- Function with no “payload” to return:
- Return
bool
if function can only fail or succeed - Return error code if function can fail in multiple ways
- Return
- Function with “payload” to return:
- Return
std::optional<Payload>
if it can fail or succeed - Return
std::variant<Payload, Code>
if it can fail in multiple ways
- Return
- If
std::expected
not available, usestd::variant
- Or make your own
expected
class
- Or make your own
- Andrei Alexandrescu: “Expect the expected”
- C++ API Design - Jason Turner - CppCon 2022