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 throwsand 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 expectedis similar to variant, but provides a convenient interface for working with "result" and "error." By default, the expected“result” is stored. The implementation file_sizemight 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 intand 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_categoryand 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_codevalue 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_errorand 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_filefile 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:


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

    You can use this information with the help of std::uninitialized_relocatewhich 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:


    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 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
    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 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::expectedany 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 tryin the same terms that the call throwsfunctions try i + safe_divide(j, k). This will reduce the number of instances where throwsfunctions 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:


    1. status_code- 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. Tmust be trivially relocatable and replicable (the last, IMHO, should not be mandatory). When copying and deleting, virtual functions are called fromstatus_code_category
    3. status_code can store not only the error data, but also additional information about the successfully completed operation
    4. The “virtual” function code.message()returns not std::string, but string_refrather a rather heavy type of string, which is a virtual “possibly owning” std::string_view. There you can shove string_viewor string, or std::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 ( throwsno 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_codeone whose value ( value) is placed in 1 pointer. Since the mechanism status_code_categoryensures the correct deletion, movement and copying, theoretically, erroryou can save any data structure. In practice, this will be one of the following options:


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

    The problem is that case 3 is not planned to give the opportunity to bring errorback 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 errorvalue, you must throw it away as a dynamic exception (!), Then catch and wrap it in error. In general, Niall believes that erroronly 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_castnot 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 exceptions
    • throws(E): throws only static exceptions
    • (nothing): throws only dynamic exceptions

    throwsimplies 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 failsfunctions 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 ++ 20
    • noexcept - function specifier, the function does not throw dynamic exceptions
    • noexcept(expression) - the function specifier, the function does not throw dynamic exceptions provided
    • noexcept(expression) - Does the expression throw dynamic exceptions?
    • throws(E) - function specifier, function throws static exceptions
    • throws = 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. Examples stoi(); 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 errnofor 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_handlerthat can:


    1. Eliminate out of memory and continue execution
    2. Throw an exception
    3. 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 noexceptand will crash the program when std::bad_alloc. At the same time, new APIs will be added, such vector::try_push_backas 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_errorreport 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:


    1. P709 - the original document from the coat of arms of Sutter
    2. P1095 - Deterministic exceptions in Niall Douglas vision, some points are changed, C language compatibility has been added.
    3. P1028 - API from test implementationstd::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.

    Do I need static exceptions in C ++?

    How are memory allocation errors handled in your C ++ programs?

    Should an unexpected memory allocation error (except vector :: try_push_back, nothrow, etc.) terminate the program?

    Do I need an additional try annotation (as in Rust, Swift) when calling `throws` functions?

    Should an alternative type of “strings with unknown ownership” be used in status_code :: message () string_ref?

    Should it be possible to put any type in std :: error without throwing a dynamic exception?

    Are exceptions allowed in the C ++ code you are working with?

    Exceptions needed in C?


    Also popular now: