Выбор правильной стратегии обработки ошибок (части 3 и 4)

в 12:28, , рубрики: c++, Анализ и проектирование систем, Блог компании Mail.Ru Group, никто не читает теги, обработка ошибок, Проектирование и рефакторинг, Совершенный код

image

Части 1 и 2: ссылка

В первой части мы поговорили о разных стратегиях обработки ошибок и о том, когда их рекомендуется применять. В частности, я рассказал, что предусловия функций должны проверяться с помощью отладочных утверждений (debug assertions), т. е. только в режиме отладки.

Для проверки условия библиотека С предоставляет макрос assert(), но только если не определён NDEBUG. Однако, как и в случае со многими другими вещами в С, это простое, но иногда неэффективное решение. Главная проблема, с которой я столкнулся, — глобальность решения: у вас есть утверждения либо везде, либо нигде. Плохо это потому, что вы не сможете отключить утверждения в библиотеке, оставив их только в собственном коде. Поэтому многие авторы библиотек самостоятельно пишут макросы утверждений, раз за разом.

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

Исходный код.

Проблема с assert()

Хотя assert() хорошо делает свою работу, у этого решения есть ряд проблем:

  1. Невозможно задать дополнительное сообщение, предоставляющее больше информации об условии сбоя (failed condition). Отображается только преобразованное в строку выражение. Это позволяет делать хаки вродеassert(cond && !"my message"). Дополнительное сообщение могло бы быть полезным, если само по себе условие не даёт достаточно информации, наподобие assert(false). Более того, иногда нужно передавать и дополнительные параметры.
  2. Глобальность: либо все утверждения активны, либо ни одно не активно. Нельзя управлять утверждениями для какого-то отдельного модуля.
  3. Содержимое сообщения и способ его вывода определяются реализацией. А ведь вы можете захотеть управлять им или даже интегрировать в свой код журналирования.
  4. Не поддерживаются уровни утверждений. Некоторые из утверждений дороже других, так что иногда требуется более тонкое управление.
  5. Здесь используются макросы, причём один даже в нижнем регистре (lower-case)! Макросы — не лучшая вещь, их применение лучше минимизировать.

Давайте напишем универсальный усовершенствованный assert().

Первый подход

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

struct source_location
{
    const char* file_name;
    unsigned line_number;
    const char* function_name;
};

#define CUR_SOURCE_LOCATION source_location{__FILE__, __LINE__, __func__}

void do_assert(bool expr, const source_location& loc, const char* expression)
{
    if (!expr)
    {
        // handle failed assertion
        std::abort();
    }
}

#if DEBUG_ASSERT_ENABLED
    #define DEBUG_ASSERT(Expr) 
        do_assert(expr, CUR_SOURCE_LOCATION, #Expr)
#else
    #define DEBUG_ASSERT(Expr)
#endif

Я определил вспомогательную структуру struct, которая содержит информацию о местонахождении в коде (source location). При этом саму работу выполняет функция do_assert(), а макрос просто переадресует.

Это позволяет избежать трюков с do ... while(0). Размер макросов должен быть как можно меньше.

Теперь у нас есть макрос, который просто получает текущее местонахождение в коде (source location), используемое в макросе утверждения. С помощью настройки макроса DEBUG_ASSERT_ENABLED можно включать и отключать утверждения.

Возможная проблема: предупреждение о неиспользуемой переменной

Если вы когда-либо компилировали релизную сборку с включёнными предупреждениями, то знаете, что из-за любой переменной, которая использовалась только в утверждении, появится предупреждение «неиспользованная переменная» (unused variable).

Вы можете попытаться это предотвратить, написав не-утверждение (non-assertion) вроде:

#define DEBUG_ASSERT(Expr) (void)Expr

Не делайте так!

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

iterator binary_search(iterator begin, iterator end, int value)
{
    assert(is_sorted(begin, end));
    // binary search
}

is_sorted() — это линейная операция, в то время как binary_search() имеет временную сложность O(log n). Даже при отключённых утверждениях is_sorted() всё ещё может вычисляться компилятором, потому что нет доказательств отсутствия его побочных эффектов!

Когда я совершил такую ошибку, то получил очень похожую ситуацию. Производительность сильно упала.

Но в любом случае DEBUG_ASSERT() не сильно лучше, чем assert(), так что остановимся на нём.

Внедряем настраиваемость и модульность

Проблемы номер 2 и 3 можно решить с помощью политики (policy). Это дополнительный шаблонный параметр, управляющий активацией утверждения и способом вывода сообщения на экран. В каждом модуле, в котором требуется обеспечить отдельное управление утверждениями, нужно определить свой собственный Handler:

template <class Handler>
void do_assert(bool expr, const source_location& loc, const char* expression) noexcept
{
    if (Handler::value && !expr)
    {
        // handle failed assertion
        Handler::handle(loc, expression);
        std::abort();
    }
}

#define DEBUG_ASSERT(Expr, Handler) 
    do_assert<Handler>(Expr, CUR_SOURCE_LOCATION, #Expr)

Вместо жёсткого прописывания в коде способа вычисления выражения мы вызываем функцию static handle() применительно к конкретному Handler.

Чтобы предотвратить бросания исключений Handler’ом при покидании функции, я сделал do_assert() noexcept, а для возвратов функции обработчика сделал вызов std::abort().

Функция также управляет проверкой выражения с помощью константы value (std::true_type/std::false_type). Теперь макрос утверждения безоговорочно переадресует в do_assert().

Однако у этого кода тот же недостаток, что описан выше: выражение вычисляется всегда, когда выполняется ветка Handler::value!

Вторая проблема решается легко: Handler::value — это константа, поэтому мы можем воспользоваться эмуляцией constexpr if. Но как предотвратить вычисление выражения? Пойдём на хитрость — используем лямбду:

template <class Handler, class Expr>
void do_assert(std::true_type, const Expr& e, const source_location& loc, const char* expression) noexcept
{
    if (!e())
    {
        Handler::handle(loc, expression);
        std::abort();
    }
}

template <class Handler, class Expr>
void do_assert(std::false_type, const Expr&, const source_location&, const char*) noexcept {}

template <class Handler, class Expr>
void do_assert(const Expr& e, const source_location& loc, const char* expression)
{
    do_assert<Handler>(Handler{}, e, loc, expression);
}

#define DEBUG_ASSERT(Expr, Handler) 
    do_assert<Handler>([&] { return Expr; }, CUR_SOURCE_LOCATION, #Expr)

Теперь этот код считает, что Handler наследует от std::true_type или std::false_type.

Чтобы реализовать статическую диспетчеризацию (static dispatch), мы делаем здесь «классическую» теговую диспетчеризацию (tag dispatching). Но что ещё важнее, мы изменили обработку выражения: вместо прямой передачи выражения bool (что означает вычисление выражения) макрос создаёт лямбду, которая возвращает выражение. Теперь оно будет вычисляться только при вызове лямбды.

  • Это выполняется только при включённых утверждениях.

Трюк с обёртыванием в лямбду ради откладывания вычисления полезен во всех ситуациях, когда у вас исключительно опциональные проверки, а вы не хотите использовать макросы. Например, в memory я применяю этот подход для проверок на двойное освобождение ресурсов (double deallocation).

Есть ли здесь какие-то издержки?

Макрос постоянно активен, так что он всегда будет вызывать функцию do_assert(). Для сравнения, при условном компилировании (conditional compilation) макрос работает вхолостую. Так есть ли какие-то издержки?

Я тщательно проанализировал несколько компиляторов. При компилировании с выключенными оптимизациями мы имеем только вызов do_assert(), который переадресуется в неоптимизированную версию. Выражение остаётся нетронутым, и уже на начальном уровне оптимизаций вызов полностью устраняется.

Я хотел улучшить генерирование кода при отключённых оптимизациях, поэтому включил SFINAE, чтобы выбрать перегрузку вместо теговой диспетчеризации. Благодаря этому отпадает необходимость в функции-трамплине, которая вставляет тег. Теперь макрос напрямую вызывает неоптимизированную версию. Я также пометил, чтобы он принудительно встраивался (force-inline), так что компилятор будет это делать даже без оптимизаций. Всё, что он делает, — это создаёт объект source_location.

Но, как и прежде, при любых оптимизациях макрос как будто работает вхолостую.

Добавление уровней утверждений

При таком подходе очень легко добавлять другие уровни утверждений:

template <class Handler, unsigned Level, class Expr>
auto do_assert(const Expr& expr, const source_location& loc, const char* expression) noexcept
-> typename std::enable_if<Level <= Handler::level>::type
{
    static_assert(Level > 0, "level of an assertion must not be 0");
    if (!expr())
    {
        Handler::handle(loc, expression);
        std::abort();
    }
}

template <class Handler, unsigned Level, class Expr>
auto do_assert(const Expr&, const source_location&, const char*) noexcept
-> typename std::enable_if<(Level > Handler::level)>::type {}

#define DEBUG_ASSERT(Expr, Handler, Level) 
    do_assert<Handler, Level>([&] { return Expr; }, CUR_SOURCE_LOCATION, #Expr)

Также здесь вместо тегов используется SFINAE.

При определении активированности утверждений вместо Handler::value теперь включается условие Level <= Handler::level. Чем выше уровень, тем больше утверждений активируется. Уровень 0 означает, что не выполняются никакие утверждения.

Обратите внимание: это также означает, что минимальный уровень частичного утверждения — 1.

Последний шаг: добавляем сообщение

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

Так что нам нужен макрос утверждения, который сможет обработать любое количество аргументов. То есть макрос с переменным количеством аргументов (variadic):

template <unsigned Level>
using level = std::integral_constant<unsigned, Level>;

// overload 1, with level, enabled
template <class Expr, class Handler, unsigned Level, typename ... Args>
auto do_assert(const Expr& expr, const source_location& loc, const char* expression,
               Handler, level<Level>,
               Args&&... args) noexcept
-> typename std::enable_if<Level <= Handler::level>::type
{
    static_assert(Level > 0, "level of an assertion must not be 0");
    if (!expr())
    {
        Handler::handle(loc, expression, std::forward<Args>(args)...);
        std::abort();
    }
}

// overload 1, with level, disabled
template <class Expr, class Handler, unsigned Level, typename ... Args>
auto do_assert(const Expr&, const source_location&, const char*,
               Handler, level<Level>,
               Args&&...) noexcept
-> typename std::enable_if<(Level > Handler::level)>::type {}

// overload 2, without level, enabled
template <class Expr, class Handler, typename ... Args>
auto do_assert(const Expr& expr, const source_location& loc, const char* expression,
               Handler,
               Args&&... args) noexcept
-> typename std::enable_if<Handler::level != 0>::type
{
    if (!expr())
    {
        Handler::handle(loc, expression, std::forward<Args>(args)...);
        std::abort();
    }
}

// overload 2, without level, disabled
template <class Expr, class Handler, typename ... Args>
auto do_assert(const Expr&, const source_location&, const char*,
               Handler,
               Args&&...) noexcept
-> typename std::enable_if<Handler::level == 0>::type {}

#define DEBUG_ASSERT(Expr, ...) 
    do_assert([&] { return Expr; }, CUR_SOURCE_LOCATION, #Expr, __VA_ARGS__)

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

Это вносит некоторые изменения в характер использования: перед Handler может идти имя типа и константа Level, и теперь их нужно настраивать, потому что они являются параметрами регулярной функции. Handler должен быть объектом типа обработчика, и Level, и объектом типа level<N>. Это позволяет сделать дедукцию аргумента (argument deduction) для вычисления подходящих параметров.

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

  1. DEBUG_ASSERT(expr, handler{}) — без уровня, без дополнительных аргументов.
  2. DEBUG_ASSERT(expr, handler{}, level<4>{}) — с уровнем, но без дополнительных аргументов.
  3. DEBUG_ASSERT(expr, handler{}, msg) — без уровня, но с дополнительным аргументом (сообщение).
  4. DEBUG_ASSERT(expr, handler{}, level<4>{}, msg) — с уровнем и дополнительным аргументом (сообщение).

Чтобы это реализовать, нам нужно две перегрузки (overloads) do_assert(). Первая обрабатывает все перегрузки с уровнем (2 и 4), вторая — без (1 и 3).

Но это всё ещё макрос!

Одной из проблем assert() является то, что это макрос. Да, всё ещё макрос!

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

  1. Получить текущее местонахождение в коде (source location).
  2. Преобразовать выражение в строку.
  3. Преобразовать выражение в лямбду, чтобы включить отложенное вычисление.

Что касается 1, то в Library Fundamentals V2 есть std::experimental::source_location. Этот класс представляет расположение исходного кода, как написанная мной структура struct. Но за его извлечение во время компилирования отвечают не макросы, а статическая функция класса — current(). Более того, если использовать этот класс таким образом:

void foo(std::experimental::source_location loc = std::experimental::source_location::current());

то loc получит местонахождение вызывающего фрагмента кода, а не параметра! Это именно то, что нужно для макросов утверждения.

К сожалению, во втором и третьем вариантах мы ничем не можем заменить макрос. Это нужно делать вручную посредством вызывающего фрагмента кода. Так что мы не избавимся от макроса, покуда нам нужна гибкость использования.

Промежуточное заключение

Мы создали простую утилиту для утверждений (assertion utility), гибкую в использовании, дженерик (generic) и поддерживающую отдельные уровни утверждений для каждого модуля. Во время написания этой статьи я решил опубликовать код в виде header-only библиотеки: debug-assert.

В ней вы найдёте дополнительный код, например легко генерируемые модульные обработчики:

struct my_module
: debug_assert::set_level<2>, // set the level, normally done via buildsystem macro
  debug_assert::default_handler // use the default handler
{};

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

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

Мотивация

Я работаю над standardese, генератором документации C++. И там мне приходится иметь дело с большим количеством строковых значений. В частности, я постоянно удаляю пробелы в конце строк. Поскольку это очень простая задача, а определение пробела варьируется в зависимости от ситуации, я не озаботился написанием для этого отдельной функции.

Оглядываясь назад, могу сказать, что следовало бы.

Я использую подобный код:

while (is_whitespace(str.back())
    str.pop_back();

Пишу две строки, коммичу, выполняю push и, привычно дождавшись, когда сработает CI, получаю письмо с сообщением о сбое в Windows-сборке. Я в недоумении: у меня на машине всё работало, как и во всех Linux- и MacOS-сборках! Смотрю лог: тестовое исполнение завершилось тайм-аутом.

Запускаю Windows и собираю там проект. При запуске тестов получаю удивительно скомпонованный диалог о сбое отладочных утверждений.

Тот самый, в котором Retry означает Debug.

Смотрю сообщение об ошибке. Рукалицо. Коммичу фикс:

while (!str.empty() && is_whitespace(str.back())
    str.pop_back();

Иногда строка бывает пустой. В libstdc++ в подобных случаях по умолчанию не включаются утверждения, что приводит к закономерному результату. Но в MSVC утверждения включены, и он замечает такие случаи.

Я совершил эту ошибку трижды. Всё-таки надо было написать функцию.

Также возникло ещё несколько проблем: я не следовал принципу DRY, libstdc++ по умолчанию не проверяла предусловия, Appveyor’у не нравятся графические диалоги утверждений, а MSVC под Linux не существует.

Но я считаю, что главную роль в произошедшем сыграла архитектура std::string::back(). Если бы этот класс был сделан по уму, то код бы не скомпилировался и система не напомнила мне о том факте, что строка может быть пустой. Это сэкономило бы 15 минут моей жизни и одну загрузку в Windows.

Как можно было этого избежать? С помощью системы типов.

Решение

Рассматриваемая функция имеет такую упрощённую сигнатуру (signature):

char& back();

Она возвращает последний символ строки. Если строка пустая, то в ней просто нет последнего символа, а значит, её вызов в любом случае является неопределённым поведением. Как нам об этом узнать? Если подумать, то всё очевидно: какой char должен быть возвращён в случае пустой строки? Здесь нет «неправильного» char, так что какой попало не вернёт.

На самом деле это , но в то же время это и последний символ std::string, и вы не сможете различить их.

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

У back() есть узкий контракт (narrow contract) — предусловие. Без сомнения, труднее работать с функциями с узким контрактом, чем с широким (wide contract). Так что одной из возможных задач может быть такая: сделать как можно меньше узких контрактов.

Одна из проблем функции back() — то, что в ней не предусмотрено валидного возвращаемого символа на случай пустой строки. Но в С++ 17 есть потенциально полезное дополнение: std::optional:

std::optional<char> back();

std::optional может содержать значение, а может и не содержать. Если строка не пустая, то back() возвращает optional, содержащий последний символ. Но если строка пустая, то функция может вернуть optional, равный null. То есть мы смоделировали функцию так, что теперь нам больше не нужны предусловия.

Обратите внимание: при этом мы потеряли возможность использовать back() в качестве l-значения, потому что теперь нельзя применять std::optional<T&>. Так что std::optional — не самое лучшее решение, но об этом ниже.

Предположим, что std::string::back() имеет такую сигнатуру. Я снова сосредоточился на коде парсинга комментариев и написании пары строк для быстрого стирания «висящих» пробелов:

while (is_whitespace(str.back())
    str.pop_back();

is_whitespace() берёт char, но back() возвращает std::optional, так что я немедленно получаю на своей машине ошибку компилирования. Компилятор выловил для меня возможный баг, причём статически, с помощью одной лишь системы типов! Мне автоматически напомнили, что строка может быть пустой и что мне нужно приложить дополнительные усилия для получения символа.

Конечно, я всё ещё могу ошибиться, ведь std::optional на самом деле не предназначен для этой задачи:

while (is_whitespace(*str.back())

Этот код ведёт себя точно так же, и, вероятно, в MSVC появится отладочное утверждение. std::optional<T>::operator* не должен вызываться при optional = null, он возвращает содержащееся в нём значение. Так будет чуть лучше:

while (is_whitespace(str.back().value())

По крайней мере, std::optional<T>::value() предназначен для бросания исключения при optional = null, так что как минимум будет устойчиво сбоить в ходе runtime. Но оба этих решения не имеют абсолютно никаких преимуществ по сравнению с кодом с той же сигнатурой. Эти компонентные функции (member functions) никуда не годятся, они пробивают бреши в замечательных абстракциях, они вообще не должны существовать! Вместо них лучше применять высокоуровневые функции, благодаря которым было бы необязательно запрашивать значение. А для случаев, когда это необходимо, нужно использовать не-компонентные функции (non-member functions) с длинными, примечательными именами, которые заставляют быть внимательнее, — а не просто с одиночной звёздочкой!

std::optional и впрямь не лучшее решение. Он был создан как альтернатива std::unique_ptr<T>, который не выделяет память, не больше и не меньше. Это тип-указатель (pointer type), а не монада «может быть» (Maybe), которой он мог бы быть. Из-за этого он бесполезен для решения ряда задач, когда нужны монады. Например, как эта.

Лучше воспользоваться таким решением:

while (is_whitespace(str.back().value_or(''))

std::optional<T>::value_or() возвращает либо значение, либо его альтернативу. В этом случае optional возвращает нулевой символ, который прекрасно подходит для прерывания цикла. Но, конечно, не всегда есть правильное недопустимое значение. Так что идеальным вариантом было бы изменить сигнатуру is_whitespace() так, чтобы она принимала std::optional<char>.

Руководство 1: используйте правильный тип возвращаемого значения

Есть много функций, которые либо что-то возвращают, либо вообще не должны вызываться. К таким функциям относятся и back()/front(). Их можно настроить так, чтобы они возвращали опциональный тип (optional type) вроде std::optional<T>. Затем нужно выполнить проверку предусловия, при этом сама система типов помогает избегать ошибок, а также облегчает их обнаружение и обработку.

Конечно, мы не можем применять std::optional<T> везде, где есть вероятность нарваться на ошибку. Некоторые ошибки не относятся к ошибкам предусловий. В подобных ситуациях надо бросать исключение или использовать что-то подобное предлагаемому std::expected<T, E>, который возвращает валидное значение или тип ошибки (error type). А если функции либо что-то возвращают, либо не должны вызываться при недопустимом состоянии, для них лучше возвращать опциональный тип.

Параметрические предусловия (parameter preconditions)

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

Рассмотрим функцию:

void foo(T* ptr)
{
    assert(ptr);
    …
}

Изменим сигнатуру на:

void foo(T& ref);

Теперь мы больше не можем передавать значение нулевого указателя (null pointer value). А если это всё же сделать, то вина за неопределённое поведение в связи с разыменованием (dereferencing) ляжет на вызывающих (callers).

Этот подход работает не только с простыми указателями:

void foo(int value)
{
    assert(value >= 0);
    …
}

Изменим сигнатуру на:

void foo(unsigned value);

Теперь мы не можем передавать отрицательное значение без потери значимости (underflow). К сожалению, С++ унаследовал от С неявное преобразование из типов со знаком в типы без знаков, так что решение не идеальное.

Руководство 2: используйте правильные типы аргументов

Выбирайте типы аргументов таким образом, чтобы можно было исключить предусловия и отразить их напрямую в коде. У вас есть указатель, который не должен быть null? Передайте ссылку. Целочисленное значение, которое не должно быть отрицательным? Передайте без знака. Целочисленное значение, которое может иметь лишь определённый именованный набор значений? Сделайте перечисление (enumeration).

Можно пойти ещё дальше и написать общий обёрточный тип (general wrapper type), чей — явный! — конструктор утверждает, что у «необработанного» (raw) значения есть определённое значение, например:

class non_empty_string
{
public:
    explicit non_empty_string(std::string str)
    : str_(std::move(str))
    {
        assert(!str_.empty());
    }

    std::string get() const
    {
        return str_;
    }

    … // other functions you might want

private:
    std::string str_;
};

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

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

Заключение

Система типов в C++ достаточно мощна, чтобы помогать вам ловить ошибки.

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

Автор: Mail.Ru Group

Источник

Поделиться

* - обязательные к заполнению поля