第 2 章 Python整洁之道

第 2 章 Python整洁之道

2.1 用断言加一层保险

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

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

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

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

2.1.1 示例: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函数中设置的断言条件。

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

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

2.1.2 为什么不用普通的异常来处理

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

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

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

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

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

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

2.1.3 Python的断言语法

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

1详见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!')

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

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

2.1.4 常见陷阱

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

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

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

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

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

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

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

    下面用一个简单的例子说明这个问题。与前面一样,假设你正在用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. 注意事项2:永不失败的断言

    开发人员很容易就会添加许多总是为真的Python断言,我过去一直犯这样的错误。长话短说,来看看问题所在。

    在将一个元组作为assert语句中的第一个参数传递时,断言条件总为真,因此永远不会失败。

    例如,这个断言永远不会失败:

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

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

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

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

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

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

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

3详见Python文档:“Constants (__debug__)”。

4我写了一篇关于在Python测试中避免冒牌断言的文章,参见dbader.org/blog/catching-bogus-python-asserts。

2.1.5 Python断言总结

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

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

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

2.1.6 关键要点

  • Python断言语句是一种测试某个条件的调试辅助功能,可作为程序的内部自检。
  • 断言应该只用于帮助开发人员识别bug,它不是用于处理运行时错误的机制。
  • 设置解释器可全局禁用断言。

2.2 巧妙地放置逗号

如果需要在Python中的列表、字典或集合常量中添加或移除项,记住一个窍门:在所有行后面都添加一个逗号。

还不太明白?来看一个示例。假设在代码中有下面这个由名字组成的列表:

>>> names = ['Alice', 'Bob', 'Dilbert']

在修改这个名字列表时,通过git diff查看改动可能有点不方便。大多数源码控制系统都是基于行的,因此无法标出同一行中的多个改动。

一个快速改进是根据编码规范,将列表、字典或集合常量分割成多行,如下所示:

>>> names = [
...     'Alice',
...     'Bob',
...     'Dilbert'
... ]

这样每项独占一行,因此可以清楚地从源码控制系统的diff中看出哪里进行了添加、删除和修改操作。虽然只是一个小改动,但我发现这有助于避免很多愚蠢的错误,也让团队成员能够更方便地审阅我的代码改动。

但现在依然有两个编辑情形会导致混乱,即在列表末尾添加或移除内容时,还需要手动调整逗号来保持格式的一致性。

比如需要向列表中添加一个名字Jane,则需要在Dilbert这一行的末尾添加一个逗号来避免一个讨厌的错误:

>>> names = [
...     'Alice',
...     'Bob',
...     'Dilbert' # <- 缺失逗号!
...     'Jane'
]

在查看这个列表的内容时,请做好心理准备:

>>> names
['Alice', 'Bob', 'DilbertJane']

可以看到,Python将字符串DilbertJane合并成了DilbertJane。这称为字符串字面值拼接,是文档中有记录的刻意行为。这种行为可能会在程序中引入令人难以琢磨的bug:

“以空白符分隔多个相连的字符串或byte字面值,即使它们各自使用不同的引号,也会执行拼接操作。”5

5详见Python文档:“String literal concatenation”。

在某些情况下,字符串字面值拼接是一个有用的特性。例如,在跨越多行的长字符串中可以省去反斜杠:

my_str = ('This is a super long string constant '
          'spread out across multiple lines. '
          'And look, no backslash characters needed!')

但另一方面,这个特性有时又会成为负担。那么如何解决这个问题呢?

Dilbert后添加缺失的逗号就能避免两个字符串合并了:

>>> names = [
...     'Alice',
...     'Bob',
...     'Dilbert',
...     'Jane'
]

现在回到原来的问题。为了向列表添加一个新名字,需要修改两行代码。这同样让开发人员很难从git diff看出到底做了什么改动:到底是添加了一个新名字,还是修改了Dilbert这个名字?

幸运的是Python语法留有余地,让我们可以一劳永逸地解决这个逗号放置问题。只要遵循一种能够避免这个问题的编码风格即可,下面来看具体方法。

在Python中,可以在列表、字典和集合常量中的每一项后面都放置一个逗号,包括最后一项。因此只要记住在每一行末尾都加上一个逗号,就可以避免逗号放置问题。

下面是示例的最终版:

>>> names = [
...     'Alice',
...     'Bob',
...     'Dilbert',
... ]

看到Dilbert后面的那个逗号了吗?现在能方便地添加或移除新的项,无须再修改逗号了。这不仅让各行代码保持一致,而且源码控制系统生成的diff清晰整洁,让代码审阅者心情愉悦。看到没,有时魔法就藏在这些细微之处。

关键要点

  • 合理的格式化及逗号放置能让列表、字典和集合常量更容易维护。
  • Python的字符串字面值拼接特性既可能带来帮助,也可能引入难以发现的bug。

2.3 上下文管理器和with语句

有人认为Python的with语句是一个晦涩的特性,但只要你了解了其背后的原理,就不会感到神秘了。with语句实际上是非常有用的特性,有助于编写更清晰易读的Python代码。

with语句究竟有哪些好处?它有助于简化一些通用资源管理模式,抽象出其中的功能,将其分解并重用。

若想充分地使用这个特性,比较好的办法是查看Python标准库中的示例。内置的open()函数就是一个很好的用例:

with open('hello.txt', 'w') as f:
    f.write('hello, world!')

打开文件时一般建议使用with语句,因为这样能确保打开的文件描述符在程序执行离开with语句的上下文后自动关闭。本质上来说,上面的代码示例可转换成下面这样:

f = open('hello.txt', 'w')
try:
    f.write('hello, world')
finally:
    f.close()

很明显,这段代码比with语句冗长。注意,当中的try...finally语句也很重要,只关注其中的逻辑代码还不够:

f = open('hello.txt', 'w')
f.write('hello, world')
f.close()

如果在调用f.write()时发生异常,这段代码不能保证文件最后被关闭,因此程序可能会泄露文件描述符。此时with语句就派上用场了,它能够简化资源的获取和释放。

threading.Lock类是Python标准库中另一个比较好的示例,它有效地使用了with语句:

some_lock = threading.Lock()

# 有问题:
some_lock.acquire()
try:
    # 执行某些操作……
finally:
    some_lock.release()

# 改进版:
with some_lock:
    # 执行某些操作……

在这两个例子中,使用with语句都可以抽象出大部分资源处理逻辑。不必每次都显式地写一个try...finally语句,with语句会自行处理。

with语句不仅让处理系统资源的代码更易读,而且由于绝对不会忘记清理或释放资源,因此还可以避免bug或资源泄漏。

2.3.1 在自定义对象中支持with

无论是open()函数和threading.Lock类本身,还是它们与with语句一起使用,这些都没有什么特殊之处。只要实现所谓的上下文管理器(context manager)6,就可以在自定义的类和函数中获得相同的功能。

6详见Python文档: “With Statement Context Managers”。

上下文管理器是什么?这是一个简单的“协议”(或接口),自定义对象需要遵循这个接口来支持with语句。总的来说,如果想将一个对象作为上下文管理器,需要做的就是向其中添加__enter____exit__方法。Python将在资源管理周期的适当时间调用这两种方法。

来看看实际代码,下面是open()上下文管理器的一个简单实现:

class ManagedFile:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        self.file = open(self.name, 'w')
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            self.file.close()

其中的ManagedFile类遵循上下文管理器协议,所以与原来的open()例子一样,也支持with语句:

>>> with ManagedFile('hello.txt') as f:
...    f.write('hello, world!')
...    f.write('bye now')

当执行流程进入with语句上下文时,Python会调用__enter__获取资源;离开with上下文时,Python会调用__exit__释放资源。

在Python中,除了编写基于类的上下文管理器来支持with语句以外,标准库中的contextlib7模块在上下文管理器基本协议的基础上提供了更多抽象。如果你遇到的情形正好能用到contextlib提供的功能,那么可以节省很多精力。

7详见Python文档:“contextlib”。

例如,使用contextlib.contextmanager装饰器能够为资源定义一个基于生成器的工厂函数,该函数将自动支持with语句。下面的示例用这种技术重写了之前的ManagedFile上下文管理器:

from contextlib import contextmanager

@contextmanager
def managed_file(name):
    try:
        f = open(name, 'w')
        yield f
    finally:
        f.close()

>>> with managed_file('hello.txt') as f:
...     f.write('hello, world!')
...     f.write('bye now')

这个managed_file()是生成器,开始先获取资源,之后暂停执行并产生资源以供调用者使用。当调用者离开with上下文时,生成器继续执行剩余的清理步骤,并将资源释放回系统。

基于类的实现和基于生成器的实现基本上是等价的,选择哪一种取决于你的编码偏好。

基于@contextmanager的实现有一个缺点,即这种方式需要对装饰器和生成器等Python高级概念有所了解。如果你想学习这些知识,可阅读本书中的相关章节。

再次提醒,选择哪种实现取决于你自己和团队中其他人的编码偏好。

2.3.2 用上下文管理器编写漂亮的API

上下文管理器非常灵活,巧妙地使用with语句能够为模块和类定义方便的API。

例如,如果想要管理的“资源”是某种报告生成程序中的文本缩进层次,可以编写下面这样的代码:

with Indenter() as indent:
    indent.print('hi!')
    with indent:
        indent.print('hello')
        with indent:
            indent.print('bonjour')
    indent.print('hey')

这些语句读起来有点像用于缩进文本的领域特定语言(DSL)。注意这段代码多次进入并离开相同的文本管理器,以此来更改缩进级别。运行这段代码会在命令行中整齐地显示出下面的内容:

hi!
    hello
        bonjour
hey

那么如何实现一个上下文管理器来支持这种功能呢?

顺便说一句,这是一个不错的练习,从中可以准确理解上下文管理器的工作方式。因此在查看下面的实现之前,最好先花一些时间尝试自行实现。

如果你已经准备好查看我的实现,那么下面就是使用基于类的上下文管理器来实现的方法:

class Indenter:
    def __init__(self):
        self.level = 0

    def __enter__(self):
        self.level += 1
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.level -= 1

    def print(self, text):
        print('   ' * self.level + text)

还不错,是吧?希望你现在能熟练地在自己的Python程序中使用上下文管理器和with语句了。这两个功能很不错,可以用来以更加有Python特色和可维护的方式处理资源管理问题。

如果你还想再找一个练习来加深理解,可以尝试实现一个使用time.time函数来测量代码块执行时间的上下文管理器。一定要试着分别编写基于装饰器和基于类的变体,以此来彻底弄清楚两者的区别。

2.3.3 关键要点

  • with语句通过在所谓的上下文管理器中封装try...finally语句的标准用法来简化异常处理。
  • with语句一般用来管理系统资源的安全获取和释放。资源首先由with语句获取,并在执行离开with上下文时自动释放。
  • 有效地使用with有助于避免资源泄漏的问题,让代码更加易于阅读。

2.4 下划线、双下划线及其他

单下划线和双下划线在Python变量名和方法名中都有各自的含义。有些仅仅是作为约定,用于提示开发人员;而另一些则对Python解释器有特殊含义。

你可能有些疑惑:“Python中变量名和方法名中的单下划线、双下划线到底有什么含义?”我将竭尽全力为你解释清楚。本节将讨论以下五种下划线模式和命名约定,以及它们各自如何影响Python程序的行为。

  • 前置单下划线:_var
  • 后置单下划线:var_
  • 前置双下划线:__var
  • 前后双下划线:__var__
  • 单下划线:_

2.4.1 前置单下划线:_var

当涉及变量名和方法名时,前置单下划线只有约定含义。它对于程序员而言是一种提示——Python社区约定好单下划线表达的是某种意思,其本身并不会影响程序的行为。

前置下划线的意思是提示其他程序员,以单下划线开头的变量或方法只在内部使用。PEP 8中定义了这个约定(PEP 8是最常用的Python代码风格指南8)。

8详见PEP 8:“Style Guide for Python Code”。

不过,这个约定对Python解释器并没有特殊含义。与Java不同,Python在“私有”和“公共”变量之间并没有很强的区别。在变量名之前添加一个下划线更像是有人挂出了一个小小的下划线警告标志:“注意,这并不是这个类的公共接口。最好不要使用它。”

来看下面的例子:

class Test:
    def __init__(self):
        self.foo = 11
        self._bar = 23

如果实例化这个类并尝试访问在__init__构造函数中定义的foo_bar属性,会发生什么情况?

我们来看看:

>>> t = Test()
>>> t.foo
11
>>> t._bar
23

可以看到,_bar前面的单下划线并没有阻止我们“进入”这个类访问变量的值。

这是因为Python中的前置单下划线只是一个公认的约定,至少在涉及变量名和方法名时是这样的。但是前置下划线会影响从模块中导入名称的方式。假设在一个名为my_module的模块中有以下代码:

# my_module.py:

def external_func():
    return 23
def _internal_func():
    return 42

现在,如果使用通配符导入从这个模块中导入所有名称,Python不会导入带有前置单下划线的名称(除非模块中定义了__all__列表覆盖了这个行为9):

9详见Python文档:“Importing * From a Package”。

>>> from my_module import *
>>> external_func()
23
>>> _internal_func()
NameError: "name '_internal_func' is not defined"

顺便说一下,应避免使用通配符导入,因为这样就不清楚当前名称空间中存在哪些名称了。10为了清楚起见,最好坚持使用常规导入方法。与通配符导入不同,常规导入不受前置单下划线命名约定的影响:

10详见PEP 8:“Imports”。

>>> import my_module
>>> my_module.external_func()
23
>>> my_module._internal_func()
42

这里可能有点混乱。但如果你遵循PEP 8的建议不使用通配符导入,那么真正需要记住的只有下面这一条。

以单下划线开头的名称只是Python命名中的约定,表示供内部使用。它通常对Python解释器没有特殊含义,仅仅作为对程序员的提示。

2.4.2 后置单下划线:var_

有时,某个变量最合适的名称已被Python语言中的关键字占用。因此,诸如classdef的名称不能用作Python中的变量名。在这种情况下,可以追加一个下划线来绕过命名冲突:

>>> def make_object(name, class):
SyntaxError: "invalid syntax"

>>> def make_object(name, class_):
...     pass

总之,用一个后置单下划线来避免与Python关键字的命名冲突是一个约定。PEP 8定义并解释了这个约定。

2.4.3 前置双下划线:__var

迄今为止,我们介绍的命名模式只有约定的意义,但使用以双下划线开头的Python类属性(变量和方法)就不一样了。

双下划线前缀会让Python解释器重写属性名称,以避免子类中的命名冲突。

这也称为名称改写(name mangling),即解释器会更改变量的名称,以便在稍后扩展这个类时避免命名冲突。

听起来很抽象,下面用代码示例来实验一下:

class Test:
    def __init__(self):
        self.foo = 11
        self._bar = 23
        self.__baz = 42

接着用内置的dir()函数来看看这个对象的属性:

>>> t = Test()
>>> dir(t)
['_Test__baz', '__class__', '__delattr__', '__dict__',
 '__dir__', '__doc__', '__eq__', '__format__', '__ge__',
 '__getattribute__', '__gt__', '__hash__', '__init__',
 '__le__', '__lt__', '__module__', '__ne__', '__new__',
 '__reduce__', '__reduce_ex__', '__repr__',
 '__setattr__', '__sizeof__', '__str__',
 '__subclasshook__', '__weakref__', '_bar', 'foo']

该函数返回了一个包含对象属性的列表。在这个列表中尝试寻找之前的变量名称foo_bar__baz,你会发现一些有趣的变化。

首先,self.foo变量没有改动,在属性列表中显示为foo

接着,self._bar也一样,在类中显示为_bar。前面说了,在这种情况下前置下划线仅仅是一个约定,是对程序员的一个提示。

然而self.__baz就不一样了。在该列表中找不到__baz这个变量。

__baz到底发生了什么?

仔细观察就会看到,这个对象上有一个名为_Test__baz的属性。这是Python解释器应用名称改写之后的名称,是为了防止子类覆盖这些变量。

接着创建另一个类来扩展Test类,并尝试覆盖之前构造函数中添加的属性:

class ExtendedTest(Test):
    def __init__(self):
        super().__init__()
        self.foo = 'overridden'
        self._bar = 'overridden'
        self.__baz = 'overridden'

现在你认为这个ExtendedTest类实例上的foo_bar__baz值会是什么?来一起看看:

>>> t2 = ExtendedTest()
>>> t2.foo
'overridden'
>>> t2._bar
'overridden'
>>> t2.__baz
AttributeError:
"'ExtendedTest' object has no attribute '__baz'"

等一下,当试图访问t2.__baz的值时,为什么会得到AttributeError?因为Python又进行了名称改写!实际上,这个对象甚至没有__baz属性:

>>> dir(t2)
['_ExtendedTest__baz', '_Test__baz', '__class__',
 '__delattr__', '__dict__', '__dir__', '__doc__',
 '__eq__', '__format__', '__ge__', '__getattribute__',
 '__gt__', '__hash__', '__init__', '__le__', '__lt__',
 '__module__', '__ne__', '__new__', '__reduce__',
 '__reduce_ex__', '__repr__', '__setattr__',
 '__sizeof__', '__str__', '__subclasshook__',
 '__weakref__', '_bar', 'foo', 'get_vars']

可以看到,为了防止意外改动,__baz变成了_ExtendedTest__baz,但原来的_Test__baz还在:

>>> t2._ExtendedTest__baz
'overridden'
>>> t2._Test__baz
42

程序员无法感知双下划线名称改写,下面的例子可以证实这一点:

class ManglingTest:
    def __init__(self):
        self.__mangled = 'hello'

    def get_mangled(self):
        return self.__mangled

>>> ManglingTest().get_mangled()
'hello'
>>> ManglingTest().__mangled
AttributeError:
"'ManglingTest' object has no attribute '__mangled'"

名称改写也适用于方法名,会影响在类环境中所有以双下划线(dunder)开头的名称:

class MangledMethod:
    def __method(self):
        return 42

    def call_it(self):
        return self.__method()

>>> MangledMethod().__method()
AttributeError:
"'MangledMethod' object has no attribute '__method'"
>>> MangledMethod().call_it()
42

下面这个名称改写的示例可能会令人惊讶:

_MangledGlobal__mangled = 23

class MangledGlobal:
    def test(self):
        return __mangled

>>> MangledGlobal().test()
23

这个例子先声明_MangledGlobal__mangled为全局变量,然后在名为MangledGlobal的类环境中访问变量。由于名称改写,类中的test()方法仅用__mangled就能引用_MangledGlobal__mangled全局变量。

__mangled以双下划线开头,因此Python解释器自动将名称扩展为_MangledGlobal__mangled。这表明名称改写不专门与类属性绑定,而是能够应用于类环境中所有以双下划线开头的名称。

这里需要掌握的内容确实有点多。

说实话,我也没有把这些例子和解释记在大脑中,所以当初撰写这些例子的时候花了一些时间研究和编辑。虽然我有多年的Python使用经验,但大脑中并没有一直记着这样的规则和特殊情形。

有时,程序员最重要的技能是“模式识别”,以及知道查找哪些内容。如果你目前还有点不知所措,不要担心,慢慢来,继续尝试本章中的例子。

深入掌握这些概念之后,你就能识别出名称改写和刚刚介绍的其他行为给程序带来的影响。如果有一天在实际工作中遇到相关问题,你应该知道在文档中搜索哪些信息。

补充内容:什么是dunder

如果你听过一些有经验的Python高手谈论Python或者看过几次Python会议演讲,可能听说过dunder这个词。如果你还不知道这是什么意思,答案马上揭晓。

在Python社区中通常称双下划线为dunder。因为Python代码中经常出现双下划线,所以为了简化发音,Python高手通常会将“双下划线”(double underscore)简称为dunder11

11后续内容中会将dunder翻译成“双下划线方法”。——译者注

例如,__baz在英文中读作dunderbaz。与之类似,__init__读作dunderinit,虽然按道理说应该是dunderinitdunder。

但这只是命名约定中的另一个癖好,就像是Python开发人员的暗号

2.4.4 前后双下划线:__var__

这也许有点令人惊讶——如果名字前后都使用双下划线,则不会发生名称改写。前后由双下划线包围的变量不受Python解释器的影响:

class PrefixPostfixTest:
    def __init__(self):
        self.__bam__ = 42

>>> PrefixPostfixTest().__bam__
42

然而,同时具有前后双下划线的名称在Python中有特殊用途。像__init__这样的对象构造函数,用来让对象可调用的__call__函数,都遵循这条规则。

这些双下划线方法通常被称为魔法方法,但Python社区中的许多人(包括我自己)不喜欢这个词。因为这个词像是暗示人们要退避三舍,但实际上完全不必如此。双下划线方法是Python的核心功能,应根据需要使用,其中并没有什么神奇或晦涩的内容。

但就命名约定而言,最好避免在自己的程序中使用以双下划线开头和结尾的名称,以避免与Python语言的未来变更发生冲突。

2.4.5 单下划线:_

按照约定,单下划线有时用作名称,来表示变量是临时的或无关紧要的。

例如下面的循环中并不需要访问运行的索引,那么可以使用_来表示它只是一个临时值:

>>> for _ in range(32):
...     print('Hello, World.')

在解包表达式中还可使用单下划线表示一个“不关心”的变量来忽略特定的值。同样,这个含义只是一个约定,不会触发Python解析器中的任何特殊行为。单下划线只是一个有效的变量名,偶尔用于该目的。

下面的代码示例中,我将元组解包为单独的变量,但其中只关注colormileage字段的值。可是为了执行解包表达式就必须为元组中的所有值都分配变量,此时_用作占位符变量:

>>> car = ('red', 'auto', 12, 3812.4)
>>> color, _, _, mileage = car

>>> color
'red'
>>> mileage
3812.4
>>> _
12

除了用作临时变量之外,_在大多数Python REPL中是一个特殊变量,表示由解释器计算的上一个表达式的结果。

如果正在使用解释器会话,用下划线可以方便地获取先前计算的结果:

>>> 20 + 3
23
>>> _
23
>>> print(_)
23

如果正在实时构建对象,有单下划线的话不用事先指定名称就能与之交互:

>>> list()
[]
>>> _.append(1)
>>> _.append(2)
>>> _.append(3)
>>> _
[1, 2, 3]

2.4.6 关键要点

  • 前置单下划线_var:命名约定,用来表示该名称仅在内部使用。一般对Python解释器没有特殊含义(通配符导入除外),只能作为对程序员的提示。
  • 后置单下划线var_:命名约定,用于避免与Python关键字发生命名冲突。
  • 前置双下划线__var:在类环境中使用时会触发名称改写,对Python解释器有特殊含义。
  • 前后双下划线__var__:表示由Python语言定义的特殊方法。在自定义的属性中要避免使用这种命名方式。
  • 单下划线_:有时用作临时或无意义变量的名称(“不关心”)。此外还能表示Python REPL会话中上一个表达式的结果。

2.5 字符串格式化中令人震惊的真相

“Python之禅”告诫人们,应该只用一种明确的方式去做某件事。当你发现在Python中有四种字符串格式化的主要方法时,可能会颇感费解。

本节将介绍这四种字符串格式化方法的工作原理以及它们各自的优缺点。除此之外,还会介绍简单的“经验法则”,用来选择最合适的通用字符串格式化方法。

闲话不多说,后续还有很多内容需要讨论。下面用一个简单的示例来实验,假设有以下变量(或常量)可以使用:

>>> errno = 50159747054
>>> name = 'Bob'

基于这些变量,我们希望生成一个输出字符串并显示以下错误消息:

'Hey Bob, there is a 0xbadc0ffee error!'

这个错误可能会在周一早上破坏开发人员的好心情!不过我们的目的是讨论字符串格式化,所以直接开始吧。

2.5.1 第一种方法:“旧式”字符串格式化

Python内置了一个独特的字符串操作:通过%操作符可以方便快捷地进行位置格式化。如果你在C中使用过printf风格的函数,就会立即明白其工作方式。这里有一个简单的例子:

>>> 'Hello, %s' % name
'Hello, Bob'

这里使用%s格式说明符来告诉Python替换name值的位置。这种方式称为“旧式”字符串格式化。

在旧式字符串格式化中,还有其他用于控制输出字符串的格式说明符。例如,可以将数转换为十六进制符号,或者填充空格以生成特定格式的表格和报告。12

12详见Python文档:“printf-style String Formatting”。

下面使用%x格式说明符将int值转换为字符串并将其表示为十六进制数:

>>> '%x' % errno
'badc0ffee'

如果要在单个字符串中进行多次替换,需要对“旧式”字符串格式化语法稍作改动。由于%操作符只接受一个参数,因此需要将字符串包装到右边的元组中,如下所示:

>>> 'Hey %s, there is a 0x%x error!' % (name, errno)
'Hey Bob, there is a 0xbadc0ffee error!'

如果将别名传递给%操作符,还可以在格式字符串中按名称替换变量:

>>> 'Hey %(name)s, there is a 0x%(errno)x error!' % {
...     "name": name, "errno": errno } 'Hey
Bob, there is a 0xbadc0ffee error!'

这种方式能简化格式字符串的维护,将来也容易修改。不必确保字符串值的传递顺序与格式字符串中名称的引用顺序一致。当然,这种技巧的缺点是需要多打点字。

相信你一直在想,为什么将这种printf风格的格式化称为“旧式”字符串格式化。这是因为在技术上有“新式”的格式化方法取代了它,马上就会介绍。尽管“旧式”字符串格式化已经不再受重用,但并未被抛弃,Python的最新版本依然支持。

2.5.2 第二种方法:“新式”字符串格式化

Python 3引入了一种新的字符串格式化方式,后来又移植到了Python 2.7中。“新式”字符串格式化可以免去%操作符这种特殊语法,并使得字符串格式化的语法更加规整。新式格式化在字符串对象上调用format()函数。13

13详见Python文档:“str.format()”。

与“旧式”格式化一样,使用format()函数可以执行简单的位置格式化:

>>> 'Hello, {}'.format(name)
'Hello, Bob'

你还可以用别名以任意顺序替换变量。这是一个非常强大的功能,不必修改传递给格式函数的参数就可以重新排列显示顺序:

>>> 'Hey {name}, there is a 0x{errno:x} error!'.format(
...     name=name, errno=errno)
'Hey Bob, there is a 0xbadc0ffee error!'

从上面可以看出,将int变量格式化为十六进制字符串的语法也改变了。现在需要在变量名后面添加:x后缀来传递格式规范。

总体而言,这种字符串格式化语法更加强大,也没有额外增加复杂性。阅读Python文档14对字符串格式化语法的描述是值得的。

14详见Python文档:“Format String Syntax”。

在Python 3中,这种“新式”字符串格式化比%风格的格式化更受欢迎。但从Python 3.6开始,出现了一种更好的方式来格式化字符串,下一节会详细介绍。

2.5.3 第三种方法:字符串字面值插值(Python 3.6+)

Python 3.6增加了另一种格式化字符串的方法,称为格式化字符串字面值(formatted string literal)。采用这种方法,可以在字符串常量内使用嵌入的Python表达式。我们通过下面这个简单的示例来体验一下该功能:

>>> f'Hello, {name}!'
'Hello, Bob!'

这种新的格式化语法非常强大。因为其中可以嵌入任意的Python表达式,所以甚至能内联算术运算,如下所示:

>>> a = 5
>>> b = 10
>>> f'Five plus ten is {a + b} and not {2 * (a + b)}.'

'Five plus ten is 15 and not 30.'

本质上,格式化字符串字面值是Python解析器的功能:将f字符串转换为一系列字符串常量和表达式,然后合并起来构建最终的字符串。

假设有如下的greet()函数,其中包含f字符串:

>>> def greet(name, question):
...     return f"Hello, {name}! How's it {question}?"
...

>>> greet('Bob', 'going')
"Hello, Bob! How's it going?"

在剖析函数并明白其本质后,就可以得知函数中的f字符串实际上转换成了类似以下的内容:

>>> def greet(name, question):
...     return ("Hello, " + name + "! How's it " +
            question + "?")

CPython的实际实现比这种方式稍快,因为其中使用BUILD_STRING操作码进行了优化,15但两者在功能上是相同的:

15详见Python 3 bug-tracker issue #27078。

>>> import dis
>>> dis.dis(greet)
   2    0 LOAD_CONST       1('Hello, ')
        2 LOAD_FAST        0(name)
        4 FORMAT_VALUE     0
        6 LOAD_CONST       2("! How's it ")
        8 LOAD_FAST        1(question)
       10 FORMAT_VALUE     0
       12 LOAD_CONST       3('?')
       14 BUILD_STRING     5
       16 RETURN_VALUE

字符串字面值也支持str.format()方法所使用的字符串格式化语法,因此可以用相同的方式解决前两节中遇到的格式化问题:

>>> f"Hey {name}, there's a {errno:#x} error!"
"Hey Bob, there's a 0xbadc0ffee error!"

Python新的格式化字符串字面值与ES2015中添加的JavaScript模板字面值(template literal)类似。我认为这对各个语言来说都是一个很好的补充,并且已经开始在Python 3的日常工作中使用。你可以在官方Python文档16中了解更多有关格式化字符串字面值的信息。

16详见Python文档:“Formatted string literals”。

2.5.4 第四种方法:模板字符串

Python中的另一种字符串格式化技术是模板字符串(template string)。这种机制相对简单,也不太强大,但在某些情况下可能正是你所需要的。

来看一个简单的问候示例:

>>> from string import Template
>>> t = Template('Hey, $name!')
>>> t.substitute(name=name)
'Hey, Bob!'

从上面可以看到,这里需要从Python的内置字符串模块中导入Template类。模板字符串不是核心语言功能,而是由标准库中的模块提供。

另一个区别是模板字符串不能使用格式说明符。因此,为了让之前的报错字符串示例正常工作,需要手动将int错误码转换为一个十六进制字符串:

>>> templ_string = 'Hey $name, there is a $error error!'
>>> Template(templ_string).substitute(
...     name=name, error=hex(errno)) 'Hey
Bob, there is a 0xbadc0ffee error!'

结果不错,但是你可能想知道什么时候应该在Python程序中使用模板字符串。在我看来,最佳使用场景是用来处理程序用户生成的格式字符串。因为模板字符串较为简单,所以是更安全的选择。

其他字符串格式化技术所用的语法更复杂,因而可能会给程序带来安全漏洞。例如,格式字符串可以访问程序中的任意变量。

这意味着,如果恶意用户可以提供格式字符串,那么就可能泄露密钥和其他敏感信息!下面用一个示例来简单演示一下这种攻击方式:

>>> SECRET = 'this-is-a-secret'
>>> class Error:
...     def __init__(self):
...         pass
>>> err = Error()
>>> user_input = '{error.__init__.__globals__[SECRET]}'

# 啊哦……
>> user_input.format(error=err)
'this-is-a-secret'

注意看,假想的攻击者访问格式字符串中的__globals__字典,从中提取了秘密的字符串。吓人吧?用模板字符串就能避免这种攻击。因此,如果处理从用户输入生成的格式字符串,用模板字符串更加安全。

>>> user_input = '${error.__init__.__globals__[SECRET]}'
>>> Template(user_input).substitute(error=err)
ValueError:
"Invalid placeholder in string: line 1, col 1"

2.5.5 如何选择字符串格式化方法

我完全明白,Python提供的多种字符串格式化方法会让你感到非常困惑。现在或许应该画一些流程图来解释。

但我不打算这样做,而是归纳一个编写Python代码时可以遵循的简单经验法则。

当难以决定选择哪种字符串格式化方法时,可以结合具体情况使用下面这个经验法则。

达恩的Python字符串格式化经验法则

如果格式字符串是用户提供的,使用模板字符串来避免安全问题。如果不是,再考虑Python版本:Python 3.6+使用字符串字面值插值,老版本则使用“新式”字符串格式化。

2.5.6 关键要点

  • 也许有些令人惊讶,但Python有不止一种字符串格式化的方式。
  • 每种方式都有其优缺点,使用哪一种取决于具体情况。
  • 如果难以选择,可以试试我的字符串格式化经验法则。

2.6 “Python之禅”中的彩蛋

虽然介绍Python的图书普遍会提到Tim Peters的“Python之禅”,但这段话的确值得再次提及。许多年来,我一直从中受益,Tim的话让我成为更优秀的程序员,希望你也能从中受益。

此外,“Python之禅”还作为彩蛋藏在Python语言当中。只需进入Python解释器会话并运行以下命令就能看到:

>>> import this

Python之禅——Tim Peters

美丽好过丑陋,

浅显好过隐晦,

简单好过复合,

复合好过复杂,

扁平好过嵌套,

稀疏好过密集,

可读性最重要,

即使祭出实用性为理由,特例也不可违背这些规则。

不应默认包容所有错误,得由人明确地让它闭嘴!

面对太多的可能,不要尝试猜测,应该有一个(而且是唯一)直白的解决方法。

当然,找到这个方法不是件容易的事,谁叫你不是荷兰人呢!

但是,现在就做永远比不做要好。

若实现方案很难解释,那么它就不是一个好方案;反之也成立!

名称空间是个绝妙想法——现在就来共同体验和增进这些吧!17

17中文版来自ZoomQuite(大妈)。——译者注

目录