0
Обложка: Как в С++ обрабатывать ошибки в конструкторах без исключений?

Как в С++ обрабатывать ошибки в конструкторах без исключений?

Просматривая сабреддит С++,  я встретил следующий комментарий:

Преимущества использования исключений сомнительны, но мои претензии к ним не в этом. Исключения — экстраординарный инструмент, которые делает код более непонятным и запутанным. А прямую локальную обработку — слишком пространной. Исключения стали для меня худшим средством обработки ошибок. Печально, что создание объектов в С++ основано на их применении (позднее этот пост был отредактирован — прим.пер.).

Я не стану встревать с критикой в дискуссию об исключениях,  развернувшуюся сейчас в обсуждении к приведённому выше комментарию. Я собираюсь сконцентрироваться на той части, где автор сетует на то, что конструкторы С++ требуют применения исключений для обработки ошибок. Итак, давайте представим, что у вас в приложении нет поддержки исключений и есть конструктор, который должен сообщить об ошибке. Как вы поступите?

Предупреждение. Если вы являетесь страстным поклонником исключений: я не выступаю против их использования.

Предупреждение. Если вы являетесь ярым приверженцем политики отказа от применения исключений: я не призываю к их использованию.

Проблема

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

Однако есть несколько способов возврата значения из функции. Например, можно использовать выходные параметры:

foo(arg_t argumwent, std::error_code&ec)
{
if (initialization_failed(argument))
   ec = …;
}

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

Однако у этого метода множество недостатков. Основной в том, что ничто не мешает не проверить код ошибки: забыть сделать это очень легко. Есть ещё один — и его коварство заключается в незаметности: если исключение передано в конструктор, объект не был создан. Это значит, что его деструктор не будет вызван. Более того, получить доступ к объекту в состоянии ошибки невозможно. Исключение немедленно удаляет локальную переменную.

Если возврат вызова конструктора был успешным, то объект считается валидным. Это делает возможной идиому RAII («Получение ресурса есть инициализация» — прим.пер.). Рассмотрим класс, в распоряжении которого есть некий ресурс. Конструктор получает его, а деструктор разрушает. Мы хотим обеспечить выполнение гарантии непустого объекта (never-empty guarantee): каждый объект класса должен иметь ресурс.

Допустим, мы решили проблему семантики переноса. Тогда мы легко можем создать конструктор:

foo(arg_t argument)
: resource(acquire_resource(argument))
{
  if (!resource)
throw no_resource();
}

Благодаря гарантии это обеспечивает каждый объект ресурсом. Когда генерируется исключение, объекта не существует.

Всё это недоступно вам, если вы используете выходной параметр, чтобы установить код ошибки. Тогда деструктору приходится иметь дело со всеми возможными состояниями ошибки. А пользователь должен соблюдать осторожность и не использовать объект в состоянии ошибки. Это делает невыполнимой гарантию непустого объекта. Каждый объект имеет по крайней мере два состояния: валидное и невалидное.

Обход проблемы

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

Поэтому самый лёгкий способ обработки ошибок в конструкторе — это просто не пользоваться исправимыми техниками обработки ошибок. Используйте методы, не допускающие продолжения работы программы. Такие как, например, сообщение в stderr и вызов abort( ).

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

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

Также можно использовать метод, который я окрестил обработчиком исключений (exception handler). Но его можно  применить, только если отключить исключения с помощью оператора if.

Но, это всё только пути обхода проблемы. Так давайте же решим её.

Решение

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

Подождите, дайте мне сказать.

Я не предлагаю функцию init( ) или что-то вроде неё.  Если вы примените её, то потеряете все гарантии RAII, а также, наверное, вам придётся вызывать функцию destroy( ), так как для невалидных объектов будет вызван деструктор. И тогда с таким же успехом можно написать API для Си.

RAII нетрудный, делает жизнь намного легче и не имеет недостатков — если не считать ситуацию с исключениями в конструкторе.

Одна из особенностей С++ в том, что все возможности языка могут осуществляться самостоятельно — компилятор соберёт для вас то, что написано. А теперь, давайте взглянем на конструкторы.

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

Так же работает подход с применением методов init( ) и  destroy( ): конструктор объекта ничего не делает, поэтому компилятор только выделяет память. И на самом деле init( ) и destroy( ) затем создают объект.

Но мы не хотим создать два состояния отдельно от самого объекта.  Каждый созданный объект должен быть валидным. Вся сложность невалидного состояния и проблемы, которые она с собой несёт, должна быть передана куда-нибудь в другое место. Нам нужна оболочка, которая будет представлять нам невалидное состояние, когда объект отсутствует.

Оболочка optional, например. Вместо конструктора, который мы не поддерживаем — что делает невозможным создание объектов. Единственный способ создать объект — с помощью статической функции, например. Но это регулярная функция, следовательно, мы можем использовать оператор возврата. В частности, она возвращает объект optional:

optional<foo> make(arg_t argument, std::error_code& ec)
{
    auto resource = make_resource(argument);
    if (resource)
       return foo(resource)
    return {};
}

Если всё прошло успешно, мы можем вернуть объект. Но в случае ошибки, возвращать невалидный объект не нужно. Вместо этого мы можем вернуть пустой optional. API может выглядеть как-нибудь так:

std::error_code ec;
auto result = foo::make(arg, ec);
if (result)
{
  // everything alright
   …
}
else
  handle_error(ec);

Теперь каждый раз, когда мы получаем объект, он гарантированно валидный. Невалидный объект передаётся туда, где его обработка может быть выполнена лучшим образом. Таким образом каждой функции-члену и деструктору нет необходимости иметь дело с невалидным объектом. Так, до тех пор, пока функция make( ) только создаёт объекты, то есть вызывает конструктор, ничего не может пойти не так.

Лучшее представление отчёта об ошибках

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

Лучшим решением могла бы стать интеграция c возвращаемым значением. Вместо  возвращения optional, используйте класс «значение или ошибка». Предлагаемый здесь std::expected делает это и позволяет обрабатывать ошибки более элегантным способом.

А как насчёт конструкторов копирования?

Этот метод хорош в работе с «регулярными» конструкторами, но что насчёт копирования? С выполнением этой операции всё ещё могут быть проблемы.

Есть два решения: не поддерживать операции копирования и только перемещать — что обычно удаётся — или применить тот же метод снова. Предусмотреть статическую копирующую функцию, которая делает то же самое, возвращает optional/expected, и так далее.

Заключение

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

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

Рассказывает разработчик библиотек C++ Jonathan Müller (foonathan) Перевод YuliaJenna (juliajenna)