Pull to refresh

Assert. Что это?

Reading time8 min
Views438K
Assert — это специальная конструкция, позволяющая проверять предположения о значениях произвольных данных в произвольном месте программы. Эта конструкция может автоматически сигнализировать при обнаружении некорректных данных, что обычно приводит к аварийному завершению программы с указанием места обнаружения некорректных данных. Странная, на первый взгляд, конструкция — может завалить программу в самый неподходящий момент. Какой же в ней смысл? Давайте подумаем, что произойдет, если во время исполнения программы в какой-то момент времени некоторые данные программы стали некорректными и мы не «завалили» сразу же программу, а продолжили ее работу, как ни в чем не бывало. Программа может еще долго работать после этого без каких-либо видимых ошибок. А может в любой момент времени в будущем «завалиться» сама по известной только ей причине. Или накачать вам полный винчестер контента с гей-порносайтов. Это называется неопределенное поведение (undefined behavior) и, вопреки расхожему мнению, оно свойственно не только языкам программирования с произвольным доступом к памяти (aka C, C++). Т.к. assert завершает программу сразу же после обнаружения некорректных данных, он позволяет быстро локализировать и исправить баги в программе, которые привели к некорректным данным. Это его основное назначение. Assert'ы доступны во многих языках программирования, включая java, c#, c и python.
Какие виды assert'ов бывают?
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);
}

Что такое целочисленное переполнение.
Что такое переполнение стэка.

Важно понимать, что входящие аргументы функции могут быть неявными. Например, при вызове метода класса в функцию неявно передается указатель на объект данного класса (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;
}


Что такое арифметика с произвольной точностью.
Результат функции может быть неявным. Например, функция может модифицировать данные, на которые ссылаются (напрямую или косвенно) аргументы функции. Также функция может модифицировать данные из глобальной области видимости или из области видимости лексического замыкания. Корректность этих данных желательно проверять перед выходом из функции.
  • Проверка данных, с которыми работает функция, внутри кода функции.
Если в середине функции обнаруживаются некорректные данные, то баги могут быть где-то в районе этой проверки. Пример:
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'ов либо на этапе компиляции, либо во время выполнения программы, так что они оказывают минимальное влияние на производительность программы. Обычно assert'ы оставляют включенными во время разработки и тестирования программ, но отключают в релиз-версиях программ. Если программа написана в лучших традициях ООП, либо с помощью enterprise методологии, то assert'ы вообще можно не отключать — производительность вряд ли изменится.

Когда можно обойтись без assert'ов?
Понятно, что дублирование assert'ов через каждую строчку кода не сильно улучшит эффективность отлова багов. Не существует единого мнения насчет оптимального количества assert'ов, также как и насчет оптимального количество комментариев в программе. Когда я только узнал про существование assert'ов, мои программы стали содержать 100500 assert'ов, многие из которых многократно дублировали друг друга. С течением времени количество assert'ов в моем коде стало уменьшаться. Следующие правила позволили многократно уменьшить количество assert'ов в моих программах без существенного ухудшения в эффективности отлова багов:
Можно избегать дублирующих проверок входящих аргументов путем размещения их лишь в функциях, непосредственно работающих с данным аргументом. Т.е. если функция foo() не работает с аргументом, а лишь передает его в функцию bar(), то можно опустить проверку этого аргумента в функции foo(), т.к. она продублирована проверкой аргумента в функции bar().
Можно опускать assert'ы на недопустимые значения, которые гарантированно приводят к краху программы в непосредственной близости от данных assert'ов, т.е. если по краху программы можно быстро определить местонахождение бага. К таким assert'ам можно отнести проверки указателя на NULL перед его разыменованием и проверки на нулевое значение делителя перед делением. Еще раз повторюсь — такие проверки можно опускать лишь тогда, когда среда исполнения гарантирует крах программы в данных случаях.
Вполне возможно, что существуют и другие способы, позволяющие уменьшить количество assert'ов без ухудшения эффективности отлова багов. Если вы в курсе этих способов, делитесь ими в комментариях к данному посту.

Когда нельзя использовать 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);


Т.к. основное назначение assert'ов — отлов багов (aka ошибки программирования), то они не могут заменить обработку ожидаемых ошибок, которые не являются ошибками программирования. Например:
// Пытается записать 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();
}


Я программирую на javascript. В нем нет assert'ов. Что мне делать?
В некоторых языках программирования отсутствует явная поддержка assert'ов. При желании они легко могут быть там реализованы, следуя следующему «паттерну проектирования»:

function assert(condition)
{
  if (!condition) {
    throw "Assertion failed! See stack trace for details";
  }
}

assert(2 + 2 === 4);
assert(2 + 2 === 5);


Вообще, assert'ы обычно реализованы в различных фреймворках и библиотеках, предназначенных для автоматизированного тестирования. Иногда они там называются expect'ами. Между автоматизированным тестированием и применением assert'ов есть много общего — обе техники предназначены для быстрого выявления и исправления багов в программах. Но, несмотря на общие черты, автоматизированное тестирование и assert'ы являются не взаимоисключающими, а, скорее всего, взаимодополняющими друг друга. Грамотно расставленные assert'ы упрощают автоматизированное тестирование кода, т.к. тестирующая программа может опустить проверки, дублирующие assert'ы в коде программы. Такие проверки обычно составляют существенную долю всех проверок в тестирующей программе.
Tags:
Hubs:
Total votes 47: ↑36 and ↓11+25
Comments57

Articles