📜 ⬆️ ⬇️

Deterministic exceptions and error handling in the “C ++ future”


It is strange that on Habré until now it was not mentioned about the clause 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 ), please under the cat. You are waiting for 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:


double safe_divide(int x, int y) throws(arithmetic_error) { if (y == 0) { throw arithmetic_error::divide_by_zero; } else { return as_double(x) / y; } } void caller() 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 from 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 is 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 expected type is similar to variant , but provides a convenient interface for working with "result" and "error." By default, the “result” is stored in expected . The implementation of 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(); return std::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(const std::string& s); optional<U> get_or_null(map<T, U> m, const T& key); 

In C ++ 17 from Boost, optional got into std (without support optional<T&> ); in C ++ 20, they 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 a function added in C ++ 20. Added 3 annotations:



 double unsafe_at(vector<T> v, size_t i) [[expects: i < v.size()]]; double sqrt(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:



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 <system_error> library added in C ++ 11 allows you to unify the handling of error codes in your program. std :: error_code consists of an error code of type 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 std::error_category and implement virtual methods, the most important of which is:


 virtual std::string message(int c) const = 0; 

You also need to create a global variable for your 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(const std::string& name, int& error); auto open_file(const std::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 in 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 must 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, which is open throughout the lifetime. It is strange to observe this in one of the few languages ​​that actively use RAII.


Another problem - 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 the heap of old pointers when moving , with the subsequent removal of baby's dummies.


C ++ developers have long licked to Rust, where destructors are not called on displaced objects. 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 the 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:


  1. It is trivially moveable + trivially destructible (for example, an int or POD structure)
  2. This is a class marked with the [[trivially_relocatable]] attribute [[trivially_relocatable]]
  3. This is a class whose members are all Trivially relocatable.

You can use this information using std::uninitialized_relocate , which executes move init + delete in the usual way, or accelerated, if possible. It is proposed to mark as [[trivially_relocatable]] most types of the standard library, including std::string , std::vector , std::unique_ptr . The overhead std::vector<std::unique_ptr<T>> with this Proposal disappears.


What is wrong with the exceptions now?


The C ++ exception mechanism was developed in 1992. Various implementation 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 program execution path. Because from the very moment of their creation, it was assumed that exceptions should be thrown very rarely .


Disadvantages of dynamic (that is, ordinary) exceptions:


  1. 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.
  2. Increasing the size of a binary file by 15-38%
  3. Incompatibility with software interface C
  4. 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:


  1. Where determinism is important, that is, where it is unacceptable that the code "sometimes" worked 10, 100, 1000 times slower than usual
  2. When they are not supported in ABI, for example, in microcontrollers
  3. When much of the code is written in C
  4. In companies with a large load of Legacy code ( Google Style Guide , Qt ). If there is at least one non-exception-safe function in the code, then, according to the law of meanness, an exception will be thrown through it sooner or later and create a bug
  5. 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 -fno-exceptions flag, 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 own 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 cannot use error codes either, 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 discriminator of the bool type, which together with alignment will occupy 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 pass! Functions that will be forced to save and restore it will receive a minimum overhead, and it will still be faster than std::expected and any normal error codes.


Static exceptions look like this:


 int safe_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; } double foo(double i, double j, double k) throws(arithmetic_errc) { return i + safe_divide(j, k); } double bar(int i, double j, double k) { try { cout << foo(i, j, k); } catch (erithmetic_errc e) { cout << e; } } 

In the alternative version, it is proposed to require the try keyword in the same expression as the call to the throws function: try i + safe_divide(j, k) . This will reduce the number of instances of using throws functions in code, which 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 <system_error2> library developed by Niall Douglas will contain status_code<T> - “new, better” error_code . The main differences from error_code :


  1. status_code is a template type that can be used to store almost any conceivable error code (along with a pointer to status_code_category ), without using static exceptions.
  2. T must be Trivially relocatable and replicable (the latter, IMHO, should not be mandatory). When copying and deleting, virtual functions are called from status_code_category
  3. status_code can store not only error data, but also additional information about a successfully completed operation.
  4. The “virtual” function code.message() does not return std::string , but string_ref is a rather heavy string type, which is a virtual “possibly owning” std::string_view . There you can string_view or string , or std::shared_ptr<string> , or some other crazy way to own a string. Niall claims that #include <string> would make the <system_error2> header impermissibly “heavy”

Next, errored_status_code<T> is entered - a wrapper over 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 exception type ( throws without a type), as well as the base exception type, to which all others are thrown (like std::exception ) is an error . It is defined like this:


 using error = errored_status_code<intptr_t>; 

That is, an error is such an “erroneous” status_code for which the value is placed in 1 pointer. Since the status_code_category mechanism provides correct deletion, movement and copying, theoretically, any data structure can be saved as an error . In practice, this will be one of the following options:


  1. Integers (int)
  2. std::exception_handle , that is, a pointer to the thrown dynamic exception
  3. status_code_ptr , that is, unique_ptr to an arbitrary status_code<T> .

The problem is that case 3 does not plan to allow the error to be brought back to status_code<T> . The only thing you can do is get the message() packed status_code<T> . To be able to get back the value wrapped in error , you need to throw it away as a dynamic exception (!), Then catch and wrap it in error . In general, Niall believes that only error codes and string messages should be stored in error , 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"); } } 

Using multiple catch blocks or dynamic_cast to select the type of exception will not work!


Interaction with dynamic exceptions


A function can have one of the following specifications:



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 a status_error exception. Example:


 void foo() throws(arithmetic_errc) { throw erithmetic_errc::divide_by_zero; } void bar() throws { //  arithmetic_errc   intptr_t //     error foo(); } void baz() { // error    status_error bar(); } void qux() 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> , 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.


 int invert(int x) fails(float) { if (x != 0) return 1 / x; else return failure(2.0f); } struct expected_int_float { union { int value; float error; }; _Bool failed; }; void caller() { 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 the fails functions from C, declaring them in extern C blocks. Thus, in C ++ there will be a whole constellation of keywords for dealing with exceptions:



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:



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 to work quickly and minimally with the C and C ++ error codes 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 the new_handler global hook, which can:


  1. Eliminate out of memory and continue execution
  2. Throw an exception
  3. Emergency end the program

Now std::bad_alloc thrown by default. It is proposed to call std::terminate() by default. If you need the old behavior, replace the handler with the one you need at the beginning of main() .


All existing functions of the standard library will become noexcept and will crash the program with std::bad_alloc . At the same time, new APIs will be added, such as vector::try_push_back , which 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 a violation of the function precondition. 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 <system_error2> not entirely relevant.


The offer is described in 3 documents:


  1. P709 - the original document from the coat of arms of Sutter
  2. P1095 - Deterministic exceptions in Niall Douglas vision, some moments are changed, compatibility with C language is added
  3. P1028 - API from the test implementation of std::error

There is currently no compiler that supports static exceptions. Accordingly, it is not yet possible to make them benchmarks.


C++23. , , , C++26, , , .


Conclusion


, , . , . .


, ^^


')

Source: https://habr.com/ru/post/430690/


All Articles