天青色等烟雨,而我在等你。

什么时候该使用assert

【本文译自When to use assert

什么情况下该使用assert语句的问题很常见,经常有人误用它,因此我写了这篇文章来介绍什么时候、为什么要使用断言语句assert,什么时候不该用。

为不熟悉的人简单说明一下,Python中的assert语句用来验证一个条件。如果条件为真,什么都不发生;相反,便会触发一个AssertionError,带有一个可选的错误信息。例如:

py> x = 23
py> assert x > 0, "x is not zero or negative"
py> assert x%2 == 0, "x is not an even number"
Traceback (most recent call last):
    File "<stdin>", line 1, in <module> 
    AssertionError: x is not an even number

很多人用assert语句来快速方便地在输入参数不正确的时候触发异常。但这是错误,而且是一个很危险的错误的做法,有两个原因。其一,AssertionError不是用来测试函数参数时候该给出的错误类型。你不应该像下面这样写代码:

if not isinstance(x, int):
    raise AssertionError("not an int")

这里应该触发TypeError异常而不是AssertionErrorassert触发了错误的异常类型。

然而,更加危险的是,有一种情况[?]:当带着-O或者-OO优化参数来运行脚本时,assert语句在编译的时候将被忽略,因此不能保证assert语句一定会执行。这是正常使用assert语句的一个特性,但是当使用不恰当时,便会导致亿-O参数运行的程序崩溃。

那么什么时候该使用assert?以下几种场景可以使用,排名不分先后:

  • 防卫式编程* 运行时的程序逻辑检查* 契约式检查(比如:先验条件和后验条件)* 程序不变量* 测试注释

(在测试代码的时候使用assert也是可以的,作为一种快速丑陋的懒人的测试用例,如果你接受当程序以-O参数运行时,assert将不会起任何作用。我有时也会用assert False来标记代码分支中尚未完成的部分,我希望这一部分会断言失败。如果写的更多一些,rasie NotImplementError可能会显得更恰当)

断言的情形有很多,因为这是用来检验代码的正确性的语句。如果你确信某一部分代码是正确的,那么assert便没有意义了,因为这里永远不会断言失败你可以安全的删除这一处断言。如果你担心断言可能会失败(例如测试用户输入的数据),那么你不敢放心的使用assert因为它有可能会在编译时被忽略,那么你的检测就被跳过了。

有一种有趣的情形介于上面两种之间,有时你认为某段代码是正确的但又不是100%的确信。也许你曾经忽视过一些奇怪的边界情形(毕竟我们只是人类)。这种情况下,附加运行时的检查会让你安心一些,这样错误会在发生是被尽快的捕获而不是在程序已经运行了一段时间之后。

(这也是为什么assert的使用会出现分歧。每个人对自己代码的自信力不同,有的人觉得某一处assert是有必要的别人可能觉得是多余的)

还有一种好的实践是用assert来检测程序不变量。程序不变量是指你可以依赖的始终不变的条件,除非代码出了bug导致条件发生变化。如果程序有bug,最好今早的发现,所以我们做了测试,但是我们不希望这种测试拖慢我们的程序运行。因此,可以在开发的时候开启assert测试,在生产环境下去掉他们。

一个不变量的例子,你的某个函数,在开始执行的时候需要一个数据库连接并且确保在函数执行期间连接始终有效直到函数返回,那么这个数据库连接可以看作这个函数内部的不变量:

def some_function(arg): 
    assert not DB.closed()    
    ... # code goes here    
    assert not DB.closed()    
    return result

断言也可以作为不错的测试注释。对于下面这种注释:

# when we reach here, we know that n > 2

你可以通过如下断言确保这个注释已经测试通过,来取代上面的注释写法:

assert n > 2

在防卫式编程中使用断言也是不错的方式。你可能没有对代码中可能出现的错误采取立即的保护手段,而是在后面对可能出现错误的变化情况做保护。理想情况下,测试用例会查处这些错误,不过让我们来面对他们,即使是有测试用例的情况下,也不一定保证测试完整。开发的查错机器人可能会崩溃并且连续好几周没人注意到,或者程序员在提交代码之前忘记执行测试。在内部做检查是另一层防止错误溜入的方式,除了那种使程序功能出错返回错误结果但并不致程序崩溃的错误。

假设你知道某一个变量会有哪几种可能的取值,你要写一系列的if...elif代码段:

# target is expected to be one of x, y, or z, and nothing else.
if target == x: 
    run_x_code()
elif target == y:
    run_y_code()
else:
    run_z_code()

假象上面的代码现在是正确的。但是会保证一直正确么?需求变更。代码发生变化。如果需求变化导致当target = w时运行run_w_code会怎么样?如果我们改变了代码,给target变量赋值,却忘记修改上面这一块代码,就回错误的执行run_z_code()导致不好的事情发生。所以上述代码最好写成防卫式,这样要么正确执行,要么理解抛出异常,即使日后可能会发生变化。

在这一块开始之前写上注释是个不错的第一步,但是众人皆知人们可能不会去阅读和更新注释。会导致注释失效。不过如果是个断言,则既起到这一块的注释作用又显得很整洁,并且如果注释条件被违反,程序会立即抛错:

assert target in (x, y, z)
if target == x:    
    run_x_code()
elif target == y:    
    run_y_code()
else:   
    assert target == z    
        run_z_code()

这里,断言既是一种防卫式编程又是一个测试文档。我认为这是比下面更优雅的一种解决方案:

if target == x:
    run_x_code()
elif target == y:
    run_y_code()
elif target == z:
    run_z_code()
else:
    # This can never happen. But just in case it does...
    raise RuntimeError("an unexpected error occurred")

这种方法去掉了不必要的value = c的判断,帮助开发者使代码变得简洁,并且去掉了RuntimeError这一块死代码。另外,发生「unexpected error」(未知错误)也会显得很尴尬,而且他们也确实会发生。

契约式设计也是对断言的合理使用。在契约式设计中,我们认为函数与他的调用者之间签了契约。像下面这样:

「如果你传给我一个非空字符串,我一定会返回给你这个字符串首字母的大写模式。」

如果这个契约被函数或者调用函数的代码破坏,程序就会出现bug。我们说函数有「先验条件」(限制必须要有参数)和后置条件(限制必须要有返回值)。因此这个函数可能会这样编写:

def first_upper(astring):
    assert isinstance(astring, str) and len(astring) > 0
    result = astring[0].upper()
    assert isinstance(result, str) and len(result) == 1
    assert result == result.upper()
    return result

契约式设计的目标是,在正确的程序中,一直要保证先验条件和后验条件。始终断言,直到(断言一直在)我们发布没有bug的代码并将它们投入生产环境时,程序将会正常运行,我们也可以放心的移除断言测试。

下面是我的建议,什么情况下不该使用断言:

  • 永远不要用断言来测试用户输入的数据,或者任何其他必须要测试的情形。
  • 不要使用断言来测试程序正常运行时可能会出现的错误。断言是用在不正常的出错条件的。永远不要让你的用户看到AssertionError;如果他们看到了,修复这个bug。
  • 特别的,不要只是仅为assert比起正常的抛出异常的测试书写起来短一些就使用它。assert不是专门给懒人程序员使用的快捷方法。
  • 不要用assert来测试公共库函数的输入参数(私有的库函数没问题)因为你无法控制函数调用也无法保证断言不会破坏函数之间的契约。
  • 不要把assert用在你要修复的错误上面。换句话说,这样你便需要在生产环境中来处理AssertionError异常
  • 别用太多断言导致代码变得复杂

– Steven

【译文完】

最后附加一份lepture提交给Tornado的有关assert的PR:

https://github.com/facebook/tornado/pull/937/files

comments powered by Disqus