
There are two fundamental strategies: correctable error handling (exceptions, error return codes, handler functions) and uncorrectable (
assert()
,
abort()
). In which cases which strategy is better to use?
Types of errors
Errors occur for various reasons: the user has entered strange data, the OS cannot give you a file handler, or the code dereferences
nullptr
. Each of the described errors requires a separate approach to itself. For reasons of error, are divided into three main categories:
- User errors: here, the user is the person sitting in front of the computer and really “using” the program, and not some programmer who is tugging at your API. Such errors occur when the user does something wrong.
- System errors appear when the OS cannot complete your request. In other words, the cause of system errors is the failure of the system API call. Some arise because the programmer passed bad parameters to the system call, so this is a programmer error rather than a system error.
- Programmer errors occur when the programmer does not take into account the prerequisites of the API or programming language. If the API requires you not to call
foo()
with 0
as the first parameter, and you did it, the programmer is to blame. If the user entered 0
, which was passed to foo()
, and the programmer did not write the input data check, then this is also his fault.
Each of the described categories of errors requires a special approach to their processing.
')
User errors
I will make a very loud statement: such errors are not really errors.
All users do not comply with the instructions. A programmer dealing with data entered by people should expect that it is bad data that will be entered. Therefore, the first thing you need to check them for validity, to inform the user about the detected errors and ask to enter again.
Therefore, it does not make sense to apply any processing strategies to user errors. Input data should be checked as soon as possible so that errors do not occur.
Of course, this is not always possible. Sometimes it is too expensive to check the input data, sometimes it does not allow the code architecture or the division of responsibility. But in such cases, errors should be treated unambiguously as correctable. Otherwise, for example, your office program will fall due to the fact that you have pressed the backspace in a blank document, or your game will begin to crash when you try to shoot from a discharged weapon.
If you prefer exceptions as a strategy for handling correctable errors, then be careful: exceptions are only for
exceptions , which do not include most of the cases when users enter incorrect data. In fact, it is even the norm, according to many applications. Use exceptions only when user errors are detected in the depth of the call stack, probably of an external code, when they occur rarely or appear very hard. Otherwise, it is better to report errors using return codes.
System errors
Normally, system errors cannot be predicted. Moreover, they are non-deterministic and can occur in programs that have previously worked without complaints. Unlike user errors, which depend solely on the input data, system errors are real errors.
But how to handle them as fixable or uncorrectable?
It depends on the circumstances.
Many people think that a low memory error is unrecoverable. Often there is not enough memory even to handle this error! And then you just have to immediately interrupt the execution.
But the crash of the program due to the fact that the OS cannot allocate a socket is not very friendly behavior. So it is better to throw an exception and let
catch
gently close the program.
But throwing an exception is not always the right choice.
Someone will even say that it is
always wrong.
If you want to repeat the operation after it fails, then wrapping a function in a
try-catch
in a loop is a
slow solution. The correct choice is to return the error code and loop through until the correct value is returned.
If you create an API call only for yourself, then simply choose the appropriate path for your situation and follow it. But if you are writing a library, you do not know what the users want. Next we analyze the appropriate strategy for this case. For potentially unrecoverable errors, an “error handler” is suitable, and for other errors, you must provide two options for the development of events.
Please note that you should not use confirmations (assertions) that turn on only in debug mode. After all, system errors can occur in the release assembly!
Programmer bugs
This is the worst kind of mistakes. To handle them, I try to make my mistakes only related to function calls, that is, with bad parameters. Other types of programmer errors can only be caught in runtime using debugging macros (assertion macros) scattered around the code.
When working with bad parameters there are two strategies: to give them certain or indefinite behavior.
If the initial requirement for the function is to prohibit the transfer of bad parameters to it, then if they are transferred, this is considered to be undefined behavior and should be checked not by the function itself, but by the caller. The function should only do debug assertion.
On the other hand, if the absence of bad parameters is not part of the initial requirements, and the documentation determines that the function will throw a
bad_parameter_exception
when passing a bad parameter to it, then the transfer is a well-defined behavior (throwing an exception or any other
correctable error handling strategy) and function should always check it out.
As an example, consider the receiving functions (accessor functions)
std::vector<T>
: the specification for
operator[]
says that the index must be within the valid range, and
at()
tells us that the function will throw an exception if the index does not fall into the range. Moreover, most implementations of standard libraries provide a debug mode in which the
operator[]
index is checked, but technically this is an undefined behavior; it is not required to be checked.
Note: it is not necessary to throw an exception to get a certain behavior. As long as it is not mentioned in the initial conditions for the function, it is considered definite. Everything that is written in the initial conditions should not be checked by a function, this is an undefined behavior.
When it is necessary to check only with the help of debugging confirmations, and when - constantly?
Unfortunately, there is no definite recipe, the decision depends on the specific situation. I have only one proven rule that I follow when developing an API. It is based on the observation that the caller, not the callee, should check the initial conditions. So, the condition must be “verifiable” for the caller. Also, the condition "verifiable", if you can easily perform an operation in which the parameter value will always be correct. If this is possible for a parameter, then the initial condition is obtained, which means that it is checked only by means of debug confirmation (and if it is too expensive, then it is not checked at all).
But the final decision depends on many other factors, so it’s very difficult to give some general advice. By default, I try to reduce to indefinite behavior and using only confirmations. Sometimes it is advisable to provide both options, as the standard library does with
operator[]
and
at()
.
Although in some cases this may be a mistake.
About std::exception
hierarchy
If you chose exceptions as the strategy for handling correctable errors, it is recommended to create a new class and inherit it from one of the standard library exception classes.
I propose to inherit from only one of these four classes:
std::bad_alloc
: for memory allocation failures.std::runtime_error
: for common runtime errors.std::system_error
(derived from std::runtime_error
): for system errors with error codes.std::logic_error
: for programmer errors with a specific behavior.
Note that the standard library separates logical (that is, programmer) and runtime errors. Runtime errors are a broader definition than “system” ones. It describes "errors that are detected only during program execution." This wording is not very informative. Personally, I use it for bad parameters, which are not exclusively programmer errors, but may arise from users. But this can only be determined deep in the call stack. For example, poor formatting of comments in
standardese results in an exception during parsing, resulting from
std::runtime_error
. Later it is caught at the appropriate level and recorded in the log. But I would not use this class other than
std::logic_error
.
Let's sum up
There are two ways to handle errors:
- as correctable : exceptions or return values ​​are used (depending on the situation / religion);
- as unrecoverable : errors are logged and the program is terminated.
Confirmations are a special kind of strategy for handling uncorrectable errors, only in debug mode.
There are three main sources of error, each requires a special approach:
- User errors should not be treated as errors at the top levels of the program. Everything a user enters must be checked accordingly. This can be treated as errors only at lower levels, which do not interact with users directly. Corrective error handling strategy is applied.
- System errors can be handled as part of either of the two strategies, depending on the type and severity. Libraries should work as flexibly as possible.
- Programmer errors , i.e. bad parameters, can be prohibited by initial conditions. In this case, the function should only use verification with debug confirmations. If we are talking about a fully defined behavior, then the function should be reported in an prescribed way about the error. I try to follow the default scenario with undefined behavior and define the function to check the parameters only when it is too difficult to do on the side of the caller.
Flexible error handling techniques in C ++
Sometimes something does not work. Users enter data in an invalid format, the file is not detected, the network connection fails, the system runs out of memory. All these are errors, and they need to be processed.
This is relatively easy to do in high-level functions. You know exactly
why something went wrong, and you can handle it accordingly. But in the case of low-level functions, things are not so simple. They do not know
what went wrong, they know only about the
fact of the failure and must report it to the one who caused them.
In C ++, there are two main approaches: error return codes and exceptions. Today, the use of exceptions is widespread. But some cannot / think that they cannot / do not want to use them - for various reasons.
I will not take sides. Instead, I will describe techniques that will satisfy the proponents of both approaches. Especially techniques useful to developers of libraries.
Problem
I am working on a
foonathan / memory project. This solution provides various classes of memory allocation (allocator classes), so as an example, consider the structure of the allocation function.
For simplicity, take
malloc()
. It returns a pointer to the allocated memory. If memory allocation fails, then
nullptr
returned, that is,
NULL
, that is, an erroneous value.
There are drawbacks to this solution: you need to check
every malloc()
call. If you forget to do this, then allocate a non-existent memory. In addition, by its nature, error codes are transitive: if you call a function that can return an error code, and you cannot ignore it or process it, then you must also return an error code.
This leads us to a situation where normal and erroneous code branches alternate. Exceptions in this case look more appropriate solution. Thanks to them, you will be able to handle errors only when you need it, and otherwise - rather quietly send them back to the caller.
This can be regarded as a disadvantage.
But in such situations, exceptions also have a very big advantage: the memory allocation function either returns valid memory or returns nothing at all. This is an “all or nothing” function, the return value will always be valid. This is a useful consequence of Scott Meier’s “
Make interfaces hard to use correctly ”
principle .
Given the above, it can be argued that you should use exceptions as an error handling mechanism. This opinion is shared by most C ++ developers, including me. But the project that I do is a library that provides memory allocators, and it is intended for real-time applications. For most developers of such applications (especially for igrodelov) the use of exceptions is the exception.
Pun Detected
To respect this development team, my library is better off without exceptions. But I and many others like them for the elegance and simplicity of error handling, so for the sake of other developers my library is better to use exceptions.
So what to do?
The ideal solution: the ability to enable and disable exceptions as desired. But, taking into account the nature of the exceptions, one cannot simply change them in places with error codes, since we will not have an internal error check code - the entire internal code is based on the assumption of the transparency of exceptions. And even if inside we could use error codes and convert them into exceptions, this would deprive us of most of the advantages of the latter.
Fortunately, I can determine what you are doing when you discover a memory shortage error: most often you log this event and interrupt the program, since it cannot work correctly without memory. In such situations, exceptions are simply a way to transfer control to another part of the code that logs and interrupts the program. But there is an old and effective way to transfer control: a function pointer (function pointer), that is, a handler function.
If you have exceptions enabled, you just throw them. Otherwise, call the handler function and then abort the program. This will prevent the use of the handler function, which will allow the program to continue to run as usual. If not interrupted, a violation of the required postcondition of the function will occur: always return a valid pointer. After all, the execution of this condition can be used to build the work of another code, and indeed this is normal behavior.
I call this approach exception handling and stick with it when working with memory.
Solution 1: Exception Handler
If you need to handle an error in an environment where the most common behavior is to “log and terminate,” you can use an exception handler. This is a handler function that is called instead of throwing an exception object. It is fairly easy to implement even in already existing code. To do this, put the processing control in the exception class and wrap the
throw
expression in the macro.
First, we supplement the class and add functions for setting up and, possibly, requesting a handler function. I suggest doing it the same way the standard library handles
std::new_handler
:
class my_fatal_error { public:
Since this is within the scope of the exception class, you do not need to name it in any special way. Great, it's easier for us.
If exceptions are included, conditional compilation can be used to remove the handler. If you want, also write the usual mixed class (mixin class), giving the required functionality.
The exception constructor is elegant: it calls the current handler function, passing it the required arguments from its parameters. And then combines with the subsequent
throw
macro:
If```cpp #if EXCEPTIONS #define THROW(Ex) throw (Ex) #else #define THROW(Ex) (Ex), std::abort() #endif
> throw [foonathan/compatiblity](https:
If you have support for exceptions, an exception object will be created and thrown, as usual. But if support is turned off, an exception object will still be created, and - this is important - only after that the call to
std::abort()
will occur. And since the constructor calls the handler function, it works as it should: you get a configuration point for error logging. Thanks to calling
std::abort()
after the constructor, the user cannot break the postcondition.
When I work with memory, with exceptions enabled, I also have a handler enabled, which is called when an exception is thrown.
So with this technique you will still have a certain degree of customization available, even if you turn off exceptions. , , , . , , .
?
. ?
— . , , . , .
: . , . , , , — .
, , .
. :
void* try_malloc(..., int &error_code) noexcept; void* malloc(...);
nullptr
error_code
.
nullptr
, . , :
void* malloc(...) { auto error_code = 0; auto res = try_malloc(..., error_code); if (!res) throw malloc_error(error_code); return res; }
, , . . , , (overload) .
, . , , . , .
2:
, . , .
, . —
nullptr
, — , .
,
errno
-
GetLastError()
!
,
std::optional
- .
(exception overload) — — , . , .
std::system_error
++ 11.
(non-portable)
std::error_code
, . ,
std::error_condition
.
. ,
std::error_code
. :
std::system_error
.
std::error_code
.
, -. — — , .
, . .
std::expected
, , , . , — .
!
â„– 4109 :
std::expected
. , . :
std::expected<void*, std::error_code> try_malloc(...);
std::expected
-null , —
std::error_code
. .
std::expected
.
Conclusion
, . : , — .
— . , , callback, . , . , . .
— , , . , , . : .