Deterministic exceptions and error handling in a “C ++ future”
It is strange that on Habré until now it was not mentioned about the proposal to the C ++ standard called "Zero-overhead deterministic exceptions". I correct this annoying omission.
If you are worried about the overhead of exceptions, or you had to compile code without exception support, or just wondering what will happen with error handling in C ++ 2b (referring to a recent post ), I ask for cat. You are waiting for a squeeze out of everything that now can be found on the topic, and a couple of polls.
The conversation will continue to be conducted not only about static exceptions, but also about related proposals to the standard, and about any other ways of handling errors. If you came here to look at the syntax, then here it is:
doublesafe_divide(int x, int y)throws(arithmetic_error){
if (y == 0) {
throw arithmetic_error::divide_by_zero;
} else {
return as_double(x) / y;
}
}
voidcaller()noexcept{
try {
cout << safe_divide(5, 2);
} catch (arithmetic_error e) {
cout << e;
}
}
If the specific type of error is unimportant / unknown, then you can simply use throws
and catch (std::error e)
.
Good to know
std::optional
and std::expected
Suppose we decided that an error that could potentially arise in a function is not “fatal” enough to throw an exception out of it. Traditionally, error information is returned using the out parameter. For example, Filesystem TS offers a number of similar functions:
uintmax_t file_size(const path& p, error_code& ec);
(Do not throw an exception due to the fact that the file was not found?) However, the handling of error codes is cumbersome and prone to bugs. Error code is easy to forget to check. Modern code styles prohibit the use of output parameters; instead, it is recommended to return a structure containing the entire result.
Boost has been offering an elegant solution for some time to handle such “non-fatal” errors, which in certain scenarios can occur in the hundreds in the correct program:
expected<uintmax_t, error_code> file_size(const path& p);
The type expected
is similar to variant
, but provides a convenient interface for working with "result" and "error." By default, the expected
“result” is stored. The implementation file_size
might look something like this:
file_info* info = read_file_info(p);
if (info != null) {
uintmax_t size = info->size;
return size; // <==
} else {
error_code error = get_error();
returnstd::unexpected(error); // <==
}
If we are not interested in the cause of the error, or the error can only consist in the “absence” of the result, then you can use optional
:
optional<int> parse_int(conststd::string& s);
optional<U> get_or_null(map<T, U> m, const T& key);
In C ++ 17 from Boost, std got optional (without support optional<T&>
); in C ++ 20, it will probably add the expected (this is only Proposal, thanks to RamzesXI for the amendment).
Contracts
Contracts (not to be confused with concepts) - a new way to impose restrictions on the parameters of the function, added in C ++ 20. Added 3 annotations:
- expects checking function parameters
- ensures ensures that the return value of the function is checked (takes it as an argument)
- assert - a civilized replacement for the macro assert
doubleunsafe_at(vector<T> v, size_t i) [[expects: i < v.size()]];
doublesqrt(double x) [[expects: x >= 0]] [[ensures ret: ret >= 0]];
value fetch_single(key e){
vector<value> result = fetch(vector<key>{e});
[[assert result.size() == 1]];
return v[0];
}
You can configure breach of contract:
- Called Undefined Behaviour, or
- A custom handler was checked and called, after which
std::terminate
It is impossible to continue the work of the program after a breach of contract, because compilers use guarantees from contracts to optimize the function code. If there is the slightest doubt that the contract will be fulfilled, it is worth adding an additional check.
std :: error_code
The library <system_error>
added in C ++ 11 allows you to unify the handling of error codes in your program. std :: error_code consists of a type error code int
and a pointer to an object of some class derived from std :: error_category . This object, in fact, plays the role of a table of virtual functions and determines the behavior of this std::error_code
.
To create your own std::error_code
, you must define your own heir class std::error_category
and implement virtual methods, the most important of which is:
virtualstd::stringmessage(int c)const= 0;
You also need to create your global variable std::error_category
. Error handling with error_code + expected looks like this:
template <typename T>
using result = expected<T, std::error_code>;
my::file_handle open_internal(conststd::string& name, int& error);
autoopen_file(conststd::string& name) -> result<my::file>
{
int raw_error = 0;
my::file_handle maybe_result = open_internal(name, &raw_error);
std::error_code error{raw_error, my::filesystem_error};
if (error) {
return unexpected{error};
} else {
return my::file{maybe_result};
}
}
It is important that a std::error_code
value of 0 means no error. If this is not the case for your error codes, then before converting the system error code to std::error_code
, you need to replace code 0 with SUCCESS code, and vice versa.
All system error codes are described in errc and system_category . If at a certain stage the manual forwarding of error codes becomes too dreary, then you can always wrap the error code in the exception std::system_error
and throw it away.
Destructive move / Trivially relocatable
Suppose you need to create another class of objects that own any resources. Most likely, you will want to make it uncopyable, but moveable, because it is inconvenient to work with unmoveable objects (they could not be returned from a function before C ++ 17).
But here's the problem: in any case, the moved object must be deleted. Therefore, a special "moved-from" state, that is, an "empty" object that does not remove anything, is necessary. It turns out that every C ++ class must have an empty state, that is, it is impossible to create a class with an invariant (guarantee) of correctness, from the constructor to the destructor. For example, it is impossible to create a correct class open_file
file that is open throughout the lifetime. It is strange to observe this in one of the few languages that actively use RAII.
Another problem is that dropping old objects when moving adds an overhead: filling std::vector<std::unique_ptr<T>>
can be up to 2 times slower than std::vector<T*>
because of a heap of old pointers when moving, with the subsequent removal of soothers.
C ++ developers have long licked to Rust, where displaced objects are not called destructors. This feature is called Destructive move. Unfortunately, Proposal Trivially relocatable does not offer to add it to C ++. But the problem of the overhead projector will solve.
A class is considered Trivially relocatable if two operations: moving and deleting an old object are equivalent to memcpy from an old object to a new one. The old object is not deleted, the authors call it "drop it on the floor".
The type is Trivially relocatable from the compiler's point of view if one of the following (recursive) conditions is true:
- It is trivially moveable + trivially destructible (for example,
int
or POD structure) - This is a class marked with an attribute.
[[trivially_relocatable]]
- This is a class, all members of which are Trivially relocatable.
You can use this information with the help of std::uninitialized_relocate
which executes move init + delete in the usual way, or expedited, if possible. It is proposed to be marked as [[trivially_relocatable]]
most types of standard libraries, including std::string
, std::vector
, std::unique_ptr
. Overhead std::vector<std::unique_ptr<T>>
with this Proposal will disappear.
What is wrong with the exceptions now?
The C ++ exception mechanism was developed in 1992. Various options have been proposed. As a result, the mechanism of exclusion tables was chosen, which guarantee the absence of an overhead projector for the main path of program execution. Because from the very moment they were created, it was assumed that exceptions should be thrown very rarely .
Disadvantages of dynamic (that is, ordinary) exceptions:
- In the case of a thrown exception, the overhead head averages on the order of 10,000–100,000 CPU cycles, and in the worst case, can reach on the order of milliseconds.
- Increasing the size of a binary file by 15-38%
- Incompatibility with software interface C
- Implicit support for forwarding exceptions in all functions except
noexcept
. An exception can be thrown almost anywhere in the program, even where the author of the function does not expect it.
Because of these shortcomings, the scope of exceptions is significantly limited. When exceptions cannot apply:
- Where determinism is important, that is, where it is unacceptable that the code "sometimes" worked 10, 100, 1000 times slower than usual
- When they are not supported in ABI, for example, in microcontrollers
- When much of the code is written in C
- In companies with a large load of Legacy code ( Google Style Guide , Qt ). If the code has at least one non-exception-safe function, then according to the law of meanness, an exception will be thrown through it sooner or later and create a bug
- In companies that recruit programmers who have no idea about exception safety
According to surveys, at work sites, 52% (!) Of developers are prohibited by corporate rules.
But exceptions are an integral part of C ++! Including the flag -fno-exceptions
, developers lose the ability to use much of the standard library. This further incites companies to impose their own "standard libraries" and yes, reinvent their class strings.
But this is not the end. Exceptions are the only standard way to cancel the creation of an object in the constructor and give an error. When they are disabled, an abomination such as two-phase initialization appears. Operators also can not use error codes, so they are replaced by functions like assign
.
Proposal: future exceptions
New Exception Transfer Mechanism
Coat of arms Sutter (Herb Sutter) in P709 described a new mechanism for the transfer of exceptions. Ideally, the function returns std::expected
, but instead of a separate type discriminator bool
, which together with alignment will take up to 8 bytes on the stack, this bit of information is transferred in some faster way, for example, to Carry Flag.
Functions that do not touch CF (most of them) will be able to use static exceptions for free - both in the case of a normal return, and in the case of an exception throwing! The functions that will be forced to save and restore it will receive a minimum overhead, and it will still be faster than std::expected
any normal error codes.
Static exceptions look like this:
intsafe_divide(int i, int j)throws(arithmetic_errc){
if (j == 0)
throw arithmetic_errc::divide_by_zero;
if (i == INT_MIN && j == -1)
throw arithmetic_errc::integer_divide_overflows;
return i / j;
}
doublefoo(double i, double j, double k)throws(arithmetic_errc){
return i + safe_divide(j, k);
}
doublebar(int i, double j, double k){
try {
cout << foo(i, j, k);
} catch (erithmetic_errc e) {
cout << e;
}
}
In an alternative version proposed to oblige to put the keyword try
in the same terms that the call throws
functions try i + safe_divide(j, k)
. This will reduce the number of instances where throws
functions are used in code that is not safe for exceptions, to almost zero. In any case, unlike dynamic exceptions, the IDE will have the ability to somehow highlight expressions that throw exceptions.
The fact that the thrown exception is not stored separately, but is placed directly in place of the return value, imposes restrictions on the type of exception. First, it must be Trivially relocatable. Secondly, its size should not be very large (but it could be something like std::unique_ptr
), otherwise all functions will reserve more space on the stack.
status_code
The library <system_error2>
, developed by Niall Douglas, will contain status_code<T>
- "new, the best error_code
. " The main differences from error_code
:
status_code
- a template type that can be used to store almost any conceivable error code (along with a pointer tostatus_code_category
), without using static exceptionsT
must be trivially relocatable and replicable (the last, IMHO, should not be mandatory). When copying and deleting, virtual functions are called fromstatus_code_category
status_code
can store not only the error data, but also additional information about the successfully completed operation- The “virtual” function
code.message()
returns notstd::string
, butstring_ref
rather a rather heavy type of string, which is a virtual “possibly owning”std::string_view
. There you can shovestring_view
orstring
, orstd::shared_ptr<string>
, or even some crazy way of owning a string. Niall claims that#include <string>
would make the heading<system_error2>
prohibitively "heavy"
Next, a errored_status_code<T>
wrapper is introduced status_code<T>
with the following constructor:
errored_status_code(status_code<T>&& code)
[[expects: code.failure() == true]]
: code_(std::move(code)) {}
error
The default type of exclusion ( throws
no type), as well as the basic type of exceptions, to which all others (like std::exception
) are referred are error
. It is defined like this:
using error = errored_status_code<intptr_t>;
That is error
, it is such an “erroneous” status_code
one whose value ( value
) is placed in 1 pointer. Since the mechanism status_code_category
ensures the correct deletion, movement and copying, theoretically, error
you can save any data structure. In practice, this will be one of the following options:
- Integers (int)
std::exception_handle
, i.e. a pointer to a thrown dynamic exceptionstatus_code_ptr
, that isunique_ptr
, arbitrarystatus_code<T>
.
The problem is that case 3 is not planned to give the opportunity to bring error
back to status_code<T>
. The only thing you can do is get message()
packed status_code<T>
. To be able to get back wrapped in a error
value, you must throw it away as a dynamic exception (!), Then catch and wrap it in error
. In general, Niall believes that error
only error codes and string messages should be stored, which is enough for any program.
To distinguish between different types of errors, it is proposed to use a “virtual” comparison operator:
try {
open_file(name);
} catch (std::error e) {
if (e == filesystem_error::already_exists) {
return;
} else {
throw my_exception("Unknown filesystem error, unable to continue");
}
}
It is dynamic_cast
not possible to use several catch blocks or to select an exception type!
Interaction with dynamic exceptions
A function can have one of the following specifications:
noexcept
: throws no exceptionsthrows(E)
: throws only static exceptions- (nothing): throws only dynamic exceptions
throws
implies noexcept
. If a dynamic exception is thrown from a “static” function, then it is wrapped in error
. If a static exception is thrown from a “dynamic” function, then it is wrapped in an exception status_error
. Example:
voidfoo()throws(arithmetic_errc){
throw erithmetic_errc::divide_by_zero;
}
voidbar() throws {
// Код arithmetic_errc помещается в intptr_t// Допустимо неявное приведение к error
foo();
}
voidbaz(){
// error заворачивается в исключение status_error
bar();
}
voidqux() throws {
// error достаётся из исключения status_error
baz();
}
Exceptions in C ?!
The proposal provides for the addition of exceptions to one of the future C standards, and these exceptions will be ABI-compatible with static C ++ exceptions. A structure similar to std::expected<T, U>
that of the user will have to declare independently, although redundancy can be removed using macros. The syntax consists of (for simplicity, we will assume so) the keywords fails, failure, catch.
intinvert(int x)fails(float){
if (x != 0) return1 / x;
elsereturn failure(2.0f);
}
structexpected_int_float {union { int value; float error; };
_Bool failed;
};
voidcaller(){
expected_int_float result = catch(invert(5));
if (result.failed) {
print_error(result.error);
return;
}
print_success(result.value);
}
In C ++, it will also be possible to call fails
functions from C, declaring them in blocks extern C
. Thus, in C ++ there will be a whole constellation of keywords for dealing with exceptions:
throw()
- removed in C ++ 20noexcept
- function specifier, the function does not throw dynamic exceptionsnoexcept(expression)
- the function specifier, the function does not throw dynamic exceptions providednoexcept(expression)
- Does the expression throw dynamic exceptions?throws(E)
- function specifier, function throws static exceptionsthrows
=throws(std::error)
fails(E)
- a function imported from C throws a static exception.
So, in C ++, a cart of error-handling tools was delivered (more precisely, delivered). Then a logical question arises:
When what to use?
Overall direction
Errors are divided into several levels:
- Programmer errors. Processed through contracts. They lead to the collection of logs and the completion of the program in accordance with the concept of fail-fast . Examples: null pointer (when this is not allowed); division by zero; memory allocation errors not provided by the programmer.
- Irreparable errors provided by the programmer. They are thrown a million times less often than a normal return from a function, which makes the use of dynamic exceptions for them justified. Usually in such cases it is required to restart the whole subsystem of the program or to give an error when performing the operation. Examples: suddenly lost connection to the database; memory allocation errors provided by the programmer.
- Recoverable errors, when something prevented a function from performing its task, but the calling function probably knows what to do with it. Handled with static exceptions. Examples: working with the file system; other input / output errors (IO); incorrect user data;
vector::at()
. - The function successfully completed its task, albeit with an unexpected result. Return
std::optional
,std::expected
,std::variant
. Examplesstoi()
;vector::find()
;map::insert
.
In the standard library, it will be most reliable to completely abandon the use of dynamic exceptions in order to make the compilation "without exceptions" legal.
errno
Functions that use errno
for fast and minimalist work with error codes C and C ++ should be replaced by fails(int)
and throws(std::errc)
, respectively. For some time the old and the new versions of the standard library functions will coexist, then the old ones will be declared obsolete.
Out of memory
Memory allocation errors are handled by a global hook new_handler
that can:
- Eliminate out of memory and continue execution
- Throw an exception
- Emergency end the program
Now defaults out std::bad_alloc
. It is proposed to call the default std::terminate()
. If you need the old behavior, replace the handler with the one you need at the beginning main()
.
All existing functions of the standard library will become noexcept
and will crash the program when std::bad_alloc
. At the same time, new APIs will be added, such vector::try_push_back
as those that allow memory allocation errors.
logic_error
Exceptions std::logic_error
, std::domain_error
, std::invalid_argument
, std::length_error
, std::out_of_range
, std::future_error
report abuse precondition function. In the new error model, contracts should be used instead. The listed exception types will not be declared obsolete, but almost all cases of their use in the standard library will be replaced by [[expects: …]]
.
Current Proposal Status
Proposal is now in draft. He has already changed quite a lot, and he can still change a lot. Some developments did not have time to publish, so the proposed API is <system_error2>
not entirely relevant.
The offer is described in 3 documents:
- P709 - the original document from the coat of arms of Sutter
- P1095 - Deterministic exceptions in Niall Douglas vision, some points are changed, C language compatibility has been added.
- P1028 - API from test implementation
std::error
There is currently no compiler that supports static exceptions. Accordingly, it is not yet possible to make their benchmarks.
In the best case scenario, deterministic exceptions will be ready and fall into C ++ 23. If they do not have time, they are likely to fall into C ++ 26, since the standardization committee is generally interested in the topic.
Conclusion
I omitted many details of the proposed approach to exception handling, or intentionally simplified it, but I went through most of the topics required for understanding static exceptions. If you have additional questions, ask them in the comments or refer to the documents on the links above. Any corrections are welcome.
And of course, the promised polls ^^
Only registered users can participate in the survey. Sign in , please.