尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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);第三它应该支持在断言消息的同时打印堆栈跟踪信息。如果在代码深处触发了断言堆栈跟踪信息能让你清楚地了解是如何执行到这部分代码的。最后如果能在断言触发后存储并输出应用程序状态、上下文和/或指标的重要部分那就更好了。这将进一步有助于调试导致断言触发的问题。
返回列表