你所不知道的,Python中的防御性编程

96
妄心xyx
5.8 2019.01.29 14:07 字数 5669

今天是周五下午,你的新版本已经发布好几天了。你礼拜一开始就感到自豪和无事一身轻,但你的自豪感正在随着时间的流逝慢慢减少。发布这样一个没有bug的版本耗费巨大的精力。事实上,在发布日期你有信心认为未来几周将会很安静,因为用户不应该会有别的需求。

当然,它完美得让你难以相信:不久后你的第一个错误报告产生了。第一个错误报告只是无关痛痒的东西,一个新的对话框出现了小小的拼写错误。接着,几个小错误接踵而至,你快速修复它们并存储到存储库。

紧接着,作为每个开发人员的噩梦,最重要的系统部件报告了一个错误。你疯狂地查看代码,即使你知道它的内存内容。代码分支怎么可能在这种情况下执行了?!代码一定出错了。

快速定位到bug处,但你还是想不通这是怎么发生的。你甚至不能在测试环境中重现场景。要是有更多的失败调试消息……

真相将让你得到释放!

如果你一直在写有试用期的软件,你会意识到这种情况。你感到很沮丧,不管花费了多少精力,软件究竟还是又一次不能用了。不要担心了,已经发生了。

这是故事的一部分,在这里,我找到了为你一劳永逸地解决这个问题的方法。但很不幸,我不认为它会存在。

一个不争的事实是,所有的软件都有bug。然而,这并不意味着我们应该放弃、不追求完美。它只是意味着如果略微改变对现实的看法,我们将得到更好的服务。我们应该这样编写软件,好像我们正在计划防范性措施。我们应该编写软件防范例程,比如设置陷阱,静默捕捉不可避免的bug。

防错性编程

用来描述这种风格最好的术语是“防御性编程”。维基百科的描述并不完全符合我的想法,但这是一个好的起点:

一种防护性设计旨在确保软件一部分的持续性功能,而不管软件的不可预见性说法。这个想法可以被看做具有减少或消除墨菲定律所产生影响的前景。防御性编程技术尤其被应用尤其当一段软件代码可能被恶意或无意滥用继而产生灾难性影响。

我真正要谈论的是下列指南的结合:

指南

  • 每一行代码是一种责任
  • 编写你的假设
  • 创建可执行文档更可取[1]

这些指南是至关重要的,它们确保我们的代码以及让你头脑清醒以避免常规错误的发生。记住,从我们的角度来看,编写的代码或多或少会有bug。

我们需要记住以上的指南来帮助我们快速找到bug。很多时候发现bug是最难的环节。因此,让我们优化定位bug的算法,而不是投身于彻底预防错误这个不可能的任务。

Python工具

让我们在下面的指南中更深入地看一些有用的工具。我们将使用Python作为语言进行演示,但大多数语言都有非常相似的工具。

  • 声明
  • 日志记录
  • 单元测试

假设我们有一个函数,它能将从用户和规范化数据获取的值指定在0到1之间,也可以搭配一个新的小部件方便以后使用。

image

感谢在reddit更新文章中发现一个bug的评论!

现在,假设我们有以下“列”,它由get_column_data()函数返回:

age = numpy.array([10.0, 20.0, 30.0, 40.0, 50.0])

height = numpy.array([60.0, 66.0, 72.0, 63.0, 66.0])

接下来证明它确实会将我们给的范围转化到[0 - 1]区间内:

normalize_ranges('age')

{'max': 1.0, 'min': 0.0}

好了,这是一个很短的测试,但是它似乎起作用了。我们通过一系列的“真实”的数字和把它规范到区间(0 - 1)内。

记住上述指导方针,让我们找到这个函数中存在的错误。代码中隐含的假设是什么?

我能看到一些假设:

1. original_range只包含正数

2. 返回的比率在0.0和1.0之间

经过仔细检查,上面的代码中存在相当多的假设。不幸的是,浏览代码时它们不会立刻明显地表现出来。要是我们能使这些假设更明晰……

声明语句

声明语句在单元测试中相当常见。事实上,Python为单元测试定制了大量的声明语句。然而,没有任何理由简单地将这个有用的工具用在测试环境中。

常规代码里的声明语句非常有用。这些声明语句需要一个表达式,如果表达式为false,则会抛出一个Asserti异常以及一个可选消息。

例如,我们上面的函数总是返回一个在(0 - 1)之间的值。不幸的是,我们的假设更多地显示程序并没有达到预期:

image

你可以想象,这种情况很容易被忽视很长一段时间,这个返回值会在整个代码中传播。这正是本文开头的故事:错误的准确类型不可能被发现而导致的可悲故事。

我们可以试着考虑到用户可能输入的每个值并妥善处理它。事实上,这是正确的做法,但不能保证我们不会遗漏数据。我们已经承认了程序员会犯错这一事实。

幸运的是,既然我们已经承认我们犯错误,我们可以使用声明语句在以后重构代码。

image

我们添加了一些声明语句,如果返回值不在预期范围之内,它们会发出警告。让我们来看看这些声明如何改变我们的测试代码:

image

这种方法有以下几个优点:

  • 它是一种可执行文档
  • 在问题的根源设置警告器
  • 包含有价值的关于“无效”参数的调试信息

1. 作为一种可执行文档

典型文档通常有几种不同的特点如内联或块注释,文档字符串,以及sphinx。这些特点具有特定目的,它们几乎都是软件开发的关键。不幸的是,他们都遇到了同样的问题。它们可以很快地与快速变化的代码和需求保持同步。这会导致开发人员不信任原始文档。

将声明文档化有不同的目的。他们清晰而简明地描述应用程序在运行时的预期状态。此外,如果我们改变我们的假设时没有修改声明以匹配新的行为,应用程序就会崩溃。

声明语句最好随其他变动一起更新。因此声明比非可执行文档更值得信赖。此外,断言还具有许多诸如评论,文档字符串等的益处。

值得指出的是,有另一种被称为doctest形式的可执行文档,在Python文件系统中十分常见。这些测试/文档可能有些丑陋,但他们的关键特性是他们近乎于代码,就像声明一样。

2. 在问题的根源设置警告器

我们都有过类似的经历,你花费几个小时调试bug,最后意识到真正的bug一开始就不在开始调试的代码中(见5个为什么)。也许导致bug的原因根本就不是你第一次注意到的错误行为。

例如,你在系统中发现一个字节字符串,但你认为内部都是Unicode字符串。可能需要很长时间才能找到格式转换功能在哪里失效了。这处境真是令人沮丧。如果更早发现了bug就好了,或者至少有更多的调试信息。

声明语句不会阻值这种情况的出现,但它们确实提供了一个改善的机会。上面的声明将警告我们:这时该函数不服从它们之间的约定,没有返回一个(0 - 1)区间内的值。如果我们随后发现其他代码的范围无效,它会给我们一个有价值的线索。我们将会知道这个函数没有按照既定要求执行。这一条线索可以节省时间,因为它可以避免从症状的根源追溯其发生原因。

3. 包含有价值的关于“无效”参数的调试信息

请注意到我们声明语句还包括输入参数的信息。当用户使用那些我们没有访问权的数据而遇到错误时,这些信息将具有无可估量的价值。此外,当用户很难讲清楚错误发生时的情况时,调试信息尤其有用。因此,这些声明语句可以防止你无意把bug标记为“不可重现”状态。

输入参数信息也有其他一些微妙的好处:

  • 显示一条关于用户正在使用何种类型数据的无效假设
  • 解释我们在文档中期望使用何种类型数据的疏忽
  • 使潜在的无法执行的新实例暴露出来

使用声明语句的坏处

我们已经编写好可以提供大量好处的声明语句,但有时候它们表现得并不有效。通常,有以下几个缺点:

1. 调试模式

通常情况下,出于对技术和显示情况的考虑,声明语句并不符合代码编写规范。它们仅仅在调试常量为true时激活。然而,常量默认为true,这意味着你的代码很有可能在发布时处于调试模式。

如果你的应用程序运行在少量额外逻辑频繁引起注意的环境中,就需要注意一下了。关闭调试模式的唯一方法是以-O 选项运行Python解释器。

2. 增加代码的噪声

很容易过度使用声明语句而使代码很快变得难以阅读。这会使你的代码混入很多噪声,而实际功能被一系列的错误检查淹没。下面的代码是一个过度使用声明语句的示例,让我们看看想明白代码到底干什么有多困难。

image

适当地使用声明

假设你认定某些错误绝不会发生,就该保守地使用声明语句。不要过分使用声明语句去检查无效输入。

这没有硬性规定,每个开发人员可能会不同程度地使用它。请尝试采用你自己的标准,包括一些在你的开发中定义的风格指南。

你应该有自己的风格吧?

当然,也要记得Python支持duck-type类型(译注:鸭子类型,动态类型的一种),因此不要因为过度使用是明语句检查数据类型而把这种功能淹没了。

我发现的一个有用的技术是将所有捕捉到的Asserti异常优先级设置为最高,并结合另一个有用的技术进行处理。

日志记录

日志的使用类似于声明语句。它可以提供运行时调试信息以改进你的可执行文件。然而,日志并不完全像声明。它有一些额外的好处。

1. 更出色的控制粒度

Python的日志模块包含面非常广泛,并且可定制。你可以把消息发送到几个不同的级层,每一层可以开启或关闭。因此,你最好考虑一些更为严重的情况,以帮助你在这个日志级层编码。

记住,这里的情况与声明语句无关,它们仅仅依赖于调试模式。

2. 更好的动态控制

日志记录允许你随地浏览日志级别。常见情况是一个配置文件,环境变量或是数据库。这种灵活性允许你控制日志信息显示量,而不必重新运行或重新部署应用程序。

相比之下,Python不允许为声明语句动态分配调试常量。因此,你无法在不重新运行应用程序的前提下打开声明开关。

决定你的防御编码策略时,值得考虑一下它。如果你使用了一个“可代替”的工具,动态日志控制就显得尤其重要,如PyInstaller分配机制。

3. 静默保存回溯信息

发生错误时实时保存回溯和调试信息相当重要,智能日志可以自动完成保存过程。

这个概念是道格·赫尔曼在一个著名的的异常处理程序中提出的:

image

自动调用 log.exception 会带给我们更多异常消息。然后,我们可以配置日志记录器将回溯和异常消息分别存储到不同日志文件,方便以后在没有正常消息和警告的情况下检查程序。

如果在运行代码中启用这个异常文件,它可能包含非常有用的调试信息。这为分析日志数据创造了很多令人兴奋的可能性:

  • 用户会尝试不同排列的特性,而这些特性我们从来没有测试甚至没有考虑过,这可能导致新特性变多,以使常见实例更加易用。
  • 找到由于用户误解应用程序的工作方式而犯的类似错误,这可能为编制更好的用户文档作出相当贡献。

4. 声明的高级组合

你还可以使用声明语句配合日志记录。例如,你可以在默认调试模式下运行应用程序,然后捕捉Asserti并将日志记录到另一个文件。这可能为数据挖掘创造更多的可能性,如发现一个假定在平台中运行的环境。

这只是几个使用日志的防御性编程的例子。事实上,你可以使用日志创建低保真环境以解决各种各样的问题,另一篇文章有相关介绍。[2]

日志记录的缺点

日志和声明有许多相同的缺点。然而,日志的额外的灵活性伴随着额外的负担需要考虑。

1. 难度管理级别一致性

使用日志记录最大的困难是相同级别的一组日志始终贯穿整个代码库。这可以归为主观的命名问题,这两个问题仅在计算机科学中存在。最好的解决方案是随代码风格一同提交指南。随后添加日志消息时,会有一些具体项目的文档供新手参考。

2. 设置多个日志记录器

日志模块是非常灵活的,但它是有代价的。日志配置将变得很复杂。考虑采用以下策略:

  • 调试级别的消息保存在一个名为.debug的隐藏文件中
  • 信息、警告和错误级别消息保存在stderr文件中
  • 关键和异常级别的消息以GUI消息框形式弹出

这是一个很好的起点。同时,Python的文档包括几个绝对值得一读的优秀日志记录策略,决定你的设置之前,最好读一下。

记住,当你调试bug时,日志可以表现出相当大的优势。因此,一定要花时间去学习日志系统,确保您的配置设计得足够仔细。即使简单的应用程序也应该有一套良好的、精心设计的日志记录策略。

单元测试

保护自己免遭bug烦扰的最后一种方法是不要忽略过去的bug。过去的bug倾向于长时间潜伏在代码中,这通常是由于有人不理解为什么一些代码要以一种特殊的方式编写,继而删除了它们以”清洁“系统。

这就引出了另一个场景,创建另一种版本的可执行文件相当有用。因此,当一个毫无戒心的开发人员修改一些代码或者删除一些正在运行的代码来警告他们的错误,就没有什么灾难性后果了。

人们通常在测试驱动开发的背景下会遇到单元测试的情况。这是一个很好的概念,但往往在实践中有点过于乐观因而很难遵循(阅读材料: customer wants code now)。相关的讨论在另一个博客帖子中。

我想讨论的是如何使用单元测试使你从今后和过去的bug中解脱出来。我认为它将TDD测试和一些更有成效的能够减少前期时间成本的概念颠倒了。

我建议你修复bug后写一份测试报告 (更多讨论)

这里有几个可能不会立即表现出来的目的:

改善bug修复的文档化

要确保你提交的信息包含帮助你修复bug的内容,但不要就此止步。很可能你提交的信息和评论缺乏类似于针对以下提问的解决方案:

  • 你如何测试补丁?
  • 具体是哪种情况导致了这个bug?

这就是一个优秀的单元测试开始起作用的时候了。你已经知道如何测试bug。(你之前做过测试,对吧?)所以,描述你测试的场景吧,让每个人都受益于你的努力。

单元测试是一个出色的环节,在这里所有的bug将展现出来,并且它会提供修复bug的文档。你不仅可以解释如何以及为什么你要修复它,而且你测试它的方式也能细细道来。如果bug又出现了,那么这条信息将会非常有价值。

不要忘记,一次正常运行的测试可以为bug的定位线索(让你知道bug没有在这里发生)。

2. 能经得住时间考验的重复bug排除编码

特定bug不知不觉地嵌入代码库并不是稀罕事。由于需求变化,重构错误,或其他部分的变动,这是可能发生的。可以通过为一个特定的bug修复编写一个单元测试回归,且要记得运行你的测试代码。将单元测试看做是从稍后又会遭遇bug的麻烦中解放出来的机会。

单元测试的负面影响

单元测试的主要缺点是,你很容易忘记运行它们。这只是一个无法从代码定位的测试副产品。通过类似的实践,像持续集成化使用Travis CI 和自动化测试,这个不足可以得到弥补。

另一个缺点是单元测试通常只在自己的测试环境中执行,这是一个没有真实用户的模拟环境。

结论

这种风格的发展很难分类,不幸的是没有任何既定的规则说明何时使用它。所以,我强烈建议你牢牢记住指南。

该指南将导致你的观念发生微妙的变化。观念的变化相当重要,而不是工具和机制本身。最终你会因为过度使用声明或日志记录犯一些错误,进而开始形成自己的风格。同时,每一个项目的需求都不同,所以有必要学会所有的工具,进而以需要的解决方式混合使用它们。

脚注

[1]可执行文件是一个术语,有时用来描述测试框架doctests。术语“Literate testing”用来描述这一概念。

[2]你甚至可以使用日志来构建自己的分析工具。登录到网络或Dropbox文件,每次将使用一个特性。然后,后台将有一个shell脚本用来收集这些文件。现在你有基于简单文本格式的大量使用信息,它包含开放的大量数据分析的可能性,你可以用来帮助你的用户。

日记本
Gupao