Python-入门指南-从新手到大师-四-

228 阅读1小时+

Python 入门指南:从新手到大师(四)

原文:Beginning Python

协议:CC BY-NC-SA 4.0

八、异常

当编写计算机程序时,通常有可能区分正常的事件过程和异常的事情。这种异常事件可能是错误(比如试图将一个数除以零),或者只是一些您不希望经常发生的事情。为了处理这样的异常事件,您可以在事件可能发生的任何地方使用条件(例如,让您的程序检查每个除法的分母是否为零)。然而,这不仅效率低、不灵活,而且会使程序难以辨认。您可能会忽略这些异常事件,只希望它们不会发生,但是 Python 提供了一种异常处理机制作为强大的替代方案。

在本章中,你将学习如何创建和引发你自己的异常,以及如何以各种方式处理异常。

什么是异常?

为了表示异常情况,Python 使用异常对象。当遇到错误时,它会引发异常。如果没有处理(或捕获)这样的异常对象,程序将终止于所谓的回溯(错误消息)。

>>> 1 / 0
Traceback (most recent call last):
   File "<stdin>", line 1, in ?
ZeroDivisionError: integer division or modulo by zero

如果这样的错误消息是您可以使用异常的全部,它们就不会很有趣了。然而,事实是,每个异常都是某个类的一个实例(在本例中是ZeroDivisionError),这些实例可能会以各种方式被引发和捕获,从而允许您捕获错误并对此采取措施,而不是让整个程序失败。

让事情出错。。。你的方式

如您所见,当出现问题时,异常会自动引发。在研究如何处理这些异常之前,让我们看看如何自己引发异常,甚至创建自己的异常。

加薪声明

要引发异常,可以使用带有参数的raise语句,该参数可以是类(应该是Exception的子类)或实例。当使用一个类时,一个实例被自动创建这里是一个例子,使用内置的异常类Exception:

>>> raise Exception
Traceback (most recent call last):
   File "<stdin>", line 1, in ?
Exception
>>> raise Exception('hyperdrive overload')
Traceback (most recent call last):
   File "<stdin>", line 1, in ?
Exception: hyperdrive overload

第一个例子raise Exception,引发了一个普通的异常,没有任何关于出错的信息。在前面的例子中,我添加了错误消息hyperdrive overload

许多内置类是可用的。表 8-1 描述了一些最重要的。您可以在 Python 库参考中的“内置异常”一节中找到所有这些异常的描述所有这些异常类都可以在您的raise语句中使用。

表 8-1。

Some Built-in Exceptions

| 类别名 | 描述 | | --- | --- | | `Exception` | 几乎所有异常的基类。 | | `AttributeError` | 当属性引用或赋值失败时引发。 | | `OSError` | 当操作系统无法执行任务(例如文件)时引发。有几个特定的子类。 | | `IndexError` | 对序列使用不存在的索引时引发。`LookupError`的子类。 | | `KeyError` | 在映射中使用不存在的键时引发。`LookupError`的子类。 | | `NameError` | 找不到名称(变量)时引发。 | | `SyntaxError` | 当代码格式错误时引发。 | | `TypeError` | 当内置操作或函数应用于错误类型的对象时引发。 | | `ValueError` | 当内置操作或函数应用于类型正确但值不正确的对象时引发。 | | `ZeroDivisionError` | 当除法或模运算的第二个参数为零时引发。 |
>>> raise ArithmeticError
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
ArithmeticError

自定义异常类

尽管内置的异常覆盖了很多领域,并且足够用于许多目的,但是有时您可能想要创建自己的异常。例如,在hyperdrive overload的例子中,用一个特定的HyperdriveError类来表示超空间引擎中的错误情况不是更自然吗?错误消息似乎已经足够了,但是正如您将在下一节(“捕获异常”)中看到的,您可以根据异常的类有选择地处理特定类型的异常。因此,如果您想用特殊的错误处理代码来处理超驱动器错误,您将需要一个单独的异常类。

那么,如何创建异常类呢?就像任何其他类一样——但是一定要子类化Exception(直接或间接,这意味着子类化任何其他内置异常都是可以的)。因此,编写一个自定义异常基本上相当于这样的内容:

class SomeCustomException(Exception): pass

真的没什么工作量吧?(如果您愿意,当然也可以向异常类添加方法。)

捕捉异常

如前所述,异常的有趣之处在于您可以处理它们(通常称为捕获或捕捉异常)。您可以使用try / except语句来实现这一点。假设您创建了一个程序,让用户输入两个数字,然后用一个除以另一个,如下所示:

x = int(input('Enter the first number: '))
y = int(input('Enter the second number: '))
print(x / y)

这将很好地工作,直到用户输入零作为第二个数字。

Enter the first number: 10
Enter the second number: 0
Traceback (most recent call last):
  File "exceptions.py", line 3, in ?
    print(x / y)
ZeroDivisionError: integer division or modulo by zero

为了捕捉异常并执行一些错误处理(在这种情况下,只需打印一条更加用户友好的错误消息),您可以像这样重写程序:

try:
    x = int(input('Enter the first number: '))
    y = int(input('Enter the second number: '))
    print(x / y)
except ZeroDivisionError:
    print("The second number can't be zero!")

看起来简单的if语句检查y的值会更容易使用,在这种情况下,这可能确实是一个更好的解决方案。但是如果你给你的程序增加更多的分部,你将需要每个分部有一个if语句;通过使用try / except,您只需要一个错误处理程序。

Note

异常从函数传播到它们被调用的地方,如果它们也没有被捕获,异常将“冒泡”到程序的顶层。这意味着您可以使用try / except来捕捉在其他人的函数中引发的异常。有关详细信息,请参阅本章后面的“异常和功能”一节。

听着,妈,别争了!

如果您已经捕获了一个异常,但是您想再次引发它(可以说是传递它),您可以不带任何参数地调用raise。(如果捕捉到异常,也可以显式提供异常,如本章后面的“捕捉对象”一节所述。)

作为这可能有用的一个例子,考虑一个具有“抑制”异常能力的计算器类。如果此行为被打开,计算器将打印出一条错误消息,而不是让异常传播。如果计算器在与用户的交互会话中使用,这是很有用的,但是如果在程序内部使用,引发一个异常会更好。因此,可以关闭消声。下面是这样一个类的代码:

class MuffledCalculator:
    muffled = False
    def calc(self, expr):
        try:
            return eval(expr)
        except ZeroDivisionError:
            if self.muffled:
                print('Division by zero is illegal')
            else:
                raise

Note

如果出现被零除的情况并且消音被打开,calc方法将(隐式地)返回 None。换句话说,如果打开消音,就不应该依赖返回值。

以下是如何使用该等级的示例,包括带消声和不带消声两种情况:

>>> calculator = MuffledCalculator()
>>> calculator.calc('10 / 2')
5.0
>>> calculator.calc('10 / 0') # No muffling
Traceback (most recent call last): File "<stdin>", line 1, in ?
  File "MuffledCalculator.py", line 6, in calc
     return eval(expr)
  File "<string>", line 0, in ?
ZeroDivisionError: integer division or modulo by zero
>>> calculator.muffled = True
>>> calculator.calc('10 / 0')
Division by zero is illegal

如你所见,当计算器没有被关闭时,ZeroDivisionError被捕获但被传递。

如果您无法处理异常,在except子句中使用不带参数的raise通常是一个不错的选择。不过,有时您可能想引发一个不同的异常。在这种情况下,导致您进入except原因的异常将被存储为您的异常的上下文,并将成为最终错误消息的一部分,例如:

>>> try:
...     1/0
... except ZeroDivisionError:
...     raise ValueError
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError

您可以使用语句的raise ... from ...版本来提供自己的上下文异常,或者使用None来取消上下文。

>>> try:
...     1/0
... except ZeroDivisionError:
...     raise ValueError from None
...
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError

不止一个 except 子句

如果您再次运行上一节中的程序,并在提示符下输入一个非数字值,则会发生另一个异常。

Enter the first number: 10
Enter the second number: "Hello, world!"
Traceback (most recent call last):
  File "exceptions.py", line 4, in ?
     print(x / y)
TypeError: unsupported operand type(s) for /: 'int' and 'str'

因为except子句只寻找ZeroDivisionError异常,所以这一个会溜走并中止程序。为了捕捉这个异常,您可以简单地在同一个try / except语句中添加另一个except子句。

try:
   x = int(input('Enter the first number: '))
   y = int(input('Enter the second number: '))
   print(x / y)
except ZeroDivisionError:
   print("The second number can't be zero!")
except TypeError:
   print("That wasn't a number, was it?")

这一次使用if语句会更加困难。如何检查一个值是否可以用于除法?有很多方法,但是到目前为止,实际上最好的方法是简单地将这些值相除,看看是否可行。

还要注意异常处理并没有弄乱原始代码。添加大量的if语句来检查可能的错误条件很容易使代码变得难以阅读。

用一个块捕获两个异常

如果希望用一个块捕获多个异常类型,可以在一个元组中指定它们,如下所示:

try:
   x = int(input('Enter the first number: '))
   y = int(input('Enter the second number: '))
   print(x / y)
except (ZeroDivisionError, TypeError, NameError):
   print('Your numbers were bogus ...')

在前面的代码中,如果用户输入字符串或数字以外的内容,或者如果第二个数字是零,则打印相同的错误信息。当然,简单地打印一条错误消息并没有多大帮助。另一种选择是继续询问数字,直到除法运算成功。我将在本章后面的“一切顺利时”一节中向您展示如何做到这一点。

请注意,except子句中异常的括号非常重要。一个常见的错误是省略这些括号,在这种情况下,您可能会得到与您想要的不同的结果。有关解释,请参见下一节“捕捉对象”

抓住物体

如果您想在一个except子句中访问异常对象本身,您可以使用两个参数而不是一个。(注意,即使在捕捉多个异常时,您也只为except提供了一个参数——一个元组。)这可能是有用的(例如),如果你想让你的程序继续运行,但你想以某种方式记录错误(也许只是打印出来给用户)。下面是一个示例程序,它打印出异常(如果发生的话)但保持运行:

try:
    x = int(input('Enter the first number: '))
    y = int(input('Enter the second number: '))
    print(x / y)
except (ZeroDivisionError, TypeError) as e:
    print(e)

这个小程序中的except子句再次捕获了两种类型的异常,但是因为您也显式地捕获了对象本身,所以您可以将它打印出来,这样用户就可以看到发生了什么。(在本章后面的“当一切都好的时候”一节中,你会看到一个更有用的应用)

真正的包罗万象

即使程序处理了几种类型的异常,有些还是会漏掉。比如使用同一个除法程序,只需在提示符下试着按回车键,不用写任何东西。您应该得到一条错误消息和一些关于出错原因的信息(堆栈跟踪),如下所示:

Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: ''

这个异常通过了try / except语句——这是正确的。你没有预见到这种情况会发生,也没有为此做好准备。在这些情况下,最好是程序立即崩溃(这样你就能看到哪里出了问题),而不是简单地用一个try / except语句隐藏异常,而这个语句并不是用来捕捉异常的。

然而,如果您确实想捕捉一段代码中的所有异常,您可以简单地从except子句中省略异常类。

try:
    x = int(input('Enter the first number: '))
    y = int(input('Enter the second number: '))
    print(x / y)
except:
    print('Something wrong happened ...')

现在你可以做任何你想做的事情。

Enter the first number: "This" is *completely* illegal 123
Something wrong happened ...

像这样捕捉所有异常是有风险的,因为它会隐藏您没有想到的错误以及您已经准备好的错误。它还将捕获用户试图通过Ctrl-C终止执行的行为,以及您调用的函数试图通过sys.exit终止执行的行为,等等。在大多数情况下,使用except Exception as e可能会更好,并且可能会对异常对象e进行一些检查。这将允许那些极少数不属于Exception子类的异常漏网。这包括SystemExitKeyboardInterrupt,它们是BaseException的子类,是Exception本身的超类。

当一切都好的时候

在某些情况下,除非发生不好的事情,否则执行一段代码是很有用的;与条件句和循环一样,您可以在try / except语句中添加一个else子句。

try:
   print('A simple task')
except:
   print('What? Something went wrong?')
else:
   print('Ah ... It went as planned.')

如果运行此命令,您会得到以下输出:

A simple task
Ah ... It went as planned.

使用这个else子句,您可以实现本章前面“用一个块捕获两个异常”一节中暗示的循环。

while True:
    try:
        x = int(input('Enter the first number: '))
        y = int(input('Enter the second number: '))
        value = x / y
        print('x / y is', value)
    except:
        print('Invalid input. Please try again.')
    else:
        break

在这里,只有当没有出现异常时,循环才被中断(通过else子句中的break语句)。换句话说,只要有错误发生,程序就会不断要求新的输入。以下是一个运行示例:

Enter the first number: 1
Enter the second number: 0
Invalid input. Please try again.
Enter the first number: 'foo'
Enter the second number: 'bar'
Invalid input. Please try again.
Enter the first number: baz
Invalid input. Please try again.
Enter the first number: 10
Enter the second number: 2
x / y is 5

如前所述,使用空的except子句的一个更好的替代方法是捕获Exception类的所有异常(这也将捕获任何子类的所有异常)。你不能 100%确定你能捕捉到所有的东西,因为你的try / except语句中的代码可能很淘气,使用老式的字符串异常,或者创建一个没有子类Exception的自定义异常。然而,如果您使用的是except Exception版本,您可以使用本章前面“捕获对象”一节中的技术在您的小除法程序中打印出一个更有指导意义的错误消息。

while True:
    try:
        x = int(input('Enter the first number: '))
        y = int(input('Enter the second number: '))
        value = x / y
        print('x / y is', value)
    except Exception as e:
        print('Invalid input:', e)
        print('Please try again')
    else:
        break

以下是运行示例:

Enter the first number: 1
Enter the second number: 0
Invalid input: integer division or modulo by zero
Please try again
Enter the first number: 'x' Enter the second number: 'y'
Invalid input: unsupported operand type(s) for /: 'str' and 'str'
Please try again
Enter the first number: quuux
Invalid input: name 'quuux' is not defined
Please try again
Enter the first number: 10
Enter the second number: 2
x / y is 5

最后。。。

最后是finally条款。在可能出现异常后,您可以用它来做内务处理。它与一个try子句相结合。

x = None
try:
    x = 1 / 0
finally:
    print('Cleaning up ...')
    del x

在前面的例子中,无论在try子句中出现什么异常,都保证会执行finally子句。在try子句之前初始化x的原因是,否则它将不会因为ZeroDivisionError而被赋值。当在finally子句中对其使用del时,这将导致一个异常,您不会捕捉到这个异常。

如果你运行这个,清理会在程序崩溃和烧毁之前进行。

Cleaning up ...
Traceback (most recent call last):
  File "C:\python\div.py", line 4, in ?
     x = 1 / 0
ZeroDivisionError: integer division or modulo by zero

虽然使用del删除变量是一种相当愚蠢的清理,但是finally子句对于关闭文件或网络套接字之类的东西可能非常有用。(你会在第十四章中了解到更多。)

您还可以在一个语句中组合使用tryexceptfinallyelse(或者只使用其中的三个)。

try:
    1 / 0
except NameError:
    print("Unknown variable")
else:
    print("That went well!")
finally:
    print("Cleaning up.")

异常和功能

异常和函数很自然地一起工作。如果一个异常是在函数内部引发的,并且没有在那里得到处理,它会传播(冒泡)到调用该函数的地方。如果它也不在那里处理,它将继续传播,直到到达主程序(全局范围),如果那里没有异常处理程序,程序将中止,并返回一个堆栈跟踪。让我们来看一个例子:

>>> def faulty():
...     raise Exception('Something is wrong')
...
>>> def ignore_exception():
...     faulty()
...
>>> def handle_exception():
...     try:
...         faulty()
...     except:
...         print('Exception handled')
...
>>> ignore_exception()
Traceback (most recent call last):
  File '<stdin>', line 1, in ?
  File '<stdin>', line 2, in ignore_exception
  File '<stdin>', line 2, in faulty
Exception: Something is wrong
>>> handle_exception()
Exception handled

如您所见,faulty中引发的异常通过faultyignore_exception传播,并最终导致堆栈跟踪。类似地,它传播到handle_exception,但是在那里用try / except语句处理。

异常的禅

异常处理并不复杂。如果您知道代码的某些部分可能会导致某种异常,并且您不希望程序在这种情况发生时以堆栈跟踪终止,那么您可以根据需要添加必要的try / excepttry / finally语句(或它们的某种组合)来处理它。

有时,您可以用条件语句完成与异常处理相同的事情,但是条件语句可能会变得不自然,可读性差。另一方面,有些事情看起来像是对if / else的自然应用,实际上用try / except可以实现得更好。让我们来看几个例子。

假设您有一个字典,您想打印存储在特定键下的值,如果它在那里的话。如果它不在那里,你不想做任何事情。代码可能是这样的:

def describe_person(person):
    print('Description of', person['name'])
    print('Age:', person['age'])
    if 'occupation' in person:
        print('Occupation:', person['occupation'])

如果您为该函数提供一个包含名称 Throatwobbler 红树林和年龄 42(但没有职业)的字典,您将得到以下输出:

Description of Throatwobbler Mangrove
Age: 42

如果您添加职业“camper”,您将得到以下输出:

Description of Throatwobbler Mangrove
Age: 42
Occupation: camper

代码很直观,但有点低效(尽管这里主要关心的是代码的简单性)。它必须查找键'occupation'两次——一次是查看键是否存在(在条件中),一次是获取值(打印出来)。另一个定义如下:

def describe_person(person):
    print('Description of', person['name'])
    print('Age:', person['age'])
    try:
        print('Occupation:', person['occupation'])
    except KeyError: pass

这里,该函数简单地假设键'occupation'存在。如果您认为正常情况下是这样的,这可以节省一些精力。将获取并打印该值——不需要额外的获取来检查它是否确实存在。如果这个键不存在,就会引发一个KeyError异常,这个异常被except子句捕获。

您可能还会发现try / except在检查对象是否具有特定属性时非常有用。比方说,你想检查一个对象是否有一个write属性。那么您可以使用这样的代码:

try:
    obj.write
except AttributeError:
    print('The object is not writeable')
else:
    print('The object is writeable')

这里的try子句只是访问属性,而不对它做任何有用的事情。如果一个AttributeError被引发,那么这个对象没有这个属性;否则,它具有属性。这是第七章(在“接口和自省”一节)中介绍的getattr解决方案的自然替代方案。你更喜欢哪一个在很大程度上取决于你的品味。

请注意,这里的效率增益并不大。(更像是非常非常微小。)一般来说(除非你的程序有性能问题),你不应该太担心这种优化。关键是在很多情况下,使用try / except语句比使用if / else语句更自然(更“Pythonic 化”),你应该养成尽可能使用它们的习惯。1

并不都是异常

如果您只是想提供一个警告,说明事情并不完全像它们应该的那样,您可以使用来自warnings模块的warn函数。

>>> from warnings import warn
>>> warn("I've got a bad feeling about this.")
__main__:1: UserWarning: I've got a bad feeling about this.
>>>

该警告只会显示一次。如果你再运行最后一行,什么也不会发生。

使用您的模块的其他代码可以禁止您的警告,或者只禁止特定种类的警告,使用来自同一个模块的filterwarnings函数,指定几个可能采取的动作之一,包括"error""ignore"

>>> from warnings import filterwarnings
>>> filterwarnings("ignore")
>>> warn("Anyone out there?")
>>> filterwarnings("error")
>>> warn("Something is very wrong!")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UserWarning: Something is very wrong!

如您所见,引发的异常是UserWarning。发出警告时,可以指定不同的异常或警告类别。这个异常应该是Warning的子类。如果您将警告转换为错误,将使用您提供的异常,但是您也可以使用它来专门筛选出给定类型的警告。

>>> filterwarnings("error")
>>> warn("This function is really old...", DeprecationWarning)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
DeprecationWarning: This function is really old...
>>> filterwarnings("ignore", category=DeprecationWarning)
>>> warn("Another deprecation warning.", DeprecationWarning)
>>> warn("Something else.")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UserWarning: Something else.

除了这个基本的用途之外,warnings模块还有一些高级的功能。如果你好奇,可以参考图书馆的参考资料。

快速总结

本章涵盖的主要主题如下:

  • 异常对象:异常情况(比如发生错误时)由异常对象表示。这些可以用几种方式操作,但是如果忽略,它们会终止你的程序。
  • 引发异常:可以用raise语句引发异常。它接受异常类或异常实例作为其参数。您还可以提供两个参数(一个异常和一个错误消息)。如果在一个except子句中不带参数调用raise,它会“重新引发”该子句捕获的异常。
  • 自定义异常类:您可以通过子类化Exception来创建自己的异常类型。
  • 捕捉异常:用一个try语句的except子句捕捉异常。如果没有在except子句中指定一个类,所有的异常都会被捕获。通过将类放在一个元组中,可以指定多个类。如果你给except两个参数,第二个被绑定到异常对象。在同一个try / except语句中可以有几个except子句,以对不同的异常做出不同的反应。
  • else从句:除了except之外,你还可以使用else从句。如果主try程序块中没有出现异常,则执行else子句。
  • 最后:如果您需要确保某些代码(例如,清理代码)得到执行,无论是否引发异常,您都可以使用try / finally。这段代码随后被放入finally子句中。
  • 异常和函数:当您在函数内部引发异常时,它会传播到调用该函数的地方。(方法也一样。)
  • 警告:警告类似于异常,但(通常)只是打印出一条错误消息。您可以指定一个警告类别,它是Warning的子类。

本章的新功能

| 功能 | 描述 | | --- | --- | | `warnings.filterwarnings(action, category=Warning, ...)` | 用于过滤掉警告 | | `warnings.warn(message, category=None)` | 用于发出警告 |

什么现在?

虽然你可能认为这一章的内容很特别(原谅我的双关语),但下一章真的很神奇。嗯,几乎是神奇的。

Footnotes 1

在 Python 中对try/except的偏爱通常可以通过少将·格蕾丝·赫柏的智慧之言来解释,“请求原谅比请求许可更容易。”这种简单地尝试做一些事情并处理任何错误的策略,而不是预先做大量的检查,被称为“三思而后行”。

九、魔术方法、属性和迭代器

在 Python 中,一些名字以一种特殊的方式拼写,有两个前导下划线和两个尾随下划线。你已经遇到了其中的一些(例如__future__)。这种拼写表明该名称具有特殊的意义——您永远不应该为自己的程序发明这样的名称。语言中一组非常突出的此类名称由神奇的(或特殊的)方法名称组成。如果您的对象实现了这些方法中的一个,Python 将在特定情况下(具体取决于名称)调用该方法。很少需要直接调用这些方法。

这一章讲述了一些重要的魔法方法(最著名的是__init__方法和一些处理项目访问的方法,允许你创建自己的序列或映射)。它还处理了两个相关的主题:属性(在以前版本的 Python 中通过魔法方法处理,但现在由property函数处理)和迭代器(使用魔法方法__iter__使它们能够在for循环中使用)。在这一章的最后你会发现一个丰富的例子,这个例子使用了你到目前为止学到的一些东西来解决一个相当困难的问题。

如果您没有使用 Python 3

不久前(在 2.2 版本中),Python 对象的工作方式发生了很大变化。这种变化有几个后果,其中大部分对初学 Python 的程序员来说并不重要。不过,有一点值得注意:即使您使用的是 Python 2 的最新版本,一些特性(比如属性和super函数)也不能在“旧式”类上工作。为了让你的类成为“新风格”,你要么把赋值语句__metaclass__ = type放在模块的顶部(如第七章所述),要么(直接或间接)子类化内置类object,或者其他一些新风格的类。考虑以下两个类:

class NewStyle(object):
    more_code_here

class OldStyle:
    more_code_here

这两个中,NewStyle是新型类;OldStyle是老派班。但是,如果文件以__metaclass__ = type开头,那么这两个类都将是新样式的。

Note

你也可以在你的类的类范围内给__metaclass__变量赋值。这将只设置那个类的元类。元类是其他类的类——这是一个相当高级的话题。

我没有在本书的所有例子中明确设置元类(或子类object)。但是,如果您不特别需要让您的程序与旧版本的 Python 兼容,我建议您将所有的类都变成新的样式,并一致地使用诸如super函数之类的特性(在本章后面的“使用超级函数”一节中有描述)。

注意,Python 3 中没有“旧式”的类,也不需要显式地子类化 object 或将元类设置为 type。所有的类都将隐式地成为object的子类——如果你没有指定一个超类,那么就是直接的,否则就是间接的。

构造器

我们要看的第一个魔术方法是构造函数。如果您以前从未听说过“构造函数”这个词,它基本上是我已经在一些例子中使用的初始化方法的一个别出心裁的名字,命名为__init__。然而,构造函数与普通方法的区别在于,构造函数是在对象创建后自动调用的。因此,我没有像现在这样做:

>>> f = FooBar()
>>> f.init()

构造函数使得简单地做到这一点成为可能:

>>> f = FooBar()

用 Python 创建构造函数真的很容易;简单地把这个init方法的名字从简单的旧init改成神奇的版本__init__

class FooBar:
    def __init__(self):
        self.somevar = 42

>>> f = FooBar()
>>> f.somevar
42

现在,这很好。但是你可能想知道如果给构造函数一些参数会发生什么。请考虑以下几点:

class FooBar:
    def __init__(self, value=42):
       self.somevar = value

你觉得你会怎么用这个?因为参数是可选的,所以您当然可以像什么都没发生一样继续下去。但是如果您想使用它(或者您没有使它成为可选的)呢?我相信你已经猜到了,但还是让我给你看看吧。

>>> f = FooBar('This is a constructor argument')
>>> f.somevar
'This is a constructor argument'

在 Python 中所有魔术方法中,__init__无疑是您使用最多的一种。

Note

Python 有一个魔术方法叫做__del__,也叫析构函数。它在对象被销毁(垃圾收集)之前被调用,但是因为你不能真正知道这何时(或者是否)发生,我建议你尽可能远离__del__

重写一般的方法,尤其是构造函数

在第七章中,你学习了关于继承的知识。每个类可能有一个或多个超类,它们从这些超类中继承行为。如果在类B的实例上调用了一个方法(或者访问了一个属性),但是没有找到,那么将搜索它的超类A。考虑以下两个类:

class A:
    def hello(self):
        print("Hello, I'm A.")

class B(A):
    pass

A定义了一个名为hello的方法,该方法由类B继承。下面是这些类如何工作的示例:

>>> a = A()
>>> b = B()
>>> a.hello()
Hello, I'm A.
>>> b.hello()
Hello, I'm A.

因为B没有定义自己的hello方法,所以当hello被调用时,原始消息被打印出来。

在子类中添加功能的一个基本方法是简单地添加方法。但是,您可能希望通过重写超类的一些方法来自定义继承的行为。例如,B可以覆盖hello方法。考虑一下B的这个修改过的定义:

class B(A):
    def hello(self):
        print("Hello, I'm B.")

使用这个定义,b.hello()将给出不同的结果。

>>> b = B()
>>> b.hello()
Hello, I'm B.

一般来说,重写是继承机制的一个重要方面,对于构造函数来说尤其重要。构造函数用来初始化新构造的对象的状态,除了超类的初始化代码之外,大多数子类都需要有自己的初始化代码。尽管所有方法的重写机制都是相同的,但是与重写普通方法相比,在处理构造函数时,您很可能会遇到一个更常见的问题:如果您重写了一个类的构造函数,则需要调用超类(您继承的类)的构造函数,否则可能会有对象未正确初始化的风险。

考虑下面的类,Bird:

class Bird:
    def __init__(self):
        self.hungry = True
    def eat(self):
        if self.hungry:
            print('Aaaah ...')
            self.hungry = False
        else:
            print('No, thanks!')

这个类定义了所有鸟类最基本的能力之一:进食。下面是一个如何使用它的例子:

>>> b = Bird()
>>> b.eat()
Aaaah ...
>>> b.eat()
No, thanks!

正如你从这个例子中看到的,一旦鸟吃了东西,它就不再饿了。现在考虑子类SongBird,它将歌唱添加到行为的曲目中。

class SongBird(Bird):
    def __init__(self):
        self.sound = 'Squawk!'
    def sing(self):
        print(self.sound)

SongBird类和Bird一样容易使用。

>>> sb = SongBird()
>>> sb.sing()
Squawk!

因为SongBirdBird的子类,它继承了eat方法,但是如果你试图调用它,你会发现一个问题。

>>> sb.eat()
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "birds.py", line 6, in eat
     if self.hungry:
AttributeError: SongBird instance has no attribute 'hungry'

这个异常很清楚哪里出了问题:SongBird没有名为hungry的属性。为什么要这样?在SongBird中,构造函数被覆盖,新的构造函数不包含任何处理hungry属性的初始化代码。为了纠正这种情况,SongBird构造函数必须调用其超类Bird的构造函数,以确保基本的初始化发生。基本上有两种方法:调用超类的构造函数的未绑定版本,或者使用super函数。在接下来的两节中,我将解释这两种技术。

调用未绑定的超类构造函数

本节所描述的方法也许主要是有历史意义的。对于当前版本的 Python,使用super函数(如下一节所述)显然是一条可行之路。然而,许多现有的代码使用本节中描述的方法,所以您需要了解它。此外,它可能很有启发性——这是绑定方法和未绑定方法之间区别的一个很好的例子。

现在,让我们言归正传。如果你觉得这部分的标题有点吓人,放松。事实上,调用超类的构造函数非常容易(也非常有用)。我将首先给你上一节末尾提出的问题的解决方案。

class SongBird(Bird):
    def __init__(self):
        Bird.__init__(self)
        self.sound = 'Squawk!'
    def sing(self):
        print(self.sound)

只有一行被添加到了SongBird类中,包含代码Bird.__init__(self)。在我解释这到底意味着什么之前,让我向你展示一下这真的有效。

>>> sb = SongBird()
>>> sb.sing()
Squawk!
>>> sb.eat()
Aaaah ...
>>> sb.eat()
No, thanks!

但是为什么会这样呢?当从实例中检索方法时,方法的self参数自动绑定到实例(所谓的绑定方法)。你已经看过几个这样的例子了。但是,如果直接从类中检索方法(比如在Bird.__init__中),就没有要绑定的实例。因此,你可以自由提供任何你想要的self。这种方法称为未绑定,这解释了本节的标题。

通过将当前实例作为self参数提供给未绑定的方法,songbird 从其超类的构造函数中获得完整的处理(这意味着它已经设置了其hungry属性)。

使用超级功能

如果您没有受困于旧版本的 Python,那么super函数确实是一个不错的选择。它只适用于新类型的类,但是无论如何你都应该使用它们。它是用当前的类和实例作为它的参数来调用的,你对返回对象调用的任何方法都将从超类而不是当前的类中获取。因此,您可以使用super(SongBird, self),而不是在SongBird构造函数中使用Bird。此外,__init__方法可以以普通(绑定)方式调用。在 Python 3 中,super可以——并且通常应该——被不带任何参数地调用,并且会像“魔术般地”完成它的工作

以下是 bird 示例的更新版本:

class Bird:
    def __init__(self):
       self.hungry = True
    def eat(self):
        if self.hungry:
            print('Aaaah ...')
            self.hungry = False
        else:
            print('No, thanks!')

class SongBird(Bird):
    def __init__(self):
        super().__init__()
        self.sound = 'Squawk!'
    def sing(self):
        print(self.sound)

这种新式版本的工作原理与旧式版本一样:

>>> sb = SongBird()
>>> sb.sing()
Squawk!
>>> sb.eat()
Aaaah ...
>>> sb.eat()
No, thanks!

What’s So Super About Super?

在我看来,super函数比直接调用超类上的未绑定方法更直观,但这不是它唯一的优点。super函数其实挺聪明的,所以即使你有多个超类,也只需要用一次super(前提是所有超类构造函数也都用super)。此外,当使用旧风格的类时(例如,当你的两个超类共享一个超类时),一些难以理解的情况会被新风格的类和super自动处理。您不需要确切地理解它在内部是如何工作的,但是您应该知道,在大多数情况下,它明显优于调用超类的未绑定构造函数(或其他方法)。

那么,super到底回报了什么呢?通常情况下,您不需要担心它,您可以假装它返回您需要的超类。它实际上做的是返回一个超级对象,它将为您处理方法解析。当你访问它的一个属性时,它会检查你所有的超类(和超超类,等等),直到找到这个属性,或者引发一个AttributeError

项目访问

虽然__init__是迄今为止你会遇到的最重要的特殊方法,但是还有很多其他方法可以让你实现很多很酷的事情。本节描述的一组有用的神奇方法允许您创建行为类似序列或映射的对象。

基本的序列和映射协议非常简单。然而,要实现序列和映射的所有功能,有许多魔术方法要实现。幸运的是,有一些捷径,但我会得到这一点。

Note

Python 中经常使用 protocol 一词来描述管理某种形式行为的规则。这有点类似于第七章中提到的接口概念。该协议说明了您应该实现哪些方法以及这些方法应该做什么。因为 Python 中的多态性只基于对象的行为(而不是基于它的祖先,例如,它的类或超类等等),所以这是一个重要的概念:其他语言可能要求对象属于某个类或实现某个接口,而 Python 通常只要求它遵循某个给定的协议。所以,要成为序列,你要做的就是遵循序列协议。

基本序列和映射协议

序列和映射基本上是项目的集合。要实现它们的基本行为(协议),如果对象是不可变的,那么需要两个魔术方法,如果是可变的,那么需要四个。

  • __len__(self):这个方法应该返回集合中包含的项目数。对于一个序列,这只是元素的数量。对于映射,它将是键-值对的数量。如果__len__返回零(并且您没有实现__nonzero__,它覆盖了这个行为),那么对象在布尔上下文中被视为 false(与空列表、元组、字符串和字典一样)。
  • __getitem__(self, key):应该返回给定键对应的值。对于一个序列,密钥应该是一个从 0 到 n–1 的整数(或者,它可以是负数,如后面所述),其中 n 是序列的长度。对于一个映射,你可以有任何类型的键。
  • __setitem__(self, key, value):这个应该以与key相关联的方式存储value,以便以后可以用__getitem__检索。当然,您只为可变对象定义这个方法。
  • __delitem__(self, key):当有人在对象的一部分上使用了__del__语句,并且应该删除与key相关的元素时,就会调用这个函数。同样,只有可变的对象(而不是所有的对象——只有那些您希望删除项目的对象)应该定义这个方法。

对这些方法有一些额外的要求。

  • 对于一个序列,如果键是一个负整数,就应该用它从末尾开始计数。换句话说,对待x[-n]和对待x[len(x)-n]一样。
  • 如果键是不适当的类型(例如在序列上使用的字符串键),可能会引发TypeError
  • 如果序列的索引是正确的类型,但是在允许的范围之外,则应该引发IndexError

对于更广泛的接口,以及合适的抽象基类(Sequence),请查阅collections模块的文档。

让我们试一试——看看我们能否创建一个无限序列。

def check_index(key):
    """
    Is the given key an acceptable index?

    To be acceptable, the key should be a non-negative integer. If it
    is not an integer, a TypeError is raised; if it is negative, an
    IndexError is raised (since the sequence is of infinite length).
    """
    if not isinstance(key, int): raise TypeError
    if key < 0: raise IndexError

class ArithmeticSequence:

    def __init__(self, start=0, step=1):
        """
        Initialize the arithmetic sequence.

        start   - the first value in the sequence
        step    - the difference between two adjacent values
        changed - a dictionary of values that have been modified by
                  the user
        """
        self.start = start                        # Store the start value
        self.step = step                          # Store the step value
        self.changed = {}                         # No items have been modified

    def __getitem__(self, key):
        """
        Get an item from the arithmetic sequence.
        """
        check_index(key)

        try: return self.changed[key]             # Modified?
        except KeyError:                          # otherwise ...
            return self.start + key * self.step   # ... calculate the value

    def __setitem__(self, key, value):
        """
        Change an item in the arithmetic sequence.
        """
        check_index(key)

        self.changed[key] = value                 # Store the changed value

这实现了一个算术序列—一个数字序列,其中每个数字都比前一个数字大一个常数。第一个值由构造函数参数start给出(默认为零),而值之间的步长由step给出(默认为一)。你允许用户通过在一个叫做changed的字典中保留一般规则的例外来改变一些元素。如果元素没有被改变,它被计算为self.start + key * self.step

下面是一个如何使用该类的示例:

>>> s = ArithmeticSequence(1, 2)
>>> s[4]
9
>>> s[4] = 2
>>> s[4]
2
>>> s[5]
11

注意,我希望删除项目是非法的,这就是为什么我没有实现__del__:

>>> del s[4]
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
AttributeError: ArithmeticSequence instance has no attribute '__delitem__'

此外,该类没有__len__方法,因为它的长度是无限的。

如果使用了非法类型的索引,则引发一个TypeError,如果索引是正确的类型,但超出了范围(即,在本例中为负),则引发一个IndexError

>>> s["four"]
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "arithseq.py", line 31, in __getitem__
     check_index(key)
  File "arithseq.py", line 10, in checkIndex
     if not isinstance(key, int): raise TypeError
TypeError
>>> s[-42]
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "arithseq.py", line 31, in __getitem__
     check_index(key)
  File "arithseq.py", line 11, in checkIndex
     if key < 0: raise IndexError
IndexError

索引检查由我为此编写的实用函数check_index负责。

子类化列表、字典和字符串

虽然基本序列/映射协议的四个方法会让你走得更远,但是序列可能有许多其他有用的神奇和普通的方法,包括__iter__方法,我将在本章后面的“迭代器”一节中描述它。实现所有这些方法需要做大量的工作,而且很难做对。如果您只想要其中一个操作中的自定义行为,那么您需要重新实现所有其他操作是没有意义的。只是程序员的懒惰(也叫常识)。

那么你应该怎么做呢?神奇的词是继承。当您可以继承它们时,为什么要重新实现所有这些东西呢?标准库在collections模块中提供了抽象和具体的基类,但是您也可以简单地对内置类型本身进行子类化。因此,如果您想实现一个行为类似于内置列表的序列类型,您可以简单地子类化list

让我们做一个简单的例子——一个带有访问计数器的列表。

class CounterList(list):
    def __init__(self, *args):
        super().__init__(*args)
        self.counter = 0
    def __getitem__(self, index):
        self.counter += 1
        return super(CounterList, self).__getitem__(index)

CounterList类严重依赖于它的子类超类(list)的行为。任何未被CounterList覆盖的方法(如appendextendindex等)都可以直接使用。在被覆盖的两个方法中,super用于调用该方法的超类版本,只添加了初始化counter属性(在__init__中)和更新counter属性(在__getitem__中)的必要行为。

Note

覆盖__getitem__并不是捕获用户访问的可靠方法,因为还有其他访问列表内容的方法,比如通过pop方法。

下面是一个如何使用CounterList的例子:

>>> cl = CounterList(range(10))
>>> cl
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> cl.reverse()
>>> cl
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
>>> del cl[3:6]
>>> cl
[9, 8, 7, 3, 2, 1, 0]
>>> cl.counter
0
>>> cl[4] + cl[2]
9
>>> cl.counter
2

正如你所看到的,CounterList在大多数方面都和list一样工作。但是,它有一个counter属性(最初为零),每次访问一个列表元素时,这个属性就会增加。在执行加法cl[4] + cl[2]之后,计数器已经增加了两次,达到值 2。

更多的魔法

特殊的(神奇的)名字有很多用途——到目前为止,我向您展示的只是一个小小的尝试。大多数可用的魔法方法都是为了相当高级的用途,所以我在这里就不赘述了。但是,如果您感兴趣,可以模拟数字,制作可以像函数一样调用的对象,影响对象的比较方式,等等。有关哪些神奇方法可用的更多信息,请参见 Python 参考手册中的“特殊方法名”一节。

性能

在第七章中,我提到了访问器方法。访问器只是名字为getHeightsetHeight的方法,用于检索或重新绑定某些属性(这些属性可能是类的私有属性——参见第七章中的“隐私回顾”一节)。如果在访问给定的属性时必须采取某些动作,像这样封装状态变量(属性)可能很重要。例如,考虑下面的Rectangle类:

class Rectangle:
    def __init__(self):
        self.width = 0
        self.height = 0
    def set_size(self, size):
        self.width, self.height = size
    def get_size(self):
        return self.width, self.height

下面是一个如何使用该类的示例:

>>> r = Rectangle()
>>> r.width = 10
>>> r.height = 5
>>> r.get_size()
(10, 5)
>>> r.set_size((150, 100))
>>> r.width
150

get_sizeset_size方法是一个名为size的虚拟属性的访问器——这个虚拟属性就是由widthheight组成的元组。(可以随意用更令人兴奋的东西来代替这个,比如矩形的面积或者对角线的长度。)这段代码并没有直接错,但它有缺陷。使用这个类的程序员不需要担心它是如何实现的(封装)。如果有一天你想改变实现,使size成为一个真正的属性,并且widthheight是动态计算的,你将需要把它们包装在访问器中,并且任何使用该类的程序也必须重写。客户端代码(使用您的代码的代码)应该能够以相同的方式处理您的所有属性。

那么解决办法是什么呢?是否应该将所有属性包装在访问器中?当然,这是一种可能性。然而,如果您有许多简单的属性,这将是不切实际的(并且有点愚蠢),因为您将需要编写许多访问器,这些访问器除了检索或设置这些属性之外什么也不做,没有采取任何有用的操作。这有点复制粘贴编程或 cookiecutter 代码的味道,这显然是一件坏事(尽管在某些语言中这种特定问题很常见)。幸运的是,Python 可以为您隐藏访问器,使您的所有属性看起来都一样。那些通过它们的访问器定义的属性通常被称为属性。

Python 实际上有两种在 Python 中创建属性的机制。我将把重点放在最近的一个函数上,即property函数,它只适用于新型类。然后我会简单介绍一下如何用魔法方法实现属性。

属性函数

使用property函数非常简单。如果您已经编写了一个类,比如上一节中的Rectangle,那么您只需要添加一行代码。

class Rectangle:
    def __init__ (self):
        self.width = 0
        self.height = 0
    def set_size(self, size):
        self.width, self.height = size
    def get_size(self):
        return self.width, self.height
    size = property(get_size, set_size)

在这个新版本的Rectangle中,用property函数创建了一个属性,将访问函数作为参数(首先是 getter,然后是 setter),然后将名称size绑定到这个属性。在这之后,你不再需要担心事情是如何实现的,而是可以用同样的方式对待widthheightsize

>>> r = Rectangle()
>>> r.width = 10
>>> r.height = 5
>>> r.size
(10, 5)
>>> r.size = 150, 100
>>> r.width
150

正如您所看到的,size属性仍然受get_sizeset_size中的计算的影响,但是它看起来就像一个普通的属性。

Note

如果您的属性行为异常,请确保您使用的是新型类(通过直接或间接对 object 进行子类化,或者通过直接设置元类)。如果不是这样,属性的 getter 部分仍然可以工作,但是 setter 可能不行(取决于您的 Python 版本)。这可能有点令人困惑。

事实上,property函数也可以用零个、一个、三个或四个参数来调用。如果不带任何参数调用,则结果属性既不可读也不可写。如果只用一个参数(getter 方法)调用,则该属性是只读的。第三个(可选)参数是用于删除属性的方法(它不带参数)。第四个(可选)参数是 docstring。这些参数被称为fgetfsetfdeldoc——如果您想要一个只可写且有 docstring 的属性,可以将它们用作关键字参数。

尽管这一部分很短(证明了property函数的简单性),但它非常重要。寓意是:对于新型类,你应该使用property而不是访问器。

But How Does it Work?

如果你对房产如何变魔术感到好奇,我会在这里给你一个解释。如果你不在乎,就直接跳过。

事实上,property 并不是一个真正的函数——它是一个类,它的实例有一些魔术方法来完成所有的工作。正在讨论的方法是__get____set____delete__。这三种方法一起定义了所谓的描述符协议。实现这些方法的对象就是描述符。描述符的特殊之处在于它们是如何被访问的。比如,在读取一个属性时(具体来说,是在实例中访问时,但属性是在类中定义的),如果该属性被绑定到一个实现了__get__的对象上,那么这个对象就不会简单地被返回;相反,将调用__get__方法,并返回结果值。事实上,这是属性、绑定方法、静态和类方法(更多信息见下一节)和super的底层机制。

有关描述符的更多信息,请参见描述符使用指南( https://docs.python.org/3/howto/descriptor.html )。

静态方法和类方法

在讨论实现属性的老方法之前,让我们稍微绕一下,看看另外两个以类似于新样式属性的方式实现的特性。静态方法和类方法分别通过在staticmethodclassmethod类的对象中包装方法来创建。静态方法是在没有self参数的情况下定义的,它们可以直接在类本身上被调用。类方法是用一个类似于self的参数定义的,这个参数通常叫做cls。您也可以直接在类对象上调用类方法,但是cls参数会自动绑定到该类。这里有一个简单的例子:

class MyClass:

    def smeth():
        print('This is a static method')
    smeth = staticmethod(smeth)

    def cmeth(cls):
        print('This is a class method of', cls)
    cmeth = classmethod(cmeth)

像这样手工包装和替换方法的技术有点乏味。在 Python 2.4 中,像这样的包装方法引入了一种新的语法,称为 decorators。(它们实际上以包装器的形式与任何可调用对象一起工作,可以用在方法和函数上。)使用@操作符,通过在方法(或函数)上面列出一个或多个 decorators 来指定它们(以相反的顺序应用)。

class MyClass:

    @staticmethod
    def smeth():
        print('This is a static method')

    @classmethod
    def cmeth(cls):
        print('This is a class method of', cls)

一旦定义了这些方法,就可以像这样使用它们(也就是说,不需要实例化类):

>>> MyClass.smeth()
This is a static method
>>> MyClass.cmeth()
This is a class method of <class '__main__.MyClass'>

静态方法和类方法在 Python 中历史上并不重要,主要是因为在某种程度上,您总是可以使用函数或绑定方法来代替,但也因为在早期版本中并没有真正的支持。因此,即使您可能在当前代码中没有看到它们被大量使用,它们也有它们的用途(比如工厂函数,如果您听说过的话),并且您很可能会想到一些新的功能。

Note

实际上,您也可以对属性使用装饰语法。详情见property功能文档。

getattrsetattr,和朋友

拦截对象上的每个属性访问是可能的。除此之外,您可以用它来实现旧式类的属性(其中property不一定像它应该的那样工作)。要在访问属性时执行代码,必须使用一些魔术方法。下面四个提供了您需要的所有功能(在旧式类中,您只使用最后三个):

  • __getattribute__(self, name):取属性name时自动调用。(这仅在新型类上正确工作。)
  • __getattr__(self, name):当访问属性name且对象没有该属性时自动调用。
  • __setattr__(self, name, value):当试图将属性name绑定到value时自动调用。
  • __delattr__(self, name):试图删除属性name时自动调用。

尽管使用起来比property更棘手(在某些方面效率更低),但这些魔术方法非常强大,因为您可以用这些方法中的一种来编写代码,处理几个属性。(不过,如果你有选择的话,还是坚持用property。)

下面是第Rectangle个例子,这次用的是魔法方法:

class Rectangle:
    def __init__ (self):
        self.width = 0
        self.height = 0
    def __setattr__(self, name, value):
        if name == 'size':
            self.width, self.height = value
        else:
            self. __dict__[name] = value
    def __getattr__(self, name):
        if name == 'size':
            return self.width, self.height
        else:
            raise AttributeError()

如您所见,这个版本的类需要处理额外的管理细节。考虑这个代码示例时,请务必注意以下几点:

  • 即使所讨论的属性不是size,也会调用__setattr__方法。因此,该方法必须考虑两种情况:如果属性是size,则执行与之前相同的操作;否则使用魔法属性__dict__。它包含一个包含所有实例属性的字典。它被用来代替普通的属性赋值,以避免再次调用__setattr__(这会导致程序无休止地循环)。
  • 只有在没有找到正常属性的情况下,才会调用__getattr__方法,这意味着如果给定的名称不是size,则属性不存在,该方法会引发一个AttributeError。如果您想让类正确地使用内置函数,如hasattrgetattr,这一点很重要。如果名称是size,则使用之前实现中的表达式。

Note

就像有一个“死循环”陷阱与__setattr__相关一样,也有一个陷阱与__getattribute__相关。因为它拦截所有属性访问(在新型类中),所以它也会拦截对__dict__的访问!在__getattribute__中访问 self 属性的唯一安全的方法是使用超类的__getattribute__方法(使用super)。

迭代程序

我在前面的章节中已经简单地提到了迭代器(和可迭代对象)。在这一节中,我将详细介绍一些细节。我只介绍一个魔术方法,__iter__,它是迭代器协议的基础。

迭代器协议

迭代意味着将某件事重复几次——就像你对循环所做的那样。到目前为止,我只在for循环中迭代了序列和字典,但事实是你也可以迭代其他对象:实现了__iter__方法的对象。

__iter__方法返回一个迭代器,这个迭代器是任何带有一个叫做__next__的方法的对象,这个方法不需要任何参数就可以调用。当你调用__next__方法时,迭代器应该返回它的“下一个值”如果方法被调用并且迭代器没有更多的值要返回,它应该抛出一个StopIteration异常。有一个内置的方便函数叫做next你可以使用,其中next(it)相当于it.__next__()

Note

Python 3 中的迭代器协议有所改变。在旧协议中,迭代器对象应该有一个名为next而不是__next__的方法。

有什么意义?为什么不用列表呢?因为可能经常矫枉过正。如果你有一个函数可以一个接一个地计算值,你可能只需要一个接一个地计算它们——而不是一次全部,例如,在一个列表中。如果值的数量很大,列表可能会占用太多内存。但是还有其他原因:使用迭代器更通用、更简单、也更优雅。让我们看一个你不能用列表做的例子,仅仅因为列表需要无限长!

我们的“列表”是斐波那契数列。这些迭代器可能如下所示:

class Fibs:
    def __init__(self):
        self.a = 0
        self.b = 1
    def __next__(self):
        self.a, self.b = self.b, self.a + self.b
        return self.a
    def __iter__(self):
        return self

注意迭代器实现了__iter__方法,事实上,它将返回迭代器本身。在许多情况下,您会将__iter__方法放在另一个对象中,您会在for循环中使用该对象。然后返回你的迭代器。建议迭代器另外实现一个自己的__iter__方法(返回self,就像我在这里做的那样),这样它们自己可以直接在for循环中使用。

Note

更正式的说法是,实现__iter__方法的对象是 iterable,实现 next 的对象是迭代器。

首先做一个Fibs对象。

>>> fibs = Fibs()

然后,您可以在一个for循环中使用它——例如,查找大于 1000 的最小斐波那契数。

>>> for f in fibs:
...     if f > 1000:
...         print(f)
...         break
...
1597

这里,循环停止了,因为我在里面发出了一个break;如果我不这样做,for循环将永远不会结束。

Tip

内置函数iter可以用来从 iterable 对象中获取迭代器。

>>> it = iter([1, 2, 3])
>>> next(it)
1
>>> next(it)

2

它还可以用于从函数或其他可调用对象创建 iterable(有关详细信息,请参见库参考)。

从迭代器生成序列

除了对迭代器和可迭代对象进行迭代(这是您通常会做的),您还可以将它们转换成序列。在大多数可以使用序列的上下文中(除了索引或切片之类的操作),可以使用迭代器(或可迭代对象)来代替。一个有用的例子是使用list构造函数将迭代器显式转换为列表。

>>> class TestIterator:
...     value = 0
...     def __next__(self):
...         self.value += 1
...         if self.value > 10: raise StopIteration
...         return self.value
...     def __iter__(self):
...         return self
...
>>> ti = TestIterator()
>>> list(ti)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

发电机

生成器(出于历史原因也称为简单生成器)对 Python 来说相对较新,并且(和迭代器一起)可能是多年来最强大的特性之一。然而,生成器的概念是相当先进的,它可能需要一段时间才能“点击”,你会看到它是如何工作的,或者它如何对你有用。请放心,虽然生成器可以帮助您编写真正优雅的代码,但是您当然可以在没有生成器的情况下编写任何您想要的程序。

生成器是一种用普通函数语法定义的迭代器。通过例子可以很好地说明发电机是如何工作的。让我们先来看看你是如何制作和使用它们的,然后在引擎盖下看一看。

制作发电机

制作发电机很简单;就像做一个函数一样。我相信你现在已经开始厌倦古老的斐波那契数列了,所以让我做点别的吧。我将创建一个函数来展平嵌套列表。该参数是一个列表,可能看起来像这样:

nested = [[1, 2], [3, 4], [5]]

换句话说,这是一个列表的列表。然后,我的函数应该按顺序给我这些数字。这里有一个解决方案:

def flatten(nested):
     for sublist in nested:
         for element in sublist:
             yield element

这个函数的大部分非常简单。首先,它遍历所提供的嵌套列表的所有子列表;然后,它按顺序遍历每个子列表的元素。例如,如果最后一行是print(element),这个函数就很容易理解了,对吗?

所以这里的新内容是yield语句。任何包含yield语句的函数都称为生成器。这不仅仅是命名的问题。它的行为将与普通函数截然不同。不同之处在于,你可以一次产生几个值,而不是像使用return那样返回一个值。每次产生一个值时(用yield,函数冻结;也就是说,它正好在那个点停止执行,等待被重新唤醒。如果是,它将从停止的地方继续执行。

我可以通过遍历生成器来利用所有的值。

>>> nested = [[1, 2], [3, 4], [5]]
>>> for num in flatten(nested):
...     print(num)
...
1
2
3
4
5

或者

>>> list(flatten(nested))
[1, 2, 3, 4, 5]

Loopy Generators

在 Python 2.4 中,引入了列表理解(参见第五章)的一个亲戚:生成器理解(或生成器表达式)。它与列表理解的工作方式相同,只是没有构造列表(并且“主体”没有立即循环)。相反,会返回一个生成器,允许您逐步执行计算。

>>> g = ((i + 2) ** 2 for i in range(2, 27))
>>> next(g)
16

正如你所看到的,这与列表理解在普通括号的使用上有所不同。在这种简单的情况下,我还不如使用列表理解。但是,如果您希望“包装”一个 iterable 对象(可能会产生大量的值),那么通过立即实例化一个列表,列表理解会使迭代的优点无效。

一个好处是,当在一对现有的括号内直接使用生成器理解力时,比如在函数调用中,不需要添加另一对括号。换句话说,您可以编写如下漂亮的代码:

sum(i ** 2 for i in range(10))

递归生成器

我在上一节中设计的生成器只能处理嵌套两层的列表,为此它使用了两个for循环。如果你有一组嵌套任意深的列表怎么办?例如,也许你用它们来表示一些树的结构。(您也可以用特定的树类来实现,但是策略是一样的。)每一级嵌套都需要一个for循环,但是因为不知道有多少级,所以必须改变解决方案,使之更加灵活。是时候转向递归的魔力了。

def flatten(nested):
    try:
        for sublist in nested:
            for element in flatten(sublist):
                yield element
    except TypeError:
        yield nested

当调用flatten时,您有两种可能性(处理递归时总是这样):基本情况和递归情况。在基本情况下,函数被告知展平单个元素(例如,一个数字),在这种情况下,for循环引发一个TypeError(因为您试图迭代一个数字),生成器简单地生成元素。

然而,如果你被告知要展平一个列表(或任何可迭代的列表),你需要做一些工作。您遍历所有的子列表(其中一些可能不是真正的列表),并对它们调用flatten。然后,通过使用另一个for循环来产生展平的子列表的所有元素。这可能看起来有点神奇,但它确实有效。

>>> list(flatten([[[1], 2], 3, 4, [5, [6, 7]], 8]))
[1, 2, 3, 4, 5, 6, 7, 8]

然而,这有一个问题。如果nested是一个字符串或类似字符串的对象,那么它就是一个序列,不会引发TypeError,但是你不想对它进行迭代。

Note

不应该在flatten函数中迭代类似字符串的对象有两个主要原因。首先,您希望将类似字符串的对象视为原子值,而不是应该展平的序列。第二,迭代它们实际上会导致无限递归,因为一个字符串的第一个元素是另一个长度为 1 的字符串,而该字符串的第一个元素是字符串本身!

为了解决这个问题,您必须在生成器的开头添加一个测试。尝试用一个字符串连接对象,并查看是否会产生一个TypeError是检查对象是否类似字符串的最简单、最快的方法。 1 这里是加了测试的发电机:

def flatten(nested):
    try:
        # Don't iterate over string-like objects:
        try: nested + ''
        except TypeError: pass
        else: raise TypeError
        for sublist in nested:
            for element in flatten(sublist):
                yield element
    except TypeError:
        yield nested

如你所见,如果表达式nested + ''引发了一个TypeError,它被忽略;但是,如果表达式没有引发一个TypeError,内部try语句的else子句会引发一个自己的TypeError。这将导致类似字符串的对象按原样产生(在外层的except子句中)。明白了吗?

这里有一个例子来说明这个版本也适用于字符串:

>>> list(flatten(['foo', ['bar', ['baz']]]))
['foo', 'bar', 'baz']

注意,这里没有进行类型检查。我不测试nested是否是一个字符串,只测试它的行为是否像一个字符串(也就是说,它可以与一个字符串连接)。这个测试的一个自然的替代方法是对字符串和类似字符串的对象使用带有一些抽象超类的isinstance,但是不幸的是没有这样的标准类。针对str的类型检查甚至对UserString也不起作用。

一般发电机

如果到目前为止您已经学习了这些示例,那么您或多或少会知道如何使用生成器。您已经看到生成器是一个包含关键字yield的函数。当它被调用时,函数体中的代码不会被执行。相反,返回一个迭代器。每次请求一个值时,生成器中的代码都会被执行,直到遇到一个yield或一个return。一个yield意味着一个值应该被输出。一个return意味着生成器应该停止执行(不再产生任何东西;return只有在生成器内部使用时才能不带参数调用)。

换句话说,生成器由两个独立的组件组成:生成器函数和生成器迭代器。生成器函数是由包含一个yielddef语句定义的。生成器迭代器是这个函数返回的内容。用不太精确的术语来说,这两个实体通常被视为一个整体,统称为发电机。

>>> def simple_generator():
        yield 1
...
>>> simple_generator
<function simple_generator at 153b44>
>>> simple_generator()
<generator object at 1510b0>

生成器函数返回的迭代器可以像其他迭代器一样使用。

生成器方法

我们可以在发电机开始运行后,通过使用发电机和“外部世界”之间的通信信道,为发电机提供值,通信信道有以下两个端点:

  • 外界可以访问生成器上一个名为send的方法,它的工作方式与next类似,只是它需要一个参数(要发送的“消息”——一个任意对象)。
  • 在挂起的生成器内部,yield现在可能被用作表达式,而不是语句。换句话说,当发电机恢复时,yield返回一个值——通过send从外部发送的值。如果使用了next,则yield返回None

注意,使用send(而不是next)只有在发电机暂停后才有意义(也就是说,在它碰到第一个yield后)。如果在此之前需要给生成器一些信息,可以简单地使用 generator-function 的参数。

Tip

如果你真的想在新启动的发电机上使用send,可以用None作为它的参数。

这里有一个相当愚蠢的例子来说明这个机制:

def repeater(value):
    while True:
        new = (yield value)
        if new is not None: value = new

下面是它的用法示例:

>>> r = repeater(42)
>>> next(r)
42
>>> r.send("Hello, world!")
"Hello, world!"

注意在yield表达式中使用了括号。虽然在某些情况下并不是绝对必要的,但是安全总比抱歉好,如果您以某种方式使用返回值,只需将yield表达式括在括号中。

生成器还有另外两种方法。

  • throw方法(用异常类型、可选值和回溯对象调用)用于在生成器内部引发异常(在yield表达式处)。
  • close方法(调用时不带参数)用于停止生成器。

close方法(需要时也由 Python 垃圾收集器调用)也是基于异常的。它在屈服点引发了GeneratorExit异常,所以如果你想在你的生成器中有一些清理代码,你可以在try / finally语句中包装你的yield。如果您愿意,您也可以捕获GeneratorExit异常,但是之后您必须重新引发它(可能在清理一点之后),引发另一个异常,或者简单地返回。在close被调用后试图从生成器产生一个值将导致RuntimeError

Tip

有关生成器方法以及它们如何将生成器转化为简单的协程的更多信息,请参见 PEP 342 ( www.python.org/dev/peps/pep-0342/ )。

模拟发电机

如果您需要使用旧版本的 Python,生成器是不可用的。下面是用普通函数模拟它们的简单方法。

从生成器的代码开始,在函数体的开头插入下面一行代码:

result = []

如果代码已经使用了名称result,你应该想出另一个。(无论如何,使用更具描述性的名称可能是个好主意。)然后替换此表格的所有行:

yield some_expression with this:
result.append(some_expression)

最后,在函数的末尾,添加这一行:

return result

尽管这可能不适用于所有的生成器,但它适用于大多数生成器。(比如它用无限生成器就失败了,当然不能把它们的值塞进一个列表。)

下面是重写为普通函数的flatten生成器:

def flatten(nested):
    result = []
    try:
        # Don't iterate over string-like objects:
        try: nested + ''
        except TypeError: pass
        else: raise TypeError
        for sublist in nested:
            for element in flatten(sublist):
                result.append(element)
    except TypeError:
        result.append(nested)
    return result

八个皇后

既然你已经了解了所有这些魔法,是时候让它发挥作用了。在本节中,您将看到如何使用生成器来解决一个经典的编程问题。

生成器和回溯

生成器是逐步构建结果的复杂递归算法的理想选择。如果没有生成器,这些算法通常需要您传递一个构建了一半的解决方案作为额外的参数,以便递归调用可以在其上构建。有了生成器,所有递归调用需要做的就是yield它们的部分。这就是我对前面的递归版本flatten所做的,您可以使用完全相同的策略来遍历图和树结构。

然而,在某些应用中,你不会马上得到答案;你需要尝试几种选择,你需要在递归的每一层都这样做。为了与现实生活进行类比,假设你要参加一个重要的会议。你不确定它在哪里,但是你面前有两扇门,会议室必须在其中一扇门的后面。你选择左边并穿过去。在那里,你面对另外两扇门。你选择了左边,但事实证明是错的。所以你原路返回,选择了正确的门,结果也是错的(请原谅这个双关语)。所以,你再次原路返回,到你开始的地方,准备在那里尝试正确的门。

Graphs and Trees

如果你以前从未听说过图和树,你应该尽快了解它们,因为它们是编程和计算机科学中非常重要的概念。要了解更多,你可能需要一本关于计算机科学、离散数学、数据结构或算法的书。对于一些简明的定义,您可以查看以下网页:

快速的网络搜索或者在维基百科( http://wikipedia.org )上浏览会找到很多材料。

这种回溯策略对于解决需要你尝试每种组合直到找到解决方案的问题非常有用。这样的问题是这样解决的:

# Pseudocode
for each possibility at level 1:
    for each possibility at level 2:
        ...
           for each possibility at level n:
               is it viable?

要用for循环直接实现这一点,你需要知道你会遇到多少层。如果这是不可能的,你使用递归。

问题

这是一个非常受欢迎的计算机科学难题:你有一个棋盘和八个皇后棋子放在上面。唯一的要求是没有一只蚁后威胁到其他蚁后;也就是说,您必须放置它们,以便没有两个皇后可以捕获对方。你是怎么做到的?皇后应该放在哪里?

这是一个典型的回溯问题:你为第一个皇后尝试一个位置(在第一排),前进到第二个,以此类推。如果你发现你不能放置一个皇后,你可以回到上一个位置,尝试另一个位置。最后,你要么穷尽所有可能性,要么找到解决办法。

在所述的问题中,提供给你的信息是只有八个皇后,但是让我们假设可以有任意数量的皇后。(这更类似于现实世界的回溯问题。)那你怎么解决呢?如果你想尝试自己解决,你现在应该停止阅读,因为我即将给你答案。

Note

你可以找到更有效的方法来解决这个问题。如果你想要更多的细节,网络搜索应该会找到大量的信息。

国家代表权

为了表示一个可能的解决方案(或者它的一部分),您可以简单地使用一个元组(或者一个列表)。元组的每个元素指示对应行的皇后的位置(即列)。所以如果state[0] == 3,你知道第一行的皇后位于第四列(我们从零开始计数,记得吗?).当在递归的一个级别(一个特定的行)工作时,您只知道上面的皇后有哪些位置,所以您可能有一个长度小于 8 的状态元组(或者不管皇后的数量是多少)。

Note

我完全可以使用列表而不是元组来表示状态。这种情况下主要是口味问题。一般来说,如果序列很小并且是静态的,那么元组可能是一个很好的选择。

发现冲突

让我们从做一些简单的抽象开始。要找到一个没有冲突的配置(没有皇后可以捕获另一个),你首先必须定义什么是冲突。为什么不把它定义为一个函数呢?

conflict函数被给定到目前为止皇后的位置(以状态元组的形式),并确定下一个皇后的位置是否产生任何新的冲突。

def conflict(state, nextX):
    nextY = len(state)
    for i in range(nextY):
        if abs(state[i] - nextX) in (0, nextY - i):
            return True
    return False

nextX参数是下一个皇后的建议水平位置(x 坐标,或列),而nextY是下一个皇后的垂直位置(y 坐标,或行)。这个函数对之前的每个皇后做一个简单的检查。如果下一个皇后和(nextX, nextY)有相同的 x 坐标或者在同一条对角线上,那么就发生了冲突,返回True。如果没有这样的冲突出现,False被返回。棘手的部分是下面的表达式:

abs(state[i] - nextX) in (0, nextY - i)

如果下一个皇后和前一个皇后之间的水平距离为零(同一列)或等于垂直距离(在对角线上),则为真。否则就是假的。

基本情况

八皇后问题实现起来可能有点棘手,但是对于生成器来说就没那么糟糕了。如果你不习惯递归,我不希望你自己想出这个解决方案。还要注意,这个解决方案并不是特别有效,所以对于大量的皇后,它可能会有点慢。

让我们从基础案例开始:末代女王。你想让她做什么?假设你想找到所有可能的解决方案。在这种情况下,你会期望她产生(生成)所有她能占据的位置(可能一个也没有)。你可以直接勾画出来。

def queens(num, state):
    if len(state) == num-1:
        for pos in range(num):
            if not conflict(state, pos):
                 yield pos

用人类的话来说,这意味着,“如果除了一个皇后之外所有的皇后都被放置好了,为最后一个皇后遍历所有可能的位置,并返回不会引起任何冲突的位置。”num参数是皇后总数,state参数是前几个皇后的位置元组。例如,假设你有四个皇后,前三个分别被赋予位置 1、3 和 0,如图 9-1 所示。(此时不要理会白皇后。)

A326949_3_En_9_Fig1_HTML.jpg

图 9-1。

Placing four queens on a 4 × 4 board

如图所示,每个皇后都有一个(水平)行,皇后的位置从顶部开始编号(从零开始,这在 Python 中很常见)。

>>> list(queens(4, (1, 3, 0)))
[2]

它非常有效。使用list只是强制生成器产生它的所有值。在这种情况下,只有一个职位合格。在图 9-1 中,白皇后被放在这个位置。(注意颜色没有特别的意义,也不是节目的一部分。)

递归情况

现在让我们转向解决方案的递归部分。当您覆盖了您的基本情况时,递归情况可以正确地假设(通过归纳)来自较低级别(具有较高数字的皇后)的所有结果都是正确的。所以你需要做的是在前面实现的queens函数的if语句中添加一个else子句。

你期望递归调用的结果是什么?你想要所有低级皇后的位置,对吗?假设它们以元组的形式返回。在这种情况下,您可能需要更改您的基本情况来返回一个元组(长度为 1),但我稍后会谈到这一点。

所以,你从“上面”得到一组位置,对于当前皇后的每个合法位置,你从“下面”得到一组位置你所要做的就是把你自己的位置放在前面,产生下面的结果:

...
else:
    for pos in range(num):
        if not conflict(state, pos):
           for result in queens(num, state + (pos,)):
               yield (pos,) + result

其中的for posif not conflict部分与之前的完全相同,因此您可以稍微重写一下以简化代码。让我们也添加一些默认参数。

def queens(num=8, state=()):
    for pos in range(num):
        if not conflict(state, pos):
           if len(state) == num-1:
              yield (pos,)
           else:
              for result in queens(num, state + (pos,)):
                  yield (pos,) + result

如果您觉得代码很难理解,您可能会发现用自己的话来表述它的作用会很有帮助。(你一定记得(pos,)中的逗号是使它成为一个元组所必需的,而不仅仅是一个带括号的值,对吗?)

queens生成器给出了所有的解决方案(即所有放置皇后的合法方式)。

>>> list(queens(3))
[]
>>> list(queens(4))
[(1, 3, 0, 2), (2, 0, 3, 1)]
>>> for solution in queens(8):
...   print solution
...
(0, 4, 7, 5, 2, 6, 1, 3)
(0, 5, 7, 2, 6, 3, 1, 4)
...
(7, 2, 0, 5, 1, 4, 6, 3)
(7, 3, 0, 2, 5, 1, 6, 4)
>>>

如果你用八个皇后运行queens,你会看到许多解决方案闪过。让我们看看有多少。

>>> len(list(queens(8)))
92

包装它

在离开 queens 之前,让我们让输出更容易理解一些。清晰的输出总是一件好事,因为它更容易发现错误。

def prettyprint(solution):
    def line(pos, length=len(solution)):
        return '. ' * (pos) + 'X ' + '. ' * (length-pos-1)
    for pos in solution:
        print(line(pos))

注意,我在prettyprint里面做了一个小小的助手函数。我把它放在那里,因为我想我在外面的任何地方都不需要它。在下面,我打印出一个随机的解决方案来满足自己,它是正确的。

>>> import random
>>> prettyprint(random.choice(list(queens(8))))
. . . . . X . .
. X . . . . . .
. . . . . . X .
X . . . . . . .
. . . X . . . .
. . . . . . . X
. . . . X . . .
. . X . . . . .

该“图纸”对应于图 9-2 中的图表。

A326949_3_En_9_Fig2_HTML.jpg

图 9-2。

One of many possible solutions to the Eight Queens problem

快速总结

你在这里看到了很多魔法。我们来盘点一下。

  • 新风格与旧风格的类:Python 中类的工作方式正在发生变化。最近的 Python 3.0 之前的版本有两种类型的类,旧式的很快就过时了。新样式的类是在 2.2 版本中引入的,它们提供了几个额外的特性(例如,它们可以使用superproperty,而旧样式的类则不能)。要创建一个新样式的类,您必须直接或间接地子类化object,或者设置__metaclass__属性。
  • 魔术方法:Python 中有几个特殊的方法(名称以双下划线开头和结尾)。这些方法在功能上有很大的不同,但是它们中的大多数都是由 Python 在特定环境下自动调用的。(例如,__init__是在对象创建后调用的。)
  • 构造函数:这些是许多面向对象语言中常见的,你可能会为你写的几乎每个类实现一个构造函数。构造函数被命名为init,并在对象创建后立即被自动调用。
  • 重写:一个类可以简单地通过实现方法来重写在其超类中定义的方法(或任何其他属性)。如果新方法需要调用被覆盖的版本,它可以直接从超类调用未绑定的版本(旧样式的类),或者使用super函数(新样式的类)。
  • 序列和映射:创建自己的序列或映射需要实现序列和映射协议的所有方法,包括像getitem__setitem__这样的神奇方法。通过子类化list(或者UserList)和dict(或者UserDict),可以省去很多工作。
  • 迭代器:迭代器只是一个拥有__next__方法的对象。迭代器可以用来迭代一组值。当没有更多的值时,next方法应该引发一个StopIteration异常。Iterable 对象有一个__iter__方法,它返回一个迭代器,可以在for循环中使用,就像序列一样。通常,迭代器也是可迭代的;也就是说,它有一个返回迭代器本身的__iter__方法。
  • 生成器:生成器函数(或方法)是包含关键字yield的函数(或方法)。当被调用时,generator-function 返回一个生成器,这是一种特殊类型的迭代器。您可以通过使用sendthrowclose方法从外部与一个活动的发电机交互。
  • 八皇后:八皇后问题在计算机科学中是众所周知的,很容易用生成器实现。目标是在棋盘上放置八个皇后,这样没有一个皇后可以攻击其他的皇后。

本章的新功能

| 功能 | 描述 | | --- | --- | | `iter(obj)` | 从可迭代对象中提取迭代器 | | `next(it)` | 推进一个迭代器并返回它的下一个元素 | | `property(fget, fset, fdel, doc)` | 返回一个属性;所有参数都是可选的 | | `super(class, obj)` | 返回`class`的超类的绑定实例 |

注意,itersuper可以用这里描述的参数之外的其他参数调用。有关更多信息,请参考标准 Python 文档。

什么现在?

现在您已经了解了 Python 语言的大部分内容。那么为什么还剩下那么多章节呢?嗯,还有很多要学,大部分是关于 Python 如何以各种方式连接到外部世界。然后我们有测试、扩展、打包和项目,所以我们还没有完成——远远没有。

Footnotes 1

感谢 Alex Martelli 指出这个习语以及在这里使用它的重要性。

十、自带电池

您现在已经了解了大部分基本的 Python 语言。虽然核心语言本身就很强大,但是 Python 为您提供了更多的工具。标准安装包括一组称为标准库的模块。你已经看到了其中的一些(比如mathcmath,但是还有更多。这一章向你展示了模块是如何工作的,以及如何探索它们,学习它们所提供的东西。然后这一章提供了标准库的概述,集中在几个精选的有用模块上。

模块

你已经知道如何制作自己的程序(或脚本)并执行它们。您还看到了如何使用import从外部模块获取函数到您的程序中。

>>> import math
>>> math.sin(0)
0.0

让我们来看看如何编写自己的模块。

模块是程序

任何 Python 程序都可以作为模块导入。假设您已经编写了清单 10-1 中的程序,并将其存储在一个名为hello.py的文件中。除了扩展名.py之外,文件的名称将成为模块的名称。

# hello.py
print("Hello, world!")
Listing 10-1.A Simple Module

你把它保存在哪里也很重要;在下一节中,您将了解到更多这方面的内容,但现在我们假设您将它保存在目录C:\python (Windows)或∼/python (UNIX/macOS)中。

然后,您可以通过执行以下命令(使用 Windows 目录)告诉您的解释器在哪里查找该模块:

>>> import sys
>>> sys.path.append('C:/python')

Tip

在 UNIX 中,不能简单地将字符串'∼/python'附加到sys.path上。你必须使用完整的路径(比如'/home/yourusername/python',或者,如果你想自动化它,使用sys.path.expanduser('∼/python')

这只是告诉解释器,除了正常查找的位置之外,还应该在目录C:\python中查找模块。完成这些之后,您就可以导入您的模块(它存储在文件C:\python\hello.py中,还记得吗?).

>>> import hello
Hello, world!

Note

当您导入一个模块时,您可能会注意到在您的源文件旁边出现了一个名为__ pycache__的新目录。(在旧版本中,你会看到带有后缀.pyc的文件。)这个目录包含的文件带有 Python 可以更高效处理的已处理文件。如果你以后导入同一个模块,Python 会导入这些文件,然后是你的.py文件,除非.py文件发生了变化;在这种情况下,会生成一个新的已处理文件。删除__pycache__目录没有坏处——会根据需要创建一个新目录。

如您所见,模块中的代码在您导入它时被执行。但是,如果您尝试再次导入它,什么也不会发生。

>>> import hello
>>>

为什么这次不管用?因为模块在导入时并不是真的要做什么(比如打印文本)。它们主要是用来定义事物,比如变量、函数、类等等。因为你只需要定义一次,所以多次导入一个模块和导入一次效果是一样的。

Why Only Once?

在大多数情况下,只导入一次的行为是一种实质性的优化,在一种特殊的情况下,它可能非常重要:如果两个模块相互导入。

在许多情况下,您可能会编写两个需要相互访问函数和类才能正常工作的模块。例如,您可能已经创建了两个模块——clientdbbilling——分别包含客户端数据库和计费系统的代码。您的客户端数据库可能包含对您的计费系统的调用(例如,每月自动向客户端发送账单),而计费系统可能需要从您的客户端数据库访问功能以正确计费。

如果每个模块都可以多次导入,那么这里就会出现问题。模块clientdb会导入billing,?? 又会导入clientdb,这……你明白了。你最终会得到一个导入的无限循环(无限递归,还记得吗?).但是,因为第二次导入模块时什么也没有发生,所以循环被中断了。

如果你坚持重载你的模块,你可以使用importlib模块中的reload函数。它采用单个参数(您想要重新加载的模块)并返回重新加载的模块。如果您对模块进行了更改,并希望这些更改在程序运行时反映在程序中,这可能会很有用。为了重新加载简单的hello模块(只包含一个print语句),我将使用以下代码:

>>> import importlib
>>> hello = importlib.reload(hello)
Hello, world!

在这里,我假设hello已经被导入(一次)。通过将reload的结果赋给hello,我已经用重新加载的版本替换了之前的版本。正如您可以从打印的问候中看到的,我确实在这里导入了模块。

如果你已经通过实例化模块bar中的类Foo创建了一个对象x,然后你重新加载bar,那么x引用的对象将不会以任何方式被重新创建。x仍将是旧版本Foo的实例(来自旧版本bar)。相反,如果您希望x基于重新加载模块中的新Foo,您将需要重新创建它。

模块是用来定义事物的

所以模块在第一次被导入程序时就被执行了。这似乎有点用,但不是很有用。使它们有价值的是它们(就像类一样)在之后保持它们的作用域。这意味着您定义的任何类或函数,以及您赋值的任何变量,都将成为模块的属性。这看似复杂,但实际上非常简单。

在模块中定义函数

假设您已经编写了一个类似清单 10-2 中的模块,并将它存储在一个名为hello2.py的文件中。还假设您已经将它放在 Python 解释器可以找到的地方,或者使用前一节中的sys.path技巧,或者使用后一节“使您的模块可用”中的更传统的方法

Tip

如果您使一个程序(它是用来执行的,而不是真正作为一个模块使用)以与其他模块相同的方式可用,那么您实际上可以使用 Python 解释器的-m开关来执行它。运行命令python -m progname args将运行带有命令行参数args的程序progname,前提是文件progname.py(注意后缀)与您的其他模块一起安装(也就是说,前提是您已经导入了progname)。

# hello2.py
def hello():
    print("Hello, world!")
Listing 10-2.A Simple Module Containing a Function

然后您可以像这样导入它:

>>> import hello2

然后执行模块,这意味着函数hello是在模块的作用域中定义的,所以可以像这样访问函数:

>>> hello2.hello()
Hello, world!

在模块的全局作用域中定义的任何名称都可以以同样的方式使用。你为什么想这么做?为什么不在主程序中定义所有的东西呢?

主要原因是代码重用。如果你把你的代码放在一个模块中,你可以在不止一个程序中使用它,这意味着如果你写了一个好的客户端数据库,并把它放在一个叫做clientdb的模块中,你可以在计费时,在发送垃圾邮件时(虽然我希望你不会),以及在任何需要访问你的客户端数据的程序中使用它。如果你没有把它放在一个单独的模块中,你将需要重写每一个程序的代码。所以,记住,要使你的代码可重用,就要模块化!(而且,没错,这肯定和抽象有关。)

在模块中添加测试代码

模块用于定义诸如函数和类之类的东西,但是每隔一段时间(实际上非常频繁),添加一些测试代码来检查事情是否如它们应该的那样工作是有用的。例如,如果你想确保hello函数正常工作,你可以将模块hello2重写为一个新的模块hello3,在清单 10-3 中定义。

# hello3.py
def hello():
    print("Hello, world!")

# A test:
hello()

Listing 10-3.A Simple Module with Some Problematic Test Code

这似乎是合理的——如果你把它作为一个普通的程序运行,你会发现它是有效的。然而,如果你把它作为一个模块导入,为了在另一个程序中使用hello函数,测试代码被执行,就像在本章的第一个hello模块中一样。

>>> import hello3
Hello, world!
>>> hello3.hello()
Hello, world!

这不是你想要的。避免这种行为的关键是检查模块是作为一个程序单独运行还是导入到另一个程序中。为此,您需要变量 __ name __。

>>> __name__
'__main__'
>>> hello3.__name__
'hello3'

可以看到,在“主程序”(包括解释器的交互提示)中,变量__name__的值为' __ main __ '。在导入的模块中,它被设置为该模块的名称。因此,通过加入一个if语句,你可以让你的模块的测试代码表现得更好,如清单 10-4 所示。

# hello4.py

def hello():
    print("Hello, world!")

def test():
    hello()

if __name__ == '__main__': test()

Listing 10-4.A Module with Conditional Test Code

如果将此作为程序运行,则执行hello函数;如果导入它,它的行为就像一个普通的模块。

>>> import hello4
>>> hello4.hello()
Hello, world!

如您所见,我已经将测试代码包装在一个名为test的函数中。我可以将代码直接放入if语句中;但是,通过将它放在一个单独的测试函数中,即使您已经将它导入到另一个程序中,您也可以测试该模块。

>>> hello4.test()
Hello, world!

Note

如果你写更彻底的测试代码,把它放在一个单独的程序中可能是个好主意。参见第十六章了解更多关于编写测试的信息。

使您的模块可用

在前面的例子中,我修改了sys.path,它包含一个目录列表(字符串形式),解释器应该在其中查找模块。但是,你一般不会想这么做。理想的情况是sys.path包含正确的目录(包含您的模块的目录)。有两种方法可以做到这一点:把你的模块放在正确的位置或者告诉解释器去哪里找。以下部分讨论了这两种解决方案。如果你想让你的模块容易被其他人使用,那就是另一回事了。Python 打包经历了一个日益复杂和多样化的阶段;现在,Python 打包权威机构正在对其进行控制和精简,但仍有许多内容需要消化。与其深入这个具有挑战性的主题,我建议您参考 Python 打包用户指南,可从packaging.python.org获得。

将您的模块放在正确的位置

将您的模块放在正确的位置——或者说,一个正确的位置——是非常容易的。只需要找出 Python 解释器在哪里寻找模块,然后把你的文件放在那里。如果您正在使用的机器上的 Python 解释器是由管理员安装的,并且您没有管理员权限,则您可能无法将您的模块保存在 Python 使用的任何目录中。然后,您将需要使用下一节中描述的替代解决方案:告诉解释器在哪里查找。

您可能还记得,目录列表(所谓的搜索路径)可以在sys模块的path变量中找到。

>>> import sys, pprint
>>> pprint.pprint(sys.path)
['C:\\Python35\\Lib\\idlelib',
 'C:\\Python35',
 'C:\\Python35\\DLLs',
 'C:\\Python35\\lib',
 'C:\\Python35\\lib\\plat-win',
 'C:\\Python35\\lib\\lib-tk',
 'C:\\Python35\\lib\\site-packages']

Tip

如果你的数据结构太大,无法在一行中显示,你可以使用pprint模块中的pprint函数,而不是普通的print语句。pprint是一个漂亮的打印功能,使打印输出更加智能。

当然,你可能得不到完全相同的结果。关键是,如果您希望您的解释器找到模块,这些字符串中的每一个都提供了放置模块的位置。尽管所有这些都可以工作,site-packages目录是最好的选择,因为它就是为这类事情准备的。浏览您的sys.path并找到您的site-packages目录,并将清单 10-4 中的模块保存在其中,但给它另起一个名字,比如another_hello.py。然后尝试以下方法:

>>> import another_hello
>>> another_hello.hello()
Hello, world!

只要你的模块位于site-packages这样的地方,你所有的程序都能够导入它。

告诉翻译往哪里看

出于多种原因,将模块放在正确的位置可能不是您的正确解决方案。

  • 您不希望 Python 解释器的目录与您自己的模块混杂在一起。
  • 您没有权限在 Python 解释器的目录中保存文件。
  • 你想把你的模块放在别的地方。

底线是,如果你把你的模块放在别的地方,你必须告诉解释器去哪里找。正如您之前看到的,一种方法是直接修改sys.path,但这并不是一种常见的方法。标准方法是将您的模块目录包含在环境变量PYTHONPATH中。

根据您使用的操作系统的不同,PYTHONPATH的内容会有所不同(参见侧栏“环境变量”),但基本上它就像sys.path——一个目录列表。

Environment Variables

环境变量不是 Python 解释器的一部分,而是操作系统的一部分。基本上,它们就像 Python 变量,但是它们是在 Python 解释器之外设置的。假设您使用的是bash shell,它可以在大多数类 UNIX 系统、macOS 和最新版本的 Windows 上使用。然后,您可以执行下面的语句将∼/python追加到您的PYTHONPATH环境变量中:

export PYTHONPATH=$PYTHONPATH:∼/python

如果您想让这个语句在您启动的所有 shells 中执行,那么您可以将它添加到主目录中的.bashrc文件中。有关以其他方式编辑环境变量的说明,您应该查阅系统文档。

作为使用 PYTHONPATH 环境变量的替代方法,您可能希望考虑所谓的路径配置文件。这些是扩展名为.pth的文件,位于某些特定的目录中,包含应该添加到sys.path中的目录名。有关详细信息,请参考site模块的标准库文档。

包装

要构建模块,您可以将它们分组到包中。包基本上就是另一种类型的模块。有趣的是,它们可以包含其他模块。模块存储在一个文件中(文件扩展名为.py),而包是一个目录。要让 Python 把它当作一个包,它必须包含一个名为__init__.py的文件。如果您像导入一个普通模块一样导入它,那么这个文件的内容将是包的内容。例如,如果您有一个名为constants的包,并且文件constants/__init__.py包含语句PI = 3.14,您将能够执行以下操作:

import constants
print(constants.PI)

要将模块放入包中,只需将模块文件放入包目录中。您也可以将包嵌套在其他包中。例如,如果你想要一个名为drawing的包,其中包含一个名为shapes的模块和一个名为colors的模块,你需要表 10-1 中显示的文件和目录(UNIX 路径名)。

表 10-1。

A Simple Package Layout

| 文件/目录 | 描述 | | --- | --- | | `∼/python/` | `PYTHONPATH`中的目录 | | `∼/python/drawing/` | 包目录(`drawing`包) | | `∼/python/drawing/__init__.py` | 包装代码(`drawing`模块) | | `∼/python/drawing/colors.py` | `colors`模块 | | `∼/python/drawing/shapes.py` | `shapes`模块 |

在这种设置下,以下语句都是合法的:

import drawing             # (1) Imports the drawing package
import drawing.colors      # (2) Imports the colors module
from drawing import shapes # (3) Imports the shapes module

在第一条语句之后,drawing中的__init__.py文件的内容将是可用的;然而,shapes号和colors号模块就不是了。在第二条语句之后,colors模块将是可用的,但是只能使用它的全名drawing.colors。在第三条语句之后,shapes模块就可用了,它的名字很短(也就是简单的shapes)。请注意,这些陈述只是示例。例如,不需要像我在这里所做的那样,在导入一个模块之前导入包本身。第二个声明很可能会自行执行,第三个也是如此。

探索模块

在我描述一些标准库模块之前,我将向您展示如何自己探索模块。这是一项很有价值的技能,因为在 Python 程序员的职业生涯中,您会遇到许多有用的模块,我不可能在这里一一介绍。当前的标准图书馆足够大,可以保证所有的书都是自己的(这样的书已经被写出来了)——而且它还在增长。每个版本都添加了新的模块,通常有些模块会有细微的变化和改进。此外,你肯定会在网上找到几个有用的模块,能够快速而容易地理解它们会使你的编程更加有趣。

模块里有什么?

探测模块最直接的方法是在 Python 解释器中研究它。当然,您需要做的第一件事是导入它。假设你听说了一个叫做copy的标准模块的传闻。

>>> import copy

没有引发异常——所以它存在。但是它有什么用呢?它包含什么?

使用目录

要找出一个模块包含什么,您可以使用dir函数,它列出一个对象的所有属性(以及模块的所有函数、类、变量等等)。如果你打印出dir(copy),你会得到一长串名字。(去吧,试试看。)这些名称中有几个以下划线开头——暗示(按照惯例)它们不应该在模块外使用。所以让我们用一点列表理解来过滤掉它们(如果你不记得这是如何工作的,查看第五章中关于列表理解的部分)。

>>> [n for n in dir(copy) if not n.startswith('_')]
['Error', 'PyStringMap', 'copy', 'deepcopy', 'dispatch_table', 'error', 'name', 't', 'weakref']

结果由来自dir(copy)的所有名字组成,这些名字的第一个字母没有下划线,应该比完整的列表更容易混淆。

all 变量

我在上一节中对小列表理解所做的是猜测我应该在copy模块中看到什么。但是,你可以直接从模块本身得到正确答案。在完整的dir(copy)列表中,你可能已经注意到了__all__这个名字。这是一个包含列表的变量,类似于我用 list comprehension 创建的列表,只是这个列表是在模块本身中设置的。让我们看看它包含了什么:

>>> copy.__all__
['Error', 'copy', 'deepcopy']

我的猜测没那么糟。我只得到几个不打算给我用的额外的名字。但是这个__all__列表是从哪里来的,为什么它真的在那里?第一个问题很容易回答。它是在copy模块中设置的,像这样(直接从copy.py复制过来的):

__all__ = ["Error", "copy", "deepcopy"]

那它为什么会在那里?它定义了模块的公共接口。更具体地说,它告诉解释器从这个模块导入所有名字意味着什么。所以如果你用这个:

from copy import *

您只能获得在__all__变量中列出的四个函数。例如,要导入PyStringMap,您需要显式地导入copy并使用copy.PyStringMap,或者使用from copy import PyStringMap

这样设置__all__在编写模块时也是一种有用的技术。因为您的模块中可能有许多其他程序可能不需要或不想要的变量、函数和类,所以出于礼貌将它们过滤掉。如果不设置__all__,带星号的导入中导出的名称默认为模块中不以下划线开头的所有全局名称。

获得帮助

到目前为止,您一直在利用自己的聪明才智和对各种 Python 函数和特殊属性的了解来探索copy模块。对于这种探索,交互式解释器是一个强大的工具,因为对语言的掌握是您能够深入探索一个模块的唯一限制。但是,有一个标准函数可以提供您通常需要的所有信息。那个函数叫做help。让我们在copy功能上试试:

>>> help(copy.copy)
Help on function copy in module copy:

copy(x)
    Shallow copy operation on arbitrary Python objects.

    See the module's __doc__ string for more info.

这告诉您,__copy__接受单个参数x,这是一个“浅层复制操作”但是它也提到了模块的__doc__字符串。那是什么?你可能还记得我在第六章中提到了文档字符串。docstring 只是一个写在函数开头的字符串,用来记录函数。然后,该字符串可以被函数属性__doc__引用。正如您从前面的帮助文本中所理解的,模块也可能有文档字符串(它们写在模块的开头),类也可能有文档字符串(它们写在类的开头)。

实际上,前面的帮助文本是从 copy 函数的 docstring 中提取的:

>>> print(copy.copy.__doc__)
Shallow copy operation on arbitrary Python objects.

    See the module's __doc__ string for more info.

与像这样直接检查 docstring 相比,使用help的优点是可以获得更多的信息,比如函数签名(也就是它所采用的参数)。尝试在模块本身上调用help,看看会得到什么。它打印出了很多信息,包括对copydeepcopy之间的差异的彻底讨论(本质上是deepcopy(x)将在x中找到的值复制为属性等等,而copy(x)只是复制x,将副本的属性绑定到与x相同的值)。

文件

当然,模块信息的一个自然来源是它的文档。我推迟了对文档的讨论,因为自己先检查一下模块通常会快得多。例如,你可能想知道,“再问一次range的参数是什么?”不用在 Python 书籍或标准 Python 文档中搜索关于range的描述,您可以直接查看。

>>> print(range.__doc__)
range(stop) -> range object
range(start, stop[, step]) -> range object

Return an object that produces a sequence of integers from start (inclusive)
to stop (exclusive) by step.  range(i, j) produces i, i+1, i+2, ..., j-1.
start defaults to 0, and stop is omitted!  range(4) produces 0, 1, 2, 3.
These are exactly the valid indices for a list of 4 elements.
When step is given, it specifies the increment (or decrement).

现在您已经对range函数有了一个精确的描述,并且因为您可能已经运行了 Python 解释器(在您编程时经常会对这样的函数感到疑惑),访问这些信息只需要几秒钟。

然而,并不是每个模块和每个函数都有一个好的 docstring(尽管它们应该有),有时您可能需要对事情如何工作进行更全面的描述。你从网上下载的大多数模块都有一些相关的文档。学习 Python 编程的一些最有用的文档是 Python 库参考,它描述了标准库中的所有模块。如果我想查找关于 Python 的一些事实,十有八九,我会在那里找到它。该库参考可在线浏览(在 https://docs.python.org/library )或下载,其他几个标准文档也是如此(如 Python 教程和 Python 语言参考)。所有的文档都可以从 Python 网站 https://docs.python.org 获得。

使用源

到目前为止,我讨论的探索技术对于大多数情况来说可能已经足够了。但是那些希望真正理解 Python 语言的人可能想知道关于一个模块的事情,如果不真正阅读源代码,这些事情是无法回答的。事实上,阅读源代码是学习 Python 的最好方法之一——除了自己编码。

做实际的阅读应该不是什么大问题,但是来源在哪里呢?假设您想阅读标准模块copy的源代码。你会在哪里找到它?一种解决方案是再次检查sys.path,并像解释器一样自己寻找它。一种更快的方法是检查模块的__file__属性。

>>> print(copy.__file__)
C:\Python35\lib\copy.py

在那里!您可以在代码编辑器(例如 IDLE)中打开copy.py文件,并开始检查它是如何工作的。如果文件名以.pyc结尾,就使用对应的以.py结尾的文件。

Caution

当在文本编辑器中打开一个标准库文件时,您会有意外修改它的风险。这样做可能会破坏该文件,所以当您关闭该文件时,请确保您没有保存任何可能已做的更改。

请注意,有些模块没有任何可供阅读的 Python 源代码。它们可能内置在解释器中(如sys模块),也可能用 C 编程语言编写。 1 (参见第十七章了解更多关于使用 c 扩展 Python 的信息)

标准图书馆:几个最爱

关于 Python 的短语“包括电池”最初是由 Frank Stajano 创造的,指的是 Python 丰富的标准库。当您安装 Python 时,您可以“免费”获得许多有用的模块(“电池”)因为有很多方法可以获得关于这些模块的更多信息(如本章第一部分所解释的),所以我在这里不包括完整的参考资料(这将占用太多的空间);相反,我将描述几个我最喜欢的标准模块,以激起您的探索欲望。你会在项目章节中遇到更多的标准模块(章节 20 到 29 )。模块描述并不完整,但强调了每个模块的一些有趣的功能。

[计]系统复制命令(system 的简写)

sys模块让您可以访问与 Python 解释器密切相关的变量和函数。其中一些如表 10-2 所示。

表 10-2。

Some Important Functions and Variables in the sys Module

| 函数/变量 | 描述 | | --- | --- | | `argv` | 命令行参数,包括脚本名称 | | `exit([arg])` | 退出当前程序,可以选择返回给定的返回值或错误信息 | | `modules` | 将模块名映射到加载的模块的字典 | | `path` | 可以找到模块的目录名列表 | | `platform` | 平台标识符,如`sunos5`或`win32` | | `stdin` | 标准输入流—类似文件的对象 | | `stdout` | 标准输出流—类似文件的对象 | | `stderr` | 标准错误流—类似文件的对象 |

变量sys.argv包含传递给 Python 解释器的参数,包括脚本名。

功能sys. exit退出当前程序。(如果在第八章中讨论的try / finally块内调用,finally子句仍然执行。)您可以提供一个整数来表示程序是否成功,这是一个 UNIX 约定。如果您依赖默认值(零,表示成功),那么在大多数情况下您可能不会有问题。或者,您可以提供一个字符串,它用作错误消息,对于试图找出程序停止原因的用户非常有用;然后,程序退出,并显示错误消息和指示失败的代码。

映射sys.modules将模块名映射到实际的模块。它仅适用于当前导入的模块。

模块变量sys.path在本章前面已经讨论过了。这是一个字符串列表,其中每个字符串都是一个目录名,当执行一个import语句时,解释器将在这个目录中寻找模块。

模块变量sys.platform(一个字符串)仅仅是解释器运行的“平台”的名称。这可能是一个表示操作系统的名称(例如sunos5win32,或者它可能表示某种其他类型的平台,例如 Java 虚拟机(例如java1.4.0),如果您运行的是 Jython 的话。

模块变量sys.stdinsys.stdoutsys.stderr是类似文件的流对象。它们代表了标准输入、标准输出和标准错误的标准 UNIX 概念。简单来说,sys.stdin是 Python 获取输入的地方(例如在input中使用的),而sys.stdout是它打印的地方。在第十一章你会学到更多关于文件(和这三个流)的知识。

例如,考虑以相反顺序使用打印参数的问题。当您从命令行调用 Python 脚本时,您可以在它后面添加一些参数,即所谓的命令行参数。这些将被放入列表sys.argv,Python 脚本的名称为sys.argv[0]。按照相反的顺序打印这些内容非常简单,如清单 10-5 所示。

# reverseargs.py
import sys
args = sys.argv[1:]
args.reverse()
print(' '.join(args))
Listing 10-5.Reversing and Printing Command-Line Arguments

如你所见,我复制了一份sys.argv。您可以修改原始参数,但是一般来说,不这样做更安全,因为程序的其他部分也可能依赖于包含原始参数的sys.argv。还注意到我跳过了sys.argv的第一个元素——剧本的名字。我用args.reverse()反转列表,但是我不能打印那个操作的结果。它是一个返回None的原地修改。另一种方法如下:

print(' '.join(reversed(sys.argv[1:])))

最后,为了让输出更漂亮,我使用了join字符串方法。我们来试试结果(假设其他某个 shell 的bash)。

$ python reverseargs.py this is a test
test a is this

os模块可以让你访问几个操作系统服务。os模块广泛;表 10-3 中只描述了几个最有用的函数和变量。除了这些,os及其子模块os.path包含了几个检查、构造和删除目录和文件的函数,以及操纵路径的函数(例如,os.path.splitos.path.join让你大部分时间忽略os.pathsep)。有关此功能的更多信息,请参见标准库文档。在那里你还可以找到对pathlib模块的描述,它提供了一个面向对象的路径操作接口。

表 10-3。

Some Important Functions and Variables in the os Module

| 函数/变量 | 描述 | | --- | --- | | `environ` | 使用环境变量映射 | | `system(command)` | 在子外壳中执行操作系统命令 | | `sep` | 路径中使用的分隔符 | | `pathsep` | 分隔路径的分隔符 | | `linesep` | 行分隔符(`'\n'`、`'\r'`或`'\r\n'`) | | `urandom(n)` | 返回`n`字节的强加密随机数据 |

映射os.environ包含本章前面描述的环境变量。例如,要访问环境变量PYTHONPATH,可以使用表达式os.environ['PYTHONPATH']。这种映射也可以用来改变环境变量,尽管不是所有的平台都支持。

功能os.system用于运行外部程序。还有其他用于执行外部程序的函数,包括execv,它退出 Python 解释器,将控制权交给执行的程序,以及popen,它创建一个类似文件的程序连接。

有关这些函数的更多信息,请参考标准库文档。

Tip

检查subprocess模块。它收集了os.systemexecvpopen函数的功能。

模块变量os.sep是路径名中使用的分隔符。UNIX(以及 Python 的 macOS 命令行版本)中的标准分隔符是/。Windows 中的标准是\\(单个反斜杠的 Python 语法),在旧的 macOS 中是:。(在某些平台上,os.altsep包含一个替代路径分隔符,比如 Windows 中的/。)

当对几个路径进行分组时,使用os.pathsep,就像在PYTHONPATH中一样。pathsep用于分隔路径名:UNIX/macOS 中的:和 Windows 中的;

模块变量os.linesep是文本文件中使用的行分隔符字符串。在 UNIX/OS X 中,这是一个单独的换行符(\n),而在 Windows 中,它是回车符和换行符的组合(\r\n)。

urandom函数使用一个依赖于系统的“真实的”(或者至少是加密的)随机性来源。如果你的平台不支持,你会得到一个NotImplementedError

例如,考虑启动 web 浏览器的问题。system命令可用于执行任何外部程序,这在 UNIX 等环境中非常有用,在这些环境中,您可以从命令行执行程序(或命令)来列出目录内容、发送电子邮件等等。但是它也可以用于启动带有图形用户界面的程序——比如网络浏览器。在 UNIX 中,您可以执行以下操作(假设您在/usr/bin/firefox有浏览器):

os.system('/usr/bin/firefox')

以下是 Windows 版本(再次使用您已安装的浏览器的路径):

os.system(r'C:\"Program Files (x86)"\"Mozilla Firefox"\firefox.exe')

注意,我很小心地将Program FilesMozilla Firefox用引号括起来;否则,底层 shell 会避开空白。(这对你的PYTHONPATH中的目录也很重要。)还要注意,这里必须使用反斜杠,因为 shell 会被正斜杠搞混。如果您运行这个程序,您会注意到浏览器试图打开一个名为Files"\Mozilla…的网站,这是命令中空格后面的部分。此外,如果您试图从空闲状态运行它,会出现一个 DOS 窗口,但浏览器不会启动,直到您关闭该 DOS 窗口。总而言之,这不完全是理想的行为。

另一个更适合这项工作的功能是 Windows 特有的功能os.startfile

os.startfile(r'C:\Program Files (x86)\Mozilla Firefox\firefox.exe')

正如您所看到的,os.startfile接受一个普通的路径,即使它包含空白(也就是说,不要像在os.system的例子中那样用引号将Program Files括起来)。

注意,在 Windows 中,你的 Python 程序在os.system(或os.startfile)启动了外部程序后,还在继续运行;在 UNIX 中,您的 Python 程序等待os.system命令完成。

A Better Solution: Webbrowser

os.system函数对很多事情都很有用,但是对于启动 web 浏览器的特定任务,有一个更好的解决方案:webbrowser模块。它包含一个名为open的功能,可以让你自动启动网络浏览器打开给定的网址。例如,如果您希望您的程序在 web 浏览器中打开 Python 网站(启动一个新的浏览器或使用一个已经运行的浏览器),您只需使用:

import webbrowser
webbrowser.open('http://www.python.org')

页面应该会弹出。

fileinput

在第十一章中你会学到很多关于读写文件的知识,但是这里有一个预览。fileinput模块使您能够轻松地遍历一系列文本文件中的所有行。如果像这样调用脚本(假设是 UNIX 命令行):

$ python some_script.py file1.txt file2.txt file3.txt

您将能够依次迭代file1.txtfile3.txt的行。还可以迭代提供给标准输入(sys.stdin)的行,还记得吗?),例如在 UNIX 管道中,使用标准的 UNIX 命令cat

$ cat file.txt | python some_script.py

如果您使用fileinput,在 UNIX 管道中用cat调用您的脚本就像将文件名作为命令行参数提供给脚本一样有效。表 10-4 中描述了fileinput模块最重要的功能。

表 10-4。

Some Important Functions in the fileinput Module

| 功能 | 描述 | | --- | --- | | `input([files[, inplace[, backup]])` | 促进多个输入流中的行的迭代 | | `filename()` | 返回当前文件的名称 | | `lineno()` | 返回当前(累计)行号 | | `filelineno()` | 返回当前文件中的行号 | | `isfirstline()` | 检查当前行是否是文件中的第一行 | | `isstdin()` | 检查最后一行是否来自`sys.stdin` | | `nextfile()` | 关闭当前文件并移动到下一个文件 | | `close()` | 关闭序列 |

fileinput.input是最重要的功能。它返回一个对象,您可以在一个for循环中迭代这个对象。如果您不想要默认的行为(其中fileinput找出要迭代的文件),您可以向该函数提供一个或多个文件名(作为一个序列)。您也可以将inplace参数设置为真值(inplace=True)来启用就地处理。对于您访问的每一行,您需要打印出一个替换行,它将被放回到当前的输入文件中。当您进行就地处理时,可选的backup参数为从原始文件创建的备份文件提供一个文件扩展名。

函数fileinput.filename返回当前所在文件的文件名(即包含当前正在处理的行的文件)。

函数fileinput.lineno返回当前行的编号。此计数是累积的,因此当您处理完一个文件并开始处理下一个文件时,行号不会被重置,而是从比前一个文件的最后一个行号多 1 的位置开始。

函数fileinput.filelineno返回当前文件中当前行的编号。每当您处理完一个文件并开始处理下一个文件时,文件行号将被重置并从 1 重新开始。

如果当前行是当前文件的第一行,函数fileinput.isfirstline返回真值;否则,它将返回一个假值。

如果当前文件为sys.stdin,函数fileinput.isstdin返回真值;否则,它返回 false。

函数fileinput.nextfile关闭当前文件并跳到下一个文件。跳过的行数不计入行数。如果您知道已经完成了当前文件,这可能会很有用,例如,如果每个文件都包含排序的单词,并且您正在查找特定的单词。如果您已经通过了单词在排序顺序中的位置,您可以安全地跳到下一个文件。

函数fileinput.close关闭整个文件链并完成迭代。

作为使用fileinput的一个例子,假设你写了一个 Python 脚本,你想给这些行编号。因为您希望程序在您完成这些后继续工作,所以您必须在每行的右边添加注释中的行号。要将它们对齐,可以使用字符串格式。让我们允许每个程序行最多 40 个字符,并在其后添加注释。清单 10-6 中的程序用fileinputinplace参数展示了一种简单的方法。

# numberlines.py

import fileinput

for line in fileinput.input(inplace=True):
    line = line.rstrip()
    num = fileinput.lineno()
    print('{:<50} # {:2d}'.format(line, num))

Listing 10-6.Adding Line Numbers to a Python Script

如果你自己运行这个程序,像这样:

$ python numberlines.py numberlines.py

您最终得到清单 10-7 中的程序。注意程序本身已经被修改,如果你像这样运行几次,你会在每一行有多个数字。回想一下,rstrip是一个字符串方法,它返回一个字符串的副本,其中右边的所有空格都被删除了(参见第三章中的“字符串方法”一节和附录 B 中的表 B-6)。

# numberlines.py                                   #  1
                                                   #  2
import fileinput                                   #  3
                                                   #  4
for line in fileinput.input(inplace=True):         #  5
    line = line.rstrip()                           #  6
    num = fileinput.lineno()                       #  7
    print('{:<50} # {:2d}'.format(line, num))      #  8
Listing 10-7.The Line Numbering Program with Line Numbers Added

Caution

小心使用inplace参数——这是破坏文件的一个简单方法。你应该仔细地测试你的程序,不要原地设置(这只是打印出结果),在你让它修改你的文件之前,确保程序工作。

关于使用fileinput的另一个例子,参见本章后面关于random模块的章节。

集合、堆和 Deques

有许多有用的数据结构,Python 支持一些更常见的数据结构。其中一些,如字典(或哈希表)和列表(或动态数组),是语言不可或缺的一部分。其他的,虽然有点外围,有时仍然可以派上用场。

设置

很久以前,集合是由sets模块中的Set类实现的。尽管您可能会在现有代码中遇到Set实例,但是您自己没有理由使用它们,除非您想向后兼容。在最近的版本中,集合由内置的set类实现。这意味着您不需要导入sets模块——您可以直接创建集合。

>>> set(range(10))
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

集合由一个序列(或其他一些可迭代的对象)构成,或者用花括号明确指定。请注意,您不能用大括号指定空集,因为您最终会得到一个空字典。

>>> type({})
<class 'dict'>

相反,你需要不带参数地调用set。集合的主要用途是确定成员资格,因此重复项会被忽略:

>>> {0, 1, 2, 3, 0, 1, 2, 3, 4, 5}
{0, 1, 2, 3, 4, 5}

就像字典一样,集合元素的排序是非常随意的,不应该依赖它。

>>> {'fee', 'fie', 'foe'}
{'foe', 'fee', 'fie'}

除了检查成员资格之外,您还可以执行各种标准的集合运算(您可能从数学中已经知道了),例如并集和交集,方法是使用方法,或者使用与整数上的位运算相同的运算(请参见附录 B)。例如,您可以使用其中一个集合的union方法或按位 or 运算符|找到两个集合的并集。

>>> a = {1, 2, 3}
>>> b = {2, 3, 4}
>>> a.union(b)
{1, 2, 3, 4}
>>> a | b
{1, 2, 3, 4}

下面是一些其他的方法和它们对应的操作符;这些名称应该清楚地表明它们的含义:

>>> c = a & b
>>> c.issubset(a)
True
>>> c <= a
True
>>> c.issuperset(a)
False
>>> c >= a
False
>>> a.intersection(b)
{2, 3}
>>> a & b
{2, 3}
>>> a.difference(b)
{1}
>>> a - b
{1}
>>> a.symmetric_difference(b)
{1, 4}
>>> a ^ b
{1, 4}
>>> a.copy()
{1, 2, 3}
>>> a.copy() is a
False

还有各种到位操作,有相应的方法,还有基本方法addremove。有关详细信息,请参阅《Python 库参考》中关于集合类型的部分。

Tip

如果你需要一个函数来寻找,比如说,两个集合的并集,你可以简单地使用set类型的union方法的未绑定版本。这可能是有用的,例如,与reduce合作。

>>> my_sets = []
>>> for i in range(10):
...     my_sets.append(set(range(i, i+5)))
...
>>> reduce(set.union, my_sets)
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}

集合是可变的,因此不能用作字典中的键。另一个问题是集合本身可能只包含不可变的(可散列的)值,因此可能不包含其他集合。因为集合的集合经常在实践中出现,这可能是一个问题。幸运的是,有frozenset类型,它代表不可变的(因此是可散列的)集合。

>>> a = set()
>>> b = set()
>>> a.add(b)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
TypeError: set objects are unhashable
>>> a.add(frozenset(b))

frozenset构造函数创建给定集合的副本。每当您想要将一个集合用作另一个集合的成员或者作为字典的关键字时,它都是有用的。

另一个众所周知的数据结构是堆,一种优先级队列。优先级队列允许您以任意顺序添加对象,并在任何时候(可能在添加之间)找到(并可能删除)最小的元素。这比在列表中使用min要有效得多。

事实上,Python 中没有单独的堆类型——只有一个具有一些堆操作功能的模块。该模块名为heapq(q代表队列),包含六个函数(见表 10-5 ),其中前四个与堆操作直接相关。您必须使用列表作为堆对象本身。

表 10-5。

Some Important Functions in the fileinput Module

| 功能 | 描述 | | --- | --- | | `heappush(heap, x)` | 将`x`推到堆上 | | `heappop(heap)` | 弹出堆中最小的元素 | | `heapify(heap)` | 在任意列表上强制执行`heap`属性 | | `heapreplace(heap, x)` | 弹出最小的元素并推动 | | `x nlargest(n, iter)` | 返回`iter`的`n`个最大元素 | | `nsmallest(n, iter)` | 返回`iter`的`n`个最小元素 |

heappush函数用于向堆中添加一个项目。注意,您不应该在任何旧的列表中使用它——只能在通过使用各种堆函数构建的列表中使用。这样做的原因是元素的顺序很重要(即使它看起来有点杂乱无章;元素没有完全排序)。

>>> from heapq import *
>>> from random import shuffle
>>> data = list(range(10))
>>> shuffle(data)
>>> heap = []
>>> for n in data:
...     heappush(heap, n)
...
>>> heap
[0, 1, 3, 6, 2, 8, 4, 7, 9, 5]
>>> heappush(heap, 0.5)
>>> heap
[0, 0.5, 3, 6, 1, 8, 4, 7, 9, 5, 2]

元素的顺序并不像看起来那样随意。它们没有严格的排序顺序,但是有一个保证:位置i的元素总是大于位置i // 2的元素(或者相反,它小于位置2 * i2 * i + 1的元素)。这是底层堆算法的基础。这称为堆属性。

heappop函数弹出最小的元素,该元素总是在索引 0 处找到,并确保剩余元素中最小的元素接管这个位置(同时保留堆属性)。尽管弹出列表的第一个元素通常效率不是很高,但这在这里不是问题,因为heappop在幕后做了一些漂亮的洗牌。

>>> heappop(heap)
0
>>> heappop(heap)
0.5
>>> heappop(heap)
1
>>> heap
[2, 5, 3, 6, 9, 8, 4, 7]

heapify函数接受一个任意的列表,并通过最少的洗牌使其成为合法的堆(也就是说,它施加了堆属性)。如果你没有用heappush从头开始构建你的堆,这是在开始使用heappushheappop之前要使用的函数。

>>> heap = [5, 8, 0, 3, 6, 7, 9, 1, 4, 2]
>>> heapify(heap)
>>> heap
[0, 1, 5, 3, 2, 7, 9, 8, 4, 6]

heapreplace函数不像其他函数那样常用。它从堆中取出最小的元素,然后将一个新元素放入其中。这比一个heappop后面跟着一个heappush要高效一点。

>>> heapreplace(heap, 0.5)
0
>>> heap
[0.5, 1, 5, 3, 2, 7, 9, 8, 4, 6]
>>> heapreplace(heap, 10)
0.5
>>> heap
[1, 2, 5, 3, 6, 7, 9, 8, 4, 10]

heapq模块剩下的两个函数,nlargest(n, iter)nsmallest(n, iter),分别用于查找任何可迭代对象itern最大或最小元素。您可以通过使用排序(例如,使用sorted函数)和切片来做到这一点,但是堆算法更快,更节省内存(更不用说,更容易使用)。

德克(和其他收藏品)

当您需要按照添加元素的顺序删除元素时,双端队列会很有用。在collections模块中可以找到 deque 类型以及其他几种集合类型。

deque 是从一个 iterable 对象(就像集合一样)创建的,有几个有用的方法。

>>> from collections import deque
>>> q = deque(range(5))
>>> q.append(5)
>>> q.appendleft(6)
>>> q
deque([6, 0, 1, 2, 3, 4, 5])
>>> q.pop()
5
>>> q.popleft()
6
>>> q.rotate(3)
>>> q
deque([2, 3, 4, 0, 1])
>>> q.rotate(-1)
>>> q
deque([3, 4, 0, 1, 2])

deque 很有用,因为它允许在开始时(向左)有效地追加和弹出,这是列表所不能做到的。作为一个不错的副作用,您还可以有效地旋转元素(也就是说,将它们向左或向右移动,环绕两端)。Deque 对象也有extendextendleft方法,extend的工作方式类似于相应的 list 方法,extendleft的工作方式类似于appendleft。注意在extendleft中使用的 iterable 对象中的元素将以相反的顺序出现在队列中。

时间

time模块包含获取当前时间、操作时间和日期、从字符串中读取日期以及将日期格式化为字符串等功能。日期可以表示为一个实数(从“纪元”中的 1 月 1 日 0 点开始的秒数,这是一个依赖于平台的年份;对于 UNIX,它是 1970)或包含九个整数的元组。这些整数在表 10-6 中解释。例如,元组

表 10-6。

The Fields of Python Date Tuples

| 索引 | 田 | 价值 | | --- | --- | --- | | Zero | 年 | 例如,2000 年、2001 年等等 | | one | 月 | 在 1-12 的范围内 | | Two | 一天 | 在 1–31 的范围内 | | three | 小时 | 在 0–23 的范围内 | | four | 分钟 | 在 0–59 的范围内 | | five | 第二 | 在 0–61 的范围内 | | six | 工作日 | 在 0 到 6 的范围内,其中星期一为 0 | | seven | 儒略日 | 在 1–366 的范围内 | | eight | 日光节约时间 | 0、1 或–1 |
(2008, 1, 21, 12, 2, 56, 0, 21, 0)

表示 2008 年 1 月 21 日 12:02:56,这是一个星期一,也是一年中的第 21 天(没有夏令时)。

秒的范围是 0–61,表示闰秒和双闰秒。夏令时数字是一个布尔值(真或假),但如果使用–1,mktime(一个将这样的元组转换为自纪元以来以秒为单位测量的时间戳的函数)可能会得到正确的结果。表 10-7 中描述了time模块中一些最重要的功能。

表 10-7。

Some Important Functions in the time Module

| 功能 | 描述 | | --- | --- | | `asctime([tuple])` | 将时间元组转换为字符串 | | `localtime([secs])` | 将秒转换为本地时间的日期元组 | | `mktime(tuple)` | 将时间元组调整为本地时间 | | `sleep(secs)` | 休眠`secs`秒(不做任何事情) | | `strptime(string[, format])` | 将字符串解析为时间元组 | | `time()` | 当前时间(自纪元以来的秒数,UTC) |

函数time.asctime将当前时间格式化为字符串,如下所示:

>>> time.asctime()
'Mon Jul 18 14:06:07 2016'

如果不想要当前时间,也可以提供一个日期元组(比如那些由localtime创建的元组)。(对于更详细的格式化,您可以使用标准文档中描述的strftime函数。)

函数time.localtime将一个实数(从 epoch 开始的秒数)转换为一个日期元组,即本地时间。如果你想要世界时,用gmtime代替。

函数time.mktime将日期元组转换为从 epoch 开始的时间(以秒为单位);是localtime的逆。

函数time.sleep使解释器等待给定的秒数。

函数time.strptime将由asctime返回的格式的字符串转换为日期元组。(可选格式参数遵循与strftime相同的规则;请参见标准文档。)

函数time.time返回当前(世界)时间,以秒为单位。尽管历元可能因平台而异,但是您可以通过保存事件(比如函数调用)前后的time结果,然后计算差值,来可靠地确定某件事情的时间。关于这些函数的例子,请参见下一节,这一节将介绍random模块。

表 10-7 中显示的功能只是从time模块中选择的功能。本模块中的大多数功能执行的任务与本节中描述的任务相似或相关。如果您需要这里描述的函数没有涵盖的内容,可以看看 Python 库参考中关于time模块的部分;很有可能你会找到你想要的东西。

此外,还有两个与时间相关的模块可用:datetime(支持日期和时间算法)和timeit(帮助您对代码片段计时)。你可以在 Python 库参考中找到更多关于这两者的信息,并且timeit也在第十六章中有简要讨论。

随意

random模块包含返回伪随机数的函数,这对模拟或任何生成随机输出的程序都很有用。请注意,尽管这些数字看起来完全是随机的,但它们背后有一个可预测的系统。如果你需要真正的随机性(例如,对于密码术或任何与安全相关的东西),你应该检查一下os模块的urandom函数。在random模块中的SystemRandom类基于同样的功能,给你接近真实随机性的数据。

该模块中的一些重要功能如表 10-8 所示。

表 10-8。

Some Important Functions in the random Module

| 功能 | 描述 | | --- | --- | | `random()` | 返回一个随机实数 n,使得 0 ≤ n ≤ 1 | | `getrandbits(n)` | 以长整数的形式返回 n 个随机位 | | `uniform(a, b)` | 返回一个随机实数 n,使得`a` ≤ n ≤ `b` | | `randrange([start], stop, [step])` | 从`range(start, stop, step)`返回一个随机数 | | `choice(seq)` | 从序列`seq`中返回一个随机元素 | | `shuffle(seq[, random])` | 将序列`seq`打乱到位 | | `sample(seq, n)` | 从序列`seq`中选择`n`个随机的、唯一的元素 |

函数random.random是最基本的随机函数之一;它只是返回一个伪随机数 n,使得 0 ≤ n ≤ 1。除非这正是您所需要的,否则您可能应该使用提供额外功能的其他函数之一。函数random.getrandbits以整数的形式返回给定数量的位(二进制数字)。

当提供两个数值参数ab时,函数random.uniform返回一个随机的(均匀分布的)实数 n,使得a n ≤ b。例如,如果你想要一个随机的角度,你可以使用uniform(0, 360)

函数random.randrange是标准函数,用于生成一个范围内的随机整数,该范围是通过使用相同的参数调用range得到的。例如,要获得 1 到 10(包括 1 和 10)范围内的随机数,您可以使用randrange(1, 11)(或者,randrange(10) + 1),如果您想要一个小于 20 的随机奇数正整数,您可以使用randrange(1, 20, 2)

函数random.choice从给定的序列中(一致地)选择一个随机元素。

函数random.shuffle随机打乱一个(可变的)序列的元素,这样每一个可能的排序都是一样的。

函数random.sample从给定的序列中(一致地)选择给定数量的元素,确保它们都是不同的。

Note

对于统计倾向,有类似于uniform的其他函数返回根据各种其他分布采样的随机数,如贝塔变量、指数、高斯和其他几种分布。

让我们看一些使用random模块的例子。在这些例子中,我使用了前面描述的time模块中的几个函数。首先,让我们得到代表时间间隔极限的实数(2016 年)。您可以通过将日期表示为时间元组(使用-1表示一周中的某一天、一年中的某一天和夏令时,让 Python 自己计算)并对这些元组调用mktime来实现这一点:

from random import *
from time import *
date1 = (2016, 1, 1, 0, 0, 0, -1, -1, -1)
time1 = mktime(date1)
date2 = (2017, 1, 1, 0, 0, 0, -1, -1, -1)
time2 = mktime(date2)

然后你在这个范围内统一生成一个随机数(不包括上限):

>>> random_time = uniform(time1, time2)

然后你只需将这个数字转换回一个清晰的日期。

>>> print(asctime(localtime(random_time)))
Tue Aug 16 10:11:04 2016

对于下一个例子,让我们问用户要扔多少个骰子,每个骰子应该有多少面。抛模机构由randrangefor回路实现。

from random import randrange
num   = int(input('How many dice? '))
sides = int(input('How many sides per die? '))
sum = 0
for i in range(num): sum += randrange(sides) + 1
print('The result is', sum)

如果您将它放在脚本文件中并运行它,您会得到如下所示的交互:

How many dice? 3
How many sides per die? 6
The result is 10

现在假设你做了一个文本文件,其中每行文本包含一笔财富。然后,您可以使用前面描述的fileinput模块将运气放入一个列表中,然后随机选择一个。

# fortune.py
import fileinput, random
fortunes = list(fileinput.input())
print random.choice(fortunes)

在 UNIX 或 macOS 中,您可以在标准字典文件/usr/share/dict/words上测试这个,以获得一个随机单词。

$ python fortune.py /usr/share/dict/words
dodge

作为最后一个例子,假设您希望您的程序在每次按键盘上的 Enter 键时向您发牌,一次一张。此外,你要确保你不会得到同一张卡不止一次。首先,你做一副“卡片”——一串字符串。

>>> values = list(range(1, 11)) + 'Jack Queen King'.split()
>>> suits = 'diamonds clubs hearts spades'.split()
>>> deck = ['{} of {}'.format(v, s) for v in values for s in suits]

我们刚刚创建的这副牌不太适合玩纸牌游戏。让我们先看一些卡片:

>>> from pprint import pprint
>>> pprint(deck[:12])
['1 of diamonds',
 '1 of clubs',
 '1 of hearts',
 '1 of spades',
 '2 of diamonds',
 '2 of clubs',
 '2 of hearts',
 '2 of spades',
 '3 of diamonds',
 '3 of clubs',
 '3 of hearts',
 '3 of spades']

有点太有秩序了,不是吗?这很容易解决。

>>> from random import shuffle
>>> shuffle(deck)
>>> pprint(deck[:12])
['3 of spades',
 '2 of diamonds',
 '5 of diamonds',
 '6 of spades',
 '8 of diamonds',
 '1 of clubs',
 '5 of hearts',
 'Queen of diamonds',
 'Queen of hearts',
 'King of hearts',
 'Jack of diamonds',
 'Queen of clubs']

注意,为了节省空间,我只在这里打印了前 12 张卡片。你可以自己随意看一下整副牌。

最后,为了让 Python 在每次按下键盘上的 Enter 键时给你发一张牌,直到没有更多的牌,你只需创建一个小的while循环。假设您将创建卡片组所需的代码放入一个程序文件中,您只需在末尾添加以下内容:

while deck: input(deck.pop())

注意,如果您在交互式解释器中尝试这个while循环,您将在每次按 Enter 时得到一个空字符串。这是因为input返回你写的东西(其实什么都不是)并且会被打印出来。在一个正常的程序中,这个来自input的返回值被简单地忽略。要交互地“忽略”它,只需将input的结果赋给某个你不会再看到的变量,并将其命名为类似于ignore的东西。

搁置和 json

在下一章中,您将学习如何在文件中存储数据,但是如果您想要一个真正简单的存储解决方案,shelve模块可以为您完成大部分工作。你只需要给它提供一个文件名。在shelve中唯一感兴趣的功能是open。当被调用时(用一个文件名),它返回一个Shelf对象,你可以用它来存储东西。只要把它当作普通的字典(除了键必须是字符串),当你完成时(并希望东西保存到磁盘),调用它的close方法。

潜在的陷阱

重要的是要认识到由shelve.open返回的对象不是一个普通的映射,如下例所示:

>>> import shelve
>>> s = shelve.open('test.dat')
>>> s['x'] = ['a', 'b', 'c']
>>> s['x'].append('d')
>>> s['x']
['a', 'b', 'c']

'd'去哪里了?

解释很简单:当你在一个shelf对象中查找一个元素时,这个对象是从它的存储版本中重建的;当你给一个键分配一个元素时,它被存储。上例中发生的情况如下:

  • 列表['a', 'b', 'c']存储在s中的关键字'x'下。
  • 检索存储的表示,从中构造一个新的列表,并将'd'附加到副本中。此修改版本未被存储!
  • 最后,再次检索原始文件—没有'd'

要正确修改使用shelve模块存储的对象,您必须将一个临时变量绑定到检索到的副本,然后在副本被修改后再次存储该副本 2 :

>>> temp = s['x']
>>> temp.append('d')
>>> s['x'] = temp
>>> s['x']
['a', 'b', 'c', 'd']

还有一种方法可以解决这个问题:将open函数的writeback参数设置为true。如果这样做,您从工具架读取或分配给工具架的所有数据结构都将保留在内存中(缓存),只有在您关闭工具架时才会写回磁盘。如果您没有处理大量数据,并且不想担心这些事情,将writeback设置为 true 可能是个好主意。完成后,你必须确保关上架子;一种方法是将工具架用作上下文管理器,就像打开文件一样,这将在下一章中解释。

一个简单的数据库示例

清单 10-8 展示了一个使用shelve模块的简单数据库应用。

# database.py
import sys, shelve

def store_person(db):
    """
    Query user for data and store it in the shelf object
    """
    pid = input('Enter unique ID number: ')
    person = {}
    person['name'] = input('Enter name: ')
    person['age'] = input('Enter age: ')
    person['phone'] = input('Enter phone number: ')
    db[pid] = person

def lookup_person(db):
    """
    Query user for ID and desired field, and fetch the corresponding data from     the shelf object
    """
    pid = input('Enter ID number: ')
    field = input('What would you like to know? (name, age, phone) ')
    field = field.strip().lower()

    print(field.capitalize() + ':', db[pid][field])

def print_help():
    print('The available commands are:')
    print('store  : Stores information about a person')
    print('lookup : Looks up a person from ID number')
    print('quit   : Save changes and exit')
    print('?      : Prints this message')

def enter_command():
    cmd = input('Enter command (? for help): ')
    cmd = cmd.strip().lower()
    return cmd

def main():
    database = shelve.open('C:\\database.dat') # You may want to change this name
    try:
        while True:
            cmd = enter_command()
            if  cmd == 'store':
                store_person(database)
            elif cmd == 'lookup':
                lookup_person(database)
            elif cmd == '?':
                print_help()
            elif cmd == 'quit':
                return
    finally:
        database.close()

if name == '__main__': main()

Listing 10-8.A Simple Database Application

清单 10-8 中显示的程序有几个有趣的特性:

  • 一切都包装在函数中,使程序更加结构化。(一个可能的改进是将这些函数组合成一个类的方法。)
  • 主程序在main函数中,只有在__name__ == '__main__'时才会被调用。这意味着你可以将它作为一个模块导入,然后从另一个程序中调用main函数。
  • 我在main函数中打开一个数据库(shelf ),然后将它作为参数传递给需要它的其他函数。我也可以使用全局变量,因为这个程序很小,但是在大多数情况下最好避免使用全局变量,除非你有理由使用它们。
  • 在读入一些值后,我通过对它们调用striplower来修改版本,因为如果提供的键要与数据库中存储的键匹配,这两个键必须完全相同。如果你总是在用户输入的内容上使用striplower,你可以允许他们随意使用大写或小写字母以及额外的空格。另外,请注意,我在打印字段名称时使用了capitalize
  • 我已经使用了tryfinally来确保数据库正确关闭。您永远不知道什么时候可能会出错(并且您会得到一个异常),如果程序在没有正确关闭数据库的情况下终止,您可能会得到一个损坏的数据库文件,该文件实际上是无用的。通过使用tryfinally,你可以避免这种情况。我也可以使用书架作为上下文管理器,正如在第十一章中解释的那样。

所以,让我们把这个数据库拿出来兜一圈。下面是一个交互示例:

Enter command (? for help): ?
The available commands are:
store  : Stores information about a person
lookup : Looks up a person from ID number
quit   : Save changes and exit
?      : Prints this message
Enter command (? for help): store
Enter unique ID number: 001
Enter name: Mr. Gumby
Enter age: 42
Enter phone number: 555-1234
Enter command (? for help): lookup
Enter ID number: 001
What would you like to know? (name, age, phone) phone
Phone: 555-1234
Enter command (? for help): quit

这种互动并不十分有趣。我可以用一个普通的字典而不是shelf对象做完全相同的事情。但是现在我已经退出了这个程序,让我们看看当我重新启动它时会发生什么——也许是第二天?

Enter command (? for help): lookup
Enter ID number: 001
What would you like to know? (name, age, phone) name
Name: Mr. Gumby
Enter command (? for help): quit

如你所见,程序读入了我第一次创建的文件,Gumby 先生还在!

请随意试验这个程序,看看是否可以扩展它的功能并提高它的用户友好性。也许你能想出一个对你自己有用的版本?

Tip

如果您希望以一种其他语言编写的程序可以轻松阅读的形式保存数据,您可能希望研究 JSON 格式。Python 标准库提供了json模块来处理 JSON 字符串,在它们和 Python 值之间进行转换。

有些人在遇到问题时会想:“我知道,我会使用正则表达式。”现在他们有两个问题。——杰米·扎温斯基

re模块包含对正则表达式的支持。如果你听说过正则表达式,你可能知道它们有多强大;如果你没有,准备好大吃一惊吧。

但是,您应该注意,一开始掌握正则表达式可能有点棘手。关键是一次了解一点点——只需查找特定任务所需的部件。事先把它都记住是没有意义的。本节描述了re模块和正则表达式的主要特性,使您能够开始使用。

Tip

除了标准文档,Andrew Kuchling 的“正则表达式如何”( https://docs.python.org/3/howto/regex.html )是关于 Python 中正则表达式的有用信息来源。

什么是正则表达式?

正则表达式(也称为 regex 或 regexp)是可以匹配一段文本的模式。正则表达式最简单的形式就是一个普通的字符串,它匹配自身。换句话说,正则表达式'python'匹配字符串'python'。您可以将这种匹配行为用于诸如搜索文本中的模式、用一些计算值替换某些模式或者将文本拆分成片段之类的事情。

通配符

正则表达式可以匹配多个字符串,您可以通过使用一些特殊字符来创建这样的模式。例如,句点字符(点)匹配任何字符(换行符除外),因此正则表达式'.ython'将匹配字符串'python'和字符串'jython'。它还会匹配诸如'qython''+ython'' ython'之类的字符串(其中第一个字母是单个空格),但不会匹配诸如'cpython''ython'之类的字符串,因为句点匹配单个字母,既不是两个也不是零。

因为它匹配“任何内容”(除换行符之外的任何单个字符),所以句点被称为通配符。

转义特殊字符

普通角色匹配自己,不匹配其他。然而,特殊字符是一个不同的故事。例如,假设您想要匹配字符串'python.org'。是不是简单的用模式'python.org'?你可以,但那也会匹配'pythonzorg',例如,你可能不想要的。(点匹配除换行符以外的任何字符,记得吗?)为了让一个特殊字符表现得像正常字符一样,你要对它进行转义,就像我在第一章中演示的如何对字符串中的引号进行转义一样。你在它前面放一个反斜杠。因此,在这个例子中,您将使用'python\\.org',它将匹配'python.org'而不是其他。

注意,要得到一个反斜杠,这是re模块所需要的,你需要在字符串中写两个反斜杠——以避开解释器。因此,这里有两个级别的转义:(1)从解释器和(2)从re模块。(实际上,在某些情况下,您可以使用单个反斜杠并让解释器自动为您转义,但不要依赖它。)如果你厌倦了对折反斜杠,就用一个原始字符串,比如r'python\.org'

字符集

匹配任何字符都是有用的,但有时您需要更多的控制。您可以通过将子字符串括在括号中来创建所谓的字符集。这样的字符集将匹配它包含的任何字符。例如,'[pj]ython'将同时匹配'python''jython',但不匹配其他任何内容。还可以使用范围,比如'[a-z]'匹配从 a 到 z(按字母顺序)的任何字符,并且可以通过一个接一个地放置来组合这样的范围,比如'[a-zA-Z0-9]'匹配大小写字母和数字。(请注意,字符集将只匹配一个这样的字符。)

要反转字符集,首先放置字符^,如在'[^abc]'中匹配除 a、b 或 c 之外的任何字符。

Special Characters in Character Sets

通常,如果您希望特殊字符(如点、星号和问号)在模式中显示为文字字符,而不是作为正则表达式操作符,则必须用反斜杠对它们进行转义。在字符集内部,转义这些字符通常是不必要的(尽管完全合法)。但是,您应该记住以下规则:

  • 如果脱字符(^)出现在字符集的开头,您确实需要对它进行转义,除非您希望它充当求反运算符。(换句话说,除非是真心的,否则不要放在开头。)
  • 同样,右括号(])和破折号(-)必须放在字符集的开头,或者用反斜杠转义。(其实破折号也可能放在最后,如果你愿意的话。)
替代和子模式

当您让每个字母独立变化时,字符集很好,但是如果您只想匹配字符串'python''perl'呢?您不能用字符集或通配符来指定这样一个特定的模式。取而代之的是,您可以使用特殊的替代字符:竖线字符(|)。所以,你的模式应该是'python|perl'

然而,有时您不想在整个模式上使用选择操作符——只是它的一部分。要做到这一点,您需要用括号将部件或子模式括起来。前面的例子可以重写为'p(ython|erl)'。(注意术语子模式也可以应用于单个字符。)

可选和重复的子模式

通过在子模式后添加一个问号,可以使它成为可选的。它可能出现在匹配的字符串中,但不是严格要求的。例如,这个(有点不可读)模式:

r'(http://)?(www\.)?python\.org'

将匹配以下所有字符串(除此之外):

'http://www.python.org'
'http://python.org'
'www.python.org'
'python.org'

这些东西在这里一文不值:

  • 我对点进行了转义,以防止它们充当通配符。
  • 我使用了一个原始字符串来减少反斜杠的数量。
  • 每个可选子模式都用括号括起来。
  • 可选的子模式可能会出现,也可能不会出现,彼此独立。

问号意味着子模式可以出现一次,也可以根本不出现。其他一些操作符允许多次重复一个子模式。

  • (pattern)* : pattern重复零次或多次。
  • (pattern)+ : pattern重复一次或多次。
  • (pattern){m,n} : patternmn重复多次。

所以,比如r'w*\.python\.org'' www.python.org '还要配'.python.org'``'ww.python.org'``'wwwwwww.python.org'。同样,r'w+\.python\.org'匹配'w.python.org'但不匹配'.python.org',r'w{3,4}\.python\.org'只匹配' www.python.org ''w www.python.org '

Note

这里不严格地使用术语“匹配”来表示模式匹配整个字符串。match函数(见表 10-9 )只要求模式匹配字符串的开头。

表 10-9。

Some Important Functions in the re Module

| 功能 | 描述 | | --- | --- | | `compile(pattern[, flags])` | 用正则表达式从字符串创建模式对象 | | `search(pattern, string[, flags])` | 在`string`中搜索`pattern` | | `match(pattern, string[, flags])` | 匹配`string`开头的`pattern` | | `split(pattern, string[, maxsplit=0])` | 按`pattern`的出现次数拆分一个`string` | | `findall(pattern, string)` | 返回`string`中`pattern`的所有出现的列表 | | `sub(pat, repl, string[, count=0])` | 用`repl`替换`string`中出现的`pat` | | `escape(string)` | 转义`string`中的所有特殊正则表达式字符 |
字符串的开头和结尾

到目前为止,您只查看匹配整个字符串的模式,但是您也可以尝试查找匹配该模式的子串,例如匹配模式'w+'的字符串' www.python.org '的子串'www'。当您搜索类似这样的子字符串时,有时将该子字符串锚定在整个字符串的开头或结尾会很有用。例如,您可能希望在字符串的开头匹配'ht+p',而不是在其他任何地方。然后用一个脱字符号('^')来标记开头。例如,'^ht+p'会匹配' http://python.org '(就此而言还有'htttttp://python.org'),但不会匹配' www.http.org '。类似地,字符串的结尾可以用美元符号($)来表示。

Note

有关正则表达式运算符的完整列表,请参见 Python 库中的“正则表达式语法”一节。

re 模块的内容

如果你不能用正则表达式做任何事情,那么知道如何写正则表达式就没什么用了。re模块包含几个使用正则表达式的有用函数。表 10-9 中描述了一些最重要的方法。

函数re.compile将一个正则表达式(写成字符串)转换成一个模式对象,这可以用于更有效的匹配。如果在调用searchmatch等函数时使用表示为字符串的正则表达式,无论如何都必须在内部将其转换为正则表达式对象。通过使用compile功能,这样做一次,每次使用该模式时就不再需要这个步骤。模式对象作为方法具有搜索/匹配功能,所以re.search(pat, string)(其中pat是写成字符串的正则表达式)等价于pat.search(string)(其中pat是用compile创建的模式对象)。编译后的正则表达式对象也可以用在普通的re函数中。

函数re.search搜索一个给定的字符串,找到匹配给定正则表达式的第一个子字符串(如果有的话)。如果找到一个,则返回一个MatchObject(评估为真);否则,返回None(评估为假)。由于返回值的性质,该函数可用于条件语句中,如下所示:

if re.search(pat, string):
    print('Found it!')

但是,如果您需要关于匹配子串的更多信息,您可以检查返回的MatchObject。(您将在下一节了解更多关于MatchObject的信息。)

函数re.match试图匹配给定字符串开头的正则表达式。所以re.match('p', 'python')返回真(一个匹配对象),而re.match('p', ' www.python.org ')返回假(None)。

Note

如果模式与字符串的beginning匹配,match函数将报告匹配;不要求模式匹配整个字符串。如果你想这样做,你需要在你的模式后面加一个美元符号。美元符号将匹配字符串的结尾,从而“延长”匹配。

函数re.split根据模式的出现次数分割字符串。这类似于 string 方法split,除了您允许完整的正则表达式,而不仅仅是一个固定的分隔符字符串。例如,使用字符串方法split,您可以根据字符串', '的出现次数来拆分字符串,但是使用re.split,您可以根据空格字符和逗号的任意序列来拆分。

>>> some_text = 'alpha, beta,,,,gamma    delta'
>>> re.split('[, ]+', some_text)
['alpha', 'beta', 'gamma', 'delta']

Note

如果模式包含括号,则括号中的组分散在拆分的子字符串之间。例如,re.split('o(o)', 'foobar')将产生['f', 'o', 'bar']

从这个例子中可以看出,返回值是一个子字符串列表。maxsplit参数表示允许的最大分割数。

>>> re.split('[, ]+', some_text, maxsplit=2)
['alpha', 'beta', 'gamma    delta']
>>> re.split('[, ]+', some_text, maxsplit=1)
['alpha', 'beta,,,,gamma    delta']

函数re.findall返回给定模式的所有出现的列表。例如,要查找字符串中的所有单词,您可以执行以下操作:

>>> pat = '[a-zA-Z]+'
>>> text = '"Hm... Err -- are you sure?" he said, sounding insecure.'
>>> re.findall(pat, text)
['Hm', 'Err', 'are', 'you', 'sure', 'he', 'said', 'sounding', 'insecure']

或者你可以找到标点符号:

>>> pat = r'[.?\-",]+'
>>> re.findall(pat, text)
['"', '...', '--', '?"', ',', '.']

注意破折号(-)已经被转义,所以 Python 不会将其解释为字符范围的一部分(比如a-z)。

函数re.sub用于用给定的替换替换模式最左边的不重叠的出现。考虑以下示例:

>>> pat = '{name}'
>>> text = 'Dear {name}...'
>>> re.sub(pat, 'Mr. Gumby', text)
'Dear Mr. Gumby...'

有关如何更有效地使用该功能的信息,请参阅本章后面的“替换中的组号和功能”一节。

函数re.escape是一个实用函数,用于转义字符串中可能被解释为正则表达式操作符的所有字符。如果您有一个包含许多这些特殊字符的长字符串,并且您想要避免键入大量反斜杠,或者如果您从用户处获得一个字符串(例如,通过input函数)并且想要将它用作正则表达式的一部分,请使用此选项。下面是它如何工作的一个例子:

>>> re.escape('www.python.org')
'www\\.python\\.org'
>>> re.escape('But where is the ambiguity?')
'But\\ where\\ is\\ the\\ ambiguity\\?'

Note

在表 10-9 中,你会注意到一些函数有一个名为flags的可选参数。此参数可用于更改正则表达式的解释方式。有关这方面的更多信息,请参见 Python 库参考中关于re模块的部分。

匹配对象和组

当找到匹配时,试图将模式与字符串的一部分匹配的re函数都返回MatchObject对象。这些对象包含与模式匹配的子字符串的信息。它们还包含关于模式的哪些部分与子串的哪些部分匹配的信息。这些部分被称为组。

组只是一个被括在括号中的子模式。各组用左括号编号。零组是整个模式。所以,在这个模式中:

'There (was a (wee) (cooper)) who (lived in Fyfe)'

这些组如下:

0 There was a wee cooper who lived in Fyfe
1 was a wee cooper
2 wee
3 cooper
4 lived in Fyfe

通常,这些组包含特殊字符,如通配符或重复操作符,因此您可能有兴趣了解给定组匹配了什么。例如,在此模式中:

r'www\.(.+)\.com$'

组 0 将包含整个字符串,而组 1 将包含从'www.''.com'之间的所有内容。通过创建这样的模式,您可以提取字符串中您感兴趣的部分。

表 10-10 中描述了一些更重要的re匹配对象的方法。

表 10-10。

Some Important Methods of re Match Objects

| 方法 | 描述 | | --- | --- | | `group([group1, …])` | 检索给定子模式(组)的出现 | | `start([group])` | 返回给定组出现的起始位置 | | `end([group])` | 返回给定组出现的结束位置(独占限制,如在切片中) | | `span([group])` | 返回一个组的开始和结束位置 |

方法group返回模式中给定组匹配的(子)字符串。如果没有给出组号,则假定组为 0。如果只给定了一个组号(或者只使用默认值 0),则返回一个字符串。否则,返回对应于给定组号的字符串元组。

Note

除了整个比赛(第 0 组)之外,您只能有 99 个组,编号范围为 1-99。

方法start返回给定组出现的起始索引(默认为 0,整个模式)。

方法end类似于start,但是返回结束索引加 1。

方法span返回具有给定组的开始和结束索引的元组(start, end)(默认为 0,整个模式)。

以下示例演示了这些方法的工作原理:

>>> m = re.match(r'www\.(.*)\..{3}', 'www.python.org')
>>> m.group(1)
'python'
>>> m.start(1)
4
>>> m.end(1)
10
>>> m.span(1)
(4, 10)

替换中的组数和函数

在第一个使用re.sub的例子中,我简单地用另一个子串替换了一个子串——我可以很容易地用replace字符串方法完成这个任务(在第三章的“字符串方法”一节中有描述)。当然,正则表达式很有用,因为它允许您以更灵活的方式进行搜索,而且还允许您执行更强大的替换。

利用re.sub的最简单的方法是在替换字符串中使用组号。替换字符串中任何形式为'\\n'的转义序列都被模式中由组n匹配的字符串替换。例如,假设您想要将形式为'*something*'的单词替换为'<em>something</em>',其中前者是在纯文本文档(如电子邮件)中表达强调的正常方式,后者是相应的 HTML 代码(如在网页中使用的)。让我们首先构造正则表达式。

>>> emphasis_pattern = r'\*([^\*]+)\*'

注意正则表达式很容易变得难以阅读,所以使用有意义的变量名(可能还有一两个注释)是很重要的,如果有人(包括你!)将在某个时候查看代码。

Tip

让正则表达式更易读的一种方法是在re函数中使用VERBOSE标志。这允许你添加空白(空格字符、制表符、换行符等等)到你的模式中,这将被re忽略——除非你把它放在一个字符类中或者用反斜杠转义它。你也可以在这种冗长的正则表达式中加入注释。下面是一个等同于强调模式的模式对象,但是它使用了VERBOSE标志:

>>> emphasis_pattern = re.compile(r'''
... \*               # Beginning emphasis tag -- an asterisk
... (                # Begin group for capturing phrase
... [^\*]+           # Capture anything except asterisks
... )                # End group
... \*               # Ending emphasis tag
...            ''', re.VERBOSE)
...

现在我有了我的模式,我可以使用re.sub进行替换。

>>> re.sub(emphasis_pattern, r'<em>\1</em>', 'Hello, *world*!')
'Hello, <em>world</em>!'

如你所见,我已经成功地将文本从纯文本翻译成了 HTML。

但是,通过使用一个函数作为替换,您可以使您的替换更加强大。这个函数将被提供以MatchObject作为它唯一的参数,它返回的字符串将被用作替换。换句话说,您可以对匹配的子字符串做任何您想做的事情,并进行精心处理以生成它的替换。你会问,这种力量对你有什么用处?一旦您开始尝试正则表达式,您肯定会发现这种机制的无数用途。对于一个应用,请参阅本章后面的“样本模板系统”一节。

Greedy and Nongreedy Patterns

默认情况下,重复操作符是贪婪的,这意味着它们将尽可能地匹配。例如,假设我重写了强调程序以使用以下模式:

>>> emphasis_pattern = r'\*(.+)\*'

这匹配一个星号,后跟一个或多个字符,然后是另一个星号。听起来很完美,不是吗?但事实并非如此。

>>> re.sub(emphasis_pattern, r'<em>\1</em>', '*This* is *it*!')
'<em>This* is *it</em>!'

如您所见,该模式匹配了从第一个星号到最后一个星号的所有内容——包括中间的两个星号!这就是贪婪的含义:拿走你能拿走的一切。

在这种情况下,你显然不想要这种过分贪婪的行为。当您知道一个特定的字母是非法的时,前面文本中给出的解决方案(使用匹配除星号之外的任何字符的字符集)就可以了。但是让我们考虑另一种情况。如果你用形式'**something**'来表示强调会怎么样?现在,在强调短语中包含单个星号应该不成问题。但是如何避免太贪心呢?

实际上,这很简单——只需使用一个非 greedy 版本的重复操作符。所有的重复操作符都可以通过在它们后面加一个问号而变得不简洁。

>>> emphasis_pattern = r'\*\*(.+?)\*\*'
>>> re.sub(emphasis_pattern, r'<em>\1</em>', '**This** is **it**!')
'<em>This</em> is <em>it</em>!'

这里我使用了运算符+?而不是+,这意味着模式将匹配通配符的一次或多次出现,如前所述。但是,它将尽可能少地匹配,因为它现在不是 greedy。因此,它将只匹配到达下一个出现的'\*\*'所需的最小值,这是模式的结尾。如你所见,它工作得很好。

查找电子邮件的发件人

你曾经把电子邮件保存为文本文件吗?如果您看过,您可能已经看到它在顶部包含了许多基本上不可读的文本,类似于清单 10-9 中所示。

From foo@bar.baz Thu Dec 20 01:22:50 2008
Return-Path: <foo@bar.baz>
Received: from xyzzy42.bar.com (xyzzy.bar.baz [123.456.789.42])
        by frozz.bozz.floop (8.9.3/8.9.3) with ESMTP id BAA25436
        for <magnus@bozz.floop>; Thu, 20 Dec 2004 01:22:50 +0100 (MET)
Received: from [43.253.124.23] by bar.baz
        (InterMail vM.4.01.03.27 201-229-121-127-20010626) with ESMTP
        id <20041220002242.ADASD123.bar.baz@[43.253.124.23]>; Thu, 20 Dec 2004 00:22:42 +0000
User-Agent: Microsoft-Outlook-Express-Macintosh-Edition/5.02.2022
Date: Wed, 19 Dec 2008 17:22:42 -0700
Subject: Re: Spam
From: Foo Fie <foo@bar.baz>
To: Magnus Lie Hetland <magnus@bozz.floop>
CC: <Mr.Gumby@bar.baz>
Message-ID: <B8467D62.84F%foo@baz.com>
In-Reply-To: <20041219013308.A2655@bozz.floop> Mime- version: 1.0
Content-type: text/plain; charset="US-ASCII" Content-transfer-encoding: 7bit
Status: RO
Content-Length: 55
Lines: 6

So long, and thanks for all the spam!

Yours,
Foo Fie

Listing 10-9.A Set of (Fictitious) Email Headers

让我们试着找出这封邮件是谁发来的。如果您检查了文本,我相信您可以在这种情况下弄明白它(当然,特别是如果您查看邮件本身底部的签名)。但是你能看出一个大致的模式吗?如果没有电子邮件地址,如何提取发件人的姓名?或者你怎么能列出标题中提到的所有电子邮件地址呢?让我们先处理前一个任务。

包含发件人的行以字符串'From: '开始,以用尖括号(<>)括起来的电子邮件地址结束。您希望在这些括号之间找到文本。如果你使用fileinput模块,这应该是一个简单的任务。清单 10-10 给出了解决问题的程序。

Note

如果您愿意,可以不使用正则表达式来解决这个问题。您也可以使用email模块。

# find_sender.py
import fileinput, re
pat = re.compile('From: (.*) <.*?>$')
for line in fileinput.input():
    m = pat.match(line)
    if m: print(m.group(1))
Listing 10-10.A Program for Finding the Sender of an Email

然后,您可以像这样运行程序(假设电子邮件在文本文件message.eml中):

$ python find_sender.py message.eml
Foo Fie

关于该程序,您应该注意以下几点:

  • 我编译正则表达式以使处理更有效。
  • 我将想要提取的子模式放在括号中,使它成为一个组。
  • 我使用了一个非 greedy 模式,所以电子邮件地址只匹配最后一对尖括号(以防名称包含一些括号)。
  • 我使用一个美元符号来表示我希望模式匹配整行,一直到最后。
  • 在我尝试提取特定组的匹配之前,我使用一个if语句来确保我确实匹配了一些东西。

要列出标题中提到的所有电子邮件地址,您需要构造一个正则表达式,该表达式只匹配一个电子邮件地址。然后,您可以使用方法findall在每一行中查找所有出现的内容。为了避免重复,您将地址保存在一个集合中(在本章前面有所描述)。最后,提取密钥,对它们进行排序,并打印出来。

import fileinput, re
pat = re.compile(r'[a-z\-\.]+@[a-z\-\.]+', re.IGNORECASE)
addresses = set()

for line in fileinput.input():
    for address in pat.findall(line):
        addresses.add(address)
for address in sorted(addresses):
    print address

运行该程序时的结果输出(以清单 10-9 中的电子邮件消息作为输入)如下:

Mr.Gumby@bar.baz
foo@bar.baz
foo@baz.com
magnus@bozz.floop

注意,排序时,大写字母排在小写字母之前。

Note

我没有严格遵守这里的问题规范。问题是找到文件头中的地址,但在这种情况下,程序会找到整个文件中的所有地址。为了避免这种情况,如果发现空行,可以调用fileinput.close(),因为标题不能包含空行。或者,如果有多个文件,您可以使用fileinput.nextfile()开始处理下一个文件。

一个样本模板系统

模板是一个文件,您可以将特定的值放入其中,以获得某种完整的文本。例如,您可能有一个只需要插入收件人姓名的邮件模板。Python 已经有了一个高级的模板机制:字符串格式化。然而,使用正则表达式,您可以使系统更加高级。假设您想用 Python 中的表达式对something求值的结果替换所有出现的'[something]'(“字段”)。因此,这个字符串:

'The sum of 7 and 9 is [7 + 9].'

应该翻译成这样:

'The sum of 7 and 9 is 16.'

此外,您希望能够在这些字段中执行赋值,以便该字符串:

'[name="Mr. Gumby"]Hello, [name]'

应该翻译成这样:

'Hello, Mr. Gumby'

这听起来可能是一项复杂的任务,但是让我们回顾一下可用的工具。

  • 您可以使用正则表达式来匹配字段并提取它们的内容。
  • 您可以用eval评估表达式字符串,提供包含范围的字典。你可以在一个try / except语句中做到这一点。如果出现了SyntaxError,你可能有一个陈述(比如一个任务)要做,应该用exec来代替。
  • 您可以用exec执行赋值字符串(和其他语句),将模板的作用域存储在一个字典中。
  • 您可以使用re.sub将评估结果代入正在处理的字符串。突然,它看起来不那么令人生畏了,不是吗?

Tip

如果一项任务看起来令人生畏,把它分成小块总是有帮助的。此外,盘点一下你手头的工具,找出解决问题的方法。

参见清单 10-11 中的示例实现。

# templates.py

import fileinput, re

# Matches fields enclosed in square brackets:
field_pat = re.compile(r'\[(.+?)\]')

# We'll collect variables in this:
scope = {}

# This is used in re.sub:
def replacement(match):
    code = match.group(1)
    try:
        # If the field can be evaluated, return it:
        return str(eval(code, scope))
    except SyntaxError:
        # Otherwise, execute the assignment in the same scope ... exec code in scope
        # ... and return an empty string:
        return ''

# Get all the text as a single string:

# (There are other ways of doing this; see Chapter 11)
lines = []
for line in fileinput.input():
    lines.append(line)
text = ''.join(lines)

# Substitute all the occurrences of the field pattern:
print(field_pat.sub(replacement, text))

Listing 10-11.A Template System

简而言之,该程序执行以下操作:

  • 定义匹配字段的模式。
  • 创建一个字典作为模板的作用域。
  • 定义执行以下操作的替换函数:
    • 从比赛中抓取组 1 并将其放入code
    • 尝试使用作用域字典作为名称空间来评估code,将结果转换为字符串,并返回它。如果成功,那么这个字段就是一个表达式,一切正常。否则(即引发一个SyntaxError),进入下一步。
    • 执行用于计算表达式的同一命名空间(作用域字典)中的字段,然后返回一个空字符串(因为赋值不计算任何值)。
  • 使用fileinput读取所有可用的行,将它们放在一个列表中,并将其连接成一个大字符串。
  • 使用re.sub中的替换功能替换所有出现的field_pat,并打印结果。

Note

在 Python 的早期版本中,将这些行放入一个列表中,然后在末尾将它们连接起来,比这样做要高效得多:

text = ''
for line in fileinput.input():
    text += line

虽然这看起来很优雅,但是每个赋值都必须创建一个新的字符串,也就是旧的字符串加上新的字符串,这会导致资源的浪费并使你的程序变慢。在 Python 的旧版本中,这和使用join之间的差别可能是巨大的。在更新的版本中,使用+=操作符实际上可能会更快。如果性能对您很重要,您可以尝试这两种解决方案。如果你想要一种更优雅的方式来阅读一个文件的所有文本,看看第十一章。

所以,我们只用了 15 行代码就创建了一个非常强大的模板系统(不包括空格和注释)。我希望当您使用标准库时,您开始看到 Python 变得多么强大。让我们通过测试模板系统来结束这个例子。尝试在清单 10-12 所示的简单文件上运行它。

[x = 2]
[y = 3]
The sum of [x] and [y] is [x + y].
Listing 10-12.A Simple Template Example

您应该看到这个:

The sum of 2 and 3 is 5.

但是等等,还有更好的!因为我用过fileinput,所以可以依次处理几个文件。这意味着我可以用一个文件定义一些变量的值,然后用另一个文件作为插入这些值的模板。例如,我可能有一个定义如清单 10-13 所示的文件,名为magnus.txt,还有一个模板文件如清单 10-14 所示,名为template.txt

[name       = 'Magnus Lie Hetland' ]
[email      = 'magnus@foo.bar' ]
[language   = 'python' ]
Listing 10-13.Some Template Definitions

[import time]
Dear [name],

I would like to learn how to program. I hear you
use the [language] language a lot -- is it something I
should consider?

And, by the way, is [email] your correct email address?

Fooville, [time.asctime()]

Oscar Frozzbozz

Listing 10-14.A Template

import time语句不是赋值语句(这是我要处理的语句类型),但是因为我不挑剔,只使用简单的try / except语句,所以我的程序支持任何使用evalexec的语句或表达式。您可以像这样运行程序(假设有一个 UNIX 命令行):

$ python templates.py magnus.txt template.txt

您应该会得到类似如下的输出:

Dear Magnus Lie Hetland,

I would like to learn how to program. I hear you use the python language a lot -- is it something I
should consider?

And, by the way, is magnus@foo.bar your correct email address?

Fooville, Mon Jul 18 15:24:10 2016

Oscar Frozzbozz

尽管这个模板系统能够进行一些非常强大的替换,但它仍然有一些缺陷。例如,如果能以更灵活的方式编写定义文件就好了。如果用execfile执行,您可以简单地使用普通的 Python 语法。这也将解决在输出顶部出现所有空行的问题。

你能想出改进这个项目的其他方法吗?你能想出这个程序中使用的概念的其他用途吗?真正精通任何一种编程语言的最好方法就是尝试——测试它的局限性并发现它的优势。看看你是否能重写这个程序,使它更好地工作,满足你的需要。

其他有趣的标准模块

尽管这一章已经涵盖了很多内容,但我仅仅触及了标准库的皮毛。为了吸引您的兴趣,我将快速介绍几个更酷的库。

  • 在 UNIX 中,命令行程序通常使用各种选项或开关来运行。(Python 解释器就是一个典型的例子。)这些都会在sys.argv中找到,但是自己正确处理这些远非易事。argparse模块使得提供完整的命令行界面变得简单明了。
  • 这个模块使你能够编写一个命令行解释器,有点像 Python 交互式解释器。您可以定义自己的命令,用户可以在提示符下执行这些命令。也许你可以用它作为你某个程序的用户界面?
  • CSV 是逗号分隔值的缩写,这是许多应用(例如,许多电子表格和数据库程序)用来存储表格数据的一种简单格式。它主要用于不同程序之间的数据交换。csv模块让您可以轻松地读写 CSV 文件,并且它非常透明地处理格式中一些比较棘手的部分。
  • datetime:如果time模块不足以满足你的时间追踪需求,那么datetime也很有可能。它支持特殊的日期和时间对象,并允许您以各种方式构建和组合这些对象。该界面在许多方面比time模块更加直观。
  • 这个库可以让你计算两个序列有多相似。它还使您能够(从可能性列表中)找到与您提供的原始序列“最相似”的序列。例如,difflib可以用来创建一个简单的搜索程序。
  • 枚举类型是一种具有固定的、少量可能值的类型。许多语言都内置了这样的类型,但是如果您在 Python 中需要这样的类型,enum模块是您的好朋友。
  • 在这里,您可以找到这样的功能,它允许您只使用一个函数的一些参数(部分求值),稍后再填充其余的参数。在 Python 3.0 中,这是你可以找到filterreduce.hashlib的地方。有了这个模块,你可以从字符串中计算出小的“签名”(数字)。如果你计算两个不同字符串的签名,你几乎可以肯定这两个签名是不同的。您可以在大型文本文件中使用它。这些模块在加密和安全方面有多种用途。 3
  • 在这里,你有很多工具来创建和组合迭代器(或者其他可迭代的对象)。有链接 iterable 的函数,有创建永远返回连续整数的迭代器的函数(类似于range,但是没有上限),有重复遍历 iterable 的函数,还有其他有用的东西。
  • logging:简单地使用print语句来弄清楚你的程序中发生了什么是很有用的。如果您想在没有大量调试输出的情况下跟踪事情,您可以将这些信息写入日志文件。该模块为您提供了一套标准的工具,用于管理一个或多个中央日志,其中包括日志消息的多个优先级。
  • statistics:计算一组数字的平均值并不难,但是即使是偶数个元素,也要得到正确的中值,例如,实现总体和样本标准偏差的差异,就需要多一点小心。与其自己动手,不如使用statistics模块!timeitprofiletrace:timeit模块(及其附带的命令行脚本)是一个测量一段代码运行时间的工具。它有一些锦囊妙计,你可能应该使用它而不是time模块进行性能测量。profile模块(以及它的同伴模块pstats)可以用来对一段代码的效率进行更全面的分析。trace模块(和程序)可以给你一个覆盖率分析(也就是说,你的代码的哪些部分被执行了,哪些没有被执行)。例如,这在编写测试代码时会很有用。

快速总结

在本章中,你学习了模块:如何创建它们,如何探索它们,以及如何使用标准 Python 库中的一些模块。

  • 模块:模块基本上是一个子程序,其主要功能是定义事物,如函数、类和变量。如果一个模块包含任何测试代码,它应该放在一个if语句中,该语句检查name == '__main__'是否。如果模块在PYTHONPATH中,它们可以被导入。您用语句import foo导入存储在文件foo.py中的模块。
  • 包:包只是一个包含其他模块的模块。包被实现为包含名为__init__.py的文件的目录。
  • 探索模块:将模块导入交互式解释器后,可以用多种方式探索它。其中包括使用dir,检查__all__变量,以及使用help函数。文档和源代码也是信息和洞察力的极好来源。
  • 标准库:Python 附带了几个模块,统称为标准库。本章回顾了其中一些:
    • sys:一个模块,可以让你访问与 Python 解释器紧密相连的几个变量和函数。
    • os:一个模块,可以让你访问与操作系统紧密相连的几个变量和函数。
    • fileinput:一个模块,它使得在几个文件或流的行上迭代变得容易。
    • setsheapqdeque:三个模块,提供三种有用的数据结构。还有内置类型set的套装。
    • time:获取当前时间、操作和格式化时间和日期的模块。
    • 一个模块,具有生成随机数,从序列中选择随机元素,以及混洗列表元素的功能。
    • shelve:创建持久映射的模块,用给定的文件名将其内容存储在数据库中。
    • re:支持正则表达式的模块。

如果您想了解更多关于模块的信息,我再次建议您浏览 Python 库参考。读起来真的很有趣。

本章的新功能

| 功能 | 描述 | | --- | --- | | `dir(obj)` | 返回按字母顺序排列的属性名列表 | | `help([obj])` | 提供交互式帮助或关于特定对象的帮助 | | `imp.reload(module)` | 返回已经导入的模块的重新加载版本 |

现在怎么办?

如果您已经掌握了本章中的至少一些概念,那么您的 Python 能力可能已经向前迈进了一大步。有了唾手可得的标准库,Python 从强大变得极其强大。用你目前所学的知识,你可以编写程序来解决各种各样的问题。在下一章中,你将学到更多关于使用 Python 与外部世界的文件和网络进行交互,从而解决更大范围的问题。

Footnotes 1

如果模块是用 C 语言编写的,C 源代码应该是可用的。

  2

感谢路德·比利塞特指出了这一点。

  3

另请参见md5sha模块。