– Что-нибудь ещё эта защита делает?
– Нет, зачем? Мы будем предупреждены!
– Да… Съедены под вой сирены… И ещё… напомни, когда у нас плановые отключения электричества?…
Описание проблемы
Данный способ не претендует на концепцию обработки ошибок в комплексных и сложных проектах. Скорее это пример того, что можно сделать минимальными средствами.
Хорошая норма – считать, что в ходе выполнения программы не должен срабатывать ни один 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)
maxkomp
07.02.2019 10:12Тоже использую в процессе разработки похожие "маленькие хитрости" вместо обычного assert.
Когда хотелось бы сообщить, что "у нас тут что-то пошло не совсем так, как задумано", или "в этом месте надо получше проверить", но при этом, чтобы "без паники, ничего страшного пока не случилось".
Кроме макросов похожих на описанные в статье, для ловли своих багов иногда применяю еще макросы под кодовыми названиями "бряк" и "блям". Первая — это вызов статической функции, которая сама по себе ничего не делает, но в теле которой в отладчике постоянно установлен breakpoint.
Вторая просто издает встроенным динамиком характерный короткий звук.
Спасибо за статью.
TheDaemon
07.02.2019 10:34Вместе функции с брякой на винде можно использвать DebugBreak/__debugbreak, а в линуксе raise(SIGTRAP).
maxkomp
07.02.2019 12:13DebugBreak — это хорошая штука. Но бывает, что вдруг этот «бряк» потребовалось временно отменить (типа, «да я уже все понял, пожалуйста, перестань мне бряки сыпать и дай транзакцию завершить нормально)
В IDE точка останова BreakPoint включается и выключается при необходимости одним щелчком мыша. Плюс всякие бонусы типа счетчиков и доп. действий (»останови здесь, но не при первом проходе, а на 1587-й раз", или «не надо тут останавливаться, но в журнал запиши»)
А для отмены или изменения DebugBreak придется код на лету менять. Не всегда это удобно.
MikailBag
08.02.2019 01:09Еще есть вариант
asm __volatile("int3");
Который, насколтко я помню, работает в GCC и на винде и на линуксе.
TheDaemon
07.02.2019 10:18Автор, вы неверно понимаете назначение ассертов.
Классические труды по C++ говорят, что:
- ассерты для отладки. Т.е. если сработал ассерт, надо исправлять код.
- исключения для исключительных ситуаций, т.е. задача не может быть выполнена, но продолжение работы возможно где-то в другом месте
- обработка ошибок для нормальной работы, т.е. для обработки тех ошибок, которые встречаются в обычной работе с программой, например: пользовательский ввод
Именно поэтому обычный макрос assert в релизе превращается в тыкву :)klizardin Автор
07.02.2019 15:47+1В статье отмечено, что данное предложение:
1) для проектов без «идеальной» обработки ошибок;
2) для проектов, которые не могут по тем или иным причинам проводить достаточное тестирование перед выпуском в релиз, чтобы определить все срабатывания assert()-ов на этапе тестирования.
Согласен с вашими пунктами 1, 2 и 3. Но мы, к сожалению, не в идеальном мире живем. Предложено весьма и весьма компромиссное решение. В духе — лучше мало, чем совсем ничего. И, конечно, да «любите Бродского, почитайте наконец-то и других поэтов» — задумайтесь о нормальной системе обработки ошибок.mapron
07.02.2019 15:49Разработчики контрактов в новом стандарте как раз таки вдохновлялись идеей, что мы не в идеальном мире живем) Там возможность превратить срабатывания ассерта — в исключение — изначально задумана.
fronda
07.02.2019 16:25-1Именно поэтому обычный макрос assert в релизе превращается в тыкву :)
И более того при зрелом процессе assert-ы должны быть убраны из кода (хотя ловушки на фатальные ошибки могут продолжать иметь место быть, но это другая история)
m1n7
07.02.2019 10:58В glib/gobject для такого есть
g_return_if_fail (cond, returnable)
, так что технология не нова.
Для упрощения отладки можно задействовать макросы препроцессора для вывода строки и функции
faoriu
07.02.2019 11:31Не совсем понял: если в каждом случае первым делом вызывается обычный assert, то не происходит ли в этот момент вылетание программы во всех версиях?
Filippok
07.02.2019 17:56+1do{bool = (bool)(cond); assert(); if(!_) {return;}} while(false)
Довольно хреновая реализация. При разных вызовах ассерт будет одним и тем-же, что-то типа "Assertion failed: false". С разницей только в файле и строке. Т.е. не заглядывая в код будет не реально определить источник проблемы. А вполне вероятно, что ошибка в данных и смотреть в исходники не имеет смысла.
klizardin Автор
07.02.2019 21:18да, лучше вариант, например:
#define VERFY_EXIT(cond) do{bool _= false; assert(_ = (bool)(cond)); if(!_) {return;}} while(false) /*end macro VERIFY_EXIT()*/
mapron
Выскажу свое мнение:
макрос VERIFY_RETURN сильно нарушает принцип «вызов макроса должен быть максимально приближен к вызову обычной функции». Очень сильно снижает понимание кода на мой взгляд. Опять же, переводить потом такой код на C++20 contracts будет тяжело.
klizardin Автор
Большое спасибо. Отличное замечание.