assert()使用指南:明确场景、保障安全,理想 API 应具备这些特性!

assert()使用指南:明确场景、保障安全,理想 API 应具备这些特性!

一直以来的困扰

一直以来,`assert()` 语句都让人有些困扰。尽管有人认为简单的断言语句是编写正确、安全且易于维护软件的基石之一,但也觉得它在某些方面存在不足。一方面,很多 `assert()` 的实现功能不够强大,虽能提供一些有用信息,但不足以提供更深入、更有价值的上下文;另一方面,何时该使用断言也让人十分困惑,比如它是否是轻量级调试语句、能否在生产环境使用、该对哪些内容断言以及能否自定义其行为等。更糟糕的是,有一些实现甚至允许通过一个简单的编译时开关禁用所有断言。综合来看,要正确理解和使用 `assert()`,还有很多工作要做。

明确使用场景

要更好地利用 `assert()`,首要且关键的一步是明确这些语句的使用时机和场景。下面介绍四个断言能发挥作用的领域:正确性,对于所有可能的函数输入和输出值,如果没有实现全值覆盖,就应该使用断言来覆盖这些情况;安全性,在执行可能产生已知或未知不良副作用的操作时,应使用断言来防止这些情况发生;开发,使用断言来强制验证值和状态的假设。如果结合了适当的测试,这些断言可以选择在编译时从代码中移除;文档化,编写代码时,使用断言作为逻辑的自我文档化护栏。通过断言来强制验证那些从文档中难以明确或从代码中难以解读的值和状态。

如果能在代码中正确使用断言,还能让代码的静态分析更加有效。断言能大幅缩小数据验证和不变性分析的范围,从而得到更好的分析结果。有人可能会担心断言会让代码变慢、变臃肿,其实不然。如果使用得当,断言几乎不会带来额外开销。读取一个局部变量的值,然后跳过一个分支,这只需要几个 CPU 周期,对于大多数应用程序来说,这种开销几乎可以忽略不计。与断言带来的好处相比,实在没有理由拒绝使用它们。

正确性与安全性

正确性可能是断言最重要但却最未被充分利用的作用。编写代码时,我们很容易忽略参数或返回值可能出现的所有不同取值。断言可以弥补这一漏洞,只需断言该值符合预期即可。如果代码支持所有可能的值(通常情况如此),那就不需要断言。通过逻辑和断言的结合,所有可能的值都应该被覆盖,这样就可以说该值的覆盖是“正确的”。

例如,有这样一段逻辑,其中错误值通过断言得到了正确处理:var error = system_call(...); assert(!error);假设系统调用产生并返回了一个错误,程序就会崩溃。显然,这不是理想的行为,对于常见的值,包括错误,应该更优雅地处理。但这里有两点很重要。其一,你会意识到这个被断言的值确实存在,可以选择采取行动,比如修改代码来实际处理这个值,或者花时间深入了解发生了什么。其二,更重要的是,你从一开始就保证了 100% 的值正确性,并且能一直保持。断言失败正是按照预期工作,它准确地捕获到了一个边缘情况。如果没有这个断言,或者断言在编译时被移除,应用程序就会陷入未知和不安全的状态。

再看另一个说明值正确性的例子:var x = random(); assert(x > 0);设置了断言条件,期望 `x` 大于零。只要使用 `x` 的逻辑支持所有大于零的可能值,就可以说对于 `x` 的处理是正确的。假设由于某种原因,`random()` 函数返回了零或负数,断言就会告诉我们假设错误,程序会立即终止。这时,就有机会通过更好的处理方式来纠正这个错误,或者至少调查一下哪里出了问题。同样,如果编写这段代码时没有使用断言,当遇到零或负数时,程序就会出现意外行为,更糟糕的是,可能根本意识不到问题的存在。

一些难以处理的值的常见例子包括内存分配调用失败。虽然有一些有效的处理方法,但大多数情况下,触发断言并退出程序是合理的做法。当锁获取失败或线程创建失败时,考虑到这些事件极其罕见,使用断言来处理这些情况也是相当合理的。

确保 100% 值正确性的另一个重要原因是,有时进行测试的环境与应用程序实际运行的环境不同。不同版本、API、库和平台之间的细微行为差异可能会对应用程序造成严重破坏,可能会引入非常难以调试的问题。因此,实现全值覆盖是让应用程序能够防御性地、主动地应对这种隐性故障的一种方式。同样,如果应用程序能够忠实地处理所有可能的输入和输出值,那么可以放心地忽略这个建议。

操作安全性是断言能为应用程序带来显著改善的另一个重要领域。经典的例子是对内存的直接访问,如何确保这些访问是预期且安全的呢?这主要涉及两点。首先,必须能够定义内存的边界,一旦定义了这些边界,在访问该内存时,只需断言访问在边界之内即可。很多时候,处理定义明确的类型时,这些边界检查可以自动完成。但处理定义不太明确的类型,如 C 数组或原始内存指针时,就必须定义边界,并在访问时应用断言进行检查。

操作安全性的另一个经典例子是简单的加法运算:var c = a + b;怎么知道将 `a` 和 `b` 相加时,`c` 没有溢出呢?如果 `c` 不等于 `a + b`,应用程序会有什么表现?会处理 `c` 为零的情况吗?如果这些都是未知数,那么一个简单的断言就可以解决问题(这里假设是非负值):assert(c >= a);如果对于这样简单的加法语句使用断言看起来有些过度,那么最好思考一下应用程序中是否存在防止 `a` 和 `b` 达到可能溢出点的边界条件。这些值是否与具有限制因素的逻辑、资源或其他代码结构相关联?如果存在结构上的限制,那么可以使用开发或文档化断言。如果不存在自然的边界,那么在代码中添加一些安全断言,或者改用支持防溢出的库可能是有意义的,因为变量在不知不觉中达到溢出限制通常表明应用程序中可能存在一些不安全的因素。

开发与文档化

接下来是开发断言。这些断言通常用于检查逻辑和状态错误。如果结合适当的测试,这些断言可以在生产代码中跳过,但并非总是如此。开发断言的一个例子是,在使用基于索引的 API 时,确保不会出现“差一错误”。处理字符串时经常会遇到这种情况。例如,假设我们有一个文件名,想要将文件名和扩展名分开:var ext_position = filename.indexOf("."); var name = filename.substring(0, ext_position); var extension = filename.substring(ext_position + 1); assert_dev(name + "." + extension == filename);在这个例子中,进行了一个开发断言,通过重新组合分割后的部分并确保其与原始文件名匹配,来验证是否正确解析了文件名,并且分割的索引是否正确。只要经过测试且断言没有触发,就可以相当确定这段逻辑是正确的,并且可以在生产代码中跳过这个断言。有人可能会说这是一个适合进行单元测试的例子,也可以说这个单一的断言语句就是单元测试,不需要实际的单元测试。一个好的原则是,开发断言的粒度通常比完整的单元测试更细。然而,要使开发断言有价值,就需要定期进行测试。值得注意的是,这类断言的执行开销可能比简单的值断言更大,所以要注意,如果运行大量开销较高的断言,可能会导致程序运行变慢。这在测试环境中可能没问题,但在生产代码中就不太好了。

文档化断言与开发断言类似,不同之处在于,它们用于强化记录任何给定逻辑所做的假设。它们可能看起来多余,甚至像是过度使用断言,但实际上它们有助于开发工作。这些断言有双重作用,既可以帮助代码阅读者理解(甚至记住)任何一段逻辑的工作方式,又可以防止错误使用。经过多次重构、逻辑分散在多个源文件中的大型代码库,尤其能从这类文档化断言中受益。

延续上面的例子,可以添加一个文档化断言:var ext_position = filename.indexOf("."); assert_dev(ext_position > 0, "filename must have a name and extension, see validate_filename(): " + filename);现在,如果触发了这个断言,就会思考,验证机制出了什么问题?

在已经很复杂的代码库中添加新逻辑时,有时使用开发断言逐步梳理逻辑是个好做法。这样做不仅增加了一层文档说明,还为未来的调试和开发工作提供了保障。

如果觉得某个断言是多余的,可以将其设置为仅用于开发的断言。如果仍然觉得多余且过于冗长,那么可能就不需要这个断言了。记住,开发断言就像单元测试。虽然它们可能不太美观,但从未听说有人抱怨代码库中的测试太多。当开始有效地使用断言,并在正确性和开发过程之间建立起反馈循环时,就会更清楚地了解断言在哪些方面有价值,以及它们的价值有多大。当然,断言的有效性取决于对它们进行测试的能力。而且,在生产代码中频繁触发断言绝不是一件好事。

未来展望

那么,理想的断言 API 应该是什么样的呢?首先,它需要同时具备生产环境和开发环境的 API。生产环境的断言绝不能被禁用,而开发环境的断言应该可以轻松地开启和关闭。其次,它需要支持动态消息。像下面这样的断言只能告诉我们部分信息:assert(state == CONNECTED, "invalid state");更好的表达方式是:assert(state == CONNECTed, "invalid state found: " + state);第三,它应该支持在断言消息的同时打印堆栈跟踪信息。如果在代码深处触发了断言,堆栈跟踪信息能让你清楚地了解是如何执行到这部分代码的。最后,如果能在断言触发后存储并输出应用程序状态、上下文和/或指标的重要部分,那就更好了。这将进一步有助于调试导致断言触发的问题。