问题 捕获带有副作用的assert()


我们有几个中等大小的C代码库,可以接收来自具有各种经验级别的开发人员的提交。一些不那么有纪律的程序员承诺 assert() 带有副作用的语句会导致断言被断言。例如。

assert(function_that_should_always_be_called());

我们已经使用了自己的 assert() 实现,但用表达式来评估表达式 NDEBUG 定义会导致不可接受的性能下降。是否有我们可以传递的GCC扩展或标志会触发编译时警告/错误?通过简单的控制流程,GCC应该可以确定您只调用纯函数。


8726
2018-05-15 02:36


起源

可能需要在提交前进行代码审查 - William Morris
我不买有关“没有资源”的论据。你是 保存 通过及早捕捉错误的时间(和理智)。这不是关于审查现有代码,而是关于审核 变化 在他们承诺之前。 - jamesdlin
s /没有资源/经理说没有/
如果您在提交之前要求同事查看差异,您的经理会反对吗?你应该找一个新的工作场所。 - jamesdlin
叹。我们可以在必要时进行代码审查,但是如果没有人工审核就能抓住新手编写的错误。我只是在问一个特征是否存在,而不是对工作场所实践和拖钓的批评。


答案:


尽管这个问题已经收到许多无益的非答案,但我认为它在遗留代码库的上下文中有很多优点。

想象一下,多年来积累了许多断言,但由于没有使用NDEBUG构建/测试的习惯,一些副作用已经渗入断言,现在你不再禁用断言了。

您可以在测试套件中打开NDEBUG并检测一些测试失败,但将测试失败与“有效”断言联系起来并不是一件容易的事,因为它可能距离您检测到失败的位置非常远。即使是具有良好覆盖范围的测试套件也无法完全信任。

您可以对代码中的所有断言进行代码审查,但这可能是很多工作并且容易出现人为错误。如果某些静态分析已经可以消除所有断言,那将会好得多 证明 没有副作用出现,你只需要调查他们缺席的情况。

以下是如何使用编译器的优化器进行此类静态分析的方法。假设您组织替换的定义 assert 宏观:

extern int not_supposed_to_survive;
#define assert(expr) ((void)(not_supposed_to_survive || (expr)))

如果 expr 有任何副作用,效果的执行是以全局变量的值为条件的 not_supposed_to_survive。但如果 expr 没有任何副作用,全局变量的值无关紧要(注意 expr 结果被丢弃)。 一个好的优化器知道这一点,并将消除全局变量的负载 not_supposed_to_survive因此变量的名称。

如果我们的程序不包含符号的定义 not_supposed_to_survive,当没有消除负载时,我们将得到链接错误,我们可以使用它来检测可能有效的断言。

例如。与gcc 4.8:

int g;

int foo() { return ++g; }

int main() {
    assert(foo());
    return 0;
}

gcc -O2 assert_effect.c
/tmp/ccunynya.o: In function `main':
assert_effect.c:(.text.startup+0x2): undefined reference to `not_supposed_to_survive'
collect2: error: ld returned 1 exit status

编译器帮我找到了一个可疑的断言!另一方面,如果我更换 ++g 通过 g+1,链接错误消失,我不必调查。实际上,这种说法是无害的。

当然,可证明无副作用的概念受到优化器“可以看到”的限制。为了更精确的分析,我建议使用链接时间优化(gcc -flto)分析编译单元。

更新: 我使用gcc 5.3将它应用于现实生活中的C ++代码库。要使用链接时优化,您基本上使用 gcc -flto -g 作为编译器/链接器( -g 编译器/链接器上的选项以获取链接错误的行引用)和 gcc-ar 和 gcc-ranlib 作为任何静态库的归档器/索引器。

这种设置可以极大地减少我必须调查的断言数量。凭借最小的人力,我能够让断言干净。我仍然需要手动拒绝的误报是由于:

  • 虚函数调用
  • 非平凡的循环/递归(优化器无法证明它们是有限的)

另外,我还会得到一些确实包含副作用的断言,但它们是无害的或不重要的,例如:

  • 包含日志语句的函数
  • 缓存其结果的函数

7
2018-02-09 14:18





即使GCC可以可靠地检测纯计算(这需要解决暂停问题),标志也必须具有额外的神奇能力才能注意到非纯计算作为参数传递给您自己生成的断言宏。扩展也无济于事 - 究竟应该做什么?

你的问题的解决方案是

  1. 聘请称职的开发人员。
  2. 教育您的开发人员如何使用断言(以及其他内容)。
  3. 进行代码审查。
  4. 针对可交付版本进行所有测试 - 如果断言在交付项中是关闭的,那么断言(function_that_should_always_be_called())与简单地省略function_that_should_always_be_called()没有区别,这是一个应该在测试中捕获的明显错误。

4
2018-05-15 05:13



我认为这些“解决方案”无益。但更重要的是,因为纯计算不可能 总是 被发现,并不意味着消除 最 纯计算并不是非常有用。 stackoverflow.com/a/35294344/6918 - Bruno De Fraine
什么是没有帮助的:a)吓唬报价b)说明这些解决方案没有帮助的个人意见,没有提供反驳c)明显的稻草人 - 没有人说检测大多数纯计算是没有用的d)driveby downvotes。布鲁诺的答案提供了一种聪明的技巧,它应该被接受,但它可以提供这样的答案,而不是一个混蛋。 - Jim Balter


通过简单的控制流程,GCC应该可以确定您只调用纯函数。

如果它不是一个简单的控制流程,它将如何知道它是否纯净?


这样的事情可能是你最好的选择:

#ifdef NDEBUG
#define assert(s) do { (s); } while(false)
#else
// ...
#endif

将编译出几个表达式,包括函数 __attribute__((pure))

最合乎逻辑的解决方案是查看代码并修复错误。


3
2018-05-15 02:47



同意 - 正确使用 assert(),表达式没有副作用 - 只要你启用了优化,编译器就能够忽略代码。演员 (void) 这可能也很有用,因为它可能会阻止编译器警告没有副作用的语句。 - caf
显然更复杂的控制流和递归之类的东西可以把它变成暂停问题,但大多数断言都比较简单。我设想了一些超时或最大呼叫深度限制的检查。至于建议,正如我在问题中所说,我特别不希望在定义NDEBUG时评估所有断言。
@Matthew“我设想了一些超时或最大呼叫深度限制的检查。” - 这与非纯函数的编译时检查有什么关系?你对GCC功能的绝望是毫无意义的,那一刻的反思......或者对手册的细读......显然不存在。 - Jim Balter
这个解决方案的主要问题是它不会捕获错误,而是修复它们。 assert 副作用是一种非常糟糕的做法。问题是如何制作 assert 抓住这个,而不是如何让它发挥作用。此外,这可能会严重降低性能,就像这样 assert(check_data_structures()) 已经完成了。 - ugoren
“至于建议,正如我在问题中所说,我特别不希望在定义NDEBUG时评估所有断言。” - 你完全错过了这一点。阅读以“几个表达式......”开头的句子并尝试理解它。 - Jim Balter


我不确定它对你所描述的应用程序是否足够,但是cppcheck会查找“assertWithSideEffect”: http://cppcheck.sourceforge.net/devinfo/doxyoutput/checkassert_8cpp_source.html

这是编译时消息的样子: [assertWithSideEffect] myFile.cpp:42:警告:非纯函数:在断言语句中调用'myFunction'。从发布版本中删除断言语句,因此不会执行assert语句中的代码。如果在发布版本中也需要代码,则这是一个错误。

“Cppcheck是一个用于C / C ++代码的静态分析工具。与C / C ++编译器和许多其他分析工具不同,它不会检测代码中的语法错误.Cppcheck主要检测编译器通常无法检测到的错误类型。是只检测代码中的真实错误(即误报率为零)。“ http://cppcheck.sourceforge.net/


0
2018-02-03 16:51