Assert — это специальная конструкция, позволяющая проверять предположения о значениях произвольных данных в произвольном месте программы. Эта конструкция может автоматически сигнализировать при обнаружении некорректных данных, что обычно приводит к аварийному завершению программы с указанием места обнаружения некорректных данных.
Странная, на первый взгляд, конструкция — может завалить программу в самый неподходящий момент. Какой же в ней смысл? Давайте вместе подумаем, что произойдет, если во время исполнения программы в какой-то момент времени некоторые данные программы стали некорректными и мы не «завалили» сразу же программу, а продолжили ее работу, как ни в чем не бывало.
Программа может еще долго работать после этого без каких-либо видимых ошибок. А может в любой момент времени в будущем «завалиться» сама по известной только ей причине. Или вдруг накачать вам полный винчестер контента с гей-порносайтов.
Assert’ы доступны во многих языках программирования, включая java, c#, c и python.
Assert’ы позволяют отлавливать ошибки в программах на этапе компиляции либо во время исполнения. Проверки на этапе компиляции не так важны — в большинстве случаев их можно заменить аналогичными проверками во время исполнения программы. Иными словами, assert’ы на этапе компиляции являются ничем иным, как синтаксическим сахаром.
Поэтому в дальнейшем под assert’ами будем подразумевать лишь проверки во время исполнения программы
Assert’ы можно разделить на следующие классы.
Если найдено недопустимое значение какого-либо аргумента, значит, где-то рядом с местом вызова этой функции могут быть баги.
Вот пример:
// Считает факториал числа n. // Число n должно лежать в пределах от 0 до 10 включительно. int factorial(int n) { // Факториал отрицательного числа не считается :) assert(n >= 0); // Если n превысит 10, то это может привести либо к целочисленному // переполнению результата, либо к переполнению стэка. assert(n <= 10); if (n < 2) { return 1; } return factorial(n - 1) * n; } // мы 'забыли' об ограничениях функции factorial() и пытаемся вычислить // факториалы чисел от 0 до 99. // // проверка внутри factorial() любезно напомнит нам о своих ограничениях, // так что мы сможем быстро выявить и исправить этот баг. // // если бы эта проверка отсутствовала, то баг мог бы долго оставаться // незамеченным, периодически давая о себе знать переполнениями стэка и // некорректным поведением программы. for (int i = 0; i < 100; ++i) { a[i] = factorial(i); }
o Что такое целочисленное переполнение.
o Что такое переполнение стэка.
Важно понимать, что входящие аргументы функции могут быть неявными.
Например, при вызове метода класса в функцию неявно передается указатель на объект данного класса (aka this
и self
). Также функция может обращаться к данным, объявленным в глобальной области видимости, либо к данным из области видимости лексического замыкания. Эти аргументы тоже желательно проверять с помощью assert’ов при входе в функцию.
Если некорректные данные обнаружены на этом этапе, то код данной функции может содержать баги.
Вот пример:
int factorial(int n) { int result = 1; for (int i = 2; i <= n; ++i) { result *= i; } // С первого взгляда эта проверка никогда не сработает - факториал должен // быть всегда положительным числом. Но как только n превысит допустимый // предел, произойдет целочисленное переполнение. В этом случае // a[i] может принять отрицательное либо нулевое значение. // // После срабатывания этой проверки мы быстро локализуем баг и поймем, // что либо нужно ограничивать значение n, либо использовать целочисленную // арифметику с бесконечной точностью. assert(result > 0); return result;
o Что такое арифметика с бесконечной точностью.
Результат функции может быть неявным. Например, функция может модифицировать данные, на которые ссылаются (напрямую или косвенно) аргументы функции. Также функция может модифицировать данные из глобальной области видимости или из области видимости лексического замыкания.
Корректность этих данных желательно проверять перед выходом из функции.
Если в середине функции обнаруживаются некорректные данные, то баги могут быть где-то в районе этой проверки.
int factorial(int n) { int result = 1; while (n > 1) { // Знакомая нам проверка на целочисленное переполнение. // // При ее срабатывании мы быстро определим, что эта функция должна уметь // корректно обрабатывать слишком большие n, ведущие к переполнению. // // Эта проверка лучше, чем проверка из предыдущего пункта (перед выходом // из функции), т.к. она срабатывает перед первым переполнением result, // тогда как проверка из предыдущего пункта может пропустить случай, когда // в результате переполнения (или серии переполнений) итоговое значение // result остается положительным. assert(result <= INT_MAX / n); result *= n; --n; } return result; }
Ответ прост — используйте assert’ы всегда и везде, где они хоть чуточку могут показаться полезными. Ведь они существенно упрощают локализацию багов в коде. Даже проверка результатов выполнения очевидного кода может оказаться полезной при последующем рефакторинге, после которого код может стать не настолько очевидным и в него может запросто закрасться баг.
Не бойтесь, что большое количество assert’ов ухудшит ясность кода и замедлит выполнение вашей программы. Assert’ы визуально выделяются из общего кода и несут важную информацию о предположениях, на основе которых работает данный код.
Правильно расставленные assert’ы способны заменить большинство комментариев в коде
Большинство языков программирования поддерживают отключение assert’ов либо на этапе компиляции, либо во время выполнения программы, так что они оказывают минимальное влияние на производительность программы. Обычно assert’ы оставляют включенными во время разработки и тестирования программ, но отключают в релиз-версиях программ.
Если программа написана в лучших традициях ООП, либо с помощью enterprise методологии, то assert’ы вообще можно не отключать — производительность вряд ли изменится :)
Понятно, что дублирование assert’ов через каждую строчку кода не сильно улучшит эффективность отлова багов. Не существует единого мнения насчет оптимального количества assert’ов, также как и насчет оптимального количество комментариев в программе.
Когда я только узнал про существование assert’ов, мои программы стали содержать 100500 assert’ов, многие из которых многократно дублировали друг друга. С течением времени количество assert’ов в моем коде стало уменьшаться. Следующие правила позволили многократно уменьшить количество assert’ов в моих программах без существенного ухудшения в эффективности отлова багов:
• Можно избегать дублирующих проверок входящих аргументов путем размещения их лишь в функциях, непосредственно работающих с данным аргументом. Т.е. если функция foo()
не работает с аргументом, а лишь передает его в функцию bar()
, то можно опустить проверку этого аргумента в функции foo()
, т.к. она продублирована проверкой аргумента в функции bar()
.
• Можно опускать assert’ы на недопустимые значения, которые гарантированно приводят к краху программы в непосредственной близости от данных assert’ов, т.е. если по краху программы можно быстро определить местонахождение бага. К таким assert’ам можно отнести проверки указателя на NULL
перед его разыменованием и проверки на нулевое значение делителя перед делением. Еще раз повторюсь — такие проверки можно опускать лишь тогда, когда среда исполнения гарантирует крах программы в данных случаях.
Вполне возможно, что существуют и другие способы, позволяющие уменьшить количество assert’ов без ухудшения эффективности отлова багов.
Т.к. assert’ы могут быть удалены на этапе компиляции либо во время исполнения программы, они не должны менять поведение программы. Если в результате удаления assert’а поведение программы может измениться, то это явный признак неправильного использования assert’а.
Таким образом, внутри assert’а нельзя вызывать функции, изменяющие состояние программы либо внешнего окружения программы
Например, следующий код неправильно использует assert’ы:
// Захватывает данный мютекс. // // Возвращает 0, если невозможно захватить данный мютекс из-за следующих причин: // - мютекс уже был захвачен. // - mtx указывает на некорректный объект мютекса. // Возвращает 1, если мютекс успешно захвачен. int acquire_mutex(mutex *mtx); // Освобождает данный мютекс. // // Возвращает 0, если невозможно освободить данный мютекс из-за следующих // причин: // - мютекс не был захвачен. // - mtx указывает на некорректный объект мютекса. // Возвращает 1, если мютекс успешно захвачен. int release_mutes(mutex *mtx); // Убеждаемся, что мютекс захвачен. assert(acquire_mutex(mtx)); // Работаем с данными, "защищенными" мютексом. process_data(data_protected_by_mtx); // Убеждаемся, что мютекс освобожден. assert(release_mutes(mtx));
Очевидно, что данные могут оказаться незащищенными при отключенных assert’ах.
Чтобы исправить эту ошибку, нужно сохранять результат выполнения функции во временной переменной, после чего использовать эту переменную внутри assert’а:
int is_success; is_success = acquire_mutex(mtx); assert(is_success); // Теперь данные защищены мютексом даже при отключенных assert'ах. process_data(data_protected_by_mtx); is_success = release_mutex(mtx); assert(is_success);
Например:
// Пытается записать buf_size байт данных, на которые указывает buf, // в указанное сетевое соединение connection. // // Возвращает 0 в случае ошибки записи, возникшей не по нашей вине. Например, // произошел разрыв сетевого соединения во время записи. // Возвращает 1 в случае успешной записи данных. int write(connection *connection, const void *buf, size_t buf_size); int is_success = write(connection, buf, buf_size); // "Убеждаемся", что данные корректно записаны. assert(is_success);
Если write()
возвращает 0, то это вовсе не означает, что в нашей программе есть баг. Если assert’ы в программе будут отключены, то ошибка записи может остаться незамеченной, что впоследствие может привести к печальным результатам. Поэтому assert()
тут не подходит.
Тут лучше подходит обычная обработка ошибки.
Например:
while (!write(connection, buf, buf_size)) { // Пытаемся создать новое соединение и записать данные туда еще раз. close_connection(connection); connection = create_connection(); }
В некоторых языках программирования отсутствует явная поддержка assert’ов. При желании они легко могут быть там реализованы, следуя следующему «паттерну проектирования»:
function assert(condition) { if (!condition) { throw "Assertion failed! See stack trace for details"; } } assert(2 + 2 === 4); assert(2 + 2 === 5);
Грамотно расставленные assert’ы упрощают автоматизированное тестирование кода, т.к. тестирующая программа может опустить проверки, дублирующие assert’ы в коде программы. Такие проверки обычно составляют существенную долю всех проверок в тестирующей программе.