Python 入门指南:从新手到大师(六)
十六、测试,123
你怎么知道你的程序有效呢?你能一直依靠自己写出毫无瑕疵的代码吗?无意冒犯,我想这不太可能。当然,大多数时候用 Python 写正确的代码是很容易的,但是你的代码可能会有错误。
对于程序员来说,调试是生活的一部分,是编程工艺不可或缺的一部分。然而,开始调试的唯一方法是运行您的程序。正确仅仅运行你的程序可能还不够。例如,如果你写了一个以某种方式处理文件的程序,你将需要一些文件来运行它。或者,如果您已经编写了一个带有数学函数的实用程序库,您将需要为这些函数提供参数,以便运行您的代码。
程序员一直在做这种事情。在编译语言中,循环类似于“编辑、编译、运行”,周而复始。在某些情况下,甚至让程序编译都可能是一个问题,所以程序员只需在编辑和编译之间切换。在 Python 中,没有编译步骤——只需编辑和运行。运行你的程序是测试的全部。
在这一章中,我将讨论测试的基础。我给你一些关于如何让测试成为你的编程习惯之一的笔记,并向你展示一些编写测试的有用工具。除了标准库的测试和分析工具,我还向您展示了如何使用代码分析器 PyChecker 和 PyLint。
有关编程实践和理念的更多信息,请参见第十九章。在那里,我还提到了日志记录,这与测试有些关系。
先测试,后编码
为了计划变更和灵活性(如果您的代码甚至在您自己的开发过程结束时仍然存在,这是至关重要的),为程序的各个部分设置测试(所谓的单元测试)是很重要的。这也是设计应用的一个非常实用的部分。与直观的“一点点编码,一点点测试”实践不同,极限编程人群引入了非常有用但有点违反直觉的格言“一点点测试,一点点编码”
换句话说,先测试,后编码。这也被称为测试驱动编程。虽然这种方法一开始可能不熟悉,但它有很多优点,而且随着时间的推移,你会越来越喜欢它。最终,一旦你使用了测试驱动编程一段时间,在没有测试的情况下编写代码可能看起来真的很落后。
精确的需求规格
当开发一个软件时,你必须首先知道软件应该解决什么问题——它应该满足什么目标。你可以通过写一个需求规格说明,一个描述程序必须满足的需求的文档(或者只是一些快速注释)来阐明你的程序目标。然后很容易在以后的某个时间检查需求是否确实被满足。但是许多程序员不喜欢写报告,通常更喜欢让他们的计算机尽可能多地做他们的工作。这里有一个好消息:您可以用 Python 指定需求,并让解释器检查它们是否得到满足!
Note
有许多类型的需求,包括客户满意度这样的模糊概念。在这一节中,我将重点放在功能需求上——也就是说,程序的功能需要什么。
这个想法是从编写一个测试程序开始,然后编写一个通过测试的程序。测试程序是你的需求规格,帮助你在开发程序时坚持那些需求。
我们举一个简单的例子。假设您想编写一个模块,该模块具有一个计算给定高度和宽度的矩形面积的函数。在你开始编码之前,你写一个单元测试,用一些你知道答案的例子。您的测试程序可能看起来如清单 16-1 所示。
from area import rect_area
height = 3
width = 4
correct_answer = 12
answer = rect_area(height, width)
if answer == correct_answer:
print('Test passed ')
else:
print('Test failed ')
Listing 16-1.A Simple Test Program
在这个例子中,我对高度 3 和宽度 4 调用函数rect_area(我还没写)并将答案与正确答案进行比较,正确答案是 12。1
如果您随后不小心如下实现了rect_area(在文件area.py中)并试图运行测试程序,您将会得到一个错误消息。
def rect_area(height, width):
return height * height # This is wrong ...
然后,您可以检查代码,看看哪里出错了,并用height * width替换返回的表达式。
在你写代码之前写一个测试不仅仅是为了找到 bug——也是为了看看你的代码是否工作。这有点像古老的禅宗公案:“如果没有人听到,森林中倒下的一棵树会发出声音吗?”嗯,当然有(抱歉,禅僧),但是声音对你或者其他人没有任何影响。对于您的代码,问题是,“在您测试它之前,它实际上做什么了吗?”撇开哲学不谈,采取这样一种态度是有用的,即在你对一个特性进行测试之前,它并不真正存在(或者不是一个真正的特性)。然后你就可以清楚地证明它就在那里,并且正在做它应该做的事情。这不仅在最初开发程序时有用,而且在以后扩展和维护代码时也有用。
变革规划
除了在您编写程序时提供大量帮助之外,自动化测试还可以帮助您避免在引入变更时积累错误,这在您的程序变大时尤为重要。正如在第十九章中所讨论的,你应该准备好改变你的代码,而不是疯狂地抓住现有的不放,但是改变是有危险的。当您更改代码的某个部分时,您经常会引入一两个无法预见的错误。如果你已经很好地设计了你的程序(通过适当的抽象和封装),一个改变的影响应该是局部的,并且只影响一小部分代码。这意味着如果您发现了错误,调试会更容易。
Code Coverage
覆盖率的概念是测试知识的一个重要部分。当您运行您的测试时,您可能不会运行您代码的所有部分,即使那将是理想的情况。(实际上,最理想的情况是使用每一个可能的输入,遍历程序的每一个可能的状态,但是这真的不会发生。)一个好的测试套件的目标之一是获得良好的覆盖率,确保这一点的一种方法是使用覆盖率工具,它测量测试期间实际运行的代码的百分比。在撰写本文时,还没有真正标准化的 Python 覆盖工具,但是在网上搜索类似“test coverage python”的东西应该会出现一些选项。一个选项是 Python 发行版附带的程序trace.py。您可以在命令行上将它作为一个程序运行(可能使用-m开关,省去了您查找文件的麻烦),或者您可以将它作为一个模块导入。关于如何使用它的帮助,你可以用--help开关运行程序,或者导入模块并在解释器中执行help(trace)。
有时,您可能会被广泛测试所有内容的要求压垮。别担心——你不必测试输入和状态变量的数百种组合,至少一开始不用。测试驱动编程最重要的部分是,你实际上在编码时反复运行你的方法(或函数或脚本),以获得关于你做得如何的持续反馈。如果您想增加对代码正确性(以及覆盖率)的信心,您总是可以在以后添加更多的测试。
关键是,如果你手头没有一套完整的测试,你甚至可能直到后来才发现你引入了一个错误,那时你不再知道错误是如何引入的。如果没有一套好的测试,就很难准确找出问题所在。你不能逆来顺受,除非你看到他们来了。获得良好测试覆盖率的一个方法是遵循测试驱动编程的原则。如果在编写函数之前确保已经编写了测试,就可以确定每个函数都经过了测试。
测试的 1-2-3(和 4)
在我们进入编写测试的本质之前,这里有一个测试驱动开发过程的分解(或者至少是它的一个版本):
- 想出你想要的新功能。可能记录它,然后为它编写一个测试。
- 为该特性编写一些框架代码,这样您的程序运行时不会出现任何语法错误或类似错误,但您的测试仍然会失败。看到你的测试失败是很重要的,所以你肯定它确实会失败。如果测试有问题,并且无论如何它总是成功(这已经发生在我身上很多次了),你就没有真正测试任何东西。这一点值得重复:在你试图让测试成功之前,先看到它失败。
- 为你的骨架写伪代码,只是为了安抚测试。这不需要精确地实现功能;它只需要通过测试。这样,在开发的时候,你可以让你所有的测试都通过(除了第一次运行测试的时候,记得吗?),即使在最初实现功能时也是如此。
- 重写(或重构)代码,使它真正做它应该做的事情,同时确保你的测试一直成功。
当你离开时,你应该保持你的代码处于一个健康的状态——不要留下任何失败的测试(或者,就此而言,在你的伪代码还在的情况下成功)。他们是这么说的。我发现我有时会留下一个失败的测试,这是我目前工作的点,作为我自己的一种“待办事项”或“继续这里”。但是,如果你和其他人一起开发,这是非常不好的形式。您不应该将失败的代码签入公共代码库中。
测试工具
你可能认为编写大量的测试来确保程序的每个细节都正常工作听起来像是一件苦差事。好吧,我有好消息告诉你:标准库中有帮助(不是一直都有吗?).有两个出色的模块可以自动完成测试过程。
- 通用测试框架
- 一个更简单的模块,设计用于检查文档,但也非常适合编写单元测试
我们先来看看doctest,这是一个很好的起点。
doctest(测试)
在本书中,我使用了直接来自交互式解释器的例子。我发现这是一种展示事物如何工作的有效方式,当你有这样一个例子时,你很容易自己去测试它。事实上,交互式解释器会话是放入 docstrings 的一种有用的文档形式。例如,假设我编写了一个求数字平方的函数,并在它的 docstring 中添加了一个例子。
def square(x):
'''
Squares a number and returns the result.
>>> square(2)
4
>>> square(3)
9
'''
return x * x
如您所见,我也在 docstring 中包含了一些文本。这和测试有什么关系?假设square函数定义在模块my_math(即一个名为my_math.py的文件)中。然后,您可以在底部添加以下代码:
if name =='__main__':
import doctest, my_math
doctest.testmod(my_math)
这不是很多,是吗?您只需导入doctest和my_math模块本身,然后从doctest运行testmod(对于“测试模块”)函数。这是做什么的?让我们试试。
$ python my_math.py
$
似乎什么都没发生,但这是件好事。doctest.testmod函数读取一个模块的所有文档字符串,并从交互式解释器中找出任何看起来像例子的文本。然后它检查这个例子是否代表现实。
Note
如果我在这里编写一个真正的函数,我将(或者应该,根据我之前制定的规则)首先编写 docstring,用 doctest 运行脚本以查看测试是否失败,添加一个虚拟版本(例如使用if语句来处理 docstring 中的特定输入)以使测试成功,然后开始进行正确的实现。另一方面,如果您打算进行全面的“先测试,后编码”编程,那么unittest框架(稍后讨论)可能更适合您的需求。
为了获得更多的输入,您可以给脚本添加-v(表示“详细”)开关。
$ python my_math.py -v
该命令将产生以下输出:
Running my_math.__doc__
0 of 0 examples failed in my_math.__doc__
Running my_math.square.__doc__
Trying: square(2)
Expecting: 4
Ok
Trying: square(3)
Expecting: 9
ok
0 of 2 examples failed in my_math.square.__doc__
1 items had no tests:
test
1 items passed all tests:
2 tests in my_math.square
2 tests in 2 items.
2 passed and 0 failed.
Test passed.
如你所见,幕后发生了很多事情。testmod函数检查模块 docstring(如您所见,它不包含任何测试)和函数 docstring(它包含两个测试,两个测试都成功)。
有了测试,您就可以安全地更改代码了。假设您想使用 Python 取幂运算符而不是简单乘法,并使用x ** 2而不是x * x。你编辑了代码,但是不小心忘记输入数字 2,以x ** x结束。尝试一下,然后运行脚本来测试代码。会发生什么?这是您得到的输出:
*****************************************************************
Failure in example: square(3)
from line #5 of my_math.square
Expected: 9
Got: 27
*****************************************************************
1 items had failures:
1 of 2 in my_math.square
***Test Failed***
1 failures.
所以错误被发现了,你得到了一个非常清晰的错误描述。现在解决这个问题应该不难。
Caution
不要盲目相信自己的测试,一定要测试足够多的案例。正如你所看到的,使用square(2)的测试没有捕捉到错误,因为对于x == 2,x ** 2和x ** x是同一个东西!
要了解关于doctest模块的更多信息,您应该再次查阅库参考。
单元测试
虽然doctest非常容易使用,但是unittest(基于流行的测试框架 JUnit,for Java)更加灵活和强大。unittest可能比doctest有更陡峭的学习曲线,但是我建议你看一看这个模块,因为它允许你以更结构化的方式编写非常大和全面的测试集。
我将在这里给你一个简单的介绍。包含了一些您在大多数测试中可能不需要的特性。
Tip
标准库中单元测试工具的两个有趣的替代品是pytest ( pytest.org)和nose ( nose.readthedocs.io)。
同样,让我们看一个简单的例子。您将编写一个名为my_math的模块,其中包含一个名为product的计算乘积的函数。那么你从哪里开始呢?当然是通过测试(在一个名为test_my_math.py的文件中),使用来自unittest模块的TestCase类(参见清单 16-2 )。
import unittest, my_math
class ProductTestCase(unittest.TestCase):
def test_integers(self):
for x in range(-10, 10):
for y in range(-10, 10):
p = my_math.product(x, y)
self.assertEqual(p, x * y, 'Integer multiplication failed')
def test_floats(self):
for x in range(-10, 10):
for y in range(-10, 10):
x = x / 10
y = y / 10
p = my_math.product(x, y)
self.assertEqual(p, x * y, 'Float multiplication failed')
if __name__ == '__main__': unittest.main()
Listing 16-2.A Simple Test Using the unittest Framework
函数unittest.main负责为您运行测试。它将实例化TestCase的所有子类,并运行名称以test开头的所有方法。
Tip
如果您定义了名为setUp和tearDown的方法,它们将在每个测试方法之前和之后执行。您可以使用这些方法为所有的测试提供公共的初始化和清理代码,即所谓的测试夹具。
当然,运行这个测试脚本只会给出一个关于模块my_math不存在的异常。像assertEqual这样的方法检查一个条件,以确定给定的测试是成功还是失败。TestCase类还有许多其他类似的方法,比如assertTrue、assertIsNotNone和assertAlmostEqual。
unittest模块区分错误和失败,前者引发异常,后者由调用failUnless等导致。下一步是编写框架代码,这样我们就不会出错,只会失败。这仅仅意味着创建一个名为my_math的模块(即一个名为my_math.py的文件),包含以下内容:
def product(x, y):
pass
全是填充物,没意思。如果您现在运行测试,您应该会得到两条FAIL消息,如下所示:
FF
======================================================================
FAIL: test_floats (__main__.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_my_math.py", line 17, in testFloats
self.assertEqual(p, x * y, 'Float multiplication failed')
AssertionError: Float multiplication failed
======================================================================
FAIL: test_integers (__main__.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_my_math.py", line 9, in testIntegers
self.assertEqual(p, x * y, 'Integer multiplication failed')
AssertionError: Integer multiplication failed
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=2)
这都在意料之中,所以不要太担心。现在,至少,您知道测试确实与代码相关联——代码是错误的,测试失败了。太好了。
下一步是让它发挥作用。在这种情况下,当然没什么大不了的:
def product(x, y):
return x * y
现在输出简单如下:
..
----------------------------------------------------------------------
Ran 2 tests in 0.015s
OK
顶部的两个点是测试。如果您仔细观察失败版本的混杂输出,您也会在顶部看到两个字符:两个F表示两次失败。
只是为了好玩,改变product函数,使其对于特定的参数 7 和 9 失效。
def product(x, y):
if x == 7 and y == 9:
return 'An insidious bug has surfaced!'
else:
return x * y
如果您再次运行测试脚本,您应该会得到一个失败。
.F
======================================================================
FAIL: test_integers (__main__.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_my_math.py", line 9, in testIntegers
self.assertEqual(p, x * y, 'Integer multiplication failed')
AssertionError: Integer multiplication failed
----------------------------------------------------------------------
Ran 2 tests in 0.005s
FAILED (failures=1)
Tip
对于面向对象代码的更高级测试,请查看模块unittest.mock。
超越单元测试
测试显然是重要的,对于任何有点复杂的项目来说,它们是绝对重要的。即使你不想为单元测试的结构化套件而烦恼,你也必须有某种方法来运行你的程序,看看它是否有效。在您进行任何大量的编码之前拥有这种能力可以为您以后节省大量的工作(和痛苦)。
还有其他方法来预测你的程序,在这里我将向你展示几个工具来做这件事:源代码检查和分析。源代码检查是寻找代码中常见错误或问题的一种方式(有点像编译器对静态类型语言所做的,但远不止于此)。剖析是一种发现你的程序到底有多快的方法。我按照这个顺序来讨论这些话题,以此来尊重这条古老的好规则:“让它工作,让它更好,让它更快。”单元测试帮助它工作;源代码检查可以帮助它变得更好;最后,剖析有助于加快速度。
用 PyChecker 和 PyLint 检查源代码
在相当长的一段时间里,PyChecker ( pychecker.sf.net)是检查 Python 源代码的唯一工具,用来寻找错误,比如提供不能用于给定函数的参数等等。(好吧,在标准库中有tabnanny,但是它并不那么强大,因为它只是检查你的缩进。)随后出现了 PyLint ( pylint.org ),它支持 PyChecker 的大部分特性以及更多特性(比如你的变量名是否符合给定的命名约定,你是否遵守自己的编码标准,等等)。
安装工具很简单。它们都可以从几个包管理器系统(比如 Debian APT 和 Gentoo Portage)获得,也可以从它们各自的网站直接下载。您可以使用 Distutils 和标准命令进行安装。
python setup.py install
也可以使用pip安装 PyLint。
一旦完成,这些工具应该可以作为命令行脚本(分别用于 PyChecker 和 PyLint 的pychecker和pylint)和 Python 模块(具有相同的名称)使用。
Note
在 Windows 中,这两个工具使用批处理文件pychecker.bat和pylint.bat作为命令行工具。您可能需要将这些添加到 PATH 环境变量中,以便在命令行中使用pychecker和pylint命令。
要使用 PyChecker 检查文件,您可以使用文件名作为参数运行脚本,如下所示:
pychecker file1.py file2.py ...
使用 PyLint,您可以使用模块(或包)名称:
pylint module
您可以通过使用-h命令行开关运行这两个工具来获得更多信息。当您运行这些命令中的任何一个时,您可能会得到相当多的输出(很可能来自pylint的输出比来自pychecker的输出多)。这两个工具都可以根据您想要获得(或抑制)的警告进行配置;有关更多信息,请参见各自的文档。
在离开检查器之前,让我们看看如何将它们与单元测试结合起来。毕竟,让它们(或者只是其中的一个)作为测试在您的测试套件中自动运行,并且在没有任何问题的情况下静静地成功,这将是非常令人愉快的。那么你实际上可以有一个测试套件,不仅测试功能,也测试代码质量。
PyChecker 和 PyLint 都可以作为模块导入(分别是pychecker.checker和pylint.lint),但是它们并没有真正被设计成以编程方式使用。当你导入pychecker.checker时,它会检查后面的代码(包括导入的模块),将警告打印到标准输出。pylint.lint模块有一个名为Run的未记录的函数,它在pylint脚本本身中使用。这也打印出警告,而不是以某种方式返回它们。与其纠结于这些问题,我建议使用 PyChecker 和 PyLint 的本来用途:作为命令行工具。而 Python 中使用命令行工具的方式就是subprocess模块。清单 16-3 是早期测试脚本的一个例子,现在有两个代码检查测试。
import unittest, my_math
from subprocess import Popen, PIPE
class ProductTestCase(unittest.TestCase):
# Insert previous tests here
def test_with_PyChecker(self):
cmd = 'pychecker', '-Q', my_math.__file__.rstrip('c')
pychecker = Popen(cmd, stdout=PIPE, stderr=PIPE)
self.assertEqual(pychecker.stdout.read(), '')
def test_with_PyLint(self):
cmd = 'pylint', '-rn', 'my_math'
pylint = Popen(cmd, stdout=PIPE, stderr=PIPE)
self.assertEqual(pylint.stdout.read(), '')
if __name__ == '__main__': unittest.main()
Listing 16-3.Calling External Checkers Using the subprocess Module
我给了 checker 程序一些命令行开关,以避免干扰测试的无关输出。对于pychecker,我已经提供了-Q(静音)开关。对于pylint,我提供了-rn(其中n代表“否”)来关闭报告,这意味着它将只显示警告和错误。
pylint命令直接使用提供的模块名运行,因此非常简单。为了让pychecker正常工作,我们需要获得一个文件名。为了实现这一点,我使用了my_math模块的__file__属性,rstrip去除了可能在文件名末尾找到的任何c(因为该模块实际上可能来自于.pyc文件)。
为了安抚 PyLint(而不是将它配置为对短变量名、缺少的修订和文档字符串之类的事情闭口不谈),我稍微重写了my_math模块。
"""
A simple math module.
"""
__revision__ = '0.1'
def product(factor1, factor2):
'The product of two numbers'
return factor1 * factor2
如果您现在运行测试,您应该不会得到任何错误。试着摆弄一下代码,看看是否能让任何检查器在功能测试仍然工作的时候报告错误。(请随意删除 PyChecker 或 PyLint——一个可能就足够了。)比如试着把参数重新命名回x和y,PyLint 要抱怨变量名短。或者在return语句后添加print('Hello, world!'),两个检查者都会合理地抱怨(可能给出不同的抱怨理由)。
The Limits of Automatic Checking: Will it Ever End?
虽然像 PyChecker 或 PyLint 这样的自动检查器所能发现的东西令人惊讶,但是它们的能力是有限的。虽然他们能发现的错误和问题的广度令人印象深刻,但他们不知道你的程序最终要做什么;因此,总是需要定制的单元测试。但是除了这个明显的障碍,自动检查器还有其他限制。如果你喜欢稍微有点奇怪的理论,你可能会对计算理论世界的一个结果感兴趣,这个结果被称为停止定理。让我们考虑一个假设的检验程序,我们可以这样运行:
halts.py myprog.py data.txt
正如您可能猜到的,当对输入data.txt运行时,检查器应该检查myprog.py的行为。我们只想检查一件事:无限循环(或任何等效的东西)。换句话说,程序halts.py应该决定myprog.py在data.txt上运行时是否会停止。鉴于现有的 checker 程序可以分析代码,并确定各种变量必须是哪种类型才能工作,检测像无限循环这样简单的事情似乎轻而易举,对吗?抱歉,但是没有,至少一般情况下没有。
不要相信我的话,道理其实很简单。假设我们有一个正在工作的暂停检查器,并假设(为了简单起见)它是作为 Python 模块编写的。现在,让我们假设我们编写了下面这个小小的阴险程序,名为trouble.py。
import halts, sys
name = sys.argv[1]
if halts.check(name, name):
while True: pass
它使用halts模块的功能来检查一个作为第一个命令行参数给出的程序,如果把它自己作为输入来提供,它是否会停止。它可以这样运行,例如:
trouble.py myprog.py
这将决定如果以myprog.py(即其自身)作为输入,那么myprog.py是否会停止。如果确定它会停止运行,trouble.py将进入无限循环。否则,它将简单地结束(即,暂停)。
现在考虑以下场景:
halts.py trouble.py trouble.py
我们正在检查trouble.py是否会以trouble.py(也就是它自己)作为输入而停止。本身并不那么令人费解。但是结果会是什么呢?如果halts.py说“是”——也就是说trouble.py trouble.py将停止——那么trouble.py trouble.py被定义为不停止。如果我们得到一个“不”,我们就会遇到同样的(逆向)问题。无论哪种方式,halts.py都注定会出错,而且没有办法修复。我们以假设检查器实际工作开始这个故事,现在我们达到了一个矛盾,这意味着我们的假设是错误的。
当然,这并不意味着我们不能检测任何类型的无限循环。例如,看到一个没有break、raise或return的while True将是一个强有力的线索。只是一般情况下检测不出来。可悲的是,许多其他类似的属性在一般情况下也无法自动分析。因此,即使有 PyChecker 和 PyLint 这样漂亮的工具,我们也需要依靠根植于我们对程序特殊环境的了解的手工调试。或许,我们应该尽量避免故意编写像trouble.py这样复杂的程序。
压型
现在你已经让你的代码工作了,并且可能比最初的版本更好,是时候让它更快了。那么,同样,它可能不会。正如 Donald Knuth 引用 C. A. R. Hoare 的话说:“过早优化是编程中所有罪恶(或者至少是大部分罪恶)的根源。”如果你真的不需要,不要担心聪明的优化技巧。如果程序足够快,干净、简单、可理解的代码的价值可能比稍快的程序高得多。毕竟,再过几个月,更快的硬件可能就会出现。
但是如果您确实需要优化您的程序,因为它对于您的需求来说根本不够快,您绝对应该在做任何事情之前对它进行概要分析。这是因为很难猜测瓶颈在哪里,除非你的程序非常简单。如果你不知道是什么让你的程序变慢,很可能你优化的是错误的东西。
标准库包括一个名为profile的漂亮的剖析器模块,以及一个更快的 C 版本,名为cProfile。使用分析器很简单。只需用一个字符串参数调用它的run方法。
>>> import cProfile
>>> from my_math import product
>>> cProfile.run('product(1, 2)')
这将为您提供一份打印输出,其中包含各种函数和方法被调用的次数以及在各种函数中花费的时间。如果您提供一个文件名,例如,'my_math.profile',作为要运行的第二个参数,results将被保存到一个文件中。然后,您可以使用pstats模块来检查配置文件。
>>> import pstats
>>> p = pstats.Stats('my_math.profile')
使用这个Stats对象,您可以通过编程来检查结果。(有关 API 的详细信息,请参考标准库文档。)
Tip
标准库还包含一个名为timeit的模块,这是一种计时 Python 代码小片段的简单方法。timeit模块对于详细的概要分析来说并不真正有用,但是当您只想计算一段代码执行需要多少时间时,它会是一个很好的工具。自己尝试这样做经常会导致测量不准确(除非你知道自己在做什么)。使用timeit通常是更好的选择。
现在,如果你真的担心你的程序的速度,你可以添加一个单元测试来分析你的程序并强制执行某些约束(比如如果程序花费超过一秒钟就失败)。这可能是一件有趣的事情,但我不推荐这样做。过分的剖析很容易将你的注意力从真正重要的事情上转移开,比如干净、可理解的代码。如果程序真的很慢,你无论如何都会注意到,因为你的测试将永远无法完成。
快速总结
以下是本章涵盖的主要主题:
- 测试驱动编程:基本上,测试驱动编程意味着先测试,后编码。测试让您充满信心地重写代码,使您的开发和维护更加灵活。
- doctest 和 unittest 模块:如果您想用 Python 进行单元测试,这些是必不可少的工具。
doctest模块被设计用来检查文档串中的例子,但是也可以很容易地用来设计测试套件。为了让您的套件更加灵活和结构化,unittest框架非常有用。 - PyChecker 和 PyLint:这两个工具读取源代码并指出潜在的(和实际的)问题。他们检查从短变量名到不可及的代码片段的一切。通过一点编码,你可以使它们(或其中之一)成为你的测试套件的一部分,以确保你所有的重写和重构都符合你的编码标准。
- 剖析:如果你真的关心速度,并且想要优化你的程序(只有在绝对必要的情况下才这么做),你应该首先剖析它。使用
profile或cProfile模块找到代码中的瓶颈。
本章的新功能
| 功能 | 描述 | | --- | --- | | `doctest.testmod(module)` | 检查文档字符串示例。(需要更多的参数。) | | `unittest.main()` | 在当前模块中运行单元测试。 | | `profile.run(stmt[, filename])` | 执行并分析`statement`。可选地,将结果保存到`filename`。 |什么现在?
现在,您已经看到了使用 Python 语言和标准库可以做的各种事情。您已经看到了如何探测和调整您的代码,直到它尖叫起来(如果您认真对待剖析,不顾我的警告)。如果你仍然没有得到你需要的动力,是时候打开盖子,用一些低级工具调整引擎了。
Footnotes 1
当然,像这样只测试一种情况不会给你对代码正确性的信心。一个真正的测试程序可能会更彻底。
2
3.看看大卫·哈雷尔的《计算机有限公司:它们真的不能做什么》(牛津大学出版社,2000 年)中关于这个主题的许多有趣的材料。
十七、扩展 Python
你可以用 Python 实现任何东西,真的;这是一种强大的语言,但有时它会变得有点太慢。例如,如果您正在编写某种形式的核反应的科学模拟,或者您正在为下一部《星球大战》电影渲染图形,用 Python 编写高性能代码可能不是一个好的选择。Python 应该易于使用,并有助于加快开发速度。就效率而言,这种灵活性需要付出高昂的代价。对于大多数常见的编程任务来说,它当然足够快了,但如果您需要真正的速度,C、C++、Java 或 Julia 等语言通常可以超过它几个数量级。
两全其美
现在,我不想鼓励你们中的速度狂开始专门用 c 开发,虽然这可能会加速程序本身,但它肯定会减慢你的编程速度。所以你需要考虑什么是最重要的:快速完成程序或者最终(在遥远的将来)得到一个运行非常非常快的程序。如果 Python 足够快,所涉及的额外痛苦将使使用 C 之类的低级语言成为毫无意义的选择(除非你有其他需求,比如在没有 Python 空间的嵌入式设备上运行,或者类似的东西)。
这一章处理你确实需要额外速度的情况。那么最好的解决方案可能不是完全转向 C 语言(或者其他一些低级或中级语言);相反,我推荐下面的方法,这种方法已经在很多工业级速度狂热者那里奏效了(以这样或那样的形式):
- 用 Python 开发一个原型。(参见第十九章了解一些原型制作材料。)
- 剖析你的程序并确定瓶颈。(参见第十六章了解一些测试材料。)
- 将瓶颈重写为 C(或 C++、C#、Java、Fortran 等)扩展。
由此产生的架构——带有一个或多个 C 组件的 Python 框架——是一个非常强大的架构,因为它结合了两个世界的精华。这是为每项工作选择合适工具的问题。它为您提供了用高级语言(Python)开发复杂系统的好处,并且允许您用低级语言(C)开发较小的(可能更简单的)速度关键组件。
Note
使用 c 还有其他原因。例如,如果你想编写与一个陌生硬件接口的低级代码,你真的没有什么选择。
如果您甚至在开始之前就对系统的瓶颈有所了解,那么您可以(并且可能应该)设计您的原型,以便替换关键部分是容易的。我想我不妨以提示的形式来陈述这一点:
Tip
封装潜在的瓶颈。
您可能会发现,您不需要用 C 扩展来替换瓶颈(也许您突然得到了一台更快的计算机),但至少选项是存在的。
还有一种情况也是扩展的常见用例:遗留代码。您可能希望使用一些只存在于 C 中的代码,然后您可以“包装”这些代码(编写一个小型 C 库,为您提供一个合适的接口)并从您的包装器创建一个 Python 扩展库。
在接下来的几节中,我将为您提供一些扩展 Python 的经典 C 实现的起点,要么自己编写所有代码,要么使用名为 SWIG 的工具,以及扩展另外两个实现:Jython 和 IronPython。您还会发现一些关于访问外部代码的其他选项的提示。请继续阅读。。。
The Other Way Around
在这一章中,我主要关注用编译语言编写 Python 程序的扩展。但是反过来说——用编译语言编写一个程序,并嵌入一个 Python 解释器进行小的脚本编写和扩展——也有它的用处。在这种情况下,嵌入 Python 时您追求的不是速度,而是灵活性。在许多方面,这是用于编写编译后的扩展的相同的“两全其美”的论点;只是重心转移了而已。
嵌入方法在许多现实世界的系统中使用。例如,许多计算机游戏(几乎总是用编译语言编写,代码库主要是为了最大速度而开发的)使用 Python 等动态语言来描述高级行为(如游戏中角色的“智能”),而主代码引擎则负责图形等。
正文中引用的文档(针对 CPython、Jython 和 IronPython)也讨论了嵌入选项,以防您想走这条路。
如果你想使用快速的高级语言 Julia ( http://julialang.org )但是仍然想访问现有的 Python 库,你可以使用PyCall.jl库( https://github.com/stevengj/PyCall.jl )。
真正简单的方法:Jython 和 IronPython
如果你刚好在运行 Jython ( http://jython.org )或者 IronPython ( http://ironpython.net ),那么用原生模块扩展 Python 是相当容易的。原因是 Jython 和 IronPython 让你可以直接访问底层语言的模块和类(Java for Jython,C#和其他)。NET languages for IronPython),所以不需要遵守某些特定的 API(扩展 CPython 时必须遵守)。您只需实现您需要的功能,就像变魔术一样,它将在 Python 中工作。例如,您可以在 Jython 中直接访问 Java 标准库,在 IronPython 中直接访问 C#标准库。
清单 17-1 显示了一个简单的 Java 类。
public class JythonTest {
public void greeting() {
System.out.println("Hello, world!");
}
}
Listing 17-1.A Simple Java Class (JythonTest.java)
你可以用一些 Java 编译器来编译这个,比如javac。
$ javac JythonTest.java
Tip
如果您正在使用 Java,您也可以使用命令jythonc将您的 Python 类编译成 Java 类,然后这些 Java 类可以被导入到您的 Java 程序中。
编译完类后,启动 Jython(并将.class文件放在当前目录或 Java CLASSPATH中的某个位置)。
$ CLASSPATH=JythonTest.class jython
然后,您可以直接导入该类。
>>> import JythonTest
>>> test = JythonTest()
>>> test.greeting()
Hello, world!
看到了吗?这没什么。
Jython Property Magic
在与 Java 类交互时,Jython 有几个绝妙的技巧。最明显有用的一点是,它允许您通过普通的属性访问来访问所谓的 JavaBean 属性。在 Java 中,使用访问器方法来读取或修改它们。这意味着如果 Java 实例foo有一个名为setBar的方法,你可以简单地用foo.bar = baz代替foo.setBar(baz)。类似地,如果实例有一个名为getBar或isBar(用于布尔属性)的方法,您可以使用foo.bar来访问该值。使用 Jython 文档中的一个例子,而不是这个:
b = awt.Button()
b.setEnabled(False)
你可以用这个:
b = awt.Button()
b.enabled = False
事实上,所有属性都可以通过构造函数中的关键字参数来设置。所以你可以,事实上,简单地写下这个:
b = awt.Button(enabled=False)
这适用于多个参数的元组,甚至适用于 Java 习惯用法(如事件侦听器)的函数参数。
def exit(event):
java.lang.System.exit(0)
b = awt.Button("Close Me!", actionPerformed=exit)
在 Java 中,您需要用适当的actionPerformed方法实现一个单独的类,然后使用b.addActionListener添加它。
清单 17-2 展示了一个类似的 C#类。
using System;
namespace FePyTest {
public class IronPythonTest {
public void greeting() {
Console.WriteLine("Hello, world!");
}
}
}
Listing 17-2.A Simple C# Class (IronPythonTest.cs)
用你选择的编译器编译它。对于微软来说。NET 中,命令如下:
csc.exe /t:library IronPythonTest.cs
在 IronPython 中使用它的一种方法是将类编译成动态链接库(DLL 请参阅 C#安装的文档以获取详细信息)并根据需要更新相关的环境变量(如PATH)。那么您应该能够像下面这样使用它(使用 IronPython 交互式解释器):
>>> import clr
>>> clr.AddReferenceToFile("IronPythonTest.dll")
>>> import FePyTest
>>> f = FePyTest.IronPythonTest()
>>> f.greeting()
有关这些 Python 实现的更多细节,请访问 Jython 网站( http://jython.org )和 IronPython 网站( http://ironpython.net )。
编写 C 扩展
这才是最重要的,真的。扩展 Python 通常意味着扩展 CPython,这是 Python 的标准版本,用编程语言 c 实现。
Tip
有关基本介绍和一些背景材料,请参见维基百科上关于 C、 http://en.wikipedia.org/wiki/C_programming_language 的文章。要了解更多信息,请查阅艾弗·霍顿的书《从 C 开始:从新手到专业人士》,第五版(2013 年出版)。真正权威的信息来源是 Brian Kernighan 和 Dennis Ritchie 的空前经典,他们是语言的发明者:C 编程语言,第二版(Prentice-Hall,1988)。
C 不像 Java 或 C#那样动态,如果你只提供你编译的 C 代码,Python 也不容易自己解决问题。因此,在为 Python 编写 C 扩展时,需要遵循严格的 API。稍后,我将在“自己动手开发”一节中讨论这个 API 不过,有几个项目试图简化编写 C 扩展的过程,其中一个比较著名的项目是 SWIG,我将在下一节讨论它。(参见侧栏“其他方法”,了解一些……其他方法。)
Other Approaches
如果您使用的是 CPython,有很多工具可以帮助您加速程序,或者是通过生成和使用 C 库,或者是通过实际加速 Python 代码。以下是一些选项的概述:
- Cython (
http://cython.org):这其实是一个 Python 的编译器!它还提供了扩展的 Cython 语言,基于 Greg Ewing 的旧 Pyrex 项目,允许您添加类型声明,并使用类似 Python 的语法定义 C 类型。结果是非常高效的,并且它与 C 扩展模块(包括 Numpy)很好地交互。 - PyPy (
http://pypy.org):这是一个雄心勃勃的、前瞻性的 Python 实现——用 Python 实现。虽然这听起来可能非常慢,但实际上,通过非常高级的代码分析和编译,它通常会优于 CPython。根据该网站的说法,“有传言说秘密目标是比 C 更快,这是胡说八道,不是吗?”PyPy 的核心是 RPython,这是 Python 的一种受限方言。RPython 适用于自动类型推断等,允许翻译成静态语言或本机代码,或者翻译成其他动态语言(如 JavaScript)。 - Weave(
http://scipy.org):SciPy 发行版的一部分,但也可以单独获得,Weave 是一个工具,用于将 C 或 C++代码直接包含在 Python 代码中(作为字符串),并无缝编译和执行代码。例如,如果你想快速计算某些数学表达式,那么这可能是一种方法。Weave 还可以使用数字数组来加速表达式(见下一条)。 - NumPy (
http://numpy.org): NumPy 让您可以访问数字数组,这对于分析多种形式的数字数据(从股票价值到天文图像)非常有用。一个优点是简单的接口,这减轻了显式指定许多低级操作的需要。然而,主要的优势是速度。对数值数组中的每个元素执行许多常见操作比用列表和for循环执行类似的操作要快得多,因为隐式循环是直接在 c 中实现的。 - ctypes(
https://docs.python.org/library/ctypes.html):ctypes 模块最初是 Thomas Heller 的一个独立项目,但它现在是标准库的一部分。它采用了一种非常直接的方法——它只是让您导入现有的(共享的)C 库。虽然有一些限制,但这可能是访问 C 代码最简单的方式之一。不需要包装器或特殊的 API。您只需导入库并使用它。 - 子流程(
https://docs.python.org/3/library/subprocess.html):好吧,这个有点不一样。可以在标准库中找到子流程模块,以及具有类似功能的旧模块和函数。它允许 Python 运行外部程序,并通过命令行参数和标准输入、输出和错误流与它们通信。如果您的速度关键型代码可以在几个长时间运行的批处理作业中完成大部分工作,那么启动程序并与之通信的时间将会很少。在这种情况下,简单地将 C 代码放在一个完全独立的程序中,并作为一个子进程运行,这可能是最干净的解决方案。 - PyCXX (
http://cxx.sourceforge.net):以前称为 CXX,或 CXX/Objects,这是一套用于编写 Python 扩展的 C++工具。例如,它包括对引用计数的大量支持,以减少出错的机会。 - SIP (
http://www.riverbankcomputing.co.uk/software/sip): SIP(一语双关 SWIG?)最初是作为开发 GUI 包 PyQt 的工具创建的,由代码生成器和 Python 模块组成。它以类似 SWIG 的方式使用规范文件。 - 助推。python(
http://www.boost.org/libs/python/doc”):Boost。Python 旨在实现 Python 和 C++之间的无缝互操作性,可以在引用计数和在 C++中操作 Python 对象等问题上给你很大的帮助。使用它的一个主要方法是以类似 Python 的风格编写 C++代码(通过 Boost 实现)。Python 的宏),然后使用您最喜欢的 C++编译器将它直接编译成 Python 扩展。作为 SWIG 的一个相当不同但非常可靠的替代品,这肯定值得一看。
痛饮…痛饮
SWIG ( http://www.swig.org ),是简单包装器和接口生成器的缩写,是一个支持多种语言的工具。一方面,它让你用 C 或 C++写你的扩展代码;另一方面,它自动包装这些内容,以便您可以在几种高级语言中使用它们,如 Tcl、Python、Perl、Ruby 和 Java。这意味着,如果您决定将系统的某些部分编写为 C 扩展,而不是直接用 Python 实现,那么 C 扩展库也可以(使用 SWIG)用于许多其他语言。如果您希望用不同语言编写的几个子系统一起工作,这可能非常有用;您的 C(或 C++)扩展可以成为合作的中心。
安装 SWIG 遵循与安装其他 Python 工具相同的模式:
- 你可以从网站
http://www.swig.org获取 SWIG。 - 许多 UNIX/Linux 发行版都带有 SWIG。很多包管理器会让你直接安装。
- 有一个用于 Windows 的二进制安装程序。
- 自己编译源代码也只是简单地调用
configure和make install。
如果您在安装 SWIG 时遇到问题,您应该可以在网站上找到有用的信息。
它是做什么的?
如果您有一些 C 代码,使用 SWIG 是一个简单的过程。
- 为你的代码写一个接口文件。这与 C 头文件非常相似(对于简单的情况,您可以直接使用您的头文件)。
- 在接口文件上运行 SWIG,以便自动生成更多的 C 代码(包装代码)。
- 将原始 C 代码与生成的包装代码一起编译,以生成共享库。
在下文中,我将从一些 C 代码开始讨论每一个步骤。
我更喜欢圆周率
回文(如本节的标题)是一个倒着读的句子,如果你忽略空格和标点符号之类的话。假设你想识别巨大的回文,不考虑空格和朋友。(也许你需要它来分析蛋白质序列或其他东西。)当然,字符串必须非常大,这对于纯 Python 程序来说是个问题,但是假设字符串非常大,并且您需要进行大量的检查。您决定编写一段 C 代码来处理它(或者您可能找到一些完成的代码——如前所述,在 Python 中使用现有的 C 代码是 SWIG 的主要用途之一)。清单 17-3 显示了一个可能的实现。
#include <string.h>
int is_palindrome(char *text) {
int i, n=strlen(text);
for (i = 0; I <= n/2; ++i) {
if (text[i] != text[n-i-1]) return 0;
}
return 1;
}
Listing 17-3.A Simple C Function for Detecting a Palindrome (palindrome.c)
仅供参考,清单 17-4 中显示了一个等价的纯 Python 函数。
def is_palindrome(text):
n = len(text)
for i in range(len(text) // 2):
if text[i] != text[n-i-1]:
return False
return True
Listing 17-4.Detecting Palindromes in Python
稍后您将看到如何编译和使用 C 代码。
接口文件
假设您将清单 17-3 中的代码放在一个名为palindrome .c的文件中,现在您应该将一个接口描述放在一个名为palindrome.i的文件中。在很多情况下,如果你定义了一个头文件(也就是palindrome.h),SWIG 也许能够从中获得它所需要的信息。所以如果你有一个头文件,请随意尝试使用它。显式编写接口文件的原因之一是,您可以调整 SWIG 实际包装代码的方式;最重要的调整是排除事物。例如,如果您正在包装一个巨大的 C 库,也许您只想将几个函数导出到 Python。在这种情况下,您只需将想要导出的函数放在接口文件中。
在接口文件中,您只需声明想要导出的所有函数(和变量),就像在头文件中一样。此外,在顶部有一个部分(由%{和%}分隔),您可以在其中指定包含的头文件(比如我们的例子中的string.h),甚至在那之前还有一个%module声明,给出了模块的名称。(其中一些是可选的,您可以使用接口文件做更多的事情;有关更多信息,请参见 SWIG 文档。)清单 17-5 显示了这个接口文件。
%module palindrome
%{
#include <string.h>
%}
extern int is_palindrome(char *text);
Listing 17-5.Interface to the Palindrome Library (palindrome.i)
跑步痛饮
运行 SWIG 可能是这个过程中最简单的部分。尽管有许多命令行开关可用(试着运行swig -help获得选项列表),唯一需要的是python选项,以确保 SWIG 包装了您的 C 代码,以便您可以在 Python 中使用它。您可能会发现另一个有用的选项是-c++,如果您正在包装一个 C++库,您可以使用它。您可以使用接口文件(或者,如果您愿意,也可以使用头文件)运行 SWIG,如下所示:
$ swig -python palindrome.i
在此之后,您应该有两个新文件:一个名为palindrome_wrap.c,另一个名为palindrome.py。
编译、链接和使用
编译可能是最棘手的部分(至少我是这样认为的)。为了正确地编译,您需要知道在哪里保存您的 Python 发行版的源代码(或者,至少,名为pyconfig.h和Python.h的头文件;您可能会分别在 Python 安装的根目录和Include子目录中找到它们。您还需要找出正确的开关,用您选择的 C 编译器将您的代码编译到一个共享库中。如果您在寻找参数和开关的正确组合方面有困难,请看下一节“穿过编译器魔法森林的捷径”
这里是一个使用cc编译器的 Solaris 示例,假设$PYTHON_HOME指向 Python 安装的根目录:
$ cc -c palindrome.c
$ cc -I$PYTHON_HOME -I$PYTHON_HOME/Include -c palindrome_wrap.c
$ cc -G palindrome.o palindrome_wrap.o -o _palindrome.so
下面是在 Linux 中使用gcc编译器的顺序:
$ gcc -c palindrome.c
$ gcc -I$PYTHON_HOME -I$PYTHON_HOME/Include -c palindrome_wrap.c
$ gcc -shared palindrome.o palindrome_wrap.o -o _palindrome.so
可能是所有需要的包含文件都在一个地方找到了,比如/usr/include/python3.5(根据需要更新版本号)。在这种情况下,以下方法应该可以解决问题:
$ gcc -c palindrome.c
$ gcc -I/usr/include/python3.5 -c palindrome_wrap.c
$ gcc -shared palindrome.o palindrome_wrap.o -o _palindrome.so
在 Windows 中(再次假设您在命令行上使用gcc,您可以使用下面的命令作为最后一个命令,来创建共享库:
$ gcc -shared palindrome.o palindrome_wrap.o C:/Python25/libs/libpython25.a -o_palindrome.dll
在 macOS 中,您可以做如下事情(如果您使用官方 Python 安装,那么PYTHON_HOME就是/Library/Frameworks/Python.framework/Versions/Current):
$ gcc -dynamic -I$PYTHON_HOME/include/python3.5 -c palindrome.c
$ gcc -dynamic -I$PYTHON_HOME/include/python3.5 -c palindrome_wrap.c
$ gcc -dynamiclib palindrome_wrap.o palindrome.o -o _palindrome.so -Wl, -undefined, dynamic_lookup
Note
如果在 Solaris 上使用gcc,将标志-fPIC添加到前两个命令行中(就在命令gcc之后)。否则,当您试图在最后一个命令中链接文件时,编译器会变得非常混乱。此外,如果您使用包管理器(在许多 Linux 平台中很常见),您可能需要安装一个单独的包(称为类似于python-dev的东西)来获得编译您的扩展所需的头文件。
在这些黑暗魔法咒语之后,你应该会得到一个非常有用的叫做_palindrome.so的文件。这是您的共享库,可以直接导入到 Python 中(如果它放在您的PYTHONPATH中的某个地方,比如在当前目录中):
>>> import _palindrome
>>> dir(_palindrome)
['__doc__', '__file__', '__name__', 'is_palindrome']
>>> _palindrome.is_palindrome('ipreferpi')
1
>>> _palindrome.is_palindrome('notlob')
0
在老版本的 SWIG 中,这就是全部内容。然而,SWIG 的最新版本也用 Python 生成了一些包装代码(文件palindrome.py,还记得吗?).这个包装器代码导入了_palindrome模块,并负责一些检查。如果你想跳过这一步,你可以删除palindrome.py文件并将你的库直接链接到一个名为palindrome.so的文件中。
使用包装器代码和使用共享库一样有效。
>>> import palindrome
>>> from palindrome import is_palindrome
>>> if is_palindrome('abba'):
... print('Wow -- that never occurred to me ...')
...
Wow -- that never occurred to me ...
穿越编译器魔法森林的捷径
如果你觉得编译过程有点神秘,你并不孤单。如果您自动化编译(比如说,使用 makefile),用户将需要通过指定 Python 的安装位置、使用编译器的特定选项以及使用哪个编译器来配置设置。使用 Setuptools 可以很好地避免这种情况。事实上,它直接支持 SWIG,所以您甚至不需要手动运行它。您只需编写代码和接口文件,并运行您的安装脚本。关于这个魔术的更多信息,请参见第十八章中的“编译扩展”一节。
自己黑进去
SWIG 在幕后施展了相当多的魔法,但并不是所有的都是绝对必要的。如果你想接近金属,在处理器上咬牙切齿,可以这么说,你当然可以自己写你的包装器代码,或者干脆写你的 C 代码,让它直接用 Python C API。
Python C API 有自己的手册,Python/C API 参考手册( https://docs.python.org/3/c-api )。更温和的介绍可以在标准库手册的相关章节中找到( https://docs.python.org/3/extending )。在这里,我会试着更温和(更简短)。如果您对我遗漏的内容(相当多)感到好奇,您应该看看官方文档。
引用计数
如果您以前没有使用过它,引用计数可能是您在本节中遇到的最陌生的概念之一,尽管它并没有那么复杂。在 Python 中,内存使用是自动处理的——您只需创建对象,当您不再使用它们时,它们就会消失。在 C 语言中,情况并非如此。您必须显式地释放不再使用的对象(或者说,内存块)。如果你不这样做,你的程序可能会占用越来越多的内存,这就是所谓的内存泄漏。
在编写 Python 扩展时,您可以使用 Python 在“幕后”使用的管理内存的工具,其中之一就是引用计数。其思想是,只要代码的某些部分引用了一个对象(在 C 语言中就是指向它的指针),就不应该释放它。然而,一旦对一个对象的引用数量达到零,这个数量就不能再增加了——没有代码可以创建对它的新引用,它只是在内存中“自由浮动”。此时,解除分配它是安全的。引用计数自动化了这一过程。您遵循一组规则,在这些规则中,您在各种情况下(通过 Python API 的一部分)增加或减少对象的引用计数,如果计数变为零,对象将被自动释放。这意味着没有一段代码单独负责管理一个对象。你可以创建一个对象,从一个函数返回它,然后忘记它,安全地知道当不再需要它时它会消失。
您使用两个宏Py_INCREF和Py_DECREF,分别增加和减少一个对象的引用计数。您可以在 Python 文档中找到关于如何使用它们的详细信息,但这里是它的要点:
- 您不能拥有一个对象,但可以拥有对它的引用。对象的引用计数是对该对象拥有的引用数。
- 如果您拥有一个引用,当您不再需要该引用时,您有责任调用
Py_DECREF。 - 如果你临时借用一个引用,当你使用完这个对象时,你不应该调用
Py_DECREF;那是车主的责任。
Caution
在拥有者已经处置它之后,你当然不应该使用一个被借用的引用。有关保持安全的更多建议,请参见文档中的“薄冰”部分。
- 您可以通过调用
Py_INCREF将借用的引用转换为拥有的引用。这将创建一个新的拥有的引用;原始所有者仍然拥有原始引用。 - 当您以参数的形式接收一个对象时,您可以决定是希望转移其引用的所有权(例如,如果您要将它存储在某个地方),还是仅仅希望借用它。这应该清楚地记录下来。如果您的函数是从 Python 中调用的,那么简单地借用是安全的——对象将在函数调用期间一直存在。然而,如果你的函数是从 C 中调用的,这是不能保证的,你可能想创建一个拥有的引用,然后在完成后释放它。
希望当我们过一会儿来看一个具体的例子时,这一切会看起来更清楚。
More Garbage Collection
引用计数是垃圾收集的一种形式,垃圾一词指的是对程序不再有用的对象。Python 还使用更复杂的算法来检测循环垃圾;也就是说,对象只相互引用(因此引用计数不为零),但没有其他对象引用它们。
您可以通过 gc 模块在 Python 程序中访问 Python 垃圾收集器。你可以在 Python 库参考( https://docs.python.org/3/library/gc.html )中找到更多关于它的信息。
扩展的框架
编写 Python C 扩展需要相当多的千篇一律的代码,这就是诸如 SWIG 和 Cython 这样的工具如此之好的原因。自动化千篇一律的代码是一条出路。不过,手工操作会是一次很好的学习经历。实际上,在如何构建代码方面,你确实有相当大的回旋余地。我会告诉你一个可行的方法。
首先要记住的是,必须首先包含Python.h头文件,在其他标准头文件之前。这是因为在某些平台上,它可能会执行一些应该由其他头使用的重新定义。因此,为了简单起见,只需这样放置:
#include <Python.h>
作为代码的第一行。
你的函数可以叫任何你想叫的名字。它应该是static,返回一个指向PyObject类型对象的指针(一个拥有的引用),并接受两个参数,两个参数都指向PyObject。这些对象通常被称为self和args(其中self是自身对象,或者NULL,而args是一组参数)。换句话说,该函数应该如下所示:
static PyObject *somename(PyObject *self, PyObject *args) {
PyObject *result;
/* Do something here, including allocating result. */
Py_INCREF(result); /* Only if needed! */
return result;
}
self参数实际上只在绑定方法中使用。在其他函数中,它只是一个NULL指针。
注意,可能不需要调用Py_INCREF。如果对象是在函数中创建的(例如,使用像Py_BuildValue这样的实用函数),函数将已经拥有对它的引用,并且可以简单地返回它。然而,如果您希望从函数中返回None,您应该使用现有的对象Py_None。然而,在这种情况下,该函数不拥有对Py_None的引用,因此应该在返回之前调用Py_INCREF(Py_None)。
args参数包含函数的所有参数(除了self参数,如果有的话)。为了提取对象,使用函数PyArg_ParseTuple(用于位置参数)和PyArg_ParseTupleAndKeywords(用于位置和关键字参数)。这里我将坚持立场论点。
函数PyArg_ParseTuple具有以下签名:
int PyArg_ParseTuple(PyObject *args, char *format, ...);
格式字符串描述了您期望的参数,然后您提供了您希望在末尾填充的变量的地址。返回值是一个布尔值。如果是真的,一切都很顺利;否则,就有错误。如果有错误,引发异常的适当准备工作已经完成(您可以在文档中了解更多),您需要做的就是返回NULL来启动进程。因此,如果您不需要任何参数(空格式字符串),下面是处理参数的一种有用方法:
if (!PyArg_ParseTuple(args, "")) {
return NULL;
}
如果代码执行到该语句之外,您知道您有参数(在本例中,没有参数)。格式字符串可以看起来像是字符串的"s",整数的"i",Python 对象的"o",可能的组合有两个整数和一个字符串的"iis"。还有更多格式字符串代码。如何编写格式字符串的完整参考可以在 Python/C API 参考手册( https://docs.python.org/3/c-api/arg.html )中找到。
Note
您也可以在扩展模块中创建自己的内置类型和类。这并不太难,真的,但仍然是一个相当复杂的主题。如果您主要需要将一些瓶颈代码分解到 C 中,那么使用函数可能就足以满足您的大部分需求。如果您想学习如何创建类型和类,Python 文档是一个很好的信息来源。
一旦你有了你的函数,仍然需要一些额外的包装来使你的 C 代码作为一个模块。但是一旦我们有了一个真实的例子,让我们回到这个话题,好吗?
回文,详细描述 1 供您欣赏
事不宜迟,我给你清单 17-6 中手工编码的 Python C API 版本的palindrome模块(添加了一些有趣的新东西)。
#include <Python.h>
static PyObject *is_palindrome(PyObject *self, PyObject *args) {
int i, n;
const char *text;
int result;
/* "s" means a single string: */
if (!PyArg_ParseTuple(args, "s", &text)) {
return NULL;
}
/* The old code, more or less: */
n=strlen(text);
result = 1;
for (i = 0; i <= n/2; ++i) {
if (text[i] != text[n-i-1]) {
result = 0;
break;
}
}
/* "i" means a single integer: */
return Py_BuildValue("i", result);
}
/* A listing of our methods/functions: */
static PyMethodDef PalindromeMethods[] = {
/* name, function, argument type, docstring */
{"is_palindrome", is_palindrome, METH_VARARGS, "Detect palindromes"},
/* An end-of-listing sentinel: */
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef palindrome =
{
PyModuleDef_HEAD_INIT,
"palindrome", /* module name */
"", /* docstring */
-1, /* signals state kept in global variables */
PalindromeMethods
};
/* An initialization function for the module: */
PyMODINIT_FUNC PyInit_palindrome(void)
{
return PyModule_Create(&palindrome);
}
Listing 17-6.Palindrome Checking Again (palindrome2.c)
清单 17-6 中添加的大部分内容完全是样板文件。在你看到palindrome的地方,你可以插入你的模块的名字。在你看到is_palindrome的地方,插入你的函数名。如果你有更多的函数,只需在PyMethodDef数组中列出它们。不过,有一点值得注意:初始化函数的名字必须是initmodule,其中module是你的模块的名字;否则 Python 是找不到的。
所以,我们来编译吧!除了现在只需要处理一个文件之外,您可以像 SWIG 一节中描述的那样来做。下面是一个使用gcc的例子(记得在 Solaris 中添加-fPIC):
$ gcc -I$PYTHON_HOME -I$PYTHON_HOME/Include -shared palindrome2.c -o palindrome.so
同样,您应该有一个名为palindrome.so的文件供您使用。把它放在您的PYTHONPATH中的某个地方(比如当前目录),然后我们开始:
>>> from palindrome import is_palindrome
>>> is_palindrome('foobar')
0
>>> is_palindrome('deified')
1
就这样。现在去玩吧。(但是要小心;还记得这本书引言中引用的瓦尔迪·雷文斯的话吗?)
快速总结
扩展 Python 是一个巨大的课题。本章提供的一瞥包括以下内容:
- 扩展理念:Python 扩展主要有两个用途:使用现有(遗留)代码或加速瓶颈。如果你正在从头开始写你自己的代码,试着用 Python 做原型,找到瓶颈,如果需要的话把它们作为扩展。预先封装潜在的瓶颈可能是有用的。
- Jython 和 IronPython:扩展 Python 的这些实现非常容易。您只需在底层实现中将您的扩展实现为一个库(Java for Jython 和 C#或其他)。IronPython 的. NET 语言),代码可以直接从 Python 中使用。
- 扩展方法:有很多工具可以用来扩展或加速你的代码。您可以找到一些工具,使将 C 代码合并到 Python 程序中变得更加容易,加快数字数组操作等常见操作的速度,并加快 Python 本身的速度。这类工具包括 SWIG、Cython、Weave、NumPy、ctypes 和 subprocess。
- SWIG: SWIG 是一个为你的 C 库自动生成包装代码的工具。包装器代码负责 Python C API,因此您不必处理它。SWIG 是最简单也是最流行的 Python 扩展方式之一。
- 使用 Python/C API:您可以自己编写 C 代码,这些代码可以作为共享库直接导入 Python。为此,您必须遵守 Python/C API。对于每个函数,您需要注意的事情包括引用计数、提取参数和构建返回值。让一个 C 库像一个模块一样工作也需要一定量的代码,包括列出模块中的函数,创建一个模块初始化函数。
本章的新功能
| 功能 | 描述 | | --- | --- | | `Py_INCREF(obj)` | 增加参考计数`obj` | | `Py_DECREF(obj)` | 减少参考计数`obj` | | `PyArg_ParseTuple(args, fmt, ...)` | 提取位置参数 | | `PyArg_ParseTupleAndKeywords(args, kws, fmt, kwlist)` | 提取位置参数和关键字参数 | | `PyBuildValue(fmt, value)` | 从 C 值构建一个`PyObject` |什么现在?
现在你应该有一些非常酷的程序或者至少一些非常酷的程序想法。一旦你有了想与世界分享的东西(你确实想与世界分享你的代码,不是吗?),下一章可以是你的下一步。
Footnotes 1
也就是说,酒石酸盐已被删除。好吧,所以这个词与代码完全无关(与果汁更相关),但至少它是一个回文。
十八、打包您的程序
一旦您的程序准备好发布,您可能希望在发布它之前对它进行适当的打包。如果它只包含一个.py文件,这可能不是什么大问题。然而,如果你面对的是非程序员用户,即使在正确的位置放置一个简单的 Python 库或者摆弄一下PYTHONPATH也可能超出他们想要处理的范围。用户通常想简单地双击一个安装程序,按照一些安装向导,然后让您的程序准备运行。
最近,Python 程序员也已经习惯了类似的便利,尽管使用了稍微低级一些的接口。用于分发 Python 包的 Setuptools 工具包和旧的 Distutils 使得用 Python 编写安装脚本变得很容易。您可以使用这些脚本来构建用于分发的归档文件,程序员(用户)可以使用这些文件来编译和安装您的库。
在这一章中,我主要关注 Setuptools,因为它是每一个 Python 程序员工具箱中必不可少的工具。Setuptools 实际上超越了 Python 库的基于脚本的安装。编译扩展也相当方便,有了扩展py2exe and py2app,你甚至可以构建独立的 Windows 和 macOS 可执行程序。
设置工具基础
您可以在 Python 打包用户指南(packaging.python.org)和 Setuptools 网站( http://setuptools.readthedocs.io )上找到大量相关文档。您可以通过编写清单 18-1 中所示的简单脚本来使用 Setuptools 做各种有用的事情。(如果你还没有安装工具,你可以使用pip来安装。)
from setuptools import setup
setup(name='Hello',
version='1.0',
description='A simple example',
author='Magnus Lie Hetland',
py_modules=['hello'])
Listing 18-1.Simple Setuptools Setup Script (setup.py)
您实际上不必在setup函数中提供所有这些信息(您实际上根本不需要提供任何参数),并且您当然可以提供更多信息(例如author_email或url)。名称应该是不言自明的。将清单 18-1 中的脚本保存为setup.py(这是 Distutils 安装脚本的通用约定),并确保在同一个目录中有一个名为hello.py的简单模块。
Caution
运行安装脚本时,它会在当前目录下创建新的文件和子目录,因此您应该在一个新的目录下进行试验,以避免旧文件被覆盖。
现在让我们看看如何使用这个简单的脚本。按如下方式执行:
python setup.py
您应该得到如下所示的输出:
usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
or: setup.py --help [cmd1 cmd2 ...]
or: setup.py --help-commands
or: setup.py cmd --help
error: no commands supplied
如您所见,您可以使用--help或--help-commands开关获得更多信息。尝试发出build命令,看看 Setuptools 的运行情况。
python setup.py build
您现在应该会看到如下所示的输出:
running build
running build_py
creating build
creating build/lib
copying hello.py -> build/lib
Setuptools 创建了一个名为build的目录,子目录名为lib,并在build/lib中放置了一个hello.py的副本。build子目录是一种工作区,Setuptools 在这里组装一个包(例如,编译扩展库)。安装时你并不真的需要运行build命令,因为如果需要,当你运行install命令时,它会自动运行。
Note
在这个例子中,install命令将把hello.py模块复制到您的PYTHONPATH中某个特定于系统的目录中。这应该不会造成风险,但是如果您不想让您的系统变得混乱,您可能希望在之后删除它。记下放置的具体位置,如setup.py输出。你也可以使用-n开关来做一次演习。在撰写本文时,还没有标准的uninstall命令(尽管您可以在网上找到定制的卸载实现),所以您需要手动卸载该模块。
说到这个。。。让我们试着安装模块。
python setup.py install
现在,您应该会看到相当多的输出,以类似如下的内容结束:
Installed /path/to/python3.5/site-packages/Hello-1.0-py3.5.egg
Processing dependencies for Hello==1.0
Finished processing dependencies for Hello==1.0 byte-compiling
Note
如果您运行的 Python 版本不是您自己安装的,并且没有适当的权限,您可能不被允许安装所示的模块,因为您没有对正确目录的写权限。
这是用于安装 Python 模块、包和扩展的标准机制。您需要做的只是提供一个小的设置脚本。如您所见,作为安装过程的一部分,Setuptools 构建了一个egg,一个自包含的捆绑 Python 包。
示例脚本只使用 Setuptools 指令py_modules。如果您想安装整个包,您可以以同样的方式使用指令packages(只需列出包名)。您还可以设置许多其他选项(其中一些将在本章后面的“编译扩展”一节中介绍)。这些选项允许您指定要安装的内容和安装位置。您的配置可以用于多种用途。下一节将向您展示如何将您指定要安装的模块包装成归档文件,以备分发。
包装东西
一旦你写了一个让用户安装你的模块的setup.py脚本,你就可以用它来构建一个归档文件。您还可以构建一个 Windows installer、一个 RPM 包、egg发行版或wheel发行版,等等。(最终,轮子将取代鸡蛋。)我将带您浏览一下.tar.gz示例,您应该很容易从文档中找到其他格式。
您可以使用sdist(用于“源代码分发”)命令构建一个源代码归档文件。
python setup.py sdist
如果您运行这个,您可能会得到相当多的输出,包括一些警告。我收到的警告包括关于丢失author_email选项、丢失README文件和丢失 URL 的投诉。您可以放心地忽略所有这些(尽管您可以随意在您的setup.py脚本中添加一个author_email选项,类似于author选项,并在当前目录中添加一个README.txt文本文件)。
在警告之后,您应该会看到如下输出:
creating Hello-1.0/Hello.egg-info
making hard links in Hello-1.0...
hard linking hello.py -> Hello-1.0
hard linking setup.py -> Hello-1.0
hard linking Hello.egg-info/PKG-INFO -> Hello-1.0/Hello.egg-info
hard linking Hello.egg-info/SOURCES.txt -> Hello-1.0/Hello.egg-info
hard linking Hello.egg-info/dependency_links.txt -> Hello-1.0/Hello.egg-info
hard linking Hello.egg-info/top_level.txt -> Hello-1.0/Hello.egg-info
Writing Hello-1.0/setup.cfg
Creating tar archive
removing 'Hello-1.0' (and everything under it)
现在,除了build子目录,您应该还有一个名为dist的子目录。在里面,你会发现一个名为Hello-1.0.tar.gz的gzip编辑的tar档案。这可以分发给其他人,他们可以解包并使用附带的setup.py脚本进行安装。如果您不想要一个.tar.gz文件,可以使用其他几种发行版格式,您可以通过命令行开关--formats来设置它们。(正如复数名称所示,您可以提供多种格式,用逗号分隔,以便一次性创建更多的归档文件。)将--help-formats开关切换到sdist,您将获得可用格式的列表。
编译扩展
在第十七章中,你看到了如何为 Python 编写扩展。您可能同意编译这些扩展有时有点麻烦。幸运的是,您也可以为此使用 Distutils。你可能想参考第十七章来获得程序palindrome的源代码(在清单 17-6 中)。假设您在当前(空)目录中有源文件palindrome2.c,下面的setup.py脚本可以用来编译(和安装)它:
from setuptools import setup, Extension
setup(name='palindrome',
version='1.0',
ext_modules = [
Extension('palindrome', ['palindrome2.c'])
])
如果用这个setup.py脚本运行install命令,那么palindrome扩展模块应该会在安装之前自动编译。正如您所看到的,您没有指定模块名称的列表,而是给了ext_modules参数一个Extension实例的列表。构造函数接受一个名称和相关文件的列表;例如,您可以在这里指定头文件(.h)。
如果您更愿意就地编译扩展(对于大多数 UNIX 系统,在当前目录中生成一个名为palindrome.so的文件),您可以使用下面的命令:
python setup.py build_ext --inplace
现在我们开始真正有趣的部分。如果你安装了 SWIG(见第十七章),你可以让 Setuptools 直接使用它!
看看清单 17-3 中原始palindrome.c(没有所有包装代码)的源代码。当然比包装版简单多了。能够将它直接编译成 Python 扩展,让 Distutils 为您使用 SWIG,会非常方便。这真的非常简单——你只需将接口(.i)文件的名称(见清单 17-5)添加到Extension实例的文件列表中。
from setuptools import setup, Extension
setup(name='palindrome',
version='1.0',
ext_modules = [
Extension('_palindrome', ['palindrome.c',
'palindrome.i'])
])
如果您使用与之前相同的命令(build_ext,可能使用--inplace开关)运行这个脚本,那么您应该会再次得到一个.so文件(或者类似的文件),但是这次您不需要自己编写所有的包装器代码。请注意,我已经将扩展命名为_palindrome,因为 SWIG 将创建一个名为palindrom.py的包装器,以此名称导入一个 C 库。
用 py2exe 创建可执行程序
Setuptools 的py2exe扩展(可通过pip获得)允许你构建可执行的 Windows 程序(.exe文件),如果你不想让你的用户负担必须单独安装一个 Python 解释器,这可能是有用的。py2exe包可以用来创建带有 GUI 的可执行文件(如第十二章所述)。让我们在这里用一个非常简单的例子:
print('Hello, world!')
input('Press <enter>')
同样,从只包含这个名为hello.py的文件的空目录开始,创建一个setup.py文件,如下所示:
from distutils.core import setup
import py2exe
setup(console=['hello.py'])
您可以像这样运行这个脚本:
python setup.py py2exe
这将在dist子目录中创建一个控制台应用(名为hello.exe)和几个其他文件。您可以从命令行运行它,也可以双击它。
有关py2exe如何工作以及如何以更高级的方式使用它的更多信息,请访问py2exe网站( http://www.py2exe.org )。如果你使用的是 macOS,你可能想看看py2app ( http://pythonhosted.org/py2app ),它为那个平台提供了类似的功能。
Registering Your Package with PYPI
如果您希望其他人能够使用pip安装您的包,您必须用 Python 包索引 PyPI 注册它。标准库文档详细描述了这是如何工作的,但本质上您使用以下命令:
python setup.py register
此时,您将看到一个菜单,允许您登录或注册为新用户。一旦您的包被注册,您就可以使用upload命令将它上传到 PyPI。例如,
python setup.py sdist upload
将上传源分布。
快速总结
最后,您现在知道了如何用花哨的 GUI 安装程序创建闪亮、专业外观的软件——或者如何自动生成那些珍贵的.tar.gz文件。下面是对所涵盖的具体概念的总结:
- setup tools:setup tools 工具包允许您编写安装程序脚本,通常称为
setup.py。使用这些脚本,您可以安装模块、软件包和扩展。 - Setuptools 命令:你可以用几个命令运行你的
setup.py脚本,比如build、build_ext、install、sdist和bdist。 - 编译扩展:您可以使用 Setuptools 自动编译您的 C 扩展,Setuptools 会自动定位您的 Python 安装并确定使用哪个编译器。你甚至可以让它自动运行 SWIG。
- 可执行二进制文件:Setuptools 的
py2exe扩展可用于从 Python 程序创建可执行的 Windows 二进制文件。除了几个额外的文件(可以通过安装程序方便地安装),这些.exe文件可以在不单独安装 Python 解释器的情况下运行。py2app扩展为 macOS 提供了类似的功能。
本章的新功能
| 功能 | 描述 | | --- | --- | | `setuptools.setup(...)` | 在您的`setup.py`脚本中配置带有关键字参数的 Setuptools |什么现在?
这就是技术方面的东西——算是吧。在下一章,你会学到一些编程方法和哲学,然后是项目。好好享受!
十九、有趣的编程
此时,您应该对 Python 的工作原理有了比开始时更清晰的了解。可以说,现在橡胶上路了,在接下来的十章中,你将把你的新技能投入到工作中。每章包含一个自己动手的项目,有很大的实验空间,同时给你必要的工具来实现一个解决方案。
在这一章,我给你一些用 Python 编程的通用指南。
为什么好玩?
我认为 Python 的优势之一是它让编程变得有趣——至少对我来说是这样。当你玩得开心的时候,更容易富有成效;Python 的一个有趣之处是它让你变得非常有效率。这是一个积极的反馈循环,你在生活中得到的太少了。
戏谑编程是我发明的一个不太极端的极端编程版本,简称 XP。我喜欢 XP 运动的许多想法,但一直懒得完全遵守它们的原则。相反,我选择了一些东西,并将它们与我认为是用 Python 开发程序的一种自然方式结合起来。
编程的柔术
你可能听说过柔术?这是一种日本武术,像它的后代柔道和合气道一样, 2 专注于反应的灵活性,或“弯曲而不是打破”不要试图把你预先计划好的动作强加给对手,你要随波逐流,用对手的动作来对付他。这样(理论上)你就可以打败比你更大、更卑鄙、更强的对手。
这如何应用于编程?关键是音节“ju”,它可以(非常粗略地)翻译为灵活性。当你在编程中遇到麻烦时(你总是会遇到),不要僵硬地坚持你最初的设计和想法,要灵活。逆来顺受。准备好改变和适应。不要把不可预见的事件视为令人沮丧的干扰;将它们视为创造性探索新选择和可能性的刺激起点。
关键是,当你坐下来计划你的程序应该如何时,你对那个特定的程序没有任何实际的经验。你怎么能这样?毕竟现在还不存在。通过致力于实现,您会逐渐学到新的东西,这些东西在您进行最初的设计时可能是有用的。不要忽视你在这个过程中获得的经验,你应该用它们来重新设计(或重构)你的软件。我并不是说你应该在不知道你要去哪里的情况下就开始动手,而是说你应该为改变做好准备,并接受你最初的设计需要修改。就像老作家说的:“写作就是改写。”
这种灵活性的实践有许多方面;在这里,我将谈到其中的两个:
- 原型:Python 的一个好处是可以快速编写程序。写一个原型程序是了解你的问题的一个很好的方法。
- 配置:灵活性有多种形式。配置的目的是为了让您和您的用户更容易地更改程序的某些部分。
第三个方面,自动化测试,如果你想能够容易地改变你的程序,是绝对必要的。有了测试,你就可以确定你的程序在引入修改后仍然工作。原型和配置将在下面的章节中讨论。有关测试的信息,请参见第十六章。
样机研究
总的来说,如果你想知道 Python 中的一些东西是如何工作的,那就试试吧。你不需要做大量的预处理,比如编译或者链接,这在其他语言中是必须的。你可以直接运行你的代码。不仅如此,你还可以在交互式解释器中一点一点地运行它,直到你完全理解它的行为。
这种探索不仅仅涉及语言特性和内置函数。当然,能够准确地找出iter函数是如何工作的是有用的,但是更重要的是能够轻松地创建您将要编写的程序的原型,只是为了看看它是如何工作的。
Note
在这个上下文中,单词 prototype 意味着一个试验性的实现,一个实现最终程序的主要功能的模型,但是可能需要在以后的某个阶段完全重写,或者不重写。通常,最初的原型可以变成一个工作程序。
在您对程序的结构进行了一些思考之后(比如您需要哪些类和函数),我建议实现一个简单的版本,可能功能非常有限。你会很快注意到,当你有一个正在运行的程序时,这个过程变得简单多了。你可以添加功能,改变你不喜欢的东西,等等。你可以真正看到它是如何工作的,而不只是想想或者在纸上画图表。
您可以在任何编程语言中使用原型,但是 Python 的优势在于编写一个模型是一项非常小的投资,所以您不一定要使用它。如果你发现你的设计没有想象中的聪明,你可以简单地扔掉你的原型,从头开始。这个过程可能需要几个小时或一两天。举例来说,如果你用 C++编程,可能需要做更多的工作来启动和运行一些东西,放弃它将是一个重大的决定。通过提交一个版本,你失去了灵活性;你会被早期的决定所束缚,根据你实际执行的经验,这些决定可能是错误的。
在本章后面的项目中,我一直使用原型而不是预先的详细分析和设计。每个项目都分为两个实现。第一个是摸索性实验,在这个实验中,我拼凑了一个解决问题(或者可能只是问题的一部分)的程序,以便了解一个好的解决方案所需的组件和要求。最大的教训可能是看到程序运行中的所有缺陷。通过建立在这个新发现的知识上,我采取了另一个,希望是更明智的,打击它。当然,你可以随意修改代码,甚至第三次从头开始。通常,从零开始并不像你想象的那样需要太多时间。如果你已经考虑过程序的实用性,打字不会花太长时间。
The Case Against Rewriting
虽然我在这里提倡使用原型,但是在任何时候都有理由对从零开始重新启动项目持谨慎态度,尤其是如果您已经在原型上投入了一些时间和精力。出于几个原因,将原型重构并修改成一个更具功能性的系统可能更好。
一个常见的问题是“第二系统综合症”这是试图让第二个版本变得如此聪明或完美的趋势,以至于它永远不会完成。
“持续重写综合症”在小说写作中非常普遍,它倾向于不断修改你的程序,也许是一次又一次地从头开始。在某些时候,保持足够好的状态可能是最好的策略——只要得到有用的东西。
然后是“代码疲劳”。你厌倦了你的代码。当你使用它很长时间后,你会觉得它又丑又笨。可悲的是,它看起来粗糙和笨拙的原因之一是,它已经适应了一系列特殊情况,并合并了几种形式的错误处理等。无论如何,这些都是您需要在新版本中重新引入的特性,并且它们可能已经花费了您相当多的精力(不仅仅是以调试的形式)来实现。
换句话说,如果你认为你的原型可以变成一个可行的系统,无论如何,继续研究它,而不是重新开始。在接下来的项目章节中,我将开发清晰地分为两个版本:原型和最终程序。这部分是为了清晰,部分是为了突出通过编写一个软件的第一个版本可以获得的经验和洞察力。在现实世界中,我很可能从原型开始,朝着最终系统的方向“重构自己”。
想了解更多关于从头开始的恐惧,看看乔尔·斯波尔斯基的文章《你不该做的事,第一部分》(见他的网站, http://joelonsoftware.com )。斯波尔斯基认为,从头重写代码是任何软件公司都会犯的最严重的战略错误。
配置
在这一节中,我将回到非常重要的抽象原则。在第 6 和 7 章中,我向你展示了如何通过将代码放入函数和方法中并在类中隐藏更大的结构来提取代码。让我们来看看在程序中引入抽象的另一种更简单的方式:从代码中提取符号常量。
提取常数
我所说的常量是指内置的文字值,比如数字、字符串和列表。您可以将它们收集在全局变量中,而不是在程序中重复编写它们。我知道我已经警告过你了,但是全局变量的问题主要发生在你开始改变它们的时候,因为很难跟踪你的代码的哪个部分负责哪个改变。然而,我不去管这些变量,而是把它们当作常量来使用(因此有了符号常量这个术语)。为了表示一个变量将被视为一个符号常量,您可以使用一个特殊的命名约定,在变量名中只使用大写字母,并用下划线分隔单词。
我们来看一个例子。在一个计算圆的面积和周长的程序中,每次需要π值时,你可以一直写 3.14。但是,如果您稍后想要一个更精确的值,比如 3.14159,该怎么办呢?您需要搜索整个代码并用新值替换旧值。这并不难,在大多数优秀的文本编辑器中,这可以自动完成。然而,如果你从值 3 开始呢?您是否希望以后用 3.14159 替换所有出现的数字 3?几乎没有。一个更好的处理方法是用行PI = 3.14开始程序,然后用名字PI代替数字本身。这样,您可以简单地修改这一行,在以后的某个时间获得更精确的值。只要记住这一点:每当你写一个常数(比如数字 42 或字符串“Hello,world!”)多次,请考虑将其放入全局变量中。
Note
实际上,π的值是在数学模块中找到的,名为math.pi:
>>> from math import pi
>>> pi
3.1415926535897931
这对你来说似乎是显而易见的。但是所有这些的真正意义在下一节,我将讨论配置文件。
配置文件
为了自己的利益提取常量是一回事,但是有些常量甚至可以暴露给用户。例如,如果他们不喜欢你的 GUI 程序的背景颜色,也许你应该让他们使用另一种颜色。或者,也许您可以让用户决定当他们启动您激动人心的街机游戏或您刚刚实现的新 web 浏览器的默认起始页时,他们希望收到什么样的问候消息。
您可以将这些配置变量放在一个单独的文件中,而不是放在一个模块的顶部。最简单的方法是用一个单独的模块进行配置。例如,如果在模块文件config.py中设置了PI,您可以(在主程序中)执行以下操作:
from config import PI
然后,如果用户想要不同的值给PI,她可以简单地编辑config.py,而不必费力地通过你的代码。
Caution
使用配置文件是有代价的。一方面,配置是有用的,但是为整个项目使用一个集中的、共享的变量存储库会使它变得不那么模块化,更加单一。确保你没有破坏抽象(比如封装)。
另一种可能是使用标准库模块configparser,这将允许您使用合理的标准格式来配置文件。它允许两种标准的 Python 赋值语法,例如:
greeting = 'Hello, world!'
(尽管这会在字符串中给你两个无关的引号)和许多程序中使用的另一种配置格式:
greeting: Hello, world!
您必须使用像[files]或[colors]这样的头文件将配置文件分成几个部分。名称可以是任何东西,但是需要用括号括起来。清单 19-1 显示了一个示例配置文件,清单 19-2 显示了一个使用它的程序。有关configparser模块特性的更多信息,请参考库文档。
[numbers]
pi: 3.1415926535897931
[messages]
greeting: Welcome to the area calculation program!
question: Please enter the radius:
result_message: The area is
Listing 19-1.A Simple Configuration File
from configparser import ConfigParser
CONFIGFILE = "area.ini"
config = ConfigParser()
# Read the configuration file:
config.read(CONFIGFILE)
# Print out an initial greeting;
# 'messages' is the section to look in:
print(config['messages'].get('greeting'))
# Read in the radius, using a question from the config file:
radius = float(input(config['messages'].get('question') + ' '))
# Print a result message from the config file;
# end with a space to stay on same line:
print(config['messages'].get('result_message'), end=' ')
# getfloat() converts the config value to a float:
print(config['numbers'].getfloat('pi') * radius**2)
Listing 19-2.A Program Using ConfigParser
在接下来的项目中,我不会详细讨论配置,但是我建议你考虑让你的程序可配置。这样,用户就可以根据自己的喜好来调整程序,从而使使用程序变得更加愉快。毕竟,使用软件的主要挫折之一是你不能让它按照你想要的方式运行。
Levels of Configuration
可配置性是 UNIX 编程传统中不可或缺的一部分。在他的优秀著作《UNIX 编程的艺术》( Addison-Wesley,2003)的第十章中,Eric S. Raymond 描述了配置或控制信息的以下三个来源,这些信息(如果包括的话)可能应该按照以下顺序查阅 3 以便后面的来源覆盖前面的来源:
- 配置文件:请参阅本章中的“配置文件”一节。
- 环境变量:可以使用字典
os.environ获取这些变量。 - 命令行传递给程序的开关和参数:对于处理命令行参数,可以直接使用
sys.argv。如果你想处理开关(选项),你应该检查一下argparse模块,正如第十章中提到的。
记录
与测试有点关系(在第十六章中讨论),并且在疯狂地修改程序内部时非常有用,日志当然可以帮助你发现问题和错误。日志记录基本上是在程序运行时收集有关程序的数据,这样您就可以在以后检查它(或者在数据积累时检查它)。一种非常简单的日志形式可以用print语句来完成。只要在你的程序的开头加上这样一句话:
log = open('logfile.txt', 'w')
然后,您可以将有关程序状态的任何有趣信息放入该文件,如下所示:
print('Downloading file from URL', url, file=log)
text = urllib.urlopen(url).read()
print'File successfully downloaded', file=log)
如果你的程序在下载过程中崩溃了,这种方法就不好用了。如果您为每个log语句打开和关闭您的文件(或者,至少在写入后刷新文件)会更安全。然后,如果你的程序崩溃了,你可以看到日志文件的最后一行写着“从 URL 下载文件”,你就知道下载没有成功。
实际上,应该使用标准库中的logging模块。基本用法非常简单,如清单 19-3 中的程序所示。
import logging
logging.basicConfig(level=logging.INFO, filename='mylog.log')
logging.info('Starting program')
logging.info('Trying to divide 1 by 0')
print(1 / 0)
logging.info('The division succeeded')
logging.info('Ending program')
Listing 19-3.A Program Using the logging Module
运行该程序会产生以下日志文件(名为mylog.log):
INFO:root:Starting program
INFO:root:Trying to divide 1 by 0
如您所见,在尝试将 1 除以 0 之后,没有记录任何内容,因为这个错误实际上会杀死程序。因为这是一个如此简单的错误,所以您可以通过程序崩溃时打印的异常回溯来判断出问题所在。最难追踪的错误类型是不会停止你的程序,而只是让它行为异常的那种。检查详细的日志文件可能有助于您了解发生了什么。
这个例子中的日志文件不是很详细,但是通过正确地配置logging模块,您可以设置您想要的日志工作方式。这里有几个例子:
- 不同类型的日志条目(信息、调试信息、警告、自定义类型等)。默认情况下,只允许警告通过(这就是为什么我在清单 19-3 中将级别显式设置为
logging.INFO)。 - 只记录与程序的某些部分相关的项目。
- 记录时间、日期等信息。
- 记录到不同的位置,例如套接字。
- 配置记录器以过滤掉部分或大部分日志记录,这样您就可以在任何时候只获得您需要的内容,而无需重写程序。
模块相当复杂,文档中有很多东西需要学习。
如果你不想被打扰
“这一切都很好,”你可能会想,“但是我不可能花那么多精力去写一个简单的小程序。配置、测试、记录——听起来真的很无聊。”
嗯,那很好。简单的程序可能不需要它。即使你正在做一个更大的项目,在开始的时候你可能真的不需要所有这些。我认为最起码你有一些测试程序的方法(如第十六章所讨论的),即使它不是基于自动单元测试。例如,如果你正在编写一个自动为你煮咖啡的程序,你应该准备一个咖啡壶,看看它是否工作。
在接下来的项目章节中,我不会编写完整的测试套件、复杂的日志记录设施等等。我向您展示一些简单的测试案例来演示程序的工作原理,仅此而已。如果你发现一个项目的核心思想很有趣,你应该更进一步——尝试增强和扩展它。在这个过程中,你应该考虑你在本章读到的问题。也许配置机制是个好主意?或者更广泛的测试套件?这取决于你。
如果你想了解更多
如果你想了解更多关于编程的艺术、技巧和哲学的信息,这里有一些更深入讨论这些内容的书籍:
- 《实用程序员》,作者:安德鲁·亨特和戴维·托马斯
- 《重构》,肯特·贝克等人著(艾迪森-韦斯利,1999 年)
- 设计模式,由“四人帮”埃里希·伽马、理查德·赫尔姆、拉尔夫·约翰逊、约翰·弗利塞德斯(Addison-Wesley,1994)提出
- 《测试驱动的开发:以实例为例》,作者肯特·贝克
- 《UNIX 编程的艺术》,作者 Eric S. Raymond (Addison-Wesley,2003)4
- 《算法导论》,第二版,托马斯·h·科尔曼等著(麻省理工学院出版社,2001 年)
- 《计算机编程的艺术》,第 1-3 卷,作者唐纳德·克努特(爱迪生韦斯利公司,1998 年)
- 《计算机编程的概念、技术和模型》,Peter Van Roy 和 Seif Haridi 著(麻省理工学院出版社,2004 年)
即使你没有读完每本书的每一页(我知道我没有),只要浏览其中的几页就能给你带来相当多的洞察力。
快速总结
在这一章中,我描述了一些用 Python 编程的一般原则和技术,方便地集中在标题“有趣的编程”下以下是亮点:
- 灵活性:在设计和编程时,你应该以灵活性为目标。不要坚持你最初的想法,你应该愿意——甚至准备好——随着你对手头问题的深入了解,修改和改变你程序的每一个方面。
- 原型:学习一个问题和可能的实现的一个重要技术是写一个简单版本的程序来看看它是如何工作的。在 Python 中,这是如此简单,以至于你可以在用许多其他语言编写一个版本的时间内编写几个原型。不过,如果没有必要的话,你应该警惕重写代码——重构通常是更好的解决方案。
- 配置:从你的程序中提取常量,使得在以后的某个时候修改它们变得更加容易。将它们放在一个配置文件中,可以让你的用户按照他们的意愿配置程序。使用环境变量和命令行选项可以使您的程序更加可配置。
- 日志记录:日志记录对于发现程序中的问题非常有用——或者仅仅是监视它的普通行为。您可以使用
print语句自己实现简单的日志记录,但是最安全的做法是使用标准库中的logging模块。
什么现在?
的确,现在怎么办?现在是冒险真正开始编程的时候了。项目时间到了。所有十个项目章节都有类似的结构,包括以下部分:
- “有什么问题?”:在本节中,概述了项目的主要目标,包括一些背景信息。
- “有用的工具”:在这里,我描述了可能对项目有用的模块、类、函数等等。
- “准备工作”:本节涵盖开始编程前的任何必要准备工作。这可能包括为测试实现建立必要的框架。
- “第一次实现”:这是第一次尝试——一次尝试性的实现,以了解问题的更多信息。
- “第二次实现”:在第一次实现之后,你大概会对事物有更好的理解,这将使你能够创建一个新的改进版本。
- “进一步探索”:最后,我给出进一步实验和探索的指针。让我们从第一个项目开始,这是创建一个自动用 HTML 标记文件的程序。
Footnotes 1
极限编程是一种软件开发方法,可以说已经被程序员使用了很多年,但它是由 Kent Beck 首先命名和记录的。更多信息请参见 http://www.extremeprogramming.org 。
2
或者,就此而言,它的中国亲戚,如太极拳或八卦掌。
3
实际上,全局配置文件和系统设置的环境变量在这些之前。更多细节见书。
4
也可以在 Raymond 的网站上在线获得。
二十、项目 1:即时标记
在这个项目中,您将看到如何使用 Python 出色的文本处理功能,包括使用正则表达式将纯文本文件转换成用 HTML 或 XML 等语言标记的文件。如果你想在一个需要标记内容的系统中使用不懂这些语言的人写的文本,你需要这样的技能。
不会说流利的 XML?不要担心这个——如果你对 HTML 只是一知半解,你在这一章会做得很好。如果你需要 HTML 入门,你可以在网上找到大量的教程。有关 XML 使用的示例,请参见第二十二章。
让我们从实现一个简单的原型开始,它完成基本的处理,然后扩展这个程序,使标记系统更加灵活。
有什么问题?
您想给纯文本文件添加一些格式。假设您从一个不喜欢用 HTML 编写的人那里得到了一个文件,您需要将该文档用作网页。您希望程序自动添加所有必要的标签,而不是手动添加。
Note
近年来,这种“纯文本标记”实际上已经变得相当普遍,可能主要是因为具有纯文本界面的 wiki 和博客软件的激增。有关更多信息,请参见本章末尾的“进一步探索”一节。
你的任务基本上就是对各种文本元素进行分类,比如标题和强调的文字,然后清晰地标注出来。在这里解决的具体问题中,您将 HTML 标记添加到文本中,这样得到的文档就可以在 web 浏览器中显示并用作网页。然而,一旦构建了基本引擎,就没有理由不能添加其他类型的标记(比如各种形式的 XML 或者 LATEX 代码)。在分析一个文本文件之后,您甚至可以执行其他任务,比如提取所有的标题来制作一个目录。
Note
LATEX 是另一种标记系统(基于 TEX 排版程序),用于创建各种类型的技术文档。我在这里提到它只是作为你的程序的其他用途的一个例子。如果您想了解更多,您可以访问 TEX 用户组网站 http://www.tug.org 。
给你的文本可能包含一些线索(比如被标记为*like this*的强调文本),但是你可能需要一些独创性来让你的程序猜测文档是如何构造的。
在开始写你的原型之前,让我们定义一些目标。
- 不应该要求输入包含人工代码或标签。
- 您应该能够处理不同的块,如标题、段落和列表项,以及行内文本,如强调文本或 URL。
- 尽管这个实现处理的是 HTML,但是它应该很容易扩展到其他标记语言。
在你的程序的第一个版本中,你可能无法完全达到这些目标,但这就是原型的意义所在。你写原型是为了在你最初的想法中寻找缺陷,并学习如何写一个程序来解决你的问题。
Tip
如果可以的话,逐步修改你的原始程序可能是个好主意,而不是从头开始。为了清楚起见,我在这里给你程序的两个完全不同的版本。
有用的工具
考虑一下写这个程序可能需要什么工具。
- 你当然需要读写文件(见第十一章),或者至少从标准输入(
sys.stdin)中读取并用print输出。 - 你可能需要迭代输入的行(见第十一章)。
- 你需要一些字符串方法(见第三章)。
- 也许你会使用一两个发电机(见第九章)。
- 你可能需要
re模块(见第十章)。
如果这些概念中的任何一个对你来说是陌生的,你也许应该花一点时间来刷新你的记忆。
准备
在你开始编码之前,你需要一些方法来评估你的进展;你需要一个测试套件。在这个项目中,一个简单的测试就足够了:一个测试文档(纯文本)。清单 20-1 包含您想要自动标记的示例文本。
Welcome to World Wide Spam, Inc.
These are the corporate web pages of *World Wide Spam*, Inc. We hope
you find your stay enjoyable, and that you will sample many of our
products.
A short history of the company
World Wide Spam was started in the summer of 2000\. The business
concept was to ride the dot-com wave and to make money both through
bulk email and by selling canned meat online.
After receiving several complaints from customers who weren't
satisfied by their bulk email, World Wide Spam altered their profile,
and focused 100% on canned goods. Today, they rank as the world's
13,892nd online supplier of SPAM.
Destinations
From this page you may visit several of our interesting web pages:
- What is SPAM? (http://wwspam.fu/whatisspam)
- How do they make it? (http://wwspam.fu/howtomakeit)
- Why should I eat it? (http://wwspam.fu/whyeatit)
How to get in touch with us
You can get in touch with us in *many* ways: By phone (555-1234), by
email (wwspam@wwspam.fu) or by visiting our customer feedback page
(http://wwspam.fu/feedback).
Listing 20-1.A Sample Plain-Text Document (test_input.txt)
要测试您的实现,只需使用这个文档作为输入并在 web 浏览器中查看结果,或者直接检查添加的标签。
Note
拥有自动化测试套件通常比手动检查测试结果更好。(你有没有看到任何自动化测试的方法?)
首次实施
你需要做的第一件事是将文本分成段落。从清单 20-1 中可以明显看出,段落由一个或多个空行分隔。一个比段落更好的词可能是 block,因为这个名字也适用于标题和列表项。
查找文本块
找到这些块的一个简单方法是收集你遇到的所有行,直到你找到一个空行,然后返回你到目前为止收集的行。那会是一个街区。然后,你可以从头再来。不需要费心收集空行,也不会返回空块(遇到不止一个空行的地方)。此外,您应该确保文件的最后一行是空的;否则你不知道最后一块什么时候完成。(当然,还有其他方法可以发现。)
清单 20-2 展示了这种方法的实现。
def lines(file):
for line in file: yield line
yield '\n'
def blocks(file):
block = []
for line in lines(file):
if line.strip():
block.append(line)
elif block:
yield ''.join(block).strip()
block = []
Listing 20-2.A Text Block Generator
(util.py)
生成器只是一个小工具,它在文件末尾添加一个空行。blocks生成器实现了所描述的方法。当产生一个块时,它的行被连接起来,产生的字符串被剥离,得到一个表示该块的单个字符串,两端多余的空格(如列表缩进或换行符)被删除。(如果你不喜欢这种找段落的方式,我相信你可以想出其他几种方法。看看你能发明多少可能会很有趣。)我已经将代码放在了文件util.py中,这意味着您可以稍后在您的程序中导入实用程序生成器。
添加一些标记
使用清单 20-2 中的基本功能,您可以创建一个简单的标记脚本。该程序的基本步骤如下:
- 打印一些开始标记。
- 对于每个块,打印包含在段落标记中的块。
- 打印一些结束标记。
这不是很难,但也不是非常有用。假设您没有将第一个块包含在段落标记中,而是将它包含在顶部标题标记(h1)中。此外,您可以用强调文本(使用em标签)替换星号中的任何文本。至少这样更有用一点。给定blocks函数并使用re.sub,代码非常简单。见清单 20-3 。
import sys, re
from util import *
print('<html><head><title>...</title><body>')
title = True
for block in blocks(sys.stdin):
block = re.sub(r'\*(.+?)\*', r'<em>\1</em>', block)
if title:
print('<h1>')
print(block)
print('</h1>')
title = False
else:
print('<p>')
print(block)
print('</p>')
print('</body></html>')
Listing 20-3.A Simple Markup Program (simple_markup.py)
该程序可以对样本输入执行如下:
$ python simple_markup.py < test_input.txt > test_output.html
文件test_output.html将包含生成的 HTML 代码。图 20-1 展示了这些 HTML 代码在网络浏览器中的样子。
图 20-1。
The first attempt at generating a web page
虽然不是很令人印象深刻,但这个原型确实执行了一些重要的任务。它将文本分成可以单独处理的块,并依次对每个块应用过滤器(由对re.sub的调用组成)。这似乎是一个在你的期末项目中使用的好方法。
如果你试图扩展这个原型会发生什么?您可能会在for循环中添加检查,以查看该块是标题、列表项还是其他内容。你可以添加更多的正则表达式。它可能会很快变得一团糟。更重要的是,很难让它输出除 HTML 之外的任何内容;这个项目的目标之一是使添加其他输出格式变得容易。让我们假设您想要重构您的程序,并以稍微不同的方式构建它。
第二次实施
那么,你从第一次实现中学到了什么?为了使它更具可扩展性,你需要使你的程序更加模块化(将功能分成独立的组件)。实现模块化的一种方法是通过面向对象的设计(见第七章)。随着程序复杂性的增加,您需要找到一些抽象来使程序更易于管理。让我们首先列出一些可能的组件。
- 解析器:添加一个读取文本并管理其他类的对象。
- 规则:您可以为每种类型的块制定一条规则。该规则应该能够检测适用的块类型,并对其进行适当的格式化。
- 过滤器:使用过滤器包装一些正则表达式来处理行内元素。
- 处理程序:解析器使用处理程序来生成输出。每个处理程序可以产生不同种类的标记。
虽然这不是一个非常详细的设计,但至少它给了你一些关于如何将你的代码分成更小的部分,并使每个部分易于管理的想法。
经理人
让我们从处理程序开始。处理程序负责生成结果标记文本,但它从解析器接收详细的指令。假设它对每种块类型都有一对方法:一个用于开始块,一个用于结束块。例如,它可能有方法start_paragraph和end_paragraph来处理段落块。对于 HTML,这些可以按如下方式实现:
class HTMLRenderer:
def start_paragraph(self):
print('<p>')
def end_paragraph(self):
print('</p>')
当然,对于其他块类型,您需要类似的方法。(关于HTMLRenderer类的完整代码,请参见本章后面的清单 20-4 。)这个好像够灵活了。如果您想要一些其他类型的标记,您只需用 start 和 end 方法的其他实现创建另一个处理程序(或呈现器)。
Note
选择术语 handler(例如,与 renderer 相对)来表示它处理由解析器生成的方法调用(另请参见下一节“Handler 超类”)。它不需要像HTMLRenderer那样用某种标记语言呈现文本。一个类似的处理机制被用在名为 SAX 的 XML 解析模式中,这将在第二十二章中解释。
你是怎么处理正则表达式的?您可能还记得,re.sub函数可以将一个函数作为它的第二个参数(替换)。这个函数用match对象调用,它的返回值被插入到文本中。这非常符合前面讨论的处理程序原理——您只需让处理程序实现替换方法。例如,强调可以这样处理:
def sub_emphasis(self, match):
return '<em>{}</em>'.format(match.group(1))
如果你不明白group方法是做什么的,也许你应该再看看re模块,在第十章中有描述。
除了start、end和sub方法之外,我们将有一个名为feed的方法,我们用它向处理程序提供实际的文本。在简单的 HTML 呈现器中,让我们像这样实现它:
def feed(self, data):
print(data)
处理程序超类
出于灵活性的考虑,让我们添加一个Handler类,它将是处理程序的超类,负责一些管理细节。不需要用它们的全名(例如,start_paragraph)来调用这些方法,有时将块类型作为字符串来处理(例如,'paragraph')并提供给处理程序是很有用的。您可以通过添加一些名为start(type)、end(type)和sub(type)的通用方法来做到这一点。此外,您可以让start、end、sub检查相应的方法(如start('paragraph')的start_paragraph)是否真正实现,如果没有找到,则不做任何事情。下面是这个Handler类的一个实现。(这段代码取自后面显示的模块handlers,在清单 20-4 中。)
class Handler:
def callback(self, prefix, name, *args):
method = getattr(self, prefix + name, None)
if callable(method): return method(*args)
def start(self, name):
self.callback('start_', name)
def end(self, name):
self.callback('end_', name)
def sub(self, name):
def substitution(match):
result = self.callback('sub_', name, match)
if result is None: match.group(0)
return result
return substitution
这段代码中的几件事情需要一些解释。
callback方法负责找到正确的方法(如start_paragraph),给定一个前缀(如'start_')和一个名称(如'paragraph')。它使用None作为默认值的getattr来执行其任务。如果从getattr返回的对象是可调用的,那么它将被提供的任何附加参数调用。例如,调用handler.callback('start_', 'paragraph')会不带参数地调用方法handler.start_paragraph,假设它存在。start和end方法只是用各自的前缀start_和end_调用callback的助手方法。sub方法有点不同。它不直接调用callback而是返回一个新函数,这个新函数在re.sub中被用作替换函数(这就是为什么它把一个匹配对象作为它唯一的参数)。
让我们考虑一个例子。比方说HTMLRenderer是Handler的一个子类,它实现了上一节描述的方法sub_emphasis(参见清单 20-4 中handlers.py的实际代码)。假设您在变量处理程序中有一个HTMLRenderer实例。
>>> from handlers import HTMLRenderer
>>> handler = HTMLRenderer()
那么handler.sub('emphasis')会做什么?
>>> handler.sub('emphasis')
<function substitution at 0x168cf8>
它返回一个函数(substitution),当你调用它时,这个函数基本上会调用handler.sub_emphasis方法。这意味着您可以在re.sub语句中使用该函数:
>>> import re
>>> re.sub(r'\*(.+?)\*', handler.sub('emphasis'), 'This *is* a test')
'This <em>is</em> a test'
神奇!(正则表达式匹配用星号括起来的文本,我稍后将讨论这一点。)但为什么要走这么远呢?为什么不像简单版那样直接用r'<em>\1</em>'?因为这样一来,您将致力于使用em标记,但是您希望处理程序能够决定使用哪个标记。例如,如果您的处理程序是一个(假设的)LaTeXRenderer,您可能会得到完全不同的结果。
>> re.sub(r'\*(.+?)\*', handler.sub('emphasis'), 'This *is* a test')
'This \\emph{is} a test'
标记已经更改,但代码没有更改。
我们也有一个备份,以防没有替代实施。callback方法试图找到一个合适的sub_something方法,但是如果没有找到,它就返回None。因为你的函数是一个re.sub替换函数,你不希望它返回None。相反,如果您没有找到替换方法,您只需返回原始匹配,不做任何修改。如果回调返回None,substitution(在sub里面)反而返回原来匹配的文本(match.group(0))。
规则
既然您已经使处理程序变得非常可扩展和灵活,那么是时候转向解析(解释原始文本)了。不要像在简单的标记程序中那样,用各种条件和动作来做一个大的if语句,让我们把规则变成一个独立的对象。
规则由主程序(解析器)使用,主程序必须确定哪些规则适用于给定的块,然后让每个规则执行转换块所需的操作。换句话说,规则必须能够做到以下几点:
- 识别适用的块(条件)。
- 变换块(动作)。
所以每个规则对象必须有两个方法:condition和action。
condition方法只需要一个参数:有问题的块。它应该返回一个布尔值,表明该规则是否适用于给定的块。
Tip
对于复杂的规则解析,您可能还想让 rule 对象访问一些状态变量,这样它就能更多地了解到目前为止发生了什么,或者哪些其他规则已经应用或还没有应用。
action方法也需要块作为参数,但是为了能够影响输出,它还必须能够访问 handler 对象。
在许多情况下,可能只有一个规则适用;也就是说,如果您发现使用了标题规则(表明该块是标题),您不应该尝试使用段落规则。一个简单的实现是让解析器一个接一个地尝试规则,一旦其中一个规则被触发,就停止块的处理。这通常没问题,但是正如您将看到的,有时一个规则可能不排除其他规则的执行。因此,我们向 action 方法添加了另一项功能:它返回一个布尔值,指示当前块的规则处理是否应该停止。(你也可以为此使用一个异常,类似于迭代器的StopIteration机制。)
标题规则的伪代码可能如下:
class HeadlineRule:
def condition(self, block):
if the block fits the definition of a headline, return True;
otherwise, return False.
def action(self, block, handler):
call methods such as handler.start('headline'), handler.feed(block) and
handler.end('headline').
because we don't want to attempt to use any other rules,
return True, which will end the rule processing for this block.
规则超类
尽管您的规则并不一定需要一个公共的超类,但是它们中的几个可能共享相同的一般操作——用适当的类型字符串参数调用处理程序的start、feed和end方法,然后返回True(以停止规则处理)。假设所有的子类都有一个名为type的属性,该属性以字符串的形式包含这个类型名,您可以实现您的超类,如下面的代码所示。(Rule类位于rules模块中;完整的代码显示在清单 20-5 中。)
class Rule:
def action(self, block, handler):
handler.start(self.type)
handler.feed(block)
handler.end(self.type)
return True
condition方法是每个子类的责任。Rule类及其子类放在rules模块中。
过滤
您不需要为您的过滤器创建单独的类。给定您的Handler类的sub方法,每个过滤器可以由一个正则表达式和一个名称来表示(例如emphasis或url)。在下一节中,当我向您展示如何处理解析器时,您将会看到这一点。
解析器
我们来到了应用的核心:类Parser。它使用一个处理程序、一组规则和过滤器将一个纯文本文件转换成一个带标记的文件——在这个特定的例子中,是一个 HTML 文件。它需要哪些方法?它需要一个构造函数来设置,一个方法来添加规则,一个方法来添加过滤器,一个方法来解析给定的文件。
下面是Parser类的代码(来自清单 20-6 ,在本章的后面,它详述了markup.py):
class Parser:
"""
A Parser reads a text file, applying rules and controlling a
handler.
"""
def __init__ (self, handler):
self.handler = handler
self.rules = []
self.filters = []
def addRule(self, rule):
self.rules.append(rule)
def addFilter(self, pattern, name):
def filter(block, handler):
return re.sub(pattern, handler.sub(name), block)
self.filters.append(filter)
def parse(self, file):
self.handler.start('document')
for block in blocks(file):
for filter in self.filters:
block = filter(block, self.handler)
for rule in self.rules:
if rule.condition(block):
last = rule.action(block, self.handler)
if last: break
self.handler.end('document')
虽然这门课有很多内容需要消化,但大部分并不复杂。构造函数只是将提供的处理程序分配给一个实例变量(属性),然后初始化两个列表:一个是规则列表,一个是过滤器列表。addRule方法将规则添加到规则列表中。然而,addFilter方法做了更多的工作。像addRule一样,它将一个过滤器添加到过滤器列表中,但在此之前,它会创建那个过滤器。过滤器是一个简单的函数,它使用适当的正则表达式(模式)应用re.sub,并使用来自处理程序的替换,通过handler.sub(name)访问。
方法parse虽然看起来有点复杂,但可能是最容易实现的方法,因为它只是做了你一直计划要做的事情。它通过调用处理程序上的start('document')开始,通过调用end('document')结束。在这些调用之间,它遍历文本文件中的所有块。对于每个块,它应用过滤器和规则。应用一个过滤器只是调用带有块和处理程序作为参数的filter函数,并将块变量重新绑定到结果,如下所示:
block = filter(block, self.handler)
这使得每个过滤器都能够完成它的工作,用标记文本替换部分文本(比如用<em>this</em>替换*this*)。
规则循环中有更多的逻辑。对于每个规则,都有一个if语句,通过调用rule.condition(block)来检查规则是否适用。如果规则适用,则调用rule.action,将块和处理程序作为参数。记住,action方法返回一个布尔值,指示是否完成对这个块的规则应用。通过将变量last设置为 action 的返回值,然后有条件地退出for循环来完成规则应用。
if last: break
Note
您可以将这两条语句合并成一条,去掉last变量。
if rule.action(block, self.handler): break
是否这样做在很大程度上是一个品味问题。移除临时变量会使代码更简单,但保留它会清楚地标记返回值。
构建规则和过滤器
现在您已经拥有了所有需要的工具,但是您还没有创建任何特定的规则或过滤器。到目前为止,您编写的大部分代码背后的动机是让规则和过滤器像处理程序一样灵活。您可以编写几个独立的规则和过滤器,并通过addRule和addFilter方法将它们添加到您的解析器中,确保在您的处理程序中实现适当的方法。
复杂的规则集使得处理复杂的文档成为可能。然而,现在让我们保持简单。让我们为标题创建一个规则,为其他标题创建一个规则,为列表项创建一个规则。因为列表项应该被视为一个列表,所以您将创建一个单独的列表规则来处理整个列表。最后,您可以为段落创建一个默认规则,该规则涵盖了前面的规则没有涉及的所有块。
我们可以用如下的非正式术语来说明这些规则:
- 标题是仅由一行组成的块,其长度最多为 70 个字符。如果块以冒号结尾,则不是标题。
- 标题是文档中的第一个块,前提是它是一个标题。
- 列表项是以连字符(-)开头的块。
- 列表开始于非列表项的块和后面的列表项之间,结束于列表项和后面的非列表项的块之间。
这些规则遵循我对文本文档结构的一些直觉。你对此的看法(以及你的文本文档)可能不同。此外,这些规则也有弱点(例如,如果文档以列表项结尾会发生什么?).请随意改进它们。这些规则的完整源代码如清单 20-5 ( rules.py,其中也包含了基本的Rule类)所示。让我们从标题规则开始:
class HeadingRule(Rule):
"""
A heading is a single line that is at most 70 characters and
that doesn't end with a colon.
"""
type = 'heading'
def condition(self, block):
return not '\n' in block and len(block) <= 70 and not block[-1] == ':'
属性类型已经被设置为字符串'heading',它被从Rule继承的action方法使用。该条件只是检查该块不包含换行符(\n),其长度最多为 70,并且最后一个字符不是冒号。
标题规则类似,但只对第一个块有效一次。此后,它忽略所有块,因为它的属性first已经被设置为False。
class TitleRule(HeadingRule):
"""
The title is the first block in the document, provided that it is
a heading.
"""
type = 'title'
first = True
def condition(self, block):
if not self.first: return False
self.first = False
return HeadingRule.condition(self, block)
列表项规则条件是上述规范的直接实现。
class ListItemRule(Rule):
"""
A list item is a paragraph that begins with a hyphen. As part of
the formatting, the hyphen is removed.
"""
type = 'listitem'
def condition(self, block):
return block[0] == '-'
def action(self, block, handler):
handler.start(self.type)
handler.feed(block[1:].strip())
handler.end(self.type)
return True
它的动作是在Rule中发现的动作的重新实现。唯一的区别是它删除了块中的第一个字符(连字符),并去掉了剩余文本中多余的空白。标记提供了自己的“列表项目符号”,所以不再需要连字符。
到目前为止,所有的规则操作都返回了True。列表规则不会触发,因为当您在非列表项目后遇到列表项目或在列表项目后遇到非列表项目时,会触发该规则。因为它实际上并不标记这些块,而只是指示一个列表(一组列表项)的开始和结束,所以您不希望停止规则处理—所以它返回False。
class ListRule(ListItemRule):
"""
A list begins between a block that is not a list item and a
subsequent list item. It ends after the last consecutive list
item.
"""
type = 'list'
inside = False
def condition(self, block):
return True
def action(self, block, handler):
if not self.inside and ListItemRule.condition(self, block):
handler.start(self.type)
self.inside = True
elif self.inside and not ListItemRule.condition(self, block):
handler.end(self.type)
self.inside = False
return False
列表规则可能需要一些进一步的解释。它的条件总是真的,因为你想检查所有的块。在行动方法中,你有两个可能导致行动的选择。
- 如果属性
inside(表示解析器当前是否在列表中)为假(最初是这样),并且列表项规则的条件为真,那么您就已经进入了一个列表。调用处理程序的适当的start方法,并将inside属性设置为True。 - 反之,如果
inside为真,列表项规则条件为假,则刚刚离开了一个列表。调用处理程序的适当结束方法,并将inside属性设置为False。
在这个处理之后,函数返回False让规则处理继续。(这当然意味着规则的顺序很关键。)
最后的规则是ParagraphRule。其条件始终为真,因为这是“默认”规则。它被添加为规则列表的最后一个元素,处理任何其他规则都无法处理的所有块。
class ParagraphRule(Rule):
"""
A paragraph is simply a block that isn't covered by any of the
other rules.
"""
type = 'paragraph'
def condition(self, block):
return True
过滤器只是正则表达式。让我们添加三个过滤器:一个用于强调,一个用于 URL,一个用于电子邮件地址。让我们使用以下三个正则表达式:
r'\*(.+?)\*'
r'(http://[\.a-zA-Z/]+)'
r'([\.a-zA-Z]+@[\.a-zA-Z]+[a-zA-Z]+)'
第一个模式(强调)匹配一个星号,后跟一个或多个任意字符(尽可能少地匹配,因此是问号),再跟另一个星号。第二种模式(URL)匹配字符串'http://'(在这里,您可以添加更多的协议),后跟一个或多个点、字母或斜线字符。(这种模式不会匹配所有合法的 URL,请随意改进。)最后,email 模式匹配一个字母和点的序列,后面跟着一个 at 符号(@),再后面是更多的字母和点,最后是一个字母的序列,确保你不是以点结尾。(还是那句话,可以随意改进这个。)
把这一切放在一起
您现在只需要创建一个Parser对象,并添加相关的规则和过滤器。让我们通过创建一个Parser的子类来实现,这个子类在其构造函数中进行初始化。然后让我们用它来解析sys.stdin。最终程序如清单 20-4 至 20-6 所示。(这些清单取决于清单 20-2 中的实用程序代码。)最终的程序可以像原型一样运行。
$ python markup.py < test_input.txt > test_output.html
class Handler:
"""
An object that handles method calls from the Parser.
The Parser will call the start() and end() methods at the
beginning of each block, with the proper block name as a
parameter. The sub() method will be used in regular expression
substitution. When called with a name such as 'emphasis', it will
return a proper substitution function.
"""
def callback(self, prefix, name, *args):
method = getattr(self, prefix + name, None)
if callable(method): return method(*args)
def start(self, name):
self.callback('start_', name)
def end(self, name):
self.callback('end_', name)
def sub(self, name):
def substitution(match):
result = self.callback('sub_', name, match)
if result is None: match.group(0)
return result
return substitution
class HTMLRenderer(Handler):
"""
A specific handler used for rendering HTML.
The methods in HTMLRenderer are accessed from the superclass
Handler's start(), end(), and sub() methods. They implement basic
markup as used in HTML documents.
"""
def start_document(self):
print('<html><head><title>...</title></head><body>')
def end_document(self):
print('</body></html>')
def start_paragraph(self):
print('<p>')
def end_paragraph(self):
print('</p>')
def start_heading(self):
print('<h2>')
def end_heading(self):
print('</h2>')
def start_list(self):
print('<ul>')
def end_list(self):
print('</ul>')
def start_listitem(self):
print('<li>')
def end_listitem(self):
print('</li>')
def start_title(self):
print('<h1>')
def end_title(self):
print('</h1>')
def sub_emphasis(self, match):
return '<em>{}</em>'.format(match.group(1))
def sub_url(self, match):
return '<a href="{}">{}</a>'.format(match.group(1), match.group(1))
def sub_mail(self, match):
return '<a href="mailto:{}">{}</a>'.format(match.group(1), match.group(1))
def feed(self, data):
print(data)
Listing 20-4.The Handlers (handlers.py)
class Rule:
"""
Base class for all rules.
"""
def action(self, block, handler):
handler.start(self.type)
handler.feed(block)
handler.end(self.type)
return True
class HeadingRule(Rule):
"""
A heading is a single line that is at most 70 characters and
that doesn't end with a colon.
"""
type = 'heading'
def condition(self, block):
return not '\n' in block and len(block) <= 70 and not block[-1] == ':'
class TitleRule(HeadingRule):
"""
The title is the first block in the document, provided that
it is a heading.
"""
type = 'title'
first = True
def condition(self, block):
if not self.first: return False
self.first = False
return HeadingRule.condition(self, block)
class ListItemRule(Rule):
"""
A list item is a paragraph that begins with a hyphen. As part of the
formatting, the hyphen is removed.
"""
type = 'listitem'
def condition(self, block):
return block[0] == '-'
def action(self, block, handler):
handler.start(self.type)
handler.feed(block[1:].strip())
handler.end(self.type)
return True
class ListRule(ListItemRule):
"""
A list begins between a block that is not a list item and a
subsequent list item. It ends after the last consecutive list item.
"""
type = 'list'
inside = False
def condition(self, block):
return True
def action(self, block, handler):
if not self.inside and ListItemRule.condition(self, block):
handler.start(self.type)
self.inside = True
elif self.inside and not ListItemRule.condition(self, block):
handler.end(self.type)
self.inside = False
return False
class ParagraphRule(Rule):
"""
A paragraph is simply a block that isn't covered by any of the other rules.
"""
type = 'paragraph'
def condition(self, block):
return True
Listing 20-5.The Rules (rules.py)
import sys, re
from handlers import *
from util import *
from rules import *
class Parser:
"""
A Parser reads a text file, applying rules and controlling a handler.
"""
def __init__(self, handler):
self.handler = handler
self.rules = []
self.filters = []
def addRule(self, rule):
self.rules.append(rule)
def addFilter(self, pattern, name):
def filter(block, handler):
return re.sub(pattern, handler.sub(name), block)
self.filters.append(filter)
def parse(self, file):
self.handler.start('document')
for block in blocks(file):
for filter in self.filters:
block = filter(block, self.handler)
for rule in self.rules:
if rule.condition(block):
last = rule.action(block,
self.handler)
if last: break
self.handler.end('document')
class BasicTextParser(Parser):
"""
A specific Parser that adds rules and filters in its constructor.
"""
def __init__(self, handler):
Parser.__init__(self, handler)
self.addRule(ListRule())
self.addRule(ListItemRule())
self.addRule(TitleRule())
self.addRule(HeadingRule())
self.addRule(ParagraphRule())
self.addFilter(r'\*(.+?)\*', 'emphasis')
self.addFilter(r'(http://[\.a-zA-Z/]+)', 'url')
self.addFilter(r'([\.a-zA-Z]+@[\.a-zA-Z]+[a-zA-Z]+)', 'mail')
handler = HTMLRenderer()
parser = BasicTextParser(handler)
parser.parse(sys.stdin)
Listing 20-6.The Main Program (markup.py)
您可以在图 20-2 中的示例文本上看到程序运行的结果。
图 20-2。
The second attempt at generating a web page
第二个实现显然比第一个版本更加复杂和广泛。增加的复杂性是值得的,因为最终的程序更加灵活和可扩展。使它适应新的输入和输出格式仅仅是一个子类化和初始化现有类的问题,而不是像在第一个原型中那样从头重写一切。
进一步探索
这个程序可能有几个扩展。以下是一些可能性:
- 添加对表格的支持。找到所有对齐的左字边框,并将块拆分成列。
- 添加对将所有大写单词解释为强调的支持。(要正确地做到这一点,你需要考虑缩写词、标点符号、名称和其他大写单词。)
- 添加对 LATEX 输出的支持。
- 编写一个处理程序,做一些除了标记以外的事情。也许可以编写一个以某种方式分析文档的处理程序。
- 创建一个脚本,自动将目录中的所有文本文件转换为 HTML 文件。
- 查看一些现有的纯文本格式,如 Markdown、reStructuredText 或维基百科中使用的格式。
什么现在?
唷!在这个费力(但希望有用)的项目之后,是时候用一些更轻的材料了。在下一章,我们将基于从互联网上自动下载的数据创建一些图形。小菜一碟。