可能写太多的断言吗?
好吧,当然是。[在这里想象一下令人讨厌的示例。]但是,应用下面详细介绍的准则,您在实践中突破该限制应该不会有麻烦。我也非常主张断言,并且根据这些原则使用它们。这些建议中的大部分不是断言专用的,而是仅适用于一般的良好工程实践。
记住运行时和二进制足迹的开销
断言很棒,但是如果它们使您的程序慢得令人无法接受,那么它将非常烦人,或者您迟早将其关闭。
我想相对于包含它的函数的成本来评估一个断言的成本。请考虑以下两个示例。
// 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(n)函数中有两个O(1)断言。即使在发行版本中,保留此开销也可能不是问题。
但是要记住,渐进复杂度并不总是能提供足够的估计,因为在实践中,我们总是要处理输入大小,该输入大小受某些由“ 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))
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;
这允许询问取消迭代器的安全性。(当然,在实践中,它几乎总是只能以保证它不会是安全的提领迭代器,但我相信你仍然可以抓到很多的bug具有这样的功能。)而不是乱抛垃圾我所有的代码使用带有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
带有Early return
而不是大型表达式的语句。这使在调试器中单步执行该函数变得容易,并找出断言触发时不变式的哪一部分被破坏了。
不要对愚蠢的事情断言
断言有些事情是没有意义的。
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
发出异常。如果发现使用宏包装程序进行非断言检查很方便,请继续编写一个。只是不要称它为“断言”,“承担”,“要求”,“确保”或类似的东西。当然,它的内部逻辑与for相同assert
,只是它从未被编译出来。
更多信息
我发现约翰洛科什讲的防御性编程立刻完成,在CppCon'14(给出1 日的部分,2 次部分)很有启发。与我在此答案中所做的相比,他采用了自定义启用哪些断言以及如何对失败的异常进行响应的想法。