Детерминированные исключения и обработка ошибок в «C++ будущего»


    Странно, что на Хабре до сих пор не было упомянуто о наделавшем шуму предложении к стандарту C++ под названием "Zero-overhead deterministic exceptions". Исправляю это досадное упущение.


    Если вас беспокоит оверхед исключений, или вам приходилось компилировать код без поддержки исключений, или просто интересно, что будет с обработкой ошибок в C++2b (отсылка к недавнему посту), прошу под кат. Вас ждёт выжимка из всего, что сейчас можно найти по теме, и пара опросов.


    Разговор далее будет вестись не только про статические исключения, но и про связанные предложения к стандарту, и про всякие другие способы обработки ошибок. Если вы зашли сюда поглядеть на синтаксис, то вот он:


    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;
        }
    }

    Если конкретный тип ошибки неважен/неизвестен, то можно использовать просто throws и catch (std::error e).


    Полезно знать


    std::optional и std::expected


    Пусть мы решили, что ошибка, которая потенциально может возникнуть в функции, недостаточно «фатальная», чтобы бросать из неё исключение. Традиционно информацию об ошибке возвращают с помощью выходного параметра (out parameter). Например, Filesystem TS предлагает ряд подобных функций:


    uintmax_t file_size(const path& p, error_code& ec);

    (Не бросать же исключение из-за того, что файл не найден?) Тем не менее, обработка кодов ошибок громоздкая и подвержена багам. Код ошибки легко забыть проверить. Современные стили кода запрещают использование выходных параметров, вместо них рекомендуется возвращать структуру, содержащую весь результат.


    Boost вот уже некоторое время предлагает изящное решение для обработки таких «не-фатальных» ошибок, которые в определённых сценариях могут происходить сотнями в корректной программе:


    expected<uintmax_t, error_code> file_size(const path& p);

    Тип expected похож на variant, но предоставляет удобный интерфейс для работы с «результатом» и «ошибкой». По умолчанию, в expected хранится «результат». Реализация file_size может выглядеть как-то так:


    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);  // <==
    }

    Если причина ошибки нам неинтересна, или ошибка может заключаться только в «отсутствии» результата, то можно использовать optional:


    optional<int> parse_int(const std::string& s);
    optional<U> get_or_null(map<T, U> m, const T& key);

    В C++17 из Boost в std попал optional (без поддержки optional<T&>); в C++20, возможно, добавят expected (это только Proposal, спасибо RamzesXI за поправку).


    Contracts


    Контракты (не путать с концептами) — новый способ наложить ограничения на параметры функции, добавленный в C++20. Добавлены 3 аннотации:


    • expects проверяет параметры функции
    • ensures проверяет возвращаемое значение функции (принимает его в качестве аргумента)
    • assert — цивилизованная замена макросу assert

    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];
    }

    Можно настроить, чтобы нарушение контрактов:


    • Вызывало Undefined Behaviour, или
    • Проверялось и вызывало пользовательский обработчик, после чего std::terminate

    Продолжать работу программы после нарушения контракта никак нельзя, потому что компиляторы используют гарантии из контрактов для оптимизации кода функции. Если есть малейшее сомнение в том, что контракт выполнится, стоит добавить дополнительную проверку.


    std::error_code


    Библиотека <system_error>, добавленная в C++11, позволяет унифицировать обработку кодов ошибок в вашей программе. std::error_code состоит из кода ошибки типа int и указателя на объект какого-нибудь класса-наследника std::error_category. Этот объект, по сути, играет роль таблицы виртуальных функций и определяет поведение данного std::error_code.


    Чтобы создавать свои std::error_code, вы должны определить свой класс-наследник std::error_category и реализовать виртуальные методы, самым важным из которых является:


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

    Нужно также создать глобальную переменную вашего std::error_category. Обработка ошибок при помощи error_code + expected выглядит как-то так:


    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};
        }
    }

    Важно, что в std::error_code значение 0 означает отсутствие ошибки. Если для ваших кодов ошибок это не так, то перед тем, как конвертировать системный код ошибки в std::error_code, надо заменить код 0 на код SUCCESS, и наоборот.


    Все системные коды ошибок описаны в errc и system_category. Если на определённом этапе ручной проброс кодов ошибки становится слишком муторным, то всегда можно завернуть код ошибки в исключение std::system_error и выбросить.


    Destructive move / Trivially relocatable


    Пусть вам нужно создать очередной класс объектов, владеющих какими-нибудь ресурсами. Скорее всего, вы захотите сделать его некопируемым, но перемещаемым (moveable), потому что с unmoveable объектами неудобно работать (до C++17 их нельзя было вернуть из функции).


    Но вот беда: перемещённый объект в любом случае нужно удалить. Поэтому необходимо особое состояние "moved-from", то есть "пустого" объекта, который ничего не удаляет. Получается, каждый класс C++ обязан иметь пустое состояние, то есть невозможно создать класс с инвариантом (гарантией) корректности, от конструктора до деструктора. Например, невозможно создать корректный класс open_file файла, который открыт на всём протяжении времени жизни. Странно наблюдать это в одном из немногих языков, активно использующих RAII.


    Другая проблема — зануление старых объектов при перемещении добавляет оверхед: заполнение std::vector<std::unique_ptr<T>> может быть до 2 раз медленнее, чем std::vector<T*> из-за кучи занулений старых указателей при перемещении, с последующим удалением пустышек.


    Разработчики C++ давно облизываются на Rust, где у перемещённых объектов не вызываются деструкторы. Эта фича называется Destructive move. К сожалению, Proposal Trivially relocatable не предлагает добавить её в C++. Но проблему оверхеда решит.


    Класс считается Trivially relocatable, если две операции: перемещения и удаления старого объекта — эквивалентны memcpy из старого объекта в новый. Старый объект при этом не удаляется, авторы называют это "drop it on the floor".


    Тип является Trivially relocatable с точки зрения компилятора, если выполняется одно из следующих (рекурсивных) условий:


    1. Он trivially moveable + trivially destructible (например, int или POD структура)
    2. Это класс, помеченный атрибутом [[trivially_relocatable]]
    3. Это класс, все члены которого являются Trivially relocatable

    Использовать эту информацию можно с помощью std::uninitialized_relocate, которая исполняет move init + delete обычным способом, или ускоренным, если это возможно. Предлагается пометить как [[trivially_relocatable]] большинство типов стандартной библиотеки, включая std::string, std::vector, std::unique_ptr. Оверхед std::vector<std::unique_ptr<T>> с учётом этого Proposal исчезнет.


    Что не так с исключениями сейчас?


    Механизм исключений C++ разрабатывался в 1992 году. Были предложены различные варианты реализации. Из них в итоге был выбран механизм таблиц исключений, которые гарантируют отсутствие оверхеда для основного пути выполнения программы. Потому что с самого момента их создания создания предполагалось, что исключения должны выбрасываться очень редко.


    Недостатки динамических (то есть обычных) исключений:


    1. В случае выброшенного исключения оверхед составляет в среднем порядка 10000-100000 циклов CPU, а в худшем случае может достигать порядка миллисекунд
    2. Увеличение размера бинарного файла на 15-38%
    3. Несовместимость с программным интерфейсом С
    4. Неявная поддержка проброса исключений во всех функциях, кроме noexcept. Исключение может быть выброшено практически в любом месте программы, даже там, где автор функции его не ожидает

    Из-за этих недостатков существенно ограничивается область применения исключений. Когда исключения не могут применяться:


    1. Там, где важен детерминизм, то есть там, где недопустимо, чтобы код "иногда" работал в 10, 100, 1000 раз медленнее, чем обычно
    2. Когда они не поддерживаются в ABI, например, в микроконтроллерах
    3. Когда значительная часть кода написана на С
    4. В компаниях с большим грузом легаси-кода (Google Style Guide, Qt). Если в коде есть хоть одна не exception-safe функция, то по закону подлости через неё рано или поздно прокинут исключение и создадут баг
    5. В компаниях, набирающих программистов, которые понятия не имеют об exception safety

    По опросам, на местах работы 52% (!) разработчиков исключения запрещены корпоративными правилами.


    Но исключения — неотъемлемая часть C++! Включая флаг -fno-exceptions, разработчики теряют возможность использовать значительную часть стандартной библиотеки. Это дополнительно подстрекает компании насаждать собственные "стандартные библиотеки" и да, изобретать свой класс строки.


    Но и это ещё не конец. Исключения — единственный стандартный способ отменить создание объекта в конструкторе и выдать ошибку. Когда они отключены, появляется такая мерзость, как двухфазная инициализация. Операторы тоже не могут использовать коды ошибок, поэтому они заменяются функциями вроде assign.


    Proposal: исключения будущего


    Новый механизм передачи исключений


    Герб Саттер (Herb Sutter) в P709 описал новый механизм передачи исключений. Идейно, функция возвращает std::expected, однако вместо отдельного дискриминатора типа bool, который вместе с выравниванием будет занимать до 8 байт на стеке, этот бит информации передаётся каким-то более быстрым способом, например, в Carry Flag.


    Функции, которые не трогают CF (таких большинство), получат возможность использовать статические исключения бесплатно — и в случае обычного возврата, и в случае проброса исключения! Функции, которые вынуждены будут его сохранять и восстанавливать, получат минимальный оверхед, и это всё равно будет быстрее, чем std::expected и любые обычные коды ошибок.


    Выглядят статические исключения следующим образом:


    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;
        }
    }

    В альтернативной версии предлагается обязать ставить ключевое слово try в том же выражении, что вызов throws функции: try i + safe_divide(j, k). Это сведёт число случаев использования throws функций в коде, не безопасном для исключений, практически к нулю. В любом случае, в отличие от динамических исключений, у IDE будет возможность как-то выделять выражения, бросающие исключения.


    То, что выброшенное исключение не сохраняется отдельно, а кладётся прямо на место возвращаемого значения, накладывает ограничения на тип исключения. Во-первых, он должен быть Trivially relocatable. Во-вторых, его размер должен быть не очень большим (но это может быть что-то вроде std::unique_ptr), иначе все функции будут резервировать больше места на стеке.


    status_code


    Библиотека <system_error2>, разработанная Найл Дуглас (Niall Douglas), будет содержать status_code<T> — «новый, лучший» error_code. Основные отличия от error_code:


    1. status_code — шаблонный тип, который можно использовать для хранения практически любых мыслимых кодов ошибок (вместе с указателем на status_code_category), без использования статических исключений
    2. T должен быть Trivially relocatable и копируемым (последнее, ИМХО, не должно быть обязательным). При копировании и удалении вызываются виртуальные функции из status_code_category
    3. status_code может хранить не только данные об ошибке, но и дополнительные сведения об успешно завершённой операции
    4. «Виртуальная» функция code.message() возвращает не std::string, а string_ref — довольно тяжёлый тип строки, представляющий собой виртуальный «возможно владеющий» std::string_view. Туда можно запихнуть string_view или string, или std::shared_ptr<string>, или ещё какой-нибудь сумасшедший способ владения строкой. Найл утверждает, что #include <string> сделало бы заголовок <system_error2> непозволительно «тяжёлым»

    Далее, вводится errored_status_code<T> — обёртка над status_code<T> со следующим конструктором:


    errored_status_code(status_code<T>&& code)
        [[expects: code.failure() == true]]
        : code_(std::move(code)) {}

    error


    Тип исключения по умолчанию (throws без типа), а также базовый тип исключений, к которому приводятся все остальные (вроде std::exception) — это error. Он определён примерно так:


    using error = errored_status_code<intptr_t>;

    То есть error — это такой «ошибочный» status_code, у которого значение (value) помещается в 1 указатель. Так как механизм status_code_category обеспечивает корректное удаление, перемещение и копирование, то теоретически в error можно сохранить любую структуру данных. На практике это будет один из следующих вариантов:


    1. Целые числа (int)
    2. std::exception_handle, то есть указатель на выброшенное динамическое исключение
    3. status_code_ptr, то есть unique_ptr на произвольный status_code<T>.

    Проблема в том, что случае 3 не планируется дать возможность привести error обратно к status_code<T>. Единственное, что можно сделать — получить message() упакованного status_code<T>. Чтобы иметь возможность достать обратно завёрнутое в error значение, надо выбросить его как динамическое исключение (!), потом поймать и завернуть в error. А вообще, Найл считает, что в error должны храниться только коды ошибок и строковые сообщения, чего достаточно для любой программы.


    Чтобы различать разные виды ошибок, предлагается использовать «виртуальный» оператор сравнения:


    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");
        }
    }

    Использовать несколько catch-блоков или dynamic_cast для выбора типа исключения не получится!


    Взаимодействие с динамическими исключениями


    Функция может иметь одну из следующих спецификаций:


    • noexcept: не бросает никаких исключений
    • throws(E): бросает только статические исключения
    • (ничего): бросает только динамические исключения

    throws подразумевает noexcept. Если динамическое исключение выбрасывается из «статической» функции, то оно заворачивается в error. Если статическое исключение выбрасывается из «динамической» функции, то оно заворачивается в исключение status_error. Пример:


    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();
    }

    Исключения в C?!


    Предложение предусматривает добавление исключений в один из будущих стандартов C, причём эти исключения будут ABI-совместимы со статическими исключениями C++. Структуру, аналогичную std::expected<T, U>, пользователь должен будет объявлять самостоятельно, хотя избыточность можно убрать с помощью макросов. Синтаксис состоит из (для простоты будем так считать) ключевых слов 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);
    }

    При этом в C++ тоже можно будет вызывать fails функции из C, объявляя их в блоках extern C. Таким образом, в C++ будет целая плеяда ключевых слов по работе с исключениями:


    • throw() — удалено в C++20
    • noexcept — спецификатор функции, функция не бросает динамические исключения
    • noexcept(expression) — спецификатор функции, функция не бросает динамические исключения при условии
    • noexcept(expression) — бросает ли выражение динамические исключения?
    • throws(E) — спецификатор функции, функция бросает статические исключения
    • throws = throws(std::error)
    • fails(E) — функция, импортированная из C, бросает статические исключения

    Итак, в C++ завезли (точнее, завезут) тележку новых инструментов для обработки ошибок. Далее возникает логичный вопрос:


    Когда что использовать?


    Направление в целом


    Ошибки разделяются на несколько уровней:


    • Ошибки программиста. Обрабатываются с помощью контрактов. Приводят к сбору логов и завершению работы программы в соответствие с концепцией fail-fast. Примеры: нулевой указатель (когда это недопустимо); деление на ноль; ошибки выделения памяти, не предусмотренные программистом.
    • Непоправимые ошибки, предусмотренные программистом. Выбрасываются в миллион раз реже, чем обычный возврат из функции, что делает использование для них динамических исключений оправданным. Обычно в таких случаях требуется перезапустить целую подсистему программы или выдать ошибку при выполнении операции. Примеры: внезапно потеряна связь с базой данных; ошибки выделения памяти, предусмотренные программистом.
    • Поправимые (recoverable) ошибки, когда что-то помешало функции выполнить свою задачу, но вызывающая функция, возможно, знает, что с этим делать. Обрабатываются с помощью статических исключений. Примеры: работа с файловой системой; другие ошибки ввода-вывода (IO); некорректные пользовательские данные; vector::at().
    • Функция успешно завершила свою задачу, пусть и с неожиданным результатом. Возвращаются std::optional, std::expected, std::variant. Примеры: stoi(); vector::find(); map::insert.

    В стандартной библиотеке надёжнее всего будет полностью отказаться от использования динамических исключений, чтобы сделать компиляцию «без исключений» легальной.


    errno


    Функции, использующие errno для быстрой и минималистичной работы с кодами ошибок C и C++, должны быть заменены на fails(int) и throws(std::errc), соответственно. Некоторое время старый и новый варианты функций стандартной библиотеки будут сосуществовать, потом старые объявят устаревшими.


    Out of memory


    Ошибки выделения памяти обрабатывает глобальный хук new_handler, который может:


    1. Устранить нехватку памяти и продолжить выполнение
    2. Выбросить исключение
    3. Аварийно завершить программу

    Сейчас по умолчанию выбрасывается std::bad_alloc. Предлагается же по умолчанию вызывать std::terminate(). Если вам нужно старое поведение, замените обработчик на тот, который вам нужен, в начале main().


    Все существующие функции стандартной библиотеки станут noexcept и будут крашить программу при std::bad_alloc. В то же время, будут добавлены новые API вроде vector::try_push_back, которые допускают ошибки выделения памяти.


    logic_error


    Исключения std::logic_error, std::domain_error, std::invalid_argument, std::length_error, std::out_of_range, std::future_error сообщают о нарушении предусловия функции. В новой модели ошибок вместо них должны использоваться контракты. Перечисленные типы исключений не будут объявлены устаревшими, но почти все случаи их использования в стандартной библиотеке будут заменены на [[expects: …]].


    Текущее состояние Proposal


    Proposal сейчас находится в состоянии черновика. Он уже довольно сильно поменялся, и ещё может сильно измениться. Некоторые наработки не успели опубликовать, так что предлагаемый API <system_error2> не совсем актуален.


    Предложение описывается в 3 документах:


    1. P709 — первоначальный документ от Герба Саттера
    2. P1095 — детерминированные исключения в видении Найла Дугласа, некоторые моменты изменены, добавлена совместимость с языком C
    3. P1028 — API из тестовой реализации std::error

    На настоящий момент не существует компилятора, который поддерживает статические исключения. Соответственно, сделать их бенчмарки пока невозможно.


    При наилучшем раскладе детерминированные исключения будут готовы и попадут в C++23. Если не успеют, то, вероятно, попадут в C++26, так как комитет стандартизации, в целом, заинтересован темой.


    Заключение


    Многие детали предлагаемого подхода к обработке исключений я опустил или умышленно упростил, зато прошёлся по большинству тем, требующихся для понимания статических исключений. Если возникли дополнительные вопросы, то задайте их в комментариях или обратитесь к документам по ссылкам выше. Любые поправки приветствуются.


    И конечно, обещанные опросы ^^

    Only registered users can participate in poll. Log in, please.

    Нужны ли статические исключения в C++?

    • 34.5%Да, мне пригодится50
    • 33.1%Да, почему бы и нет48
    • 1.4%Нет, динамические исключения лучше2
    • 21.4%Нет, хватит раздувать стандарт всякой ересью!31
    • 6.2%Что такое статические исключения?9
    • 3.4%Всё равно5

    Как обрабатываются ошибки выделения памяти в ваших программах на C++?

    • 3.4%Всегда std::bad_alloc перехватывается, программа пытается продолжить работу (P709 это запрещает)5
    • 7.6%Всегда использую `nothrow` выделение памяти, программа пытается продолжить работу11
    • 12.4%В отдельных случаях обрабатываю ошибку (std::bad_alloc / nothrow) и программа продолжает работу, в остальных случаях программа завершает работу18
    • 23.4%Ошибка логируется, программа завершает работу34
    • 46.9%Никак не обрабатывается, программа крашится68
    • 6.2%Не знаю9

    Должна ли неожиданная ошибка выделения памяти (кроме vector::try_push_back, nothrow и т.д.) завершать работу программы?

    • 59.9%Да, должна. Ну можно ещё залогировать88
    • 30.6%Нет, хочу, чтобы программа всегда пыталась продолжать работу45
    • 9.5%Всё равно14

    Нужна ли дополнительная аннотация try (как в Rust, Swift) при вызове `throws`-функций?

    • 27.4%Да, нужна37
    • 30.4%Нет, не нужна41
    • 32.6%Что это? Я знаю только про блоки try-catch44
    • 9.6%Всё равно13

    Нужно ли использовать в status_code::message() альтернативный тип «строки с неизвестным владением» string_ref?

    • 14.4%Да, это умно18
    • 16.8%Да, не придётся включать заголовок string во все файлы21
    • 31.2%Нет, лучше использовать std::string, как раньше39
    • 37.6%Что? / Всё равно47

    Надо ли дать возможность класть в std::error любой тип, не выбрасывая динамическое исключение?

    • 36.3%Да, мне это понадобится45
    • 39.5%Нет, для максимальной производительности стоит ограничиться кодами ошибок49
    • 24.2%Всё равно30

    Исключения разрешены в коде C++, с которым вы работаете?

    • 61.4%Да, и меня это устраивает86
    • 10.0%Да, возмутительно!14
    • 20.7%Нет, и меня это устраивает29
    • 7.9%Нет, возмутительно!11

    Исключения нужны в C?

    • 27.2%Да40
    • 48.3%Нет, это ересь!71
    • 24.5%Всё равно36
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 46

      0

      Комментарий ноунейма.
      С одной стороны, мне дико нравится пропозал, он гораздо лучше чем std::expected который имхо имеет нерешенные проблемы в том виде, в котором его предложил саттер (а именно, выброс значения, хранимого в error, при доступе к значению — ведь базовая идея чтобы возвращать вместе с типом что-то не очень тяжелое, скажем, код ошибки; и получается, что мы кидаем энум или там int или строку… в общем, что-то, что не наследует std::exception; с одной стороны, ловить int язык позволяет, с другой — говнокод).
      С другой, вспоминается картинка про 14 конкурирующих стандартов.
      Ведь проблема не столько в легаси коде; какой-нибудь Гугл или фейсбук с монорепозиторием могут потратить месяц на поправку сигнатуры функции и исправление ошибок компиляции (хотя часто есть задачи понасущнее)…
      А вот захочу я модернизировать какой-нибудь int QString::toInt(bool *ok=nullptr) const;


      Во что-то типа
      std::optional QString::toInt() const;
      Понятное дело, у меня не получится-ок, я сделаю свободную функцию (давно пора):
      std::optional Qt::toInt(QStringView);
      А старую объявлю deprecated. А ещё через 5 лет мне придётся менять сигнатуру на
      int Qt::toInt2(QStringView) throws(ParseError);
      А пользователям придётся каждый раз исправлять код… так и приходится жить с богомерзким возвратом эррор кода через ссылку/указатель, ведь надо чуть-чуть подождать и можно будет использовать новый true-way

      Вот как поменять поведение std::vector::at без введения std2? Я не очень представляю, если честно.

        +1
        Очень хорошие новости по поводу Си.

        Сейчас обработка ошибок сводится к сильному загромождению кода. Немного спасают лишь макросы. Из-за всего этого страдает читабельность. Блок finally будет хоть каким-то выходом в ряде ситуаций из множества goto на блок освобождения ресурсов или вызовов pthread_cleanup_push()/pthread_cleanup_pop(). Хотя в остальных ситуациях лучше подошёл бы defer из Golang. Вот его очень не хватает для повышения читабельности.
          0
          Вот уменьшение boilerplate для Си как раз не собираются завозить. В комитете сказали, что если в Си когда-нибудь будут исключения, то явные. В статье приведён пример, как это будет выглядеть. Никакого автоматического проброса, явная проверка `result.failed`. А `finally` можно было бы рассматривать для C++, но там преобладает идеология RAII, поэтому этого тоже не будет.

          Edit: Наврал я! Предлагается макрос try, предназначенный для проверки failed и проброса исключения.
            0
            Ну, по крайней мере лёд тронулся с места. С одной стороны в системном языке явные проверки выгодны, а с другой — во многих компаниях они просто будут отсутствовать в силу давления менеджеров по срокам.

            А вообще, в Си очень не хватает стандартизованного thread-local стека кодов ошибок в связке с указателями на функции помимо errno в качестве достаточного минимума. Отладка упростилась бы.

            А в реализации defer'ов вообще не вижу преград. Компилятор может просто добавлять перед любым return список всех встреченных до данной строки defer'ов в рамках функции в обратном порядке. Либо формировать таблицу очистки с переходами через jmp. Удивительно, что этого до сих пор не ввели.
            0
            defer это не про ошибки. Смотрите предложения по второй версии Go
              0
              В Си по каждой ошибке требуется освобождать выделенные ресурсы в обратной порядке. Поэтому либо обработка ошибок делает goto в секцию освобождения ресурсов, либо освобождение дублируется по каждому return. Поэтому defer в данном случае очень упростил бы обработку ошибок. Достаточно будет делать обычный return.

              Грубый пример:
              static sem_t sem;
              static int some_func(void) // or not void
              {
                  int r;
                  do {
                      r = sem_wait(&sem);
                  } while ((r == -1) && (errno == EINTR));
                  if (r == -1) {
                      return -1;
                  }
              
                  int saved_errno;
              
                  char *buf = malloc(buffer_size);
                  if (!buf) {
                      saved_errno = errno;
                      goto aborting_sem;
                  }
                  int x;
              // ....
                  sem_post(&sem);
                  return x; // x>=0
              
              // ....
              aborting_sem:
                  sem_post(&sem);
                  errno = saved_errno;
                  return -1;
              }
              


              В данном случае в defer попал бы sem_post. А если будет ошибка после выделения буфера, то и free попадёт. Но для defer'а потребовалось бы указывать, должен ли он вызываться всегда, или же только по ошибке. А, соответственно, вместо return могло бы использоваться ключевое слово, означающее возврат ошибки. Например:
              return error -1;
              return_error -1;
              [[error]] return -1;
              
              0

              А еще это будет совместимо с Rust

              +1
              Даже не знаю. У меня неоднозначное отношение к исключениям. С одной стороны, это удобный механизм обработки ошибок. С другой — мне дико не нравится, что любая функция может выбросить исключение, это такой современный goto. Особенно по всякой фигне типа невозможности преобразовать строку в число потому что в строке попалась буква.

              С одной стороны хочется чтобы все явно. С другой — писать для каждой функции какие исключения она может выбросить, будет сильно утомительно. Спецификатор try перед вызовом — с одной стороны хорошо что явно, с другой плохо что писанина. И еще груз совместимости со старыми исключениями… Просто не знаю:)

              А если бы проектировать язык с нуля, с учетом ошибок дизайна предыдущих языков программирования, то какой бы вы видели идеальную систему исключений?
                0
                Ответ предназначен NeoCode, промахнулся кнопкой.

                Мне нравится, как Intellij Idea подсказывает, на какой строке вызывается корутина в Kotlin:

                image

                Ничего лишнего не нужно, но всё понятно. Со старыми исключениями такого сделать нельзя, потому что большинство функций может их выбросить. С новыми статическими исключениями IDE будет точно знать, где исключения бросаются, и сможет подсказать.

                Идеальная система исключений — сложно сказать, я не эксперт в этом ;) Хотя в разделе «Когда что использовать» написаны гайдлайны, эквивалентные тем, что сейчас используются в Swift. То есть для преобразования строки в число рекомендуется не пользоваться исключениями, потому что «в строке нет числа» — не ошибка. А вообще, в современных языках программирования наблюдается переход от динамических исключений к статическим. И `throws` в контексте C++ всё же нужен как подсказка программисту.

                Что меня расстраивает — в текущем Proposal не предусмотрены cast-ы статических исключений. Закастить можно, но только один раз, к `std::error`, при этом от исключения остаётся один message, остальное теряется. В идеале, конечно, статические исключения должны быть такими же мощными, как динамические, но с бонусом, что можно очень быстро бросить код ошибки.
                  +1
                  Все существующие функции стандартной библиотеки станут noexcept и будут крашить программу при std::bad_alloc. В то же время, будут добавлены новые API вроде vector::try_push_back, которые допускают ошибки выделения памяти.

                  Плохо. Если я решу написать программу, в которой мне хочется/надо обрабатывать нехватку памяти, то и библиотеки мне необходимо искать такие, которые используют новые try_* методы.
                  Всё же механизм должен быть единым.
                    +1
                    Это, увы, лучшее, что можно сделать сегодня.

                    Цепочка событий такая:
                    1. Всё началось ещё в прошлом веке, когда умные люди выяснили, что программы часто «заказывают» куда больше памяти, чем им реально нужно. Решение «проблемы» — всем известно.
                    2. Так как этот костыль прописался во всех современных OS, то простого использования стандартной библиотеки и аккуратной обработки std::bad_allocнедостаточно! Чтобы программа работала корректно нужен свой аллокатор, а разработчики компиляторов его не добавляют, предположительно исходя из идеи: «он будет серьёзно замедлять код и, соответственно, пользователи будут его отключать».
                    3. Обнаружив, что написать программу, корректно отрабатывающую нехватку памяти, практически нереально (по меньшей мере если не использовать специальные, нестандартные, библиотеки для аллокации памяти) 99.9% разработчиков забивают на обработку этой ситуации большой и толстый болт.
                    4. В результате если вам хочется/нужно как-то обрабатывать-таки нехватку памяти — то вам нужно как-то по косвенным признакам отловить/найти среди тысяч библиотек, не обрабатывающих эту ситуацию корректно те редкие жемчужины, которые, всё-таки, делают это правильно. А сделать это сложно, так как какие-то ошмётки обработки нехватки памяти растыканы везде — только вот они нифига не текстируются и не работают.
                    5. Комитет по стандартизации пытется облегчить вам эту работу и вводит новые try_* методы. Если разработчики компиляторов в этот раз таки сделают так, что этими функциями реально можно будет гарантировать бесперебойную работу… может быть чего и выйдет.
                      0
                      P132 предлагает добавить nothrow версии аллокаторов и nothrow версии std:: функций, использующих аллокаторы. То есть, если всё это когда-нибудь примут, то всё равно нужно будет подсовывать везде свой «кошерный» аллокатор, плюс использовать новые версии методов.
                        0
                        То есть в стандарте нормального аллокатора, аллоцирующего память с MAP_POPULATE (и аналогов в других OS) по-прежнему не будет? Тогда это — очередной мёртворождённый высер…
                          0
                          Речь в существующих Proposal-ах о том, чтобы разделить API аллокаторов на noexcept и не noexcept, а также разделить функции, работающие с аллокаторами, на noexcept и не noexcept. Обрабатывать overcommit в стандарте никто не предлагает. Если вы хотите делать дополнительные проверки, то их надо делать в вашем аллокаторе, плюс использовать новые методы, иначе исключение, выброшенное вашим аллокатором, там же и останется (краш).

                          Спрашивается, зачем вообще что-то менять, если никому не станет лучше? Дело в том, что как часть плана ухода от динамических исключений, в будущем предлагается сделать максимальную часть стандартной библиотеки noexcept. (Я неточно написал: noexcept предлагается сделать все существующие функции, *которые бросают только из-за памяти*, конечно же.) Чем больше функций noexcept, тем меньше оверхед на поддержку исключений.

                          P.S. Пишу «уход от динамических исключений», но понятно, что пока это дело необозримого будущего. На вскидку, готово всё будет к C++26, потом ещё будет процесс внедрения новшеств в std, всякие deprecate-obsolete…
                        0
                        Во-первых, overcommit можно и отключить. Во-вторых, есть всякие лимиты типа cgroups. В-третьих, может быть свой менеджер памяти прямо на уровне new/delete/malloc/free.
                        Так что лично я считаю, что здесь есть две ортогональные проблемы: написание корректно обрабатывающей недостаток памяти программы, и настройка окружения. Методы с различным подходом к обработке ошибок выделения памяти значительно усложняют решение первой проблемы.
                        А решение второй проблемы — это отдельный вопрос, не касающийся C++.
                          0
                          Так что лично я считаю, что здесь есть две ортогональные проблемы
                          Это было бы так в идеальном мире с розовыми понями и единорогами, какающими радугой. А реальном мире — никто не будет заморачиваться с написанием и поддержкой кода, который можно использовать только в экзотическом, нестандартном окружении — у большинства разработчиков хватает реальных ошибок, которые проявляются на реальных системах.

                          Во-вторых, есть всякие лимиты типа cgroups.
                          Это ничего не меняет: вы по-прежнему можете «получить» память от mmap'а, а потом упасть при попытке записать туда один байт.

                          В-третьих, может быть свой менеджер памяти прямо на уровне new/delete/malloc/free.
                          Чтобы этим кто-то озаботился — оно должно быть «из коробки» по умолчанию. А до версии 2.6.23 даже «ручек» не было, чтобы такой менеджер написать… Если реальные реализации C++20 не будут такого менеджера предоставлять «из коробки» — ничего не изменится.
                            0
                            А реальном мире — никто не будет заморачиваться с написанием и поддержкой кода, который можно использовать только в экзотическом, нестандартном окружении — у большинства разработчиков хватает реальных ошибок, которые проявляются на реальных системах.

                            Ну, раз они не обрабатывают ошибки выделения памяти, то это им и не нужно. Ибо в реальности это действительно мало кому нужно. Я просто ратую за единый механизм, а не разделение уже на уровне исходного кода.
                            Это ничего не меняет: вы по-прежнему можете «получить» память от mmap'а, а потом упасть при попытке записать туда один байт.

                            Можем. Но это помогает администрировать систему и получить нужное окружение.
                            Чтобы этим кто-то озаботился — оно должно быть «из коробки» по умолчанию. А до версии 2.6.23 даже «ручек» не было, чтобы такой менеджер написать… Если реальные реализации C++20 не будут такого менеджера предоставлять «из коробки» — ничего не изменится.

                            Если вы про MAP_POPULATE, то это не выход, ибо тормозит выделение памяти. Настраивайте ОС так, как вам необходимо, и будет счастье.
                              0
                              Я просто ратую за единый механизм, а не разделение уже на уровне исходного кода.
                              Проблема в том, что «единый» механизм добавляет в программы кучу неработающего кода. Зачем?

                              Можем. Но это помогает администрировать систему и получить нужное окружение.
                              Ну и причём тут обработка нехватки памяти, тогда?

                              Настраивайте ОС так, как вам необходимо, и будет счастье.
                              Какое вдруг счастье? Подавляющее большинство программ как не обрабатывало нехватку памяти, так и не будет. Только ещё и убивать их будут раньше. Если мы что-то и хотим получить — так это отдельные процессы (какие-нибудь «супердемоны»), которые не умирают в случае нехватки памяти.
                                0
                                Проблема в том, что «единый» механизм добавляет в программы кучу неработающего кода.

                                Код работает, если есть соответствующее окружение. Overcommit — это вопрос ОС, а не языка.
                                Ну и причём тут обработка нехватки памяти, тогда?

                                При том, что об этой обработке речь.
                                Какое вдруг счастье? Подавляющее большинство программ как не обрабатывало нехватку памяти, так и не будет.

                                Если программисты написали говнокод, при чём тут язык? Пишите нормальный код, соответствующий требованиям Стандарта, выключите overcommit и наслаждайтесь.
                                  0
                                  Код работает, если есть соответствующее окружение.
                                  Код не работает просто потому что он есть. Его нужно тестировать и отлаживать, чтобы он работал.

                                  Overcommit — это вопрос ОС, а не языка.
                                  Только если код тестируется и используется на ОС как с Overcommit, так и без. Огромный процент существующих библиотек на системах без overcommit не используется и не тестируется.

                                  Пишите нормальный код, соответствующий требованиям Стандарта, выключите overcommit и наслаждайтесь.
                                  Мы не в стране розовых поней и единорогов, какающих радугой, извините. Любой код пишется и тестируется конкретными людьми под конкретные компиляторы и конкретную среду. Как бы вам ни хотелось чего-то другого.

                                  Если программисты написали говнокод, при чём тут язык?
                                  Притом что любой язык нужен лишь потому что его могут использовать реальные программисты для решения реальных задач. Если это невозможно — язык не будет использоваться, вот и всё.
                                    0
                                    Огромный процент существующих библиотек на системах без overcommit не используется и не тестируется

                                    И предложенные изменения никак этого не изменят.
                                    Мы не в стране розовых поней и единорогов, какающих радугой, извините. Любой код пишется и тестируется конкретными людьми под конкретные компиляторы и конкретную среду. Как бы вам ни хотелось чего-то другого.

                                    Я имею иной опыт. Если вы никогда не писали кроссплатформенные приложения, не стоит проецировать это на всех.
                                    Притом что любой язык нужен лишь потому что его могут использовать реальные программисты для решения реальных задач. Если это невозможно — язык не будет использоваться, вот и всё.

                                    Хорошо, буду ждать смерти C++. Ведь в других языках можно обработать ошибку нехватки памяти, и overcommit не мешает, правда?
                          0

                          Просто надо различать два разных вида выделений памяти.


                          Одно дело, когда я выделяю память, чтобы прочитать пару килобайт из сокета. Или создать какой-нибудь там виджет на экране. Или, ну, вы поняли, в общем. Тут, если я не могу выделить память, всё уже очень плохо, и я бы предпочёл просто тупо упасть. Такие ошибки выделения памяти я не обрабатываю.


                          Другое дело, когда я создаю матрицу сто тыщ на сто тыщ и что-то там потом с ней хочу делать. Такие ошибки выделения памяти обрабатывать вполне имеет смысл.

                        0
                        Еще мысль. Я как-то думал о том, какими должны быть исключения… и пришла в голову такая мысль (возможно странная): было бы хорошо, если бы функции были написаны некоторым универсальным образом, таким, что один и тот же оператор мог бы и генерировать исключение, и возвращать код возврата — в зависимости от того, как устроен вызывающий код. Т.е. объединить throw и return в специальный общий оператор.
                        Например, если выше в стеке вызовов явно были try{} catch{} — то генерируем исключение; если контекст вызовов такой, что исключений этого типа быть не должно (или например какой-нибудь блок noexcept{} ) — возвращаем код возврата. Это не исключает возможности всегда явно генерировать исключения или всегда явно возвращать код возврата.
                        Как такое сделать? Например, какой-то глобальной таблицей/стеком активных блоков try, т.е. при входе в блок try в этот глобальный стек заносится какой-то объект, при выходе — извлекается. При необходимости бросить исключение — проверяется, а нет ли в этом стеке объекта соответствующего типа. При нормальной (успешной) работе функций это вроде бы никаких накладных расходов не создает.
                        Зато это дает возможность использовать одни и те же функции и в коде без исключений, и в коде с исключениями, и никаких сюрпризов типа необработанных исключений не будет.
                        Добавлю, что такое наверное можно даже специальной библиотекой реализовать… Только нужно в точке «try {» знать какие будут «catch», можно сделать макрос и явно прописывать, но компилятор с этим справился бы лучше.
                          +1
                          Т.е. разматывать стек при попытке бросить исключение, и, если нигде не ловится, возвращать код ошибки?
                            –1
                            Ну не разматывать, а просматривать.
                            0

                            Не совсем понятно, как будет ваш сценарий работать для случая "кода возврата". Или давать доступ к обоим состояниям, с возможностью отстрелить себе ногу по самые гланды, или по сути классический try-catch.

                              0
                              Например, если выше в стеке вызовов явно были try{} catch{} — то генерируем исключение; если контекст вызовов такой, что исключений этого типа быть не должно (или например какой-нибудь блок noexcept{} ) — возвращаем код возврата.

                              Чем-то напоминает контекст использования в перле. Скажем, в скалярном контексте массив дает число элементов в массиве, в нескалярном — собственно массив. И сиди, думай, что в каждом конкретном случае программист сказать хотел. :)
                              Только вот в перле контекст обычно понятен из непосредственного окружения, а у вас получается, что по коду фиг поймешь, будет исключение или нет. И вообще… Это ж сколько глюков будет: программист забыл try/catch написать, а ему вместо исключения — код ошибки, который он, естественно, не проверяет.
                                0
                                Да, наверное вы правы, так еще больше неявности…
                                Тогда еще вариант: если функция может генерировать исключение, то компилятор должен убедиться, что оно где-то обрабатывается, иначе — ошибка компиляции. Можно ввести в прототип функции информацию об исключениях, которые она может сгенерировать.
                                Конечно, программист может влепить глобальный catch куда нибудь в main, и тогда никаких ошибок компиляции не будет… но я например ни в коем случае не буду так делать, а напротив — постараюсь перехватить исключения как можно раньше.
                                  0
                                  если функция может генерировать исключение, то компилятор должен убедиться, что оно где-то обрабатывается

                                  Осталось отменить принцип раздельной компиляции. :)
                                    0
                                    Скорее ввести новый формат объектных файлов, ориентированных на модули и содержащих достаточно метаинформации. Заодно и реализовать компиляцию шаблонов в промежуточное двоичное представление.
                                0
                                Как такое сделать? Например, какой-то глобальной таблицей/стеком активных блоков try, т.е. при входе в блок try в этот глобальный стек заносится какой-то объект, при выходе — извлекается. При необходимости бросить исключение — проверяется, а нет ли в этом стеке объекта соответствующего типа. При нормальной (успешной) работе функций это вроде бы никаких накладных расходов не создает.


                                Вообще-то, именно так и реализованы блоки try-catch в с++ =)
                                Например, если обработчика исключения нет, то throw смотрит в таблицу текущих обработчиков, видит, что там пусто и вызывает terminate не разматывая стек до main().
                                –1

                                Herb Sutter — Герб Саттер.
                                Niall Douglas — Найл Дуглас.
                                Если бы кто-то из них был китайцем, имя было бы записано иероглифами? Что за идиотская манера не переводить имена?


                                boilerplate — избыточность.

                                  +1
                                  Спасибо, поправил.
                                    –1

                                    Манера правильная, т. к. такие имена можно быстро погуглить. В случае китайцев — надо давать их латинские имена, потому что их личную страничку на китайском языке большинство читателей статей на хабре не смогут прочесть.

                                      –4

                                      При проблемах религиозного толка лучше просто уходить с русскоязычного сайта.

                                        +3
                                        Есть более многословный вариант, который можно считать хорошим тоном: писать имена на русском и при первом упоминании в скобках оригинал. Для не латинских имён — еще и латинское. Только вот мало кто с этим заморачивается.
                                          +3

                                          В первый раз — транслитерация на русский и оригинал в скобках для гугления:


                                          [...] первоначальный документ от Герба Саттера (Herb Sutter)

                                          Во второй раз — только по-русски, кому интересно уже погуглили:


                                          [...] где Герб Сеттер предлагает [...]

                                          Для людей с китайскими именами можно приводить и иероглифы, и пиньинь.

                                        0
                                        ИМХО, исключения не нужны, коды ошибок наше всё. При правильном использовании макросов, меток перехода, флагов состояний и функций инициализации/деинициализации можно написать вполне читабельный код без лишних вложенных if'ов. Чего действительно не хватает, так это проверки типов в макросах, а также их перегрузки с разным количеством параметров. Пример — тот же RETURN_IF_NULL со строкой для вывода в дебаг и без неё. Также не радует разный подход в Windows и Linux к разворачиванию эллипсиса, который в винде понимается как один параметр, что мешает использовать разнообразные хитрые трюки.
                                          0
                                          Мне больше интересно, когда же в C++ завезут блок finally?
                                          Писать код в стиле RAII не всегда удобно.
                                            +2
                                            Никто в C++20 не добавлял expected, не вводите людей в заблуждение. Это все еще proposal.
                                              0
                                              Спасибо, поправил. Мне казалось, что уже решили добавить, ан нет — в последнее время про P0323 никаких вестей.
                                              0
                                              Кажется странным решение, что функция может бросать или только статические исключения, или только динамические. Сравним с Java, где есть checked и unchecked исключения. Обычные динамические исключения хорошо выполняют роль unchecked исключений, когда непонятно, как исправить проблему, но где-то на верхнем уровне имеет смысл всё-таки поймать исключение и обработать. Или сравните с паниками Rust.

                                              Но вот согласно текущему предложению, динамическое исключение, проходящее через throws-функцию, завернётся в std::error, и чтобы достать его обратно, нужно или дождаться, пока оно окажется в не-throws функции, или танцевать с бубном. Плюс, при обработке std::error надо будет помнить, что там может оказаться завёрнутое динамическое исключение, которое нельзя игнорировать с той же лёгкостью, что и коды ошибок.

                                              По-моему, было бы логичнее сделать отдельные варианты throws(E) и throws(E) noexcept. Первый будет пропускать и статические, и динамические исключения. Второй будет пропускать только статические. Причём заворачивать динамическое исключение в std::error стоило бы только явным образом. Не уверен, что это лучшее решение, но что-то мне подсказывает, что для динамических исключений нужна «выделенная полоса».
                                                0
                                                Главная мысль запрета исключений в больших компаниях (например, Google) не в том, что они медленные на стадии проброса, а потому что при чтении кода вообще непонятно, бросает ли функция исключение или нет. Обработка ошибок должна быть явной, статические исключения не решают эту проблему, а потому Google так и продолжит банить исключения.
                                                  0
                                                  Вообще-то там совсем другое написано: Our advice against using exceptions is not predicated on philosophical or moral grounds, but practical ones. Because we'd like to use our open-source projects at Google and it's difficult to do so if those projects use exceptions, we need to advise against exceptions in Google open-source projects as well. Things would probably be different if we had to do it all over again from scratch.

                                                  Я вот пока не понимаю как детерменированные исключения будут стыковаться с кодом, который на них не рассчитан…
                                                  0

                                                  Обработка ошибок станет если не явной, то по крайней мере, предсказуемой. В статических исключениях будет аннотация throws для функций, которая будет означать "да, я кину исключение, обработай это, пожалуйста". Далее IDE сможет подсвечивать вызовы таких функций, см. пример выше. Сейчас так делать невозможно, потому что расставлять noexcept разработчики "ленятся". Кто-то считает, что нельзя отдавать такие вещи на откуп IDE, и нужно ещё и ключевое слово try при всех вызовах бросающих функций. Мне кажется, что это уже лишнее.

                                                Only users with full accounts can post comments. Log in, please.