捕获带副作用的assert()

21

我们有几个中等规模的C代码库,接收来自经验水平不同的开发人员的提交。一些纪律性较差的程序员会提交带有产生禁用断言时引起错误的副作用的 assert() 语句。例如:

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

4
在提交之前或许需要进行代码审核。 - William Morris
1
我不相信“没有资源”的论点。通过尽早发现错误,你可以节省时间(和心智)。这不是关于审查现有代码,而是在提交之前审查更改 - jamesdlin
2
没有资源/经理说不行。 - user221137
1
如果你让同事在你提交之前查看差异,你的经理会反对吗?那么你应该找一个新的工作地点。 - jamesdlin
4
叹息。我们可以在必要时进行代码审查,但能够在不需要手动审查的情况下捕捉新手编码错误会更好。我只是在询问是否存在这样的功能,而不是要求对工作场所实践进行批评和恶意挑衅。 - user221137
显示剩余6条评论
4个回答

25
尽管这个问题收到了许多无益的非答案,但我认为在遗留代码库的背景下它有很多价值。
想象一下,多年来积累了许多断言,但由于没有习惯使用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 4.4 及更高版本上,可以使用 error 函数属性在编译时(而非链接时)获得人类可读的错误消息。由于此属性仅适用于函数而不适用于变量,因此我们还需要告诉 GCC 这是一个纯函数,这意味着函数本身不会产生副作用。这确保了如果返回值不相关,则可以安全地删除对函数的调用。

extern int not_supposed_to_survive() __attribute__((pure)) __attribute__((error("assert() cannot be proven to have no side effects")));
#define assert(expr) do { (void)(not_supposed_to_survive() || (expr)); } while(0)

更新: 我在使用gcc 5.3编写的真实C ++代码库中应用了带有全局变量的简单变体。要使用链接时优化,您基本上需要将 gcc-flto -g 用作编译器/链接器(使用-g 选项来获取链接错误的行引用),并且对于任何静态库,需要使用gcc-argcc-ranlib 作为存档程序/索引器。

这个设置可以极大地减少我需要调查的断言数量。在人力资源最小的情况下,我能够清理掉断言。我仍然需要手动关闭的虚函数调用、非平凡循环/递归(优化器无法证明它们是有限的)造成的误报仅占一小部分。

此外,我还会收到一些包含副作用但是无害或不重要的断言,例如:

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

恐怕只要在assert()内部有任何内存访问,例如void foo(int *p) { assert(p[0] > 0); },编译器就无法删除保护条件,因此这不起作用。 - yugr
2
@yugr 确定它可以工作,编译器可以将您的 foo 优化为一个空函数:https://godbolt.org/z/e69xwZ - Bruno De Fraine

5

我不确定这是否足够适用于你所描述的应用程序,但cppcheck可以查找 "assertWithSideEffect":

http://cppcheck.sourceforge.net/devinfo/doxyoutput/checkassert_8cpp_source.html

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

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


3

如果控制流程足够简单,GCC应该能够确定您只调用纯函数。

如果控制流程不够简单,它将如何知道它是纯的还是不纯的?


类似以下代码可能是最佳选择:

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

一些表达式将被编译掉,包括使用 __attribute__((pure)) 的函数。

最合理的解决方案是审查您的代码并修复错误。


1
同意 - 对于 assert() 的正确使用,其中表达式没有副作用 - 只要启用了优化,编译器就能够省略代码。在这里进行 (void) 强制转换也很有用,因为它可以防止编译器警告没有副作用的语句。 - caf
2
这个解决方案的主要问题是它不能捕获错误,而只是修复它们。带有副作用的assert是非常糟糕的实践。问题是如何使assert捕获错误,而不是让它工作。此外,如果执行类似于assert(check_data_structures())的操作,这可能会严重降低性能。 - ugoren
1
就建议而言,正如我在问题中所说,当定义 NDEBUG 时,我特别不希望评估所有断言。你完全错过了重点。请阅读以“Several expressions…”开头的句子并试着理解它。 - Jim Balter
@JimBalter,你的回答并不是特别有建设性,我认为你没有理解问题的重点。我不是在寻找一个能解决所有问题的神奇GCC药膏,我只是希望能够捕捉到明显的错误。 - user221137
通过这种技术,你可以运行编译器并使用 grep 查找警告没有效果的语句。然后,你可以在代码中查找所有的 assert。任何不匹配都可能是潜在的副作用。这个过程可以自动化。挑战在于如何以安全的方式“永久”处理模糊的情况。 - Aaron McDaid
显示剩余6条评论

0

即使GCC能够可靠地检测纯计算(这需要解决停机问题),一个标志也必须具有额外的神奇力量,以注意到非纯计算作为参数传递给您自己编写的assert宏。扩展也无法帮助--它应该做什么呢?

解决您问题的方法是:

  1. 雇用有能力的开发人员。
  2. 教育您的开发人员如何使用assert(以及其他事项)。
  3. 进行代码审查。
  4. 针对可交付版本进行所有测试--如果在可交付版本中关闭了asserts,则assert(function_that_should_always_be_called())与简单省略function_that_should_always_be_called()没有任何区别,这是明显的错误,在测试中应该被捕获。

5
我认为这些所谓的“解决方案”并没有什么帮助。但更重要的是,因为纯计算无法 始终 被检测到,这并不意味着消除 大部分 的纯计算没有极大的用处。 - Bruno De Fraine
不太有帮助的是:a)恐吓性引号 b)陈述个人意见认为这些解决方案没有帮助,却没有提供反驳 c)明显的草人论证——没有人说检测大多数纯计算不是有用的 d)随意贬低投票。Bruno的答案提供了一个巧妙的技巧,应该被接受,但是可以在不成为混蛋的情况下提供这样的答案。 - Jim Balter

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接