Чи можна написати занадто багато тверджень?
Ну, звичайно, так і є. [Уявіть тут нечесний приклад.] Однак, застосовуючи вказівки, докладно описані нижче, у вас не повинно виникнути проблем із просуванням цієї межі на практиці. Я теж великий шанувальник тверджень, і я використовую їх відповідно до цих принципів. Значна частина цих порад не стосується тверджень, а лише загальної належної інженерної практики, застосованої до них.
Майте на увазі час роботи та бінарний слід
Ствердження чудові, але якщо вони зроблять вашу програму неприйнятно повільною, вона буде або дуже дратує, або ви рано чи пізно вимкнете їх.
Мені подобається оцінювати вартість твердження щодо вартості функції, в якій він міститься. Розглянемо наступні два приклади.
// 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 ). Мабуть, це не буде проблемою, щоб утримати це накладні витрати навіть у версії версій.
Але майте на увазі, що асимптотичні складності не завжди дають адекватну оцінку, оскільки на практиці ми завжди маємо справу з розмірами вхідних даних, обмеженими деякими кінцевими постійними та постійними факторами, прихованими “Big- 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))
стосуються компілятора, як увімкнено my::assertion_failed
або __builtin_expect(…, false)
увімкнено, !(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
, за винятком того, що вона ніколи не складається, звичайно.
Більше інформації
Я знайшов розмова Джон LAKOS ' Оборонна Програмування Done Right , враховуючи при CppCon'14 ( 1 - й частини , 2 - й частини ) дуже повчальний. Він бере ідею налаштувати, які твердження ввімкнено, і як реагувати на невдалі винятки навіть далі, ніж я робив у цій відповіді.