Можно ли написать слишком много утверждений?
Ну, конечно. [Представьте здесь отвратительный пример.] Однако, применяя руководящие принципы, подробно изложенные ниже, у вас не должно быть проблем с продвижением этого предела на практике. Я тоже большой поклонник утверждений и использую их в соответствии с этими принципами. Большая часть этого совета не является особенной для утверждений, а применяется только к общепринятой хорошей инженерной практике.
Помните время выполнения и двоичные накладные расходы
Утверждения - это здорово, но если они делают вашу программу неприемлемо медленной, это будет либо очень раздражать, либо вы отключите их рано или поздно.
Мне нравится оценивать стоимость утверждения относительно стоимости функции, в которой оно содержится. Рассмотрим следующие два примера.
// Precondition: queue is not empty
// Invariant: queue is sorted
template <typename T>
const T&
sorted_queue<T>::max() const noexcept
{
assert(!this->data_.empty());
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
return this->data_.back();
}
Сама функция является операцией O (1), но утверждения учитывают O ( n) накладные расходы ). Я не думаю, что вы хотели бы, чтобы такие проверки были активными, за исключением особых случаев.
Вот еще одна функция с похожими утверждениями.
// Requirement: op : T -> T is monotonic [ie x <= y implies op(x) <= op(y)]
// Invariant: queue is sorted
// Postcondition: each item x in the queue is replaced by op(x)
template <typename T>
template <typename FuncT>
void
sorted_queue<T>::apply_monotonic_function(FuncT&& op)
{
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
std::transform(std::cbegin(this->data_), std::cend(this->data_),
std::begin(this->data_), std::forward<FuncT>(op));
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
}
Сама функция является O ( n операцией ), поэтому гораздо больно добавлять дополнительные издержки O ( n ) для утверждения. Замедление функции с помощью небольшого (в данном случае, вероятно, менее 3) постоянного фактора - это то, что мы обычно можем себе позволить в отладочной сборке, но, возможно, не в сборке выпуска.
Теперь рассмотрим этот пример.
// Precondition: queue is not empty
// Invariant: queue is sorted
// Postcondition: last element is removed from queue
template <typename T>
void
sorted_queue<T>::pop_back() noexcept
{
assert(!this->data_.empty());
return this->data_.pop_back();
}
В то время как многим людям, вероятно, будет гораздо удобнее это утверждение O (1), чем двум O ( n) утверждениям ) в предыдущем примере, на мой взгляд, они морально эквивалентны. Каждый добавляет накладные расходы на порядок сложности самой функции.
Наконец, существуют «действительно дешевые» утверждения, в которых преобладает сложность функции, в которой они содержатся.
// Requirement: cmp : T x T -> bool is a strict weak ordering
// Precondition: queue is not empty
// Postcondition: if x is returned, then there is no y in the queue
// such that cmp(x, y)
template <typename T>
template <typename CmpT>
const T&
sorted_queue<T>::max(CmpT&& cmp) const
{
assert(!this->data_.empty());
const auto pos = std::max_element(std::cbegin(this->data_),
std::cend(this->data_),
std::forward<CmpT>(cmp));
assert(pos != std::cend(this->data_));
return *pos;
}
Здесь у нас есть два O (1) утверждения в O ( n ) -функции. Вероятно, не будет проблемой удерживать эти накладные расходы даже в релизных сборках.
Имейте в виду, однако, что асимптотические сложности не всегда дают адекватную оценку, потому что на практике мы всегда имеем дело с размерами входных данных, ограниченными некоторыми конечными постоянными и постоянными факторами, скрытыми «Большой O », которые вполне могут быть незначительными.
Итак, теперь мы определили разные сценарии, что мы можем с ними сделать? (Возможно, слишком) простой подход состоит в том, чтобы следовать правилу, например «Не используйте утверждения, которые доминируют в функции, в которой они содержатся». Хотя это может работать для некоторых проектов, другим может потребоваться более дифференцированный подход. Это можно сделать, используя разные макросы утверждений для разных случаев.
#define MY_ASSERT_IMPL(COST, CONDITION) \
( \
( ((COST) <= (MY_ASSERT_COST_LIMIT)) && !(CONDITION) ) \
? ::my::assertion_failed(__FILE__, __LINE__, __FUNCTION__, # CONDITION) \
: (void) 0 \
)
#define MY_ASSERT_LOW(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_LOW, CONDITION)
#define MY_ASSERT_MEDIUM(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_MEDIUM, CONDITION)
#define MY_ASSERT_HIGH(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_HIGH, CONDITION)
#define MY_ASSERT_COST_NONE 0
#define MY_ASSERT_COST_LOW 1
#define MY_ASSERT_COST_MEDIUM 2
#define MY_ASSERT_COST_HIGH 3
#define MY_ASSERT_COST_ALL 10
#ifndef MY_ASSERT_COST_LIMIT
# define MY_ASSERT_COST_LIMIT MY_ASSERT_COST_MEDIUM
#endif
namespace my
{
[[noreturn]] extern void
assertion_failed(const char * filename, int line, const char * function,
const char * message) noexcept;
}
Теперь вы можете использовать три макроса MY_ASSERT_LOW
, MY_ASSERT_MEDIUM
и MY_ASSERT_HIGH
вместо стандартного assert
макроса «один размер подходит всем» для утверждений, в которых доминируют, не доминируют и не доминируют и не доминируют над сложностью их содержащей функции соответственно. Когда вы создаете программное обеспечение, вы можете предварительно определить символ препроцессора, MY_ASSERT_COST_LIMIT
чтобы выбрать, какие утверждения должны делать его в исполняемом файле. Константы MY_ASSERT_COST_NONE
и MY_ASSERT_COST_ALL
не соответствуют никаким макросам утверждений и предназначены для использования в качестве значений для MY_ASSERT_COST_LIMIT
того, чтобы выключить или включить все утверждения соответственно.
Здесь мы полагаем, что хороший компилятор не будет генерировать код для
if (false_constant_expression && run_time_expression) { /* ... */ }
и преобразовать
if (true_constant_expression && run_time_expression) { /* ... */ }
в
if (run_time_expression) { /* ... */ }
что я считаю безопасным предположением в настоящее время.
Если вы собираетесь настроить вышеприведенный код, рассмотрите аннотации, специфичные для компилятора, например __attribute__ ((cold))
on my::assertion_failed
или __builtin_expect(…, false)
on, !(CONDITION)
чтобы уменьшить накладные расходы на передаваемые утверждения. В сборках релизов вы также можете рассмотреть замену вызова функции наmy::assertion_failed
чем-то вроде __builtin_trap
уменьшения следа при неудобстве потери диагностического сообщения.
Эти виды оптимизации действительно актуальны только в чрезвычайно дешевых утверждениях (например, при сравнении двух целых чисел, которые уже приведены в качестве аргументов) в функции, которая сама по себе очень компактна, не принимая во внимание дополнительный размер двоичного файла, накопленный путем включения всех строк сообщения.
Сравните, как этот код
int
positive_difference_1st(const int a, const int b) noexcept
{
if (!(a > b))
my::assertion_failed(__FILE__, __LINE__, __FUNCTION__, "!(a > b)");
return a - b;
}
компилируется в следующую сборку
_ZN4test23positive_difference_1stEii:
.LFB0:
.cfi_startproc
cmpl %esi, %edi
jle .L5
movl %edi, %eax
subl %esi, %eax
ret
.L5:
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $.LC0, %ecx
movl $_ZZN4test23positive_difference_1stEiiE12__FUNCTION__, %edx
movl $50, %esi
movl $.LC1, %edi
call _ZN2my16assertion_failedEPKciS1_S1_
.cfi_endproc
.LFE0:
пока следующий код
int
positive_difference_2nd(const int a, const int b) noexcept
{
if (__builtin_expect(!(a > b), false))
__builtin_trap();
return a - b;
}
дает эту сборку
_ZN4test23positive_difference_2ndEii:
.LFB1:
.cfi_startproc
cmpl %esi, %edi
jle .L8
movl %edi, %eax
subl %esi, %eax
ret
.p2align 4,,7
.p2align 3
.L8:
ud2
.cfi_endproc
.LFE1:
с которым я чувствую себя намного комфортнее. (Примеры были протестированы с помощью GCC 5.3.0 с помощью -std=c++14
, -O3
и -march=native
флаги на 4.3.3-2-ARCH x86_64 GNU / Linux. Не показано в приведенных выше фрагментах являются заявления test::positive_difference_1st
и test::positive_difference_2nd
который я добавил __attribute__ ((hot))
к.my::assertion_failed
Был объявлен с __attribute__ ((cold))
.)
Утвердить предпосылки в функции, которая зависит от них
Предположим, у вас есть следующая функция с указанным контрактом.
/**
* @brief
* Counts the frequency of a letter in a string.
*
* The frequency count is case-insensitive.
*
* If `text` does not point to a NUL terminated character array or `letter`
* is not in the character range `[A-Za-z]`, the behavior is undefined.
*
* @param text
* text to count the letters in
*
* @param letter
* letter to count
*
* @returns
* occurences of `letter` in `text`
*
*/
std::size_t
count_letters(const char * text, int letter) noexcept;
Вместо того чтобы писать
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
const auto frequency = count_letters(text, letter);
на каждом сайте вызова, поместите эту логику один раз в определение count_letters
std::size_t
count_letters(const char *const text, const int letter) noexcept
{
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
auto frequency = std::size_t {};
// TODO: Figure this out...
return frequency;
}
и называть это без лишних слов.
const auto frequency = count_letters(text, letter);
Это имеет следующие преимущества.
- Вам нужно только один раз написать код подтверждения. Поскольку самой целью функций является то, что они вызываются - часто более одного раза - это должно уменьшить общее количество
assert
операторов в вашем коде.
- Он сохраняет логику, которая проверяет предпосылки, близкие к логике, которая зависит от них. Я думаю, что это самый важный аспект. Если ваши клиенты неправильно используют ваш интерфейс, нельзя предположить, что они правильно применяют утверждения, поэтому лучше, чтобы функция сообщала им.
Очевидным недостатком является то, что вы не получите исходное местоположение сайта вызова в диагностическом сообщении. Я считаю, что это незначительная проблема. Хороший отладчик должен иметь возможность удобно отслеживать происхождение нарушения договора.
То же самое относится и к «специальным» функциям, таким как перегруженные операторы. Когда я пишу итераторы, я обычно - если природа итератора это позволяет - даю им функцию-член
bool
good() const noexcept;
это позволяет спросить, безопасно ли разыменовывать итератор. (Конечно, на практике это почти всегда возможно только гарантии , что она не будет в безопасности разыменования итератора. Но я верю , что вы все еще можете поймать много ошибок с такой функцией.) Вместо того , захламление все мой код который использует итератор с assert(iter.good())
утверждениями, я бы предпочел поставить одинassert(this->good())
в качестве первой строки operator*
в реализации итератора.
Если вы используете стандартную библиотеку, вместо того чтобы вручную указывать ее предварительные условия в исходном коде, включите их проверки в отладочных сборках. Они могут выполнять даже более сложные проверки, например, проверять, существует ли еще контейнер, на который ссылается итератор. (См. Документацию для libstdc ++ и libc ++ (работа в процессе) для получения дополнительной информации.)
Фактор общих условий
Предположим, вы пишете пакет линейной алгебры. Многие функции будут иметь сложные предварительные условия, и их нарушение часто приводит к неправильным результатам, которые не сразу распознаются как таковые. Было бы очень хорошо, если бы эти функции утверждали свои предварительные условия. Если вы определите группу предикатов, которые сообщают вам определенные свойства о структуре, эти утверждения становятся намного более читабельными.
template <typename MatrixT>
auto
cholesky_decompose(MatrixT&& m)
{
assert(is_square(m) && is_symmetric(m));
// TODO: Somehow decompose that thing...
}
Это также даст более полезные сообщения об ошибках.
cholesky.hxx:357: cholesky_decompose: assertion failed: is_symmetric(m)
помогает намного больше, чем, скажем,
detail/basic_ops.hxx:1289: fast_compare: assertion failed: m(i, j) == m(j, i)
где вы сначала должны пойти посмотреть исходный код в контексте, чтобы выяснить, что на самом деле было проверено.
Если у тебя есть class
с нетривиальными инвариантами, то, вероятно, будет хорошей идеей время от времени утверждать их, когда вы перепутались с внутренним состоянием и хотите убедиться, что вы возвращаете объект в действительное состояние по возвращении.
Для этой цели я нашел полезным определить функцию- private
член, которую я обычно вызываю class_invaraiants_hold_
. Предположим, что вы повторно внедряете std::vector
(поскольку мы все знаем, что это недостаточно хорошо), возможно, у него есть такая функция.
template <typename T>
bool
vector<T>::class_invariants_hold_() const noexcept
{
if (this->size_ > this->capacity_)
return false;
if ((this->size_ > 0) && (this->data_ == nullptr))
return false;
if ((this->capacity_ == 0) != (this->data_ == nullptr))
return false;
return true;
}
Обратите внимание на несколько вещей по этому поводу.
- Функция предиката сама по себе
const
и noexcept
в соответствии с руководящими принципами утверждает, что утверждения не должны иметь побочных эффектов. Если это имеет смысл, также объявите этоconstexpr
.
- Предикат сам по себе ничего не утверждает. Он предназначен для вызова внутри утверждений, таких как
assert(this->class_invariants_hold_())
. Таким образом, если утверждения компилируются, мы можем быть уверены, что не возникнет никаких накладных расходов во время выполнения.
- Поток управления внутри функции разбит на несколько
if
операторов с ранним return
s, а не с большим выражением. Это позволяет легко пройтись по функции в отладчике и выяснить, какая часть инварианта была нарушена при срабатывании утверждения.
Не утверждай глупостей
Некоторые вещи просто не имеют смысла утверждать.
auto numbers = std::vector<int> {};
numbers.push_back(14);
numbers.push_back(92);
assert(numbers.size() == 2); // silly
assert(!numbers.empty()); // silly and redundant
Эти утверждения не делают код даже чуть-чуть более читабельным или легче рассуждать. Каждый программист C ++ должен быть достаточно уверенным в том, какstd::vector
работает, чтобы быть уверенным, что приведенный выше код верен, просто взглянув на него. Я не говорю, что вы никогда не должны утверждать о размере контейнера. Если вы добавили или удалили элементы, используя какой-то нетривиальный поток управления, такое утверждение может быть полезным. Но если он просто повторяет то, что было написано в коде без утверждений чуть выше, никакого выигрыша не будет.
Также не утверждайте, что библиотечные функции работают правильно.
auto w = widget {};
w.enable_quantum_mode();
assert(w.quantum_mode_enabled()); // probably silly
Если вы так мало доверяете библиотеке, лучше подумайте об использовании другой библиотеки.
С другой стороны, если документация библиотеки не ясна на 100%, и вы обретаете уверенность в ее контрактах, читая исходный код, имеет смысл утверждать об этом «предполагаемом контракте». Если он будет сломан в будущей версии библиотеки, вы заметите это быстро.
auto w = widget {};
// After reading the source code, I have concluded that quantum mode is
// always off by default but this isn't documented anywhere.
assert(!w.quantum_mode_enabled());
Это лучше, чем следующее решение, которое не скажет вам, были ли ваши предположения правильными.
auto w = widget {};
if (w.quantum_mode_enabled())
{
// I don't think that quantum mode is ever enabled by default but
// I'm not sure.
w.disable_quantum_mode();
}
Не злоупотребляйте утверждениями для реализации логики программы
Утверждения следует использовать только для выявления ошибок , которые заслуживают немедленного уничтожения вашего приложения. Их не следует использовать для проверки каких-либо других условий, даже если соответствующая реакция на это условие также будет прекращена немедленно.
Поэтому напишите это ...
if (!server_reachable())
{
log_message("server not reachable");
shutdown();
}
…вместо этого.
assert(server_reachable());
Также никогда не используйте утверждения для проверки ненадежного ввода или проверки того, что вы std::malloc
не сделали . Даже если вы знаете, что никогда не отключите утверждения, даже в сборках релиза, утверждение сообщает читателю, что оно проверяет что-то, что всегда верно, учитывая, что программа не содержит ошибок и не имеет видимых побочных эффектов. Если это не тот тип сообщения, которое вы хотите передать, используйте альтернативный механизм обработки ошибок, например , исключение. Если вам удобно иметь макропакет для проверок без утверждений, продолжайте писать. Только не называйте это «утверждать», «предполагать», «требовать», «обеспечивать» или что-то в этом роде. Его внутренняя логика может быть такой же, как для , за исключением того, что она, конечно, никогда не компилируется.return
nullptr
throw
assert
Больше информации
Я считаю, что выступление Джона Лакоса « Защитное программирование сделано правильно» , данное на CppCon'14 ( 1- я часть , 2- я часть ), очень поучительно. Он берет идею настроить, какие утверждения включены и как реагировать на неудачные исключения даже дальше, чем я в этом ответе.