Python 中的防御性编程
介绍
现在是星期五下午,你的新版本已经发布几天了。这一周一开始,你感到自豪和如释重负,但随着时间的流逝,你的自豪感逐渐消退。发布这样一个没有错误的版本需要付出很多努力和奉献。事实上,在发布之日,你确信接下来的几周会很平静,因为用户没有其他需要或想要的东西。
当然,这太好了,以至于你都不敢相信,发布后不久,你的第一份错误报告就来了。第一份错误报告只是一些无关紧要的小问题,比如新对话框中的一个小拼写错误。然后,又有几张小错误单子陆续进来,你很快就修复了它们,并将它们推送到存储库。
然后事情发生了,这是每个开发人员最可怕的噩梦,你最珍视的系统部分报告了一个错误。你疯狂地查看代码,尽管你记住了它。在这种情况下,代码分支怎么可能被执行?!代码肯定在骗你。
几天后,你仍然不知道这是怎么发生的。你甚至无法在测试环境中重现该场景。如果你有更多关于故障的调试信息就好了……
真相会让你自由
如果您编写软件已有一段时间,您就会意识到这种情况。尽管您尽了最大努力,但还是再次交付了有问题的软件,这令人沮丧。别担心,这种情况会发生。
故事的这一部分,我将揭示一劳永逸地为您解决这个问题的妙招。不幸的是,我不能,而且我认为这样的事情不存在。
隐藏的事实是所有软件都有错误。然而,这并不意味着我们应该放弃,不追求完美。这只是意味着我们最好稍微改变一下对这一现实的看法。我们编写软件时应该几乎就像在为缺陷做计划一样。我们应该防御性地编写软件,即冷静地为不可避免的和毫无防备的错误设置陷阱。
防御性编程
描述这种风格的最佳术语是防御性编程。维基百科的描述并没有完全表达我的想法,但它是一个很好的起点:
一种防御性设计,旨在确保软件在不可预见的使用情况下仍能继续运行。这个想法可以被视为减少或消除墨菲定律发挥作用的可能性。防御性编程技术尤其适用于软件可能被恶意或无意地滥用而造成灾难性后果的情况。
我真正谈论的是以下指导原则的组合:
- 每行代码都是一项责任
- 整理你的假设
- 最好有可执行文档 [1]
这些准则对于确保我们能够保护代码和理智免受不可避免的错误的影响至关重要。请记住,我们是从不会编写无错误的代码的角度出发进行操作的。
我们需要牢记这些准则,以帮助我们快速找到错误。很多时候,找到错误是最困难的部分。所以,让我们优化查找,而不是完全阻止它们这一不可能完成的任务。
Python 工具
让我们深入了解一些可用于帮助遵循指南的工具。我们将使用Python作为我们的语言进行演示,但大多数语言都有非常相似的工具。
- 断言
- 日志记录
- 单元测试
假设我们有以下函数,它从用户那里获取值并将指定范围的数据规范化为 0 到 1 之间的某个值,以便以后的新小部件可以使用它。
def normalize_ranges(colname):
"""
Normalize given data range to values in [0 - 1]
Return dictionary new 'min' and 'max' keys in range [0 - 1]
"""
# 1-D numpy array of data we loaded application with
original_range = get_base_range(colname)
colspan = original_range['datamax'] - original_range['datamin']
# User filtered data from GUI
live_data = get_column_data(colname)
live_min = numpy.min(live_data)
live_max = numpy.max(live_data)
ratio = {}
try:
ratio['min'] = (live_min - original_range['datamin']) / colspan
ratio['max'] = (live_max - original_range['datamin']) / colspan
except ZeroDivisionError:
ratio['min'] = 0.0
ratio['max'] = 0.0
return ratio
更新感谢reddit 上这条出色的评论发现了文章中的错误!
现在,假设我们有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] 空间中的某个值。
记住上面提到的指导原则,让我们找出这个函数中存在的错误。我们在代码中隐含地做了哪些假设?
我可以看到几个假设:
- original_range仅包含正数
- 返回的比例介于 0.0 和 1.0 之间
仔细检查后,发现上述代码中存在不少假设。不幸的是,这些假设在浏览代码时并不明显。如果我们能让这些假设更清楚就好了……
断言
断言在单元测试中非常常见。事实上,Python有大量针对单元测试的自定义断言集合。但是,没有理由只在测试世界中使用这个有用的工具。
普通代码中的断言语句也非常有用。这些语句接受一个表达式,如果表达式为False ,则引发AssertionError以及可选消息。
例如,上面的函数声称总是返回 [0 - 1] 之间的值。不幸的是,进一步强调我们的假设表明事实并非如此:
>>> age = numpy.array([-10.0, 20.0, 30.0, 40.0, 50.0])
>>> normalize_ranges('age')
{'max': 1.0, 'min': -0.5}
可以想象,这种情况很容易在很长一段时间内被忽视,并且这个返回值可能会传播到整个代码库。这正是无法找到的错误类型,并导致了本文开头的悲伤故事。
我们可以尝试考虑用户可能传入的每个值并正确处理它。事实上,这是正确的做法,但不能保证我们不会遗漏任何东西。我们已经承认程序员是会犯错的。
幸运的是,既然我们已经接受了我们会犯错误的事实,我们就可以使用断言语句来对付我们未来的自己。
def normalize_ranges(colname):
"""
Normalize given data range to values in [0 - 1]
Return dictionary new 'min' and 'max' keys in range [0 - 1]
"""
# 1-D numpy array of data we loaded application with
original_range = get_base_range(colname)
colspan = original_range['datamax'] - original_range['datamin']
# User filtered data from GUI
live_data = get_column_data(colname)
live_min = numpy.min(live_data)
live_max = numpy.max(live_data)
ratio = {}
try:
ratio['min'] = (live_min - original_range['datamin']) / colspan
ratio['max'] = (live_max - original_range['datamin']) / colspan
except ZeroDivisionError:
ratio['min'] = 0.0
ratio['max'] = 0.0
assert 0.0 <= ratio['min'] <= 1.0, (
'"%s" min (%f) not in [0-1] given (%f) colspan (%f)' % (
colname, ratio['min'], original_range['datamin'], colspan))
assert 0.0 <= ratio['max'] <= 1.0, (
'"%s" max (%f) not in [0-1] given (%f) colspan (%f)' % (
colname, ratio['max'], original_range['datamax'], colspan))
return ratio
我们添加了一些断言语句,如果我们没有返回预期范围内的值,这些语句会提醒我们。让我们看看这些断言如何改变我们的小测试用例:
>>> age = numpy.array([-10.0, 20.0, 30.0, 40.0, 50.0])
>>> normalize_ranges('age')
AssertionError: "age" min (-0.500000) not in [0-1] given (10.000000) colspan(40.000000)
这个小改变有几个好处:
- 作为一种可执行文档的形式
- 将警告置于更接近根本问题的位置
- 包含有关“无效”参数的宝贵调试信息
作为一种可执行文档的形式
通常,文档有几种不同的形式,例如内联注释或块注释、文档字符串和 Sphinx。每种形式都有特定的用途,并且对软件开发几乎必不可少。不幸的是,它们都存在同样的问题。它们很快就会与快速变化的代码和需求脱节。这导致开发人员无法信任文档。
断言充当了具有不同目的的文档。它们清晰简洁地描述了应用程序在运行时的预期状态。此外,如果我们更改假设而不修改断言以匹配新行为,应用程序会发出抱怨。
断言语句更有可能与其他更改一起更新。因此,断言比不可执行的文档更值得信赖。此外,断言仍然提供注释、文档字符串等的许多好处。
值得一提的是,Python 生态系统中还有另一种相当常见的可执行文档形式,称为doctests。这些测试/文档看起来可能有些丑陋,但它们的主要特点是它们接近代码,就像断言一样。
将警告置于更接近根本问题的位置
我们都经历过这种情况,你花了几个小时调试一个问题,然后才意识到真正的错误甚至离你开始的地方还很远(参见5 个为什么)。也许错误的根本原因在逻辑上离你第一次看到症状的地方很远。
例如,您在系统深处发现了一个字节字符串,但您假设内部所有内容都是 Unicode 字符串。可能需要很长时间才能找到转换首次中断的位置。这是一种令人沮丧的情况。如果能早点发现错误或至少有更多的调试信息就好了。
断言不会阻止这种情况,但它们确实提供了改进的机会。上面的断言将在该函数不遵守其契约以返回 [0 - 1] 之间的值时提醒我们。如果我们发现其他具有无效范围的代码,这可能会给我们一个有价值的线索。我们会知道这个函数没有履行契约的义务。这个线索实际上可以节省数小时,因为它可以避免从症状一直追溯到原因。
包含有关“无效”参数的宝贵调试信息
请注意,我们的断言语句还包含有关输入参数的信息。当用户使用我们无法访问的数据遇到错误时,这些信息将非常有价值。此外,当用户难以解释错误场景时,调试信息将特别有用。因此,这几个断言语句可以防止您成为可耻地将错误标记为“不可重现”状态的人。
输入参数信息还有其他一些微妙的好处:
- 显示有关用户正在运行的数据类型的无效假设
- 解释我们在文档中对预期数据类型的疏忽
- 暴露无法执行的潜在新用例
断言缺点
def normalize_ranges(colname):
"""
Normalize given data range to values in [0 - 1]
Return dictionary new 'min' and 'max' keys in range [0 - 1]
"""
assert isinstance(colname, str)
original_range = get_base_range(colname)
assert original_range['datamin'] >= 0
assert original_range['datamax'] >= 0
assert original_range['datamin'] <= original_range['datamax']
colspan = original_range['datamax'] - original_range['datamin']
assert colspan >= 0, 'Colspan (%f) is negative' % (colspan)
live_data = get_column_data(colname)
assert len(live_data), 'Empty live data'
live_min = numpy.min(live_data)
live_max = numpy.max(live_data)
ratio = {}
try:
ratio['min'] = (live_min - original_range['datamin']) / colspan
ratio['max'] = (live_max - original_range['datamin']) / colspan
except ZeroDivisionError:
ratio['min'] = 0.0
ratio['max'] = 0.0
assert 0.0 <= ratio['min'] <= 1.0
assert 0.0 <= ratio['max'] <= 1.0
return ratio
正确使用断言
谨慎使用断言,只用于那些你认为永远不会发生的事情。不要过度使用断言来检查无效输入。
对此没有硬性规定,每个开发人员对断言使用的容忍度可能不同。尝试采用一些自己的标准并将其包含在<font style="vertic
免责声明:本内容来源于第三方作者授权、网友推荐或互联网整理,旨在为广大用户提供学习与参考之用。所有文本和图片版权归原创网站或作者本人所有,其观点并不代表本站立场。如有任何版权侵犯或转载不当之情况,请与我们取得联系,我们将尽快进行相关处理与修改。感谢您的理解与支持!
请先 登录后发表评论 ~