Python 用断言的使用

Python 用断言的使用,有时,真正有用的语言特性得到的关注反而不多,比如Python内置的assert语句就没有受到重视。

本文将介绍如何在Python中使用断言。你将学习用断言来自动检测Python程序中的错误,让程序更可靠且更易于调试。

读到这里,你可能想知道什么是断言,以及它到底有什么好处。下面就来一一揭晓答案。

从根本上来说,Python的断言语句是一种调试工具,用来测试某个断言条件。如果断言条件为真,则程序将继续正常执行;但如果条件为假,则会引发AssertionError异常并显示相关的错误消息。

Python 用断言的使用 示例

下面举一个断言能派上用场的简单例子。本书中的例子会尝试结合你可能在实际工作中遇到的问题。

假设你需要用Python构建在线商店。为了添加打折优惠券的功能,你编写了下面这个apply_discount函数:

def apply_discount(product, discount):
    price = int(product['price'] * (1.0 - discount))
    assert 0 <= price <= product['price']
    return price

注意到assert语句了吗?这条语句确保在任何情况下,通过该函数计算的折后价不低于0,也不会高于产品原价。
来看看调用该函数能否正确计算折后价。在这个例子中,商店中的产品用普通的字典表示。这样能够很好地演示断言的使用方法,当然实际的应用程序可能不会这么做。下面先创建一个示例产品,即一双价格为149美元的漂亮鞋子:

>>> shoes = {'name': 'Fancy Shoes', 'price': 14900}

顺便说一下,这里使用整数来表示以分为单位的价格,以此来避免货币的舍入问题。一般而言,这是个好办法……好吧,有点扯远了。现在如果为这双鞋打七五折,即优惠了25%,则售价变为111.75美元:

>>> apply_discount(shoes, 0.25)
11175

嗯,还不错。接着再尝试使用一些无效的折扣,比如200%的“折扣”会让商家向顾客付钱:

>>> apply_discount(shoes, 2.0)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    apply_discount(prod, 2.0)
  File "<input>", line 4, in apply_discount
     assert 0 <= price <= product['price']
AssertionError

从上面可以看到,当尝试使用无效的折扣时,程序会停止并触发一个AssertionError。发生这种情况是因为200%的折扣违反了在apply_discount函数中设置的断言条件。

从异常栈跟踪信息中还能得知断言验证失败的具体位置。如果你(或者团队中的另一个开发人员)在测试在线商店时遇到这些错误,那么查看异常回溯就可以轻松地了解是哪里出了问题。

这极大地加快了调试工作的速度,并且长远看来,程序也更易于维护。朋友们,这就是断言的力量。

Python 用断言的使用 为什么不用普通的异常来处理

你可能很奇怪为什么不在前面的示例中使用if语句和异常。

要知道,断言是为了告诉开发人员程序中发生了不可恢复的错误。对于可以预料的错误(如未找到相关文件),用户可以予以纠正或重试,断言并不是为此而生的。

断言用于程序内部自检,如声明一些代码中不可能出现的条件。如果触发了某个条件,即意味着程序中存在相应的bug。

如果程序没有bug,那么这些断言条件永远不会触发。但如果违反了断言条件,程序就会崩溃并报告断言错误,告诉开发人员究竟违反了哪个“不可能”的情况。这样可以更轻松地追踪和修复程序中的bug。我喜欢能让生活变轻松的东西,你也是吧?

现在请记住,Python的断言语句是一种调试辅助功能,不是用来处理运行时错误的机制。使用断言的目的是让开发人员更快速地找到可能导致bug的根本原因。除非程序中存在bug,否则绝不应抛出断言错误。

下面先详细了解一下断言的语法,接着介绍在实际工作中使用断言时常见的两个陷阱。

Python 用断言的使用 Python的断言语法

在开始使用Python的某项特性之前,最好先研究它是如何实现的。根据Python文档,assert语句的语法如下所示:详见Python文档:“The Assert Statement”。

assert_stmt ::= "assert" expression1 ["," expression2]

其中expression1是需要测试的条件,可选的expression2是错误消息,如果断言失败则显示该消息。在执行时,Python解释器将每条断言语句大致转换为以下这些语句:

if __debug__:
    if not expression1:
        raise AssertionError(expression2)

这段代码有两个有趣之处。

第一,代码在检查断言条件之前,还会检查__debug__全局变量。这是一个内置的布尔标记,在一般情况下为真,若进行代码优化则为假。下一节将进一步讨论。

第二,还可以使用expression2传递一个可选的错误消息,该消息将与回溯中的AssertionError一起显示,用来进一步简化调试。例如,我见过这样的代码:

>>> if cond == 'x':
...    do_x()
... elif cond == 'y':
...    do_y()
... else:
...    assert False, (
...        'This should never happen, but it does '
...        'occasionally. We are currently trying to '
...        'figure out why. Email dbader if you '
...        'encounter this in the wild. Thanks!')

虽然这段代码很丑,但如果在应用程序中遇到海森堡bug,那么这绝对是一种有效且有用的技术。

指在尝试研究时似乎会消失或者改变行为的bug,参见维基百科“海森堡bug”词条。

Python 用断言的使用 常见陷阱

在Python中使用断言时,需要注意两点:第一,断言会给应用程序带来安全风险和bug;第二,容易形成语法怪癖,开发人员会很容易编写出许多无用的断言。

这些问题看上去(而且可能确实)相当严重,所以你应该至少对以下两个注意事项有所了解。

注意事项1:不要使用断言验证数据

在Python中使用断言时要注意的一个重点是,若在命令行中使用-O-OO标识,或修改CPython中的PYTHONOPTIMIZE环境变量,都会全局禁用断言。

此时所有断言语句都无效,程序会直接略过而不处理断言,因此不会执行任何条件表达式。

许多其他的编程语言也有类似的设计决策。因此使用断言语句来快速验证输入数据非常危险。

进一步解释一下,如果程序使用断言来检查一个函数参数是否包含“错误”或意想不到的值,那么很快就会发现事与愿违并会导致错误或安全漏洞。

下面用一个简单的例子说明这个问题。与前面一样,假设你正在用Python构建一个在线商店应用程序,代码中有一个函数会根据用户的请求来删除产品。

由于刚刚学习了断言,因此你可能会急于在代码中使用(反正我会这么做)。于是,你写下这样的实现:

def delete_product(prod_id, user):
    assert user.is_admin(), 'Must be admin'
    assert store.has_product(prod_id), 'Unknown product'
    store.get_product(prod_id).delete()

仔细看这个delete_product函数,如果禁用断言会发生什么?

这个仅有三行代码的函数示例存在两个严重的问题,都是由不正确地使用断言语句引起的。

(1) 使用断言语句检查管理员权限很危险。如果在Python解释器中禁用断言,这行代码则会变为空操作,不会执行权限检查,之后任何用户都可以删除产品。这可能会引发安全问题,攻击者可能会借此摧毁或严重破坏在线商店中的数据。这太糟糕了!

(2) 禁用断言后会跳过has_product()检查。这意味着可以使用无效的产品ID调用get_product(),这可能会导致更严重的bug,具体情况取决于程序的编写方式。在最糟的情况下,有人可能借此对商店发起拒绝服务(denial of service,DoS)攻击。例如,如果尝试删除未知产品会导致商店应用程序崩溃,那么攻击者就可以发送大量无效的删除请求让程序无法工作。

那么如何避免这些问题呢?答案是绝对不要使用断言来验证数据,而是使用常规的if语句验证,并在必要时触发验证异常,如下所示:

def delete_product(product_id, user):
    if not user.is_admin():
        raise AuthError('Must be admin to delete')
    if not store.has_product(product_id):
        raise ValueError('Unknown product id')
    store.get_product(product_id).delete()

修改后的示例还有一个好处,即代码不会触发通用的AssertionError异常,而是触发与语义相关的异常,如ValueErrorAuthError(后者需要自行定义)。

注意事项2:永不失败的断言

开发人员很容易就会添加许多总是为真的Python断言,我过去一直犯这样的错误。长话短说,来看看问题所在。
在将一个元组作为assert语句中的第一个参数传递时,断言条件总为真,因此永远不会失败。
例如,这个断言永远不会失败:

assert(1 == 2, 'This should fail')

这是因为在Python中非空元组总为真值。如果将元组传递给assert语句,则会导致断言条件始终为真,因此上述assert语句毫无用处,永远不会触发异常。

这种不直观的行为很容易导致开发人员写出糟糕的多行断言。比如我曾经欢快地为一个测试套件写了一堆无用的测试用例,带来了并不真实的安全感。假设在单元测试中有这样的断言:

assert (
    counter == 10,
    'It should have counted all the items'
)

第一次检查时,这个测试用例看起来非常好。但它实际上永远不会得到错误的结果:无论计数器变量的状态如何,断言总是计算为True。为什么会这样?因为其中只是声明了一个布尔值总是为真的元组对象。

就像之前说的那样,这样很容易就会搬起石头砸自己的脚(我的脚仍然很痛)。有一个很好的对策能防止这种语法巧合导致的麻烦,那就是使用代码linter。新版本的Python 3也会对这些可疑断言给出语法警告。

顺便说一下,这也是为什么应该总是对单元测试用例先做一个快速的冒烟测试。要确保在编写下一个测试之前,当前测试用例的确会失败。

Python 用断言的使用 总结

尽管有这些需要注意的事项,但Python的断言依然是功能强大的调试工具,且常常得不到充分的利用。

了解断言的工作方式及使用场景有助于编写更易维护和调试的Python程序。

学习断言有助于将你的Python知识提升到新的水平,让你成为一个全方位的Python高手。我确信这一点,因为断言让我在调试过程中节省了大量时间。

关键要点

  • Python断言语句是一种测试某个条件的调试辅助功能,可作为程序的内部自检。

  • 断言应该只用于帮助开发人员识别bug,它不是用于处理运行时错误的机制。

  • 设置解释器可全局禁用断言。

Python教程

Java教程

Web教程

数据库教程

图形图像教程

大数据教程

开发工具教程

计算机教程