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

habr.png

Странно, что на Хабре до сих пор не было упомянуто о наделавшем шуму предложении к стандарту 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 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 parse_int(const std::string& s);
optional get_or_null(map m, const T& key);

В C++17 из Boost в std попал optional (без поддержки optional), в C++20 добавили expected.


Contracts

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


  • expects проверяет параметры функции
  • ensures проверяет возвращаемое значение функции (принимает его в качестве аргумента)
  • assert — цивилизованная замена макросу assert
double unsafe_at(vector 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 result = fetch(vector{e});
    [[assert result.size() == 1]];
    return v[0];
}

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


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

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


std: error_code

Библиотека , добавленная в 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 
using result = expected;

my::file_handle open_internal(const std::string& name, int& error);

auto open_file(const std::string& name) -> result
{
    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> может быть до 2 раз медленнее, чем std::vector из-за кучи занулений старых указателей при перемещении, с последующим удалением пустышек.

Разработчики 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> с учётом этого 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

Библиотека , разработанная Niall Douglas, будет содержать status_code — «новый, лучший» 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, или ещё какой-нибудь сумасшедший способ владения строкой. Niall утверждает, что #include сделало бы заголовок непозволительно «тяжёлым»

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

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


error

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

using error = errored_status_code;

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


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

Проблема в том, что случае 3 не планируется дать возможность привести error обратно к status_code. Единственное, что можно сделать — получить message() упакованного status_code. Чтобы иметь возможность достать обратно завёрнутое в error значение, надо выбросить его как динамическое исключение (!), потом поймать и завернуть в error. А вообще, Niall считает, что в 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, пользователь должен будет объявлять самостоятельно, хотя boilerplate можно убрать с помощью макросов. Синтаксис состоит из (для простоты будем так считать) ключевых слов 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), соответственно. Некоторое время старый и новый варианты функций стандартной библиотеки будут сосуществовать, потом старые объявят deprecated.


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 сообщают о нарушении предусловия функции. В новой модели ошибок вместо них должны использоваться контракты. Перечисленные типы исключений не будут объявлены deprecated, но почти все случаи их использования в стандартной библиотеке будут заменены на [[expects: …]].


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

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

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


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

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

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


Заключение

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

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

© Habrahabr.ru