– Сир, я придумал защиту от дракона. Он нам больше не страшен! Она срабатывает от взмахов крыльев дракона и включает громкую сирену, так чтобы все слышали, что приближается дракон.
– Что-нибудь ещё эта защита делает?
– Нет, зачем? Мы будем предупреждены!
– Да… Съедены под вой сирены… И ещё… напомни, когда у нас плановые отключения электричества?…

Описание проблемы


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

Хорошая норма – считать, что в ходе выполнения программы не должен срабатывать ни один assert(). А если сработал хотя бы один assert() при тестировании приложения, то нужно отправить эту ошибку разработчику. Но, что если приложение не будет протестировано полностью? И assert() сработает у клиента? Отправить ошибку разработчику? Прервать выполнение программы? В реальности это будет release версия приложения и стандартный assert() просто будет отключен. Также возникает вопрос с внутренним противоречием системы: assert()-ов должно быть много, что бы было легче обнаружить ошибки, но assert()-ов должно быть меньше, чтобы меньше прерывать пользователя и его работу с приложением. Особенно не хотелось бы «падать», если от стабильности работы зависит то, сколько человек использует приложение и если assert() по сути был незначительным (требующим исправления, но позволявшим, например, вполне успешно продолжить работу).

Такие размышления приводят к необходимости доработать assert() c/c++. И определить свои макросы, которые расширяют функциональность стандартного assert()-а путем добавления минимальной обработки ошибок. Пусть такими макросами будут.

VERIFY_EXIT(Condition);
VERIFY_RETURN(Condition, ReturnValue);
VERIFY_THROW(Condition, Exception);
VERIFY_DO(Condition) {/*fail block*/};


(Эти макросы можно назвать и по другому. Например, VERIFY_OR_EXIT(), VERIFY_OR_RETURN(), VERIFY_OR_THROW(), VERIFY_OR_DO(). Или наоборот в более сокращенном варианте.)

Эти макросы, во-первых, имеют реализацию как для debug версии компиляции так и для release версии. Что позволяет им иметь поведение и в release версии программы. Т.е. выполнять действия не только при тестировании, но и у пользователя.

Описание макросов


(Описание макросов примерное, возможен и другой их дизайн.)

1) VERIFY_EXIT(Condition);

Проверяет условие Condition и если оно false, то вызывает стандартный assert() (debug версия), а также выходит из текущей функции (debug и release версии).

2) VERIFY_RETURN(Condition, ReturnValue);

Проверяет условие Condition и если оно false, то вызывает стандартный assert() (debug версия), а также выходит из текущей функции возвращая значение ReturnValue (debug и release версии).

3) VERIFY_THROW(Condition, Exception);

Проверяет условие Condition и если оно false, то вызывает стандартный assert() (debug версия), а также бросает исключение Exception (debug и release версии).

4) VERIFY_DO(Condition) {/*fail block*/};

Проверяет условие Condition и если оно false, то вызывает стандартный assert() (debug версия), а также выполняет блок операций (fail block) или операцию сразу следующий за макросом (debug и release версии).

Для всех макросов важно:

  • Во всех случаях Condition должен быть истинным для «прохождения» макроса и ложным для активации пути минимальной обработки ошибки.
  • Каждый из макросов реализует некоторый минимальный способ обработки ошибки. Это необходимо для реализации поведения в случае ошибок, которые не были обнаружены при тестировании, но произошли у пользователя. В зависимости от реализации, можно сообщать разработчику о произошедшей у клиента ошибке, но также каждая реализация дает минимальный способ восстановиться при ошибке.

Паттерны использования макросов


Конечно же, самое интересное, супермены энтропии (герои уменьшения ошибок в программах), это использование этих макросов.

1) Pre и post условия.

Первый вариант использования это pre и post условия. Напомню, что pre условия проверяют состояние программы (входные аргументы, состояние объекта, используемые переменные) на соответствие необходимым требованиям выполняемого фрагмента кода. Post условия (они реже встречаются в программах) предназначены для проверки того, что мы достигли необходимого результата и состояние объектов осталось валидным для текущего фрагмента кода.

Использование предлагаемых макросов прямолинейное – каждую проверку мы прописываем в отдельном макросе. Макросы мы выбираем исходя из того, какая обработка ошибок нам требуется. (VERIFY_EXIT() – обработка ошибки с выходом из данной функции, VERIFY_RETURN() – обработка ошибки с возвратом некоторого значения, VERRIFY_THROW() – обработка ошибки с генерацией исключения и т.д.)

Также можно добавить или использовать макрос VERIFY(), который не будет совершать никакой обработки ошибки. Это может быть полезным, например в post условия в конце функции.

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

2) Семантика транзакции.

Также эти макросы могут быть использованы для реализации кода с семантикой транзакции. Под такой семантикой понимается: 1) постепенная подготовка к выполнению операции с проверкой результатов каждого из этапов подготовки; 2) выполнение действия только если все этапы подготовки прошли успешно; 3) отказ от выполнения, если некоторые условия не соблюдены на этапе подготовки (с возможным откатом от выполнения).

3) Проектирование кода с учетом возможного расширения.

Это особенно актуально для библиотек и общего кода, который первоначально может разрабатываться в рамках одного контекста условий выполнения, а позже может начать использоваться с другими условиями (начать использоваться иначе). В таком случае данные макросы могут описать «границы» функциональности кода. Определить, что первоначально рассматривалось как ошибка, а что являлось успешным выполнением. (Этот подход близок к классическим pre post условиям.) Конечно, «границы» я пишу в кавычках, т.к. эти границы могут быть пересмотрены, но важно определить (а точнее передать будущим разработчикам) знание о допустимых границах проектирования кода.

Реализация макросов


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

Макросы должны быть представимы в виде одного оператора. Что можно сделать с помощью конструкций do{}while(false) или аналогичной. Например так:

#define VERFY_EXIT(cond)	do{bool _= (bool)(cond); assert(_); if(!_) {return;}} while(false)	/*end macro VERIFY_EXIT()*/

Тогда можно написать следующий код:

if(a > 0) VERIFY_EXIT(a%2==0);

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

P.S. Успешного сражения с энтропией, супермены!

Комментарии (16)


  1. mapron
    07.02.2019 09:24

    Выскажу свое мнение:
    макрос VERIFY_RETURN сильно нарушает принцип «вызов макроса должен быть максимально приближен к вызову обычной функции». Очень сильно снижает понимание кода на мой взгляд. Опять же, переводить потом такой код на C++20 contracts будет тяжело.


    1. klizardin Автор
      07.02.2019 15:47

      Большое спасибо. Отличное замечание.


  1. maxkomp
    07.02.2019 10:12

    Тоже использую в процессе разработки похожие "маленькие хитрости" вместо обычного assert.
    Когда хотелось бы сообщить, что "у нас тут что-то пошло не совсем так, как задумано", или "в этом месте надо получше проверить", но при этом, чтобы "без паники, ничего страшного пока не случилось".


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


    Вторая просто издает встроенным динамиком характерный короткий звук.


    Спасибо за статью.


    1. TheDaemon
      07.02.2019 10:34

      Вместе функции с брякой на винде можно использвать DebugBreak/__debugbreak, а в линуксе raise(SIGTRAP).


      1. maxkomp
        07.02.2019 12:13

        DebugBreak — это хорошая штука. Но бывает, что вдруг этот «бряк» потребовалось временно отменить (типа, «да я уже все понял, пожалуйста, перестань мне бряки сыпать и дай транзакцию завершить нормально)
        В IDE точка останова BreakPoint включается и выключается при необходимости одним щелчком мыша. Плюс всякие бонусы типа счетчиков и доп. действий (»останови здесь, но не при первом проходе, а на 1587-й раз", или «не надо тут останавливаться, но в журнал запиши»)

        А для отмены или изменения DebugBreak придется код на лету менять. Не всегда это удобно.


      1. MikailBag
        08.02.2019 01:09

        Еще есть вариант
        asm __volatile("int3");
        Который, насколтко я помню, работает в GCC и на винде и на линуксе.


  1. TheDaemon
    07.02.2019 10:18

    Автор, вы неверно понимаете назначение ассертов.
    Классические труды по C++ говорят, что:

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


    Именно поэтому обычный макрос assert в релизе превращается в тыкву :)


    1. klizardin Автор
      07.02.2019 15:47
      +1

      В статье отмечено, что данное предложение:
      1) для проектов без «идеальной» обработки ошибок;
      2) для проектов, которые не могут по тем или иным причинам проводить достаточное тестирование перед выпуском в релиз, чтобы определить все срабатывания assert()-ов на этапе тестирования.

      Согласен с вашими пунктами 1, 2 и 3. Но мы, к сожалению, не в идеальном мире живем. Предложено весьма и весьма компромиссное решение. В духе — лучше мало, чем совсем ничего. И, конечно, да «любите Бродского, почитайте наконец-то и других поэтов» — задумайтесь о нормальной системе обработки ошибок.


      1. mapron
        07.02.2019 15:49

        Разработчики контрактов в новом стандарте как раз таки вдохновлялись идеей, что мы не в идеальном мире живем) Там возможность превратить срабатывания ассерта — в исключение — изначально задумана.


    1. fronda
      07.02.2019 16:25
      -1

      Именно поэтому обычный макрос assert в релизе превращается в тыкву :)

      И более того при зрелом процессе assert-ы должны быть убраны из кода (хотя ловушки на фатальные ошибки могут продолжать иметь место быть, но это другая история)


  1. m1n7
    07.02.2019 10:58

    В glib/gobject для такого есть g_return_if_fail (cond, returnable), так что технология не нова.


    Для упрощения отладки можно задействовать макросы препроцессора для вывода строки и функции


    1. dev96
      07.02.2019 18:32

      В UE4 и PhysX тоже пачка таких макросов


  1. faoriu
    07.02.2019 11:31

    Не совсем понял: если в каждом случае первым делом вызывается обычный assert, то не происходит ли в этот момент вылетание программы во всех версиях?


    1. SmallSnowball
      07.02.2019 13:14
      +1

      assert в релизных сборках препроцессором превращается в ((void)0)


  1. Filippok
    07.02.2019 17:56
    +1

    do{bool = (bool)(cond); assert(); if(!_) {return;}} while(false)

    Довольно хреновая реализация. При разных вызовах ассерт будет одним и тем-же, что-то типа "Assertion failed: false". С разницей только в файле и строке. Т.е. не заглядывая в код будет не реально определить источник проблемы. А вполне вероятно, что ошибка в данных и смотреть в исходники не имеет смысла.


    1. klizardin Автор
      07.02.2019 21:18

      да, лучше вариант, например:

      #define VERFY_EXIT(cond)	do{bool _= false; assert(_ = (bool)(cond)); if(!_) {return;}} while(false)	/*end macro VERIFY_EXIT()*/