Dive Into Python3 中文版(三)
Chapter 9 单元测试
" Certitude is not the test of certainty. We have been cocksure of many things that were not so. " — Oliver Wendell Holmes, Jr.
(不要)深入
在此章节中,你将要编写及调试一系列用于阿拉伯数字与罗马数字相互转换的方法。你阅读了在“案例学习:罗马数字”中关于构建及校验罗马数字的机制。那么,现在考虑扩展该机制为一个双向的方法。
罗马数字的规则引出很多有意思的结果:
- 只有一种正确的途径用阿拉伯数字表示罗马数字。
- 反过来一样,一个字符串类型的有效的罗马数字也仅可以表示一个阿拉伯数字(即,这种转换方式也是只有一种)。
- 只有有限范围的阿拉伯数字可以以罗马数字表示,那就是 1-3999。而罗马数字表示大数字却有几种方式。例如,为了表示一个数字连续出现时正确的值则需要乘以
1000。为了达到本节的目的,限定罗马数字在 1 到 3999 之间。 - 无法用罗马数字来表示 0 。
- 无法用罗马数字来表示负数 。
- 无法用罗马数字来表示分数或非整数 。
现在,开始设计 roman.py 模块。它有两个主要的方法:to_roman() 及 from_roman()。to_roman() 方法接收一个从 1 到 3999 之间的整型数字,然后返回一个字符串类型的罗马数字。
在这里停下来。现在让我们进行一些意想不到的操作:编写一个测试用例来检测 to_roman 函数是否实现了你想要的功能。你想得没错:你正在编写测试尚未编写代码的代码。
这就是所谓的测试驱动开发 或 TDD。那两个转换方法( to_roman() 及之后的 from_roman())可以独立于任何使用它们的大程序而作为一个单元来被编写及测试。Python 自带一个单元测试框架,被恰当地命名为 unittest 模块。
单元测试是整个以测试为中心的开发策略中的一个重要部分。编写单元测试应该安排在项目的早期,同时要让它随同代码及需求变更一起更新。很多人都坚持测试代码应该先于被测试代码的,而这种风格也是我在本节中所主张的。但是,不管你何时编写,单元测试都是有好处的。
- 在编写代码之前,通过编写单元测试来强迫你使用有用的方式细化你的需求。
- 在编写代码时,单元测试可以使你避免过度编码。当所有测试用例通过时,实现的方法就完成了。
- 重构代码时,单元测试用例有助于证明新版本的代码跟老版本功能是一致的。
- 在维护代码期间,如果有人对你大喊:你最新的代码修改破坏了原有代码的状态,那么此时单元测试可以帮助你反驳(“先生,所有单元测试用例通过了我才提交代码的...”)。
- 在团队编码中,缜密的测试套件可以降低你的代码影响别人代码的机会,这是因为你需要优先执行别人的单元测试用例。(我曾经在代码冲刺见过这种实践。一个团队把任务分解,每个人领取其中一小部分任务,同时为其编写单元测试;然后,团队相互分享他们的单元测试用例。这样,所有人都可以在编码过程中提前发现谁的代码与其他人的不可以良好工作。)
一个简单的问题
每个测试都是一个孤岛。
一个测试用例仅回答一个关于它正在测试的代码问题。一个测试用例应该可以:
- ……完全自动运行,而不需要人工干预。单元测试几乎是全自动的。
- ……自主判断被测试的方法是通过还是失败,而不需要人工解释结果。
- ……独立运行,而不依赖其它测试用例(即使测试的是同样的方法)。即,每一个测试用例都是一个孤岛。
让我们据此为第一个需求建立一个测试用例:
to_roman()方法应该返回代表1-3999的罗马数字。
这些代码功效如何并不那么显而易见。它定义了一个没有__init__ 方法的类。而该类当然有其它方法,但是这些方法都不会被调用。在整个脚本中,有一个main 块,但它并不引用该类及它的方法。但我承诺,它做别的事情了。
import roman1
import unittest
known_values = ( (1, 'I'),
(2, 'II'),
(3, 'III'),
(4, 'IV'),
(5, 'V'),
(6, 'VI'),
(7, 'VII'),
(8, 'VIII'),
(9, 'IX'),
(10, 'X'),
(50, 'L'),
(100, 'C'),
(500, 'D'),
(1000, 'M'),
(31, 'XXXI'),
(148, 'CXLVIII'),
(294, 'CCXCIV'),
(312, 'CCCXII'),
(421, 'CDXXI'),
(528, 'DXXVIII'),
(621, 'DCXXI'),
(782, 'DCCLXXXII'),
(870, 'DCCCLXX'),
(941, 'CMXLI'),
(1043, 'MXLIII'),
(1110, 'MCX'),
(1226, 'MCCXXVI'),
(1301, 'MCCCI'),
(1485, 'MCDLXXXV'),
(1509, 'MDIX'),
(1607, 'MDCVII'),
(1754, 'MDCCLIV'),
(1832, 'MDCCCXXXII'),
(1993, 'MCMXCIII'),
(2074, 'MMLXXIV'),
(2152, 'MMCLII'),
(2212, 'MMCCXII'),
(2343, 'MMCCCXLIII'),
(2499, 'MMCDXCIX'),
(2574, 'MMDLXXIV'),
(2646, 'MMDCXLVI'),
(2723, 'MMDCCXXIII'),
(2892, 'MMDCCCXCII'),
(2975, 'MMCMLXXV'),
(3051, 'MMMLI'),
(3185, 'MMMCLXXXV'),
(3250, 'MMMCCL'),
(3313, 'MMMCCCXIII'),
(3408, 'MMMCDVIII'),
(3501, 'MMMDI'),
(3610, 'MMMDCX'),
(3743, 'MMMDCCXLIII'),
(3844, 'MMMDCCCXLIV'),
(3888, 'MMMDCCCLXXXVIII'),
(3940, 'MMMCMXL'),
'''to_roman should give known result with known input'''
for integer, numeral in self.known_values:
if __name__ == '__main__':
unittest.main()
- 为了编写测试用例,首先使该测试用例类成为
unittest模块的TestCase类的子类。TestCase 提供了很多你可以用于测试特定条件的测试用例的有用的方法。 - 这是一张我手工核实过的整型数字-罗马数字对的列表。它包括最小的十个数字、最大数字、每一个有唯一一个字符串格式的罗马数字的数字以及一个有其它有效数字产生的随机数。你没有必要测试每一个可能的输入,而需要测试所有明显的边界用例。
- 每一个独立的测试都有它自己的不含参数及没有返回值的方法。如果方法不抛出异常而正常退出则认为测试通过;否则,测试失败。
- 这里调用了真实的
to_roman()方法. (当然,该方法还没编写;但一旦该方法被实现,这就是调用它的行号)。注意,现在你已经为to_roman()方法定义了 接口:它必须包含一个整型(被转换的数字)及返回一个字符串(罗马数字的表示形式)。如果 接口 实现与这些定义不一致,那么测试就会被视为失败。同样,当你调用to_roman()时,不要捕获任何异常。这些都是 unittest 故意设计的。当你以有效的输入调用to_roman()时它不会抛出异常。如果to_roman()抛出了异常,则测试被视为失败。 - 假设
to_roman()方法已经被正确定义,正确调用,成功实现以及返回了一个值,那么最后一步就是去检查它的返回值是否 right 。这是测试中一个普遍的问题。TestCase类提供了一个方法assertEqual来检查两个值是否相等。如果to_roman()(result) 的返回值跟已知的期望值 g (numeral)不一致,则抛出异常,并且测试失败。如果两值相等,assertEqual不会做任何事情。如果to_roman()的所有返回值均与已知的期望值一致,则assertEqual不会抛出任何异常,于是,test_to_roman_known_values最终会会正常退出,这就意味着to_roman()通过此次测试。
编写一个失败的测试,然后进行编码直到该测试通过。
一旦你有了测试用例,你就可以开始编写 to_roman() 方法。首先,你应该用一个空方法作为存根,同时确认该测试失败。因为如果在编写任何代码之前测试已经通过,那么你的测试对你的代码是完全不会有效果的!单元测试就像跳舞:测试先行,编码跟随。编写一个失败的测试,然后进行编码直到该测试通过。
# roman1.py
def to_roman(n):
'''convert integer to Roman numeral'''
- 在此阶段,你想定义 to_roman()方法的 API ,但是你还不想编写(首先,你的测试需要失败)。为了存根,需要使用 Python 保留关键字
pass,它恰恰什么都没做。
在命令行上运行 romantest1.py 来执行该测试。如果使用-v 命令行参数的话,会有更详细的输出来帮助你精确地查看每一条用例的执行过程。幸运的话,你的输出应该如下:
you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
======================================================================
FAIL: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest1.py", line 73, in test_to_roman_known_values
self.assertEqual(numeral, result)
----------------------------------------------------------------------
- 运行脚本就会执行
unittest.main(), 该方法执行了每一条测试用例。而每一条测试用例都是romantest.py中的类方法。这些测试类没有必要的组织要求;它们每一个都包括一个独立的测试方法,或者你也可以编写一个含有多个测试方法的类。唯一的要求就是每一个测试类都必须继承unittest.TestCase。 - 对于每一个测试用例,
unittest模块会打印出测试方法的docstring,并且说明该测试失败还是成功。正如预期那样,该测试用例失败了。 - 对于每一个失败的测试用例,
unittest模块会打印出详细的跟踪信息。在该用例中,assertEqual()的调用抛出了一个AssertionError的异常,这是因为to_roman(1)本应该返回'I'的,但是它没有。(因为没有显示的返回值,故方法返回了 Python 的空值None) - 在说明每个用例的详细执行结果之后,
unittest打印出一个简述来说明“多少用例被执行了”和“测试执行了多长时间”。 - 从整体上说,该测试执行失败,因为至少有一条用例没有成功。如果测试用例没有通过的话,
unittest可以区别用例执行失败跟程序错误的。像assertXYZ、assertRaises这样的assertEqual方法的失败是因为被声明的条件不是为真,或者预期的异常没有抛出。错误,则是另一种异常,它是因为被测试的代码或者单元测试用例本身的代码问题而引起的。
至此,你可以实现 to_roman() 方法了。
roman_numeral_map = (('M', 1000),
('CM', 900),
('D', 500),
('CD', 400),
('C', 100),
('XC', 90),
('L', 50),
('XL', 40),
('X', 10),
('IX', 9),
('V', 5),
('IV', 4),
def to_roman(n):
'''convert integer to Roman numeral'''
result = ''
for numeral, integer in roman_numeral_map:
result += numeral
n -= integer
return result
roman_numeral_map是一个由元组组成的元组,它定义了三样东西:代表最基本的罗马数字的字符、罗马数字的顺序(逆序,从M到I)、每一个罗马数字的阿拉伯数值。每一个内部的元组都是一个(数,值)对。它不但定义了单字符罗马数字,也定义了双字符罗马数字,如CM(“比一千小一百”)。该元组使得to_roman()方法实现起来更简单。- 这里得益于
roman_numeral_map的数据结构,因为你不需要任何特别得逻辑去处理减法。为了转化成罗马数字,通过查找等于或者小于输入值的最大值来简化对roman_numeral_map的迭代。一旦找到,就把罗马数字的字符串追加至输出值(result)末段,同时输入值要减去相应的数值,如此重复。
如果你仍然不清楚 to_roman() 如何工作,可以在 while 循环末段添加 print() 调用:
while n >= integer:
result += numeral
n -= integer
print('subtracting {0} from input, adding {1} to output'.format(integer, numeral))
因为用于调试的 print() 声明,输出会如下:
>>> import roman1
>>> roman1.to_roman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'
这样, to_roman() 至少在手工检查下是工作正常的。但它会通过你编写的测试用例么?
you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
----------------------------------------------------------------------
Ran 1 test in 0.016s
OK
- 万岁!
to_roman()函数通过了“known values” 测试用例。该测试用例并不复杂,但是它的确使该方法按着输入值的变化而执行,其中的输入值包括:每一个单字符罗马数字、最大值数字(3999)、最长字符串数字(3888)。通过这些,你就可以有理由对“该方法接收任何正常的输入值都工作正常”充满信心了。
“正常”输入?”嗯。那“非法”输入呢?
“停止然后着火”
Python 方式的停止并点火实际是引发一个例外。
仅仅在“正常”值时证明方法通过的测试是不够的;你同样需要测试当输入“非法”值时方法失败。但并不是说要枚举所有的失败类型,而是说必要在你预期的范围内失败。
>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
'MMMMMMMMM'
- 这明显不是你所期望的──那也不是一个合法的罗马数字!事实上,这些输入值都超过了允许的范围,但该函数却返回了假值。悄悄返回的错误值是 很糟糕 的,因为如果一个程序要挂掉的话,迅速且引人注目地挂掉会好很多。正如谚语“停止然后着火”。Python 方式的停止并点火实际是引发一个例外。
那问题是:我该如何表达这些内容为可测试需求呢?下面就是一个开始:
当输入值大于
3999时,to_roman()函数应该抛出一个OutOfRangeError异常。
具体测试代码如下:
'''to_roman should fail with large input'''
- 如前一个测试用例,创建一个继承于
unittest.TestCase的类。你可以在每个类中实现多个测试(正如你在本节中将会看到的一样),但是我却选择了创建一个新类,因为该测试与上一个有点不同。这样,我们可以把正常输入的测试跟非法输入的测试分别放入不同的两个类中。 - 如前一个测试用例,测试本身是类一个方法,并且该方法以
test开头命名。 unittest.TestCase类提供 eassertRaises方法,该方法需要以下参数:你期望的异常、你要测试的方法及传入给方法的参数。(如果被测试的方法需要多个参数的话,则把所有参数依次传入assertRaises, assertRaises 会正确地把参数传递给被测方法的。)
请关注代码的最后一行。这里并不需要直接调用 to_roman() ,同时也不需要手动检查它抛出的异常类型(通过 一个 try...except 块来包装),而这些 assertRaises 方法都给我们完成了。你要做的所有事情就是告诉 assertRaises 你期望的异常类型( roman2.OutOfRangeError)、被测方法(to_roman())以及方法的参数(4000)。assertRaises 方法负责调用 to_roman() 和检查方法抛出 roman2.OutOfRangeError 的异常。
另外,注意你是把 to_roman() 方法作为参数传递;你没有调用被测方法,也不是把被测方法作为一个字符串名字传递进去。我是否在之前提到过 Python 中万物皆对象有多么轻便?
那么,当你执行该含有新测试的测试套件时,结果如下:
you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
======================================================================
ERROR: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest2.py", line 78, in test_too_large
self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
----------------------------------------------------------------------
Ran 2 tests in 0.000s
FAILED (errors=1)
- 测试本应该是失败的(因为并没有任何代码使它通过),但是它没有真正的“失败”,而是出现了“错误”。这里有些微妙但是重要的区别。单元测试事实上有 三种 返回值:通过、失败以及错误。“通过”,但当然就是说测试成功了──被测代码符合你的预期。“失败”就是就如之前的测试用例一样(直到你编写代码令它通过)──执行了被测试的代码但返回值并不是所期望的。“错误”就是被测试的代码甚至没有正确执行。
- 为什么代码没有正确执行呢?回溯说明了一切。你正在测试的模块没有叫
OutOfRangeError的异常。回忆一下,该异常是你传递给assertRaises()方法的,因为你期望当传递给被测试方法一个超大值时可以抛出该异常。但是,该异常并不存在,因此assertRaises()的调用会失败。事实上测试代码并没有机会测试to_roman()方法,因为它还没有到达那一步。
为了解决该问题,你需要在 roman2.py 中定义 OutOfRangeError 。
- 异常也是类。“越界”错误是值错误的一类──参数值超出了可接受的范围。所以,该异常继承了内建的
ValueError异常类。这并不是严格的要求(它同样也可以继承于基类Exception),只要它正确就行了。 - 事实上,异常类可以不做任何事情,但是至少添加一行代码使其成为一个类。
pass的真正意思是什么都不做,但是它是一行 Python 代码,所以可以使其成为类。
再次执行该测试套件。
you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
======================================================================
FAIL: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest2.py", line 78, in test_too_large
self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
----------------------------------------------------------------------
Ran 2 tests in 0.016s
FAILED (failures=1)
- 新的测试仍然没有通过,但是它并没有返回错误而是失败。相反,测试失败了。这就是进步!它意味着这回
assertRaises()方法的调用是成功的,同时,单元测试框架事实上也测试了to_roman()函数。 - 当然
to_roman()方法没有引发你所定义的OutOfRangeError异常,因为你并没有让它这么做。这真是个好消息!因为它意味着这是个合格的测试案例——在编写代码使之通过之前它将会以失败为结果。
现在可以编写代码使其通过了。
def to_roman(n):
'''convert integer to Roman numeral'''
if n > 3999:
result = ''
for numeral, integer in roman_numeral_map:
while n >= integer:
result += numeral
n -= integer
return result
- 非常直观:如果给定的输入 (
n) 大于3999,引发一个OutOfRangeError例外。本单元测试并不检测那些与例外相伴的人类可读的字符串,但你可以编写另一个测试来检查它(但请注意用户的语言或环境导致的不同国际化问题)。
这样能让测试通过吗?让我们来寻找答案。
you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
- 万岁!两个测试都通过了。因为你是在测试与编码之间来回反复开发的,所以你可以肯定使得其中一个测试从“失败”转变为“通过”的原因就是你刚才新添的两行代码。虽然这种信心来得并不简单,但是这种代价会在你代码的生命周期中得到回报。
More Halting, More Fire
与测试超大值一样,也必须测试超小值。正如我们在功能需求中提到的那样,罗马数字无法表达 0 或负数。
>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''
显然,这不是好的结果。让我们为这些条件逐条添加测试。
class ToRomanBadInput(unittest.TestCase):
def test_too_large(self):
'''to_roman should fail with large input'''
def test_zero(self):
'''to_roman should fail with 0 input'''
def test_negative(self):
'''to_roman should fail with negative input'''
test_too_large()方法跟之前的步骤一样。我把它包含进来是为了说明新代码的位置。- 这里是新的测试方法:
test_zero()。如test_too_large()一样,它调用了在 nunittest.TestCase中定义的assertRaises()方法,并且以参数值 0 传入给to_roman(),最后检查它抛出相应的异常:OutOfRangeError。 test_negative()也几乎类似,除了它给to_roman()函数传入-1。如果新的测试中 没有 任何一个抛出了异常OutOfRangeError(或者由于该函数返回了实际的值,或者由于它抛出了其他类型的异常),那么测试就被视为失败。
检查测试是否失败:
you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... FAIL
======================================================================
FAIL: to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest3.py", line 86, in test_negative
self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman
======================================================================
FAIL: to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest3.py", line 82, in test_zero
self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman
----------------------------------------------------------------------
Ran 4 tests in 0.000s
FAILED (failures=2)
太棒了!两个测试都如期地失败了。接着转入被测试的代码并且思考如何才能使得测试通过。
def to_roman(n):
'''convert integer to Roman numeral'''
result = ''
for numeral, integer in roman_numeral_map:
while n >= integer:
result += numeral
n -= integer
return result
- 这是 Python 优雅的快捷方法:一次性的多比较。它等价于
if not ((0 < n) and (n < 4000)),但前者更适合阅读。这一行代码应该捕获那些超大的、负值的或者为 0 的输入。 - 当你改变条件的时候,要确保同步更新那些提示错误信息的可读字符串。
unittest框架并不关心这些,但是如果你的代码抛出描述不正确的异常信息的话会使得手工调试代码变得困难。
我本应该给你展示完整的一系列与本章节不相关的例子来说明一次性多比较的快捷方式是有效的,但是我将仅仅运行本测试用例来证明它的有效性。
you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.016s
OK
还有一件事情……
还有一个把阿拉伯数字转换成罗马数字的 功能性需求 :处理非整型数字。
>>> import roman3
''
'I'
- 喔,糟糕了。
- 喔,更糟糕了。两个用例都本该抛出异常的。但却返回了假的结果。
测试非整数并不困难。首先,定义一个 NotIntegerError 例外。
# roman4.py
class OutOfRangeError(ValueError): pass
<mark>class NotIntegerError(ValueError): pass</mark>
然后,编写一个检查 NotIntegerError 例外的案例。
class ToRomanBadInput(unittest.TestCase):
.
.
.
def test_non_integer(self):
'''to_roman should fail with non-integer input'''
<mark>self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)</mark>
然后,检查该测试是否可以正确地失败。
you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok
======================================================================
FAIL: to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest4.py", line 90, in test_non_integer
self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
<mark>AssertionError: NotIntegerError not raised by to_roman</mark>
----------------------------------------------------------------------
Ran 5 tests in 0.000s
FAILED (failures=1)
编修代码,使得该测试可以通过。
def to_roman(n):
'''convert integer to Roman numeral'''
if not (0 < n < 4000):
raise OutOfRangeError('number out of range (must be 1..3999)')
result = ''
for numeral, integer in roman_numeral_map:
while n >= integer:
result += numeral
n -= integer
return result
- 内建的
isinstance()方法可以检查一个变量是否属于某一类型(或者,技术上的任何派生类型)。 - 如果参数
n不是int,则抛出新定义的NotIntegerError异常。
最后,验证修改后的代码的确通过测试。
you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK
to_roman() 方法通过了所有的测试,而且我也想不出别的测试了,因此,下面着手 from_roman()吧!
可喜的对称性
转换罗马数字为阿拉伯数字的实现难度听起来比反向转换要困难。当然,这种想法不无道理。例如,检查数值是否比 0 大容易,而检查一个字符串是否为有效的罗马数字则要困难些。但是,我们已经构造了一个用于检查罗马数字的规则表,因此规则表的工作可以免了。
现在剩余的工作就是转换字符串了。正如我们将要看到的一样,多亏我们定义的用于单个罗马数字映射至阿拉伯数字的良好的数据结构,from_roman() 的实现本质上与 to_roman() 一样简单。
不过,测试先行!为了证明其准确性,我们将需要一个对“已知取值”进行的测试。我们的测试套件已经包含了一个已知取值的映射表,那么,我们就重用它。
def test_from_roman_known_values(self):
'''from_roman should give known result with known input'''
for integer, numeral in self.known_values:
result = roman5.from_roman(numeral)
self.assertEqual(integer, result)
这里看到了令人高兴的对称性。to_roman() 与 from_roman() 函数是互逆的。前者把整型数字转换为特殊格式化的字符串,而后者则把特殊格式化的字符串转换为整型数字。理论上,我们应该可以使一个数字“绕一圈”,即把数字传递给 to_roman() 方法,得到一个字符串;然后把该字符串传入 from_roman() 方法,得到一个整型数字,并且跟传给 to_roman()方法的数字是一样的。
n = from_roman(to_roman(n)) for all values of n
在本用例中,“全有取值”是说 从 1 到 3999 的所有数值,因为这是 to_roman() 方法的有效输入范围。为了表达这两个方法之间的对称性,我们可以设计这样的测试用例,它的测试数据集是从1 到 3999 之间(包括 1 和 3999)的所有数值,首先调用 to_roman() ,然后调用 from_roman(),最后检查输出是否与原始输入一致。
class RoundtripCheck(unittest.TestCase):
def test_roundtrip(self):
'''from_roman(to_roman(n))==n for all n'''
for integer in range(1, 4000):
numeral = roman5.to_roman(integer)
result = roman5.from_roman(numeral)
self.assertEqual(integer, result)
这些测试连失败的机会都没有。因为我们根本还没定义 from_roman() 函数,所以它们仅仅会抛出错误的结果。
you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest5.py", line 78, in test_from_roman_known_values
result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'
======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest5.py", line 103, in test_roundtrip
result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'
----------------------------------------------------------------------
Ran 7 tests in 0.019s
FAILED (errors=2)
一个简易的留空函数可以解决此问题。
# roman5.py
def from_roman(s):
'''convert Roman numeral to integer'''
(嘿,你注意到了么?我定义了一个除了 docstring 之外没有任何东西的方法。这是合法的 Python 代码。事实上,一些程序员喜欢这样做。“不要留空;写点文档!”)
现在测试用力将会失败。
you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest5.py", line 79, in test_from_roman_known_values
self.assertEqual(integer, result)
AssertionError: 1 != None
======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest5.py", line 104, in test_roundtrip
self.assertEqual(integer, result)
AssertionError: 1 != None
----------------------------------------------------------------------
Ran 7 tests in 0.002s
FAILED (failures=2)
现在是时候编写 from_roman() 函数了。
def from_roman(s):
"""convert Roman numeral to integer"""
result = 0
index = 0
for numeral, integer in roman_numeral_map:
result += integer
index += len(numeral)
return result
- 此处的匹配模式与
to_roman()完全相同。遍历整个罗马数字数据结构 (一个元组的元组),与前面不同的是不去一个个地搜索最大的整数,而是搜寻 “最大的”罗马数字字符串。
如果不清楚 from_roman() 如何工作,在 while 结尾处添加一个 print 语句:
def from_roman(s):
"""convert Roman numeral to integer"""
result = 0
index = 0
for numeral, integer in roman_numeral_map:
while s[index:index+len(numeral)] == numeral:
result += integer
index += len(numeral)
<mark>print('found', numeral, 'of length', len(numeral), ', adding', integer)</mark>
>>> import roman5
>>> roman5.from_roman('MCMLXXII')
found M of length 1, adding 1000
found CM of length 2, adding 900
found L of length 1, adding 50
found X of length 1, adding 10
found X of length 1, adding 10
found I of length 1, adding 1
found I of length 1, adding 1
1972
重新执行一遍测试。
you@localhost:~/diveintopython3/examples$ python3 romantest5.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s
OK
这儿有两个令人激动的消息。一个是 from_roman() 对于所有有效输入运转正常,至少对于你测试的已知值是这样。第二个好消息是,完备性测试也通过了。与已知值测试的通过一起来看,你有理由相信 to_roman() 和 from_roman() 对于所有有效输入值工作正常。(尚不能完全相信,理论上存在这种可能性: to_roman() 存在错误而导致一些特定输入会产生错误的罗马数字表示,and from_roman() 也存在相应的错误,把 to_roman() 错误产生的这些罗马数字错误地转换为最初的整数。取决于你的应用程序和你的要求,你或许需要考虑这个可能性;如果是这样,编写更全面的测试用例直到解决这个问题。)
更多错误输入
现在 from_roman() 对于有效输入能够正常工作了,是揭开最后一个谜底的时候了:使它正常工作于无效输入的情况下。这意味着要找出一个方法检查一个字符串是不是有效的罗马数字。这比中验证有效的数字输入困难,但是你可以使用一个强大的工具:正则表达式。(如果你不熟悉正则表达式,现在是该好好读读正则表达式那一章节的时候了。)
如你在 个案研究:罗马字母 s 中所见到的,构建罗马数字有几个简单的规则:使用的字母M , D , C , L , X , V和I 。让我们回顾一下:
- 有时字符是叠加组合的。
I是1,II是2,而III是3.VI是6(从字面上理解, “5和1”),VII是7, 而VIII是8。 - 十位的字符 (
I、X、C和M) 可以被重复最多三次。对于4,你则需要利用下一个能够被 5 整除的字符进行减操作得到。你不能把4表示为IIII,而应该表示为IV(“比5小1”)。40则被写作XL(“比50小10”),41表示为XLI,42表示为XLII,43表示为XLIII,44表示为XLIV(“比50小10,加上5小1”)。 - 有时,字符串是……加法的对立面。通过将某些字符串放的其他一些之前,可以从最终值中相减。例如,对于
9,你需要从下一个最高十位字符串中减去一个值:8是VIII,但9是IX(“ 比10小1”),而不是VIIII(由于I字符不能重复四次)。90是XC,900是CM。 - 表示 5 的字符不能重复。
10总是表示为X,而决不能是VV。100总是C,决不能是LL。 - 罗马数字从左向右读,因此字符的顺序非常重要。
DC是600;CD则是完全不同的数字 (400, “比500小100”)。CI是101;IC甚至不是合法的罗马数字(因为你不能直接从100减1;你将不得不将它表示为XCIX,“比100小10,然后比10” 小1)。
因此,有用的测试将会确保 from_roman() 函数应当在传入太多重复数字时失败。“太多”是多少取决于数字。
class FromRomanBadInput(unittest.TestCase):
def test_too_many_repeated_numerals(self):
'''from_roman should fail with too many repeated numerals'''
for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
另一有效测试是检查某些未被重复的模式。例如,IX 代表 9,但 IXIX 绝不会合法。
def test_repeated_pairs(self):
'''from_roman should fail with repeated pairs of numerals'''
for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
第三个测试应当检测数字是否以正确顺序出现,从最高到最低位。例如,CL 是 150,而 LC 永远是非法的,因为代表 50 的数字永远不能在 100 数字之前出现。 该测试包括一个随机的可选项:I 在 M 之前, V 在 X 之前,等等。
def test_malformed_antecedents(self):
'''from_roman should fail with malformed antecedents'''
for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
这些测试中的每个都依赖于 from_roman() 引发一个新的例外 InvalidRomanNumeralError,而该例外尚未定义。
# roman6.py
class InvalidRomanNumeralError(ValueError): pass
所有的测试都应该是失败的,因为 from_roman() 方法还没有任何有效性检查。 (如果没有失败,它们在测什么呢?)
you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest6.py", line 113, in test_malformed_antecedents
self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman
======================================================================
FAIL: test_repeated_pairs (__main__.FromRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest6.py", line 107, in test_repeated_pairs
self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman
======================================================================
FAIL: test_too_many_repeated_numerals (__main__.FromRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest6.py", line 102, in test_too_many_repeated_numerals
self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman
----------------------------------------------------------------------
Ran 10 tests in 0.058s
FAILED (failures=3)
好!现在,我们要做的所有事情就是添加正则表达式到 from_roman() 中以测试有效的罗马数字。
roman_numeral_pattern = re.compile('''
^ # beginning of string
M{0,3} # thousands - 0 to 3 Ms
(CM|CD|D?C{0,3}) # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
# or 500-800 (D, followed by 0 to 3 Cs)
(XC|XL|L?X{0,3}) # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
# or 50-80 (L, followed by 0 to 3 Xs)
(IX|IV|V?I{0,3}) # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
# or 5-8 (V, followed by 0 to 3 Is)
$ # end of string
''', re.VERBOSE)
def from_roman(s):
'''convert Roman numeral to integer'''
<mark>if not roman_numeral_pattern.search(s):
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))</mark>
result = 0
index = 0
for numeral, integer in roman_numeral_map:
while s[index : index + len(numeral)] == numeral:
result += integer
index += len(numeral)
return result
再运行一遍测试……
you@localhost:~/diveintopython3/examples$ python3 romantest7.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s
OK
本年度的虎头蛇尾奖颁发给……单词“OK”,在所有测试通过时,它由 unittest 模块输出。
Chapter 10 重构
" After one has played a vast quantity of notes and more notes, it is simplicity that emerges as the crowning reward of art. " — Frédéric Chopin
深入
就算是竭尽了全力编写全面的单元测试,还是会遇到错误。我所说的“错误”是什么意思?错误是尚未写到的测试实例。
>>> import roman7
0
- 这就是错误。和其它无效罗马数字的一系列字符一样,空字符串将引发
InvalidRomanNumeralError例外。
在重现该错误后,应该在修复前写出一个导致该失败情形的测试实例,这样才能描述该错误。
class FromRomanBadInput(unittest.TestCase):
.
.
.
def testBlank(self):
'''from_roman should fail with blank string'''
- 这段代码非常简单。通过传入一个空字符串调用
from_roman(),并确保其引发一个InvalidRomanNumeralError例外。难的是发现错误;找到了该错误之后对它进行测试是件轻松的工作。
由于代码有错误,且有用于测试该错误的测试实例,该测试实例将会导致失败:
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... FAIL
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
======================================================================
FAIL: from_roman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest8.py", line 117, in test_blank
self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, '')
<mark>AssertionError: InvalidRomanNumeralError not raised by from_roman</mark>
----------------------------------------------------------------------
Ran 11 tests in 0.171s
FAILED (failures=1)
现在 可以修复该错误了。
def from_roman(s):
'''convert Roman numeral to integer'''
raise InvalidRomanNumeralError('Input can not be blank')
if not re.search(romanNumeralPattern, s):
result = 0
index = 0
for numeral, integer in romanNumeralMap:
while s[index:index+len(numeral)] == numeral:
result += integer
index += len(numeral)
return result
- 只需两行代码:一行明确地对空字符串进行检查,另一行为
raise语句。 - 在本书中还尚未提到该内容,因此现在让我们讲讲 字符串格式化 最后一点内容。从 Python 3.1 起,在格式化标示符中使用位置索引时可以忽略数字。也就是说,无需使用格式化标示符
{0}来指向format()方法的第一个参数,只需简单地使用{}而 Python 将会填入正确的位置索引。该规则适用于任何数量的参数;第一个{}代表{0},第二个{}代表{1},以此类推。
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 11 tests in 0.156s
- 现在空字符串测试实例通过了测试,也就是说错误被修正了。
- 所有其它测试实例仍然可以通过,说明该错误修正没有破坏其它部分。代码编写结束。
用此方式编写代码将使得错误修正变得更困难。简单的错误(像这个)需要简单的测试实例;复杂的错误将会需要复杂的测试实例。在以测试为中心的环境中,由于必须在代码中精确地描述错误(编写测试实例),然后修正错误本身,看起来 好像 修正错误需要更多的时间。而如果测试实例无法正确地通过,则又需要找出到底是修正方案有错误,还数测试实例本身就有错误。然而从长远看,这种在测试代码和经测试代码之间的来回折腾是值得的,因为这样才更有可能在第一时间修正错误。同时,由于可以对新代码轻松地重新运行 所有 测试实例,在修正新代码时破坏旧代码的机会更低。今天的单元测试就是明天的回归测试。
控制需求变化
为了获取准确的需求,尽管已经竭力将客户“钉”在原地,并经历了反复剪切、粘贴的痛苦,但需求仍然会变化。大多数客户在看到产品之前不知道自己想要什么,而且就算知道,他们也不擅长清晰地表述自己的想法。而即便擅长表述,他们在下一个版本中也会提出更多要求。因此,必须随时准备好更新测试实例以应对需求变化。
举个例子来说,假定我们要扩展罗马数字转换函数的能力范围。正常情况下,罗马数字中的任何一个字符在同一行中不得重复出现三次以上。但罗马人却愿意该规则有个例外:通过一行中的 4 个 M 字符来代表 4000 。进行该修改后,将会把可转换数字的范围从 1..3999 拓展为 1..4999。但首先必须对测试实例进行一些修改。
class KnownValues(unittest.TestCase):
known_values = ( (1, 'I'),
.
.
.
(3999, 'MMMCMXCIX'),
(4500, 'MMMMD'),
(4888, 'MMMMDCCCLXXXVIII'),
(4999, 'MMMMCMXCIX') )
class ToRomanBadInput(unittest.TestCase):
def test_too_large(self):
'''to_roman should fail with large input'''
.
.
.
class FromRomanBadInput(unittest.TestCase):
def test_too_many_repeated_numerals(self):
'''from_roman should fail with too many repeated numerals'''
self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)
.
.
.
class RoundtripCheck(unittest.TestCase):
def test_roundtrip(self):
'''from_roman(to_roman(n))==n for all n'''
numeral = roman8.to_roman(integer)
result = roman8.from_roman(numeral)
self.assertEqual(integer, result)
- 现有的已知数值不会变(它们依然是合理的测试数值),但必须在
4000范围之内(外)增加一些。在此,我已经添加了4000(最短)、4500(第二短)、4888(最长) 和4999(最大)。 - “过大值输入” 的定义已经发生了变化。该测试用于通过传入
4000调用to_roman()并期望引发一个错误;目前4000-4999是有效的值,必须将该值调整为5000。 - “太多重复数字”的定义也发生了变化。该测试通过传入
'MMMM'调用from_roman()并预期发生一个错误;目前MMMM被认定为有效的罗马数字,必须将该条件修改为'MMMMM'。 - 对范围内的每个数字进行完整循环测试,从
1到3999。由于范围已经进行了拓展,该for循环同样需要修改为以4999为上限。
现在,测试实例已经按照新的需求进行了更新,但代码还没有,因按照预期,某些测试实例将返回失败结果。
you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
======================================================================
ERROR: from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest9.py", line 82, in test_from_roman_known_values
result = roman9.from_roman(numeral)
File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
<mark>roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM</mark>
======================================================================
ERROR: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest9.py", line 76, in test_to_roman_known_values
result = roman9.to_roman(integer)
File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
raise OutOfRangeError('number out of range (must be 0..3999)')
<mark>roman9.OutOfRangeError: number out of range (must be 0..3999)</mark>
======================================================================
ERROR: from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
File "romantest9.py", line 131, in testSanity
numeral = roman9.to_roman(integer)
File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
raise OutOfRangeError('number out of range (must be 0..3999)')
<mark>roman9.OutOfRangeError: number out of range (must be 0..3999)</mark>
----------------------------------------------------------------------
Ran 12 tests in 0.171s
FAILED (errors=3)
- 一旦遇到
'MMMM',from_roman()已知值测试将会失败,因为from_roman()仍将其视为无效罗马数字。 - 一旦遇到
4000,to_roman()已知值测试将会失败,因为to_roman()仍将其视为超范围数字。 - 而往返(译注:指在普通数字和罗马数字之间来回转换)检查遇到
4000时也会失败,因为to_roman()仍认为其超范围。
现在,我们有了一些由新需求导致失败的测试实例,可以考虑修正代码让它与新测试实例一致起来。(刚开始编写单元测试的时候,被测试代码绝不会在测试实例“之前”出现确实让人感觉有点怪。)尽管编码工作被置后安排,但还是不少要做的事情,一旦与测试实例相符,编码工作就可以结束了。一旦习惯单元测试后,您可能会对自己曾在编程时不进行测试感到很奇怪。)
roman_numeral_pattern = re.compile('''
^ # beginning of string
(CM|CD|D?C{0,3}) # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
# or 500-800 (D, followed by 0 to 3 Cs)
(XC|XL|L?X{0,3}) # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
# or 50-80 (L, followed by 0 to 3 Xs)
(IX|IV|V?I{0,3}) # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
# or 5-8 (V, followed by 0 to 3 Is)
$ # end of string
''', re.VERBOSE)
def to_roman(n):
'''convert integer to Roman numeral'''
raise OutOfRangeError('number out of range (must be 1..4999)')
if not isinstance(n, int):
raise NotIntegerError('non-integers can not be converted')
result = ''
for numeral, integer in roman_numeral_map:
while n >= integer:
result += numeral
n -= integer
return result
def from_roman(s):
.
.
.
- 根本无需对
from_roman()函数进行任何修改。唯一需要修改的是roman_numeral_pattern。仔细观察下,将会发现我已经在正则表达式的第一部分中将M字符的数量从3优化为4。该修改将允许等价于4999而不是3999的罗马数字。实际的from_roman()函数完全是通用的;它只查找重复的罗马数字字符并将它们加起来,而不关心它们重复了多少次。之前无法处理'MMMM'的唯一原因是我们通过正则表达式匹配明确地阻止了它这么做。 to_roman()函数只需在范围检查中进行一个小改动。将之前检查0 < n < 4000的地方现在修改为检查0 < n < 5000。同时修改引发的错误信息,以体现新的可接受范围 (1..4999取代1..3999) 。无需对函数剩下部分进行任何修改;它已经能够应对新的实例。(它将对找到的每个千位增加'M';如果给定4000,它将给出'MMMM'。之前它不这么做的唯一原因是我们通过范围检查明确地阻止了它。)
所需做的就是这两处小修改,但你可能会有点怀疑。嗨,别光听我说,你自己看看吧。
you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 12 tests in 0.203s
- 所有测试实例均通过了。代码编写结束。
全面单元测试的意思是:无需依赖某个程序员来说“相信我吧。”
重构
关于全面单元测试,最美妙的事情不是在所有的测试实例通过后的那份心情,也不是别人抱怨你破坏了代码,而你通过实践 证明 自己没有时的快感。单元测试最美妙之处在于它给了你大刀阔斧进行重构的自由。
重构是修改可运作代码,使其表现更佳的过程。通常,“更佳”指的是“更快”,但它也可能指的是“占用更少内存“、”占用更少磁盘空间“或者”更加简洁”。对于你的环境、你的项目来说,无论重构意味着什么,它对程序的长期健康都至关重要。
本例中,“更佳”的意思既包括“更快”也包括“更易于维护”。具体而言,因为用于验证罗马数字的正则表达式生涩冗长,该 from_roman() 函数比我所希望的更慢,也更加复杂。现在,你可能会想,“当然,正则表达式就又臭又长的,难道我有其它办法验证任意字符串是否为罗马数字吗?”
答案是:只针对 5000 个数进行转换;为什么不知建立一个查询表呢?意识到 根本不需要使用正则表达式 之后,这个主意甚至变得更加理想了。在建立将整数转换为罗马数字的查询表的同时,还可以建立将罗马数字转换为整数的逆向查询表。在需要检查任意字符串是否是有效罗马数字的时候,你将收集到所有有效的罗马数字。“验证”工作简化为一个简单的字典查询。
最棒的是,你已经有了一整套单元测试。可以修改模块中一半以上的代码,而单元测试将会保持不变。这意味着可以向你和其他人证明:新代码运作和最初的一样好。
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass
roman_numeral_map = (('M', 1000),
('CM', 900),
('D', 500),
('CD', 400),
('C', 100),
('XC', 90),
('L', 50),
('XL', 40),
('X', 10),
('IX', 9),
('V', 5),
('IV', 4),
('I', 1))
to_roman_table = [ None ]
from_roman_table = {}
def to_roman(n):
'''convert integer to Roman numeral'''
if not (0 < n < 5000):
raise OutOfRangeError('number out of range (must be 1..4999)')
if int(n) != n:
raise NotIntegerError('non-integers can not be converted')
return to_roman_table[n]
def from_roman(s):
'''convert Roman numeral to integer'''
if not isinstance(s, str):
raise InvalidRomanNumeralError('Input must be a string')
if not s:
raise InvalidRomanNumeralError('Input can not be blank')
if s not in from_roman_table:
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
return from_roman_table[s]
def build_lookup_tables():
def to_roman(n):
result = ''
for numeral, integer in roman_numeral_map:
if n >= integer:
result = numeral
n -= integer
break
if n > 0:
result += to_roman_table[n]
return result
for integer in range(1, 5000):
roman_numeral = to_roman(integer)
to_roman_table.append(roman_numeral)
from_roman_table[roman_numeral] = integer
build_lookup_tables()
让我们打断一下,进行一些剖析工作。可以说,最重要的是最后一行:
build_lookup_tables()
可以注意到这是一次函数调用,但没有 if 语句包裹住它。这不是 if __name__ == '__main__' 语块;模块被导入时 它将会被调用。(重要的是必须明白:模块将只被导入一次,随后被缓存了。如果导入一个已导入模块,将不会导致任何事情发生。因此这段代码将只在第一此导入时运行。)
那么,该 build_lookup_tables() 函数究竟进行了哪些操作呢?很高兴你问这个问题。
to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
result = ''
for numeral, integer in roman_numeral_map:
if n >= integer:
result = numeral
n -= integer
break
if n > 0:
result += to_roman_table[n]
return result
for integer in range(1, 5000):
from_roman_table[roman_numeral] = integer
- 这是一段聪明的程序代码……也许过于聪明了。上面定义了
to_roman()函数;它在查询表中查找值并返回结果。而build_lookup_tables()函数重定义了to_roman()函数用于实际操作(像添加查询表之前的例子一样)。在build_lookup_tables()函数内部,对to_roman()的调用将会针对该重定义的版本。一旦build_lookup_tables()函数退出,重定义的版本将会消失 — 它的定义只在build_lookup_tables()函数的作用域内生效。 - 该行代码将调用重定义的
to_roman()函数,该函数实际计算罗马数字。 - 一旦获得结果(从重定义的
to_roman()函数),可将整数及其对应的罗马数字添加到两个查询表中。
查询表建好后,剩下的代码既容易又快捷。
def to_roman(n):
'''convert integer to Roman numeral'''
if not (0 < n < 5000):
raise OutOfRangeError('number out of range (must be 1..4999)')
if int(n) != n:
raise NotIntegerError('non-integers can not be converted')
def from_roman(s):
'''convert Roman numeral to integer'''
if not isinstance(s, str):
raise InvalidRomanNumeralError('Input must be a string')
if not s:
raise InvalidRomanNumeralError('Input can not be blank')
if s not in from_roman_table:
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
- 像前面那样进行同样的边界检查之后,
to_roman()函数只需在查询表中查找并返回适当的值。 - 同样,
from_roman()函数也缩水为一些边界检查和一行代码。不再有正则表达式。不再有循环。O(1) 转换为或转换到罗马数字。
但这段代码可以运作吗?为什么可以,是的它可以。而且我可以证明。
you@localhost:~/diveintopython3/examples$ python3 romantest10.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
OK
- 它不仅能够回答你的问题,还运行得非常快!好象速度提升了 10 倍。当然,这种比较并不公平,因为此版本在导入时耗时更长(在建造查询表时)。但由于只进行一次导入,启动的成本可以由对
to_roman()和from_roman()函数的所有调用摊薄。由于该测试进行几千次函数调用(来回单独测试上万次),节省出来的效率成本得以迅速提升!
这个故事的寓意是什么?
- 简单是一种美德。
- 特别在涉及到正则表达式的时候。
- 单元测试令你在进行大规模重构时充满自信。
摘要
单元测试是一个威力强大的概念,如果正确实施,不但可以降低维护成本,还可以提高长期项目的灵活性。但同时还必须明白:单元测试既不是灵丹妙药,也不是解决问题的魔术,更不是银弹。编写良好的测试实例非常艰难,确保它们时刻保持最新必须成为一项纪律(特别在客户要求关键错误修正时)。单元测试不是功能测试、集成测试或用户承受能力测试等其它测试的替代品。但它是可行的、行之有效的,见识过其功用后,你将对之前曾没有用它而感到奇怪。
这几章覆盖的内容很多,很大一部分都不是 Python 所特有的。许多语言都有单元测试框架,但所有框架都要求掌握同一基本概念:
- 设计测试实例是件具体、自动且独立的工作。
- 在编写被测试代码 之前 编写测试实例。
- 编写用于检查好输入并验证正确结果的测试
- 编写用于测试“坏”输入并做出正确失败响应的测试。
- 编写并更新测试实例以反映新的需求
- 毫不留情地重构以提升性能、可扩展性、可读性、可维护性及任何缺乏的特性。
Chapter 11 文件
" A nine mile walk is no joke, especially in the rain. " — Harry Kemelman, The Nine Mile Walk
概要
在没有安装任何一个应用程序之前,我的笔记本上 Windows 系统有 38,493 个文件。安装 Python 3 后,大约增加了 3,000 个文件。文件是每一个主流操作系统的主要存储模型;这种观念如此根深蒂固以至于难以想出一种替代物。打个比方,你的电脑实际上就是泡在文件里了。
读取文本文件
在读取文件之前,你需要先打开它。在 Python 里打开一个文件很简单:
a_file = open('examples/chinese.txt', encoding='utf-8')
Python 有一个内置函数 open(),它使用一个文件名作为其参数。在以上代码中,文件名是 'examples/chinese.txt'。关于这个文件名,有五件值得一讲的事情:
- 它不仅是一个文件的名字;实际上,它是文件路径和文件名的组合;一般来说,文件打开函数应该有两个参数 — 路径和文件名 — 但是函数
open()只使用一个参数。在 Python 里,当你使用“filename,”作为参数的时候,你可以将部分或者全部的路径也包括进去。 - 在这个例子中,目录路径中使用的是斜杠(forward slash),但是我并没有说明我正在使用的操作系统。Windows 使用反斜杠来表示子目录,但是 Mac OS X 和 Linux 使用斜杠。但是,在 Python 中,斜杠永远都是正确的,即使是在 Windows 环境下。
- 不使用斜杠或者反斜杠的路径被称作相对路径(relative path)。你也许会问,相对于什么呢?耐心一些,伙计。
- “filename,”参数是一个字符串。所有现代的操作系统(甚至 Windows!)使用 Unicode 编码方式来存储文件名和目录名。Python 3 全面支持非 ASCII 编码的路径。
- 文件不一定需要在本地磁盘上。也许你挂载了一个网络驱动器。它也可以是一个完全虚拟的文件系统(an entirely virtual filesystem)上的文件。只要你的操作系统认为它是一个文件,并且能够以文件的方式访问,那么,Python 就能打开它。
但是对open()函数的调用不局限于filename。还有另外一个叫做encoding参数。天哪,似乎非常耳熟的样子!
字符编码抬起了它腌臜的头…
字节即字节;字符是一种抽象。字符串由使用 Unicode 编码的字符序列构成。但是磁盘上的文件不是 Unicode 编码的字符序列。文件是字节序列。所以你可能会想,如果从磁盘上读取一个“文本文件”,Python 是怎样把那个字节序列转化为字符序列的呢?实际上,它是根据特定的字符解码算法来解释这些字节序列,然后返回一串使用 Unicode 编码的字符(或者也称为字符串)。
# This example was created on Windows. Other platforms may
# behave differently, for reasons outlined below.
# 这个样例在 Windows 平台上创建。其他平台可能会有不同的表现,理由描述在下边
>>> file = open('examples/chinese.txt')
>>> a_string = file.read()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "C:\Python31\lib\encodings\cp1252.py", line 23, in decode
return codecs.charmap_decode(input,self.errors,decoding_table)[0]
UnicodeDecodeError: 'charmap' codec can't decode byte 0x8f in position 28: character maps to <undefined>
>>>
默认的编码方式是平台相关的。
刚才发生了什么?由于你没有指定字符编码的方式,所以 Python 被迫使用默认的编码。那么默认的编码方式是什么呢?如果你仔细看了跟踪信息(traceback),错误出现在cp1252.py,这意味着 Python 此时正在使用 CP-1252 作为默认的编码方式。(在运行微软视窗操作系统的机器上,CP-1252 是一种常用的编码方式。)CP-1252 的字符集不支持这个文件上的字符编码,所以它以这个可恶的UnicodeDecodeError错误读取失败。
但是,还有更糟糕的!因为默认的编码方式是平台相关的(platform-dependent),所以,当前的代码也许能够在你的电脑上运行(如果你的机器的默认编码方式是 UTF-8),但是当你把这份代码分发给其他人的时候可能就会失败(因为他们的默认编码方式可能跟你的不一样,比如说 CP-1252)。
☞如果你需要获得默认编码的信息,则导入
locale模块,然后调用locale.getpreferredencoding()。在我安装了 Windows 的笔记本上,它的返回值是'cp1252',但是在我楼上安装了 Linux 的台式机上边,它返回'UTF8'。你看,即使在我自己家里我都不能保证一致性(consistency)!你的运行结果也许不一样(即使在 Windows 平台上),这依赖于操作系统的版本和区域/语言选项的设置。这就是为什么每次打开一个文件的时候指定编码方式是如此重要了。
流对象
到目前为止,我们都知道 Python 有一个内置的函数叫做open()。open()函数返回一个流对象(stream object),它拥有一些用来获取信息和操作字符流的方法和属性。
>>> a_file = open('examples/chinese.txt', encoding='utf-8')
'examples/chinese.txt'
'utf-8'
'r'
name属性反映的是当你打开文件时传递给open()函数的文件名。它没有被标准化(normalize)成绝对路径。- 同样的,
encoding属性反映的是在你调用open()函数时指定的编码方式。如果你在打开文件的时候没有指定编码方式(不好的开发人员!),那么encoding属性反映的是locale.getpreferredencoding()的返回值。 mode属性会告诉你被打开文件的访问模式。你可以传递一个可选的mode参数给open()函数。如果在打开文件的时候没有指定访问模式,Python 默认设置模式为'r',意思是“在文本模式下以只读的方式打开。”在这章的后面你会看到,文件的访问模式有各种用途;不同模式能够使你写入一个文件,追加到一个文件,或者以二进制模式打开一个文件(在这种情况下,你处理的是字节,不再是字符)。
☞
open()函数的文档列出了所有可用的文件访问模式。
从文本文件读取数据
在打开文件以后,你可能想要从某处开始读取它。
>>> a_file = open('examples/chinese.txt', encoding='utf-8')
'Dive Into Python 是为有经验的程序员编写的一本 Python 书。\n'
''
- 只要成功打开了一个文件(并且指定了正确的编码方式),你只需要调用流对象的
read()方法即可以读取它。返回的结果是文件的一个字符串表示。 - 也许你会感到意外,再次读取文件不会产生一个异常。Python 不认为到达了文件末尾(end-of-file)还继续执行读取操作是一个错误;这种情况下,它只是简单地返回一个空字符串。
无论何时,打开文件时指定encoding参数。
如果想要重新读取文件呢?
# continued from the previous example
# 接着前一个例子
''
0
'Dive Into Python'
' '
>>> a_file.read(1)
'是'
20
- 由于你依旧在文件的末尾,继续调用
read()方法只会返回一个空字符串。 seek()方法使定位到文件中的特定字节。read()方法可以使用一个可选的参数,即所要读取的字符个数。- 只要愿意,你甚至可以一次读取一个字符。
- 16 + 1 + 1 = … 20?
我们再来做一遍。
# continued from the previous example
# 继续上一示例
17
'是'
20
- 移动到第 17th 个字节位置。
- 读取一个字符。
- 当前在第 20 个字节位置处。
你是否已经注意到了?seek()和tell()方法总是以字节的方式计数,但是,由于你是以文本文件的方式打开的,read()方法以字符的个数计数。中文字符的 UTF-8 编码需要多个字节。而文件里的英文字符每一个只需要一个字节来存储,所以你可能会产生这样的误解:seek()和read()方法对相同的目标计数。而实际上,只有对部分字符的情况是这样的。
但是,还有更糟的!
18
Traceback (most recent call last):
File "<pyshell#12>", line 1, in <module>
a_file.read(1)
File "C:\Python31\lib\codecs.py", line 300, in decode
(result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf8' codec can't decode byte 0x98 in position 0: unexpected code byte
- 定位到第 18th 个字节,然后试图读取一个字符。
- 为什么这里会失败?因为在第 18 个字节处不存在字符。距离此处最近的字符从第 17 个字节开始(长度为三个字节)。试图从一个字符的中间位置读取会导致程序以
UnicodeDecodeError错误失败。
关闭文件
打开文件会占用系统资源,根据文件的打开模式不同,其他的程序也许不能够访问它们。当已经完成了对文件的操作后就立即关闭它们,这很重要。
# continued from the previous example
# 继续前面的例子
>>> a_file.close()
然而,这还不够(anticlimactic)。
流对象a_file仍然存在;调用close()方法并没有把对象本身销毁。所以这并不是非常有效。
# continued from the previous example
# 接着上一示例
Traceback (most recent call last):
File "<pyshell#24>", line 1, in <module>
a_file.read()
ValueError: I/O operation on closed file.
Traceback (most recent call last):
File "<pyshell#25>", line 1, in <module>
a_file.seek(0)
ValueError: I/O operation on closed file.
Traceback (most recent call last):
File "<pyshell#26>", line 1, in <module>
a_file.tell()
ValueError: I/O operation on closed file.
True
- 不能读取已经关闭了的文件;那样会引发一个
IOError异常。 - 也不能对一个已经关闭了的文件执行定位操作。
- 由于文件已经关闭了,所以也就不存在所谓当前的位置了,所以
tell()也会失败。 - 也许你会有些意外,文件已经关闭,调用原来流对象的
close()方法并没有引发异常。其实那只是一个空操作(no-op)而已。 - 已经关闭了的流对象确实还有一个有用的属性:
closed用来确认文件是否已经被关闭了。
自动关闭文件
try..finally也行。但是with更好
流对象有一个显式的close()方法,但是如果代码有缺陷,在调用close()方法以前就崩溃了呢?理论上,那个文件会在相当长的一段时间内一直打开着,这是没有必要地。当你在自己的机器上调试的时候,这不算什么大问题。但是当这种代码被移植到服务器上运行,也许就得三思了。
对于这种情况,Python 2 有一种解决办法:try..finally块。这种方法在 Python 3 里仍然有效,也许你可以在其他人的代码,或者从比较老的被移植到 Python 3 的代码中看到它。但是 Python 2.5 引入了一种更加简洁的解决方案,并且 Python 3 将它作为首选方案:with语句。
with open('examples/chinese.txt', encoding='utf-8') as a_file:
a_file.seek(17)
a_character = a_file.read(1)
print(a_character)
这段代码调用了open()函数,但是它却一直没有调用a_file.close()。with语句引出一个代码块,就像if语句或者for循环一样。在这个代码块里,你可以使用变量a_file作为open()函数返回的流对象的引用。所以流对象的常规方法都是可用的 — seek(),read(),无论你想要调用什么。当with块结束时,Python 自动调用a_file.close()。
这就是它与众不同的地方:无论你以何种方式跳出with块,Python 会自动关闭那个文件…即使是因为未处理的异常而“exit”。是的,即使代码中引发了一个异常,整个程序突然中止了,Python 也能够保证那个文件能被关闭掉。
☞从技术上说,
with语句创建了一个运行时环境(runtime context)。在这几个样例中,流对象的行为就像一个上下文管理器(context manager)。Python 创建了a_file,并且告诉它正进入一个运行时环境。当with块结束的时候,Python 告诉流对象它正在退出这个运行时环境,然后流对象就会调用它的close()方法。请阅读 附录 B,“能够在with块中使用的类”以获取更多细节。
with语句不只是针对文件而言的;它是一个用来创建运行时环境的通用框架(generic framework),告诉对象它们正在进入和离开一个运行时环境。如果该对象是流对象,那么它就会做一些类似文件对象一样有用的动作(就像自动关闭文件!)。但是那个行为是被流对象自身定义的,而不是在with语句中。还有许多跟文件无关的使用上下文管理器(context manager)的方法。在这章的后面可以看到,你甚至可以自己创建它们。
一次读取一行数据
正如你所想的,一行数据就是这样 — 输入一些单词,按ENTER键,然后就在新的一行了。一行文本就是一串被某种东西分隔的字符,到底是被什么分隔的呢?好吧,这有些复杂,因为文本文件可以使用几个不同的字符来标记行末(end of a line)。每种操作系统都有自己的规矩。有一些使用回车符(carriage return),另外一些使用换行符(line feed),还有一些在行末同时使用这两个字符来标记。
其实你可以舒口气了,因为Python 默认会自动处理行的结束符。如果你告诉它,“我想从这个文本文件一次读取一行,”Python 自己会弄明白这个文本文件到底使用哪种方式标记新行,然后正确工作。
☞如果想要细粒度地控制(fine-grained control)使用哪种新行标记符,你可以传递一个可选的参数
newline给open()函数。请阅读open()函数的文档以获取更多细节。
那么,实际中你会怎样做呢?我是指一次读取文件的一行。它如此简单优美…
line_number = 0
line_number += 1
- 使用
with语句,安全地打开这个文件,然后让 Python 为你关闭它。 - 为了一次读取文件的一行,使用
for循环。是的,除了像read()这样显式的方法,流对象也是一个迭代器(iterator),它能在你每次请求一个值时分离出单独的一行。 - 使用字符串的
format()方法,你可以打印出行号和行自身。格式说明符{:>4}的意思是“使用最多四个空格使之右对齐,然后打印此参数。”变量a_line是包括回车符等在内的完整的一行。字符串方法rstrip()可以去掉尾随的空白符,包括回车符。
you@localhost:~/diveintopython3$ python3 examples/oneline.py
1 Dora
2 Ethan
3 Wesley
4 John
5 Anne
6 Mike
7 Chris
8 Sarah
9 Alex
10 Lizzie
是否遇到了这个错误?
you@localhost:~/diveintopython3$ python3 examples/oneline.py Traceback (most recent call last): File "examples/oneline.py", line 4, in <module> print('{:>4} {}'.format(line_number, a_line.rstrip())) ValueError: zero length field name in format如果结果是这样,也许你正在使用 Python 3.0。你真的应该升级到 Python 3.1。
Python 3.0 支持字符串格式化,但是只支持显式编号了的格式说明符。Python 3.1 允许你在格式说明符里省略参数索引号。作为比照,下面是一个 Python 3.0 兼容的版本。
print('{<mark>0</mark>:>4} {<mark>1</mark>}'.format(line_number, a_line.rstrip()))
写入文本文件
打开文件然后开始写入即可。
写入文件的方式和从它们那儿读取很相似。首先打开一个文件,获取流对象,然后你调用一些方法作用在流对象上来写入数据到文件,最后关闭文件。
为了写入而打开一个文件,可以使用open()函数,并且指定写入模式。有两种文件模式用于写入:
- “写”模式会重写文件。传递
mode='w'参数给open()函数。 - “追加”模式会在文件末尾添加数据。传递
mode='a'参数给open()函数。
如果文件不存在,两种模式下都会自动创建新文件,所以就不需要“如果文件还不存在,创建一个新的空白文件以能够打开它”这种琐碎的过程了。所以,只需要打开一个文件,然后开始写入即可。
在完成写入后你应该马上关闭文件,释放文件句柄(file handle),并且保证数据被完整地写入到了磁盘。跟读取文件一样,可以调用流对象的close()方法,或者你也可以使用with语句让 Python 为你关闭文件。我敢打赌,你肯定能猜到我推荐哪种方案。
>>> with open('test.log', encoding='utf-8') as a_file:
... print(a_file.read())
test succeeded
... a_file.write('and again')
>>> with open('test.log', encoding='utf-8') as a_file:
... print(a_file.read())
- 大胆地创建新文件
test.log(或者重写已经存在的文件),然后以写入方式打开文件。参数mode='w'的意思是文件以写入的模式打开。是的,这听起来似乎比较危险。我希望你确定不再关心那个文件以前的内容(如果有的话),因为那份数据已经没了。 - 你可以通过
open()函数返回的流对象的write()方法来给新打开的文件添加数据。当with块结束的时候,Python 自动关闭文件。 - 多么有趣,我们再试一次。这一次,使用
with='a'参数来添加数据到文件末尾,而不是重写它。追加模式绝不会破坏现有文件的内容。 - 原来写入的行,还有追加上去的第二行现在都在文件
test.log里了。同时请注意,回车符没有被包括进去。你可以通过'\n'写入一个回车符。由于一开始没有这样做,所有写入到文件的数据现在都在同一行。
再次讨论字符编码
你是否注意到当你在打开文件用于写入数据的时候传递给open()函数的encoding参数。它“非常重要”,不要忽略了!就如你在这章开头看到的,文件中并不存在字符串,它们由字节组成。只有当你告诉 Python 使用何种编码方式把字节流转换为字符串,从文件读取“字符串”才成为可能。相反地,写入文本到文件面临同样的问题。实际上你不能直接把字符写入到文件;字符只是一种抽象。为了写入字符到文件,Python 需要知道如何将字符串转换为字节序列。唯一能保证正确地执行转换的方法就是当你为写入而打开一个文件的时候,指定encoding参数。
二进制文件
不是所有的文件都包含文本内容。有一些还包含了我可爱的狗的照片。
'rb'
'examples/beauregard.jpg'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: '_io.BufferedReader' object has no attribute 'encoding'
- 用二进制模式打开文件很简单,但是很精细。与文本模式唯一不同的是
mode参数包含一个字符'b'。 - 以二进制模式打开文件得到的流对象与之前的有很多相同的属性,包括
mode属性,它记录了你调用open()函数时指定的mode参数的值。 - 二进制文件的流对象也有
name属性,就如文本文件的流对象一样。 - 然而,确实有不同之处:二进制的流对象没有
encoding属性。你能明白其中的道理的,对吧?现在你读写的是字节,而不是字符串,所以 Python 不需要做转换工作。从二进制文件里读出的跟你所写入的是完全一样的,所以没有执行转换的必要。
我是否提到当前正在读取字节?噢,的确如此。
# continued from the previous example
# 继续前一样例
>>> an_image.tell()
0
>>> data
b'\xff\xd8\xff'
<class 'bytes'>
3
>>> an_image.seek(0)
0
>>> data = an_image.read()
>>> len(data)
3150
- 跟读取文本文件一样,你也可以从二进制文件一次读一点儿。但是它们之间有一个重大的不同之处处&#hellip;
-
&#hellip;你正在读取字节,而不是字符串。由于你以二进制模式打开文件,
read()方法每次读取指定的字节数,而非字符数。 - 这就意味着,你传递给
read()方法的数目和你从tell()方法得到的位置序号不会出现意料之外的不匹配(unexpected mismatch)
非文件来源的流对象
使用read()方法即可从虚拟文件读取数据。
想象一下你正在编写一个库(library),其中有一库函数用来从文件读取数据。它使用文件名作为参数,以只读的方式打开文件,读取数据,关闭文件,返回。但是你不应该只做到这个程度。你的 API 应该能够接纳任意的类型的流对象。
最简单的情况,只要对象包含read()方法,这个方法使用一个可选参数size并且返回值为一个串,它就是是流对象。不使用size参数调用read()的时候,这个方法应该从输入源读取所有可读的信息然后以单独的一个值返回所有数据。当使用size参数调用read()时,它从输入源读取并返回指定量的数据。当再一次被调用时,它从上一次离开的地方开始读取并返回下一个数据块。
这听起来跟你从打开一个真实文件得到的流对象一样。不同之处在于你不再受限于真实的文件。能够“读取”的输入源可以是任何东西:网页,内存中的字符串,甚至是另外一个程序的输出。只要你的函数使用的是流对象,调用对象的read()方法,你可以处理任何行为与文件类似的输入源,而不需要为每种类型的输入指定特别的代码。
>>> a_string = 'PapayaWhip is the new black.'
'PapayaWhip is the new black.'
''
0
'PapayaWhip'
>>> a_file.tell()
10
>>> a_file.seek(18)
18
>>> a_file.read()
'new black.'
io模块定义了StringIO类,你可以使用它来把内存中的字符串当作文件来处理。- 为了从字符串创建一个流对象,可以把想要作为“文件”使用的字符串传递给
io.StringIO()来创建一个StringIO的实例。 - 调用
read()方法“读取”整个“文件”,以StringIO对象为例即返回原字符串。 - 就像一个真实的文件一样,再次调用
read()方法返回一个空串。 - 通过使用
StringIO对象的seek()方法,你可以显式地定位到字符串的开头,就像在一个真实的文件中定位一样。 - 通过传递
size参数给read()方法,你也可以以数据块的形式读取字符串。
☞
io.StringIO让你能够将一个字符串作为文本文件来看待。另外还有一个io.ByteIO类,它允许你将字节数组当做二进制文件来处理。
处理压缩文件
Python 标准库包含支持读写压缩文件的模块。有许多种不同的压缩方案;其中,gzip和bzip2是非 Windows 操作系统下最流行的两种压缩方式。
gzip模块允许你创建用来读写 gzip 压缩文件的流对象。该流对象支持read()方法(如果你以读取模式打开)或者write()方法(如果你以写入模式打开)。这就意味着,你可以使用从普通文件那儿学到的技术来直接读写 gzip 压缩文件,而不需要创建临时文件来保存解压缩了的数据。
作为额外的功能,它也支持with语句,所以当你完成了对 gzip 压缩文件的操作,Python 可以为你自动关闭它。
you@localhost:~$ python3
>>> import gzip
... z_file.write('A nine mile walk is no joke, especially in the rain.'.encode('utf-8'))
...
>>> exit()
-rw-r--r-- 1 mark mark 79 2009-07-19 14:29 out.log.gz
A nine mile walk is no joke, especially in the rain.
- 你应该问题以二进制模式打开 gzip 压缩文件。(注意
mode参数里的'b'字符。) - 我在 Linux 系统上完成的这个例子。如果你对命令行不熟悉,这条命令用来显示刚才你在 Python shell 创建的 gzip 压缩文件的“长清单(long listings)”,你可以看到,它有 79 个字节长。而实际上这个值比一开始的字符串还要长!由于 gzip 文件包括了一个固定长度的文件头来存放一些关于文件的元数据(metadata),所以它对于极小的文件来说效率不高。
gunzip命令(发音:“gee-unzip”)解压缩文件然后保存其内容到一个与原来压缩文件同名的新文件中,并去掉其.gz扩展名。cat命令显示文件的内容。当前文件包含了原来你从 Python shell 直接写入到压缩文件out.log.gz的那个字符串。
标准输入、输出和错误
sys.stdin, sys.stdout, sys.stderr.
命令行高手已经对标准输入,标准输出和标准错误的概念相当熟悉了。这部分内容是对另一部分还不熟悉的人员准备的。
标准输出和标准错误(通常缩写为stdout和stderr)是被集成到每一个类 UNIX 操作系统中的两个管道(pipe),包括 Mac OS X 和 Linux。当你调用print()的时候,需要打印的内容即被发送到stdout管道。当你的程序出错并且需要打印跟踪信息(traceback)时,它们被发送到stderr管道。默认地,这两个管道都被连接到你正在工作的终端窗口上(terminal window);当你的程序打印某些东西,你可以在终端上看到这些输出,当程序出错,你也可以从终端上看到这些错误信息。在图形化的 Python shell 里,stdout和stderr管道默认连接到“交互式窗口(Interactive Window)”
>>> for i in range(3):
PapayaWhip
PapayaWhip
PapayaWhip
>>> import sys
>>> for i in range(3):
is theis theis the
>>> for i in range(3):
new blacknew blacknew black
- 循环调用
print()函数。没有什么特别的。 stdout被定义在sys模块里,它是一个流对象(stream object)。使用任意字符串调用其write()函数会按原样输出。事实上,这就是print()函数实际在做的事情;它在串的结尾添加一个回车符,然后调用sys.stdout.write。- 最简单的情况下,
sys.stdout和sys.stderr把他们的输出发送到同一个位置:Python IDE(如果你在那里执行操作),或者终端(如果你从命令行执行 Python 指令)。跟标准输出一样,标准错误也不会自动为你添加回车符。如果你需要回车符,你需要手工写入回车符到标准错误。
sys.stdout和sys.stderr都是流对象,但是他们都只支持写入。试图调用他们的read()方法会引发IOError异常。
>>> import sys
>>> sys.stdout.read()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IOError: not readable
标准输出重定向
sys.stdout和sys.stderr都是流对象,尽管他们只支持写入。但是他们是变量而不是常量。这就意味着你可以给它们赋上新值 — 任意其他流对象 — 来重定向他们的输出。
import sys
class RedirectStdoutTo:
def __init__(self, out_new):
self.out_new = out_new
def __enter__(self):
self.out_old = sys.stdout
sys.stdout = self.out_new
def __exit__(self, *args):
sys.stdout = self.out_old
print('A')
with open('out.log', mode='w', encoding='utf-8') as a_file, RedirectStdoutTo(a_file):
print('B')
print('C')
验证一下:
you@localhost:~/diveintopython3/examples$ python3 stdout.py
A
C
you@localhost:~/diveintopython3/examples$ cat out.log
B
你是否遇到了以下错误?
you@localhost:~/diveintopython3/examples$ python3 stdout.py File "stdout.py", line 15 with open('out.log', mode='w', encoding='utf-8') as a_file, RedirectStdoutTo(a_file): ^ SyntaxError: invalid syntax如果是这样,你可能正在使用 Python 3.0。应该升级到 Python 3.1。
Python 3.0 支持
with语句,但是每个语句只能使用一个上下文管理器。Python 3.1 允许你在一条with语句中链接多个上下文件管理器。
我们先来处理最后那一部分。
print('A')
with open('out.log', mode='w', encoding='utf-8') as a_file, RedirectStdoutTo(a_file):
print('B')
print('C')
这是一个复杂的with语句。让我改写它使之更有可读性。
with open('out.log', mode='w', encoding='utf-8') as a_file:
with RedirectStdoutTo(a_file):
print('B')
正如改动后的代码所展示的,实际上你使用了两个with语句,其中一个嵌套在另外一个的作用域(scope)里。“外层的”with语句你应该已经熟悉了:它打开一个使用 UTF-8 编码的叫做out.log的文本文件用来写入,然后把返回的流对象赋给一个叫做a_file的变量。但是,在此处,它并不是唯一显得古怪的事情。
with RedirectStdoutTo(a_file):
as子句(clause)到哪里去了?其实with语句并不一定需要as子句。就像你调用一个函数然后忽略其返回值一样,你也可以不把with语句的上下文环境赋给一个变量。在这种情况下,我们只关心RedirectStdoutTo上下文环境的边际效应(side effect)。``
``那么,这些边际效应都是些什么呢?我们来看一看RedirectStdoutTo类的内部结构。这是一个用户自定义的上下文管理器(context manager)。任何类只要定义了两个特殊方法:code>enter()和exit()就可以变成上下文管理器。
`class RedirectStdoutTo:
self.out_new = out_new
self.out_old = sys.stdout
sys.stdout = self.out_new
sys.stdout = self.out_old
- 在实例被创建后
__init__()方法马上被调用。它使用一个参数,即在上下文环境的生命周期内你想用做标准输出的流对象。这个方法只是把该流对象保存在一个实例变量里(instance variable)以使其他方法在后边能够使用到它。 __enter__()方法是一个特殊的类方法(special class method);在进入一个上下文环境时 Python 会调用它(即,在with语句的开始处)。该方法把当前sys.stdout的值保存在self.out_old内,然后通过把self.out_new赋给sys.stdout来重定向标准输出。__exit__()是另外一个特殊类方法;当离开一个上下文环境时(即,在with语句的末尾)Python 会调用它。这个方法通过把保存的self.out_old的值赋给sys.stdout来恢复标准输出到原来的状态。
放到一起:
- 这条代码会输出到 IDE 的“交互式窗口(Interactive Window)”(或者终端,如果你从命令行运行这段脚本)。
- 这条
with语句使用逗号分隔的上下文环境列表。这个列表就像一系列相互嵌套的with块。先列出的是“外层”的块;后列出的是“内层”的块。第一个上下文环境打开一个文件;第二个重定向sys.stdout到由第一个上下环境创建的流对象。 - 由于这个
print()函数在with语句创建的上下文环境里执行,所以它不会输出到屏幕;它会写入到文件out.log。 with语句块结束了。Python 告诉每一个上下文管理器完成他们应该在离开上下文环境时应该做的事。这些上下文环境形成一个后进先出的栈。当离开一个上下文环境的时候,第二个上下文环境将sys.stdout的值恢复到它的原来状态,然后第一个上下文环境关闭那个叫做out.log的文件。由于标准输出已经被恢复到原来的状态,再次调用print()函数会马上输出到屏幕上。
重定向标准错误的原理跟这个完全一样,将sys.stdout替换为sys.stderr即可。
进一步阅读
- 读写文件 Python.org 上的教程
io模块- 流对象
- 上下文管理器类型
sys.stdoutandsys.stderr- FUSE 来自维基百科
Chapter 12 XML
" In the archonship of Aristaechmus, Draco enacted his ordinances. " — Aristotle
概述
这本书的大部分章节都是以样例代码为中心的。但是 XML 这章不是;它以数据为中心。最常见的 XML 应用为“聚合订阅(syndication feeds)”,它用来展示博客,论坛或者其他会经常更新的网站的最新内容。大多数的博客软件都会在新文章,新的讨论区,或者新博文发布的时候自动生成和更新 feed。我们可以通过“订阅(subscribe)”feed 来关注它们,还可以使用专门的“feed 聚合工具(feed aggregator)”,比如Google Reader。
以下的 XML 数据是我们这一章中要用到的。它是一个 feed — 更确切地说是一个Atom 聚合 feed
<?xml version='1.0' encoding='utf-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
<title>dive into mark</title>
<subtitle>currently between addictions</subtitle>
<id>tag:diveintomark.org,2001-07-29:/</id>
<updated>2009-03-27T21:56:07Z</updated>
<link rel='alternate' type='text/html' href='http://diveintomark.org/'/>
<link rel='self' type='application/atom+xml' href='http://diveintomark.org/feed/'/>
<entry>
<author>
<name>Mark</name>
<uri>http://diveintomark.org/</uri>
</author>
<title>Dive into history, 2009 edition</title>
<link rel='alternate' type='text/html'
href='http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'/>
<id>tag:diveintomark.org,2009-03-27:/archives/20090327172042</id>
<updated>2009-03-27T21:56:07Z</updated>
<published>2009-03-27T17:20:42Z</published>
<category scheme='http://diveintomark.org' term='diveintopython'/>
<category scheme='http://diveintomark.org' term='docbook'/>
<category scheme='http://diveintomark.org' term='html'/>
<summary type='html'>Putting an entire chapter on one page sounds
bloated, but consider this &mdash; my longest chapter so far
would be 75 printed pages, and it loads in under 5 seconds&hellip;
On dialup.</summary>
</entry>
<entry>
<author>
<name>Mark</name>
<uri>http://diveintomark.org/</uri>
</author>
<title>Accessibility is a harsh mistress</title>
<link rel='alternate' type='text/html'
href='http://diveintomark.org/archives/2009/03/21/accessibility-is-a-harsh-mistress'/>
<id>tag:diveintomark.org,2009-03-21:/archives/20090321200928</id>
<updated>2009-03-22T01:05:37Z</updated>
<published>2009-03-21T20:09:28Z</published>
<category scheme='http://diveintomark.org' term='accessibility'/>
<summary type='html'>The accessibility orthodoxy does not permit people to
question the value of features that are rarely useful and rarely used.</summary>
</entry>
<entry>
<author>
<name>Mark</name>
</author>
<title>A gentle introduction to video encoding, part 1: container formats</title>
<link rel='alternate' type='text/html'
href='http://diveintomark.org/archives/2008/12/18/give-part-1-container-formats'/>
<id>tag:diveintomark.org,2008-12-18:/archives/20081218155422</id>
<updated>2009-01-11T19:39:22Z</updated>
<published>2008-12-18T15:54:22Z</published>
<category scheme='http://diveintomark.org' term='asf'/>
<category scheme='http://diveintomark.org' term='avi'/>
<category scheme='http://diveintomark.org' term='encoding'/>
<category scheme='http://diveintomark.org' term='flv'/>
<category scheme='http://diveintomark.org' term='GIVE'/>
<category scheme='http://diveintomark.org' term='mp4'/>
<category scheme='http://diveintomark.org' term='ogg'/>
<category scheme='http://diveintomark.org' term='video'/>
<summary type='html'>These notes will eventually become part of a
tech talk on video encoding.</summary>
</entry>
</feed>
5 分钟 XML 速成
如果你已经了解 XML,可以跳过这一部分。
XML 是一种描述层次结构化数据的通用方法。XML文档包含由起始和结束标签(tag)分隔的一个或多个元素(element)。以下也是一个完整的(虽然空洞)XML 文件:
- 这是
foo元素的起始标签。 - 这是
foo元素对应的结束标签。就如写作、数学或者代码中需要平衡括号一样,每一个起始标签必须有对应的结束标签来闭合(匹配)。
元素可以嵌套到任意层次。位于foo中的元素bar可以被称作其子元素。
<foo>
<mark><bar></bar></mark>
</foo>
XML 文档中的第一个元素叫做根元素(root element)。并且每份 XML 文档只能有一个根元素。以下不是一个 XML 文档,因为它存在两个“根元素”。
<foo></foo>
<bar></bar>
元素可以有其属性(attribute),它们是一些名字-值(name-value)对。属性由空格分隔列举在元素的起始标签中。一个元素中属性名不能重复。属性值必须用引号包围起来。单引号、双引号都是可以。
</foo>
foo元素有一个叫做lang的属性。lang的值为enbar元素则有两个属性,分别为id和lang。其中lang属性的值为fr。它不会与foo的那个属性产生冲突。每个元素都其独立的属性集。
如果元素有多个属性,书写的顺序并不重要。元素的属性是一个无序的键-值对集,跟 Python 中的列表对象一样。另外,元素中属性的个数是没有限制的。
元素可以有其文本内容(text content)
<foo lang='en'>
<bar lang='fr'><mark>PapayaWhip</mark></bar>
</foo>
如果某一元素既没有文本内容,也没有子元素,它也叫做空元素。
<foo></foo>
表达空元素有一种简洁的方法。通过在起始标签的尾部添加/字符,我们可以省略结束标签。上一个例子中的 XML 文档可以写成这样:
<foo<mark>/</mark>>
就像 Python 函数可以在不同的模块(modules)中声明一样,也可以在不同的名字空间(namespace)中声明 XML 元素。XML 文档的名字空间通常看起来像 URL。我们可以通过声明xmlns来定义默认名字空间。名字空间声明跟元素属性看起来很相似,但是它们的作用是不一样的。
</feed>
feed元素处在名字空间http://www.w3.org/2005/Atom中。title元素也是。名字空间声明不仅会作用于当前声明它的元素,还会影响到该元素的所有子元素。
也可以通过xmlns:prefix``声明来定义一个名字空间并取其名为 prefix。然后该名字空间中的每个元素都必须显式地使用这个前缀(prefix)来声明。
</atom:feed>
feed元素属于名字空间http://www.w3.org/2005/Atom。title元素也在那个名字空间。
对于 XML 解析器而言,以上两个 XML 文档是一样的。名字空间 + 元素名 = XML 标识。前缀只是用来引用名字空间的,所以对于解析器来说,这些前缀名(atom:)其实无关紧要的。名字空间相同,元素名相同,属性(或者没有属性)相同,每个元素的文本内容相同,则 XML 文档相同。
最后,在根元素之前,字符编码信息可以出现在 XML 文档的第一行。(这里存在一个两难的局面(catch-22),直观上来说,解析 XML 文档需要这些编码信息,而这些信息又存在于 XML 文档中,如果你对 XML 如何解决此问题有兴趣,请参阅XML 规范中 F 章节)
<?xml version='1.0' <mark>encoding='utf-8'</mark>?>
现在我们已经知道足够多的 XML 知识,可以开始探险了!
Atom Feed 的结构
想像一下网络上的博客,或者互联网上任何需要频繁更新的网站,比如CNN.com。该站点有一个标题(“CNN.com”),一个子标题(“Breaking News, U.S., World, Weather, Entertainment & Video News”),包含上次更新的日期(“updated 12:43 p.m. EDT, Sat May 16, 2009”),还有在不同时期发布的文章的列表。每一篇文章也有自己的标题,第一次发布的日期(如果曾经修订过或者改正过某个输入错误,或许也有一个上次更新的日期),并且每篇文章有自己唯一的 URL。
Atom 聚合格式被设计成可以包含所有这些信息的标准格式。我的博客无论在设计,主题还是读者上都与 CNN.com 大不相同,但是它们的基本结构是相同的。CNN.com 能做的事情,我的博客也能做…
每一个 Atom 订阅都共享着一个根元素:即在名字空间http://www.w3.org/2005/Atom中的元素feed。
http://www.w3.org/2005/Atom表示名字空间 Atom。- 每一个元素都可以包含
xml:lang属性,它用来声明该元素及其子元素使用的语言。在当前样例中,xml:lang在根元素中被声明了一次,也就意味着,整个 feed 都使用英文。
描述 Atom feed 自身的一些信息在根元素feed的子元素中被声明。
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
- 该行表示这个 feed 的标题为
dive into mark。 - 这一行表示子标题为
currently between addictions。 - 每一个 feed 都要有一个全局唯一标识符(globally unique identifier)。想要知道如何创建它,请查阅RFC 4151。
- 表示当前 feed 上次更新的时间为 March 27, 2009, at 21:56 GMT。通常来说,它与最近一篇文章最后一次被修改的时间是一样的。
- 事情开始变得有趣了…
link元素没有文本内容,但是它有三个属性:rel,type和href。rel元素的值能告诉我们链接的类型;rel='alternate'表示这个链接指向当前 feed 的另外一个版本。type='text/html'表示链接的目标是一个 HTML 页面。然后目标地址在href属性中指出。
现在我们知道这个 feed 上一更新是在 on March 27, 2009,它是为一个叫做“dive into mark”的站点准备的,并且站点的地址为http://diveintomark.org/。
☞在有一些 XML 文档中,元素的排列顺序是有意义的,但是 Atom feed 中不需要这样做。
feed 级的元数据后边就是最近文章的列表了。单独的一篇文章就像这样:
<entry>
<name>Mark</name>
<uri>http://diveintomark.org/</uri>
</author>
href='http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'/>
<published>2009-03-27T17:20:42Z</published>
<category scheme='http://diveintomark.org' term='docbook'/>
<category scheme='http://diveintomark.org' term='html'/>
bloated, but consider this &mdash; my longest chapter so far
would be 75 printed pages, and it loads in under 5 seconds&hellip;
On dialup.</summary>
author元素指示文章的作者:一个叫做 Mark 的伙计,并且我们可以在http://diveintomark.org/找到他的事迹。(这就像是 feed 元素里的备用链接,但是没有规定一定要这样。许多网络日志由多个作者完成,他们都有自己的个人主页。)*title元素给出这篇文章的标题,即“Dive into history, 2009 edition”。如feed元素中的备用链接一样,link元素给出这篇文章的 HTML版本地址。- 每个条目也像 feed 一样,需要一个唯一的标识。
- 每个条目有两个日期与其相关:第一次发布日期(
published)和上次修改日期(updated)。 - 条目可以属于任意多个类别。这篇文章被归类到
diveintopython,docbook,和html。 summary元素中有这篇文章的概要性描述。(还有一个元素这里没有展示出来,即content,我们可以把整篇文章的内容都放在里边。)当前样例中,summary元素含有一个 Atom 特有的type='html'属性,它用来告知这份概要为 HTML 格式,而非纯文本。这非常重要,因为概要内容中包含了 HTML 中特有的实体(—和…),它们不应该以纯文本直接显示,正确的形式应该为“—”和“…”。- 最后就是
entry元素的结束标记了,它指示文章元数据的结尾。
解析 XML
Python 可以使用几种不同的方式解析 XML 文档。它包含了DOM和SAX解析器,但是我们焦点将放在另外一个叫做 ElementTree 的库上边。
<Element {http://www.w3.org/2005/Atom}feed at cd1eb0>
- ElementTree 属于 Python 标准库的一部分,它的位置为
xml.etree.ElementTree。 parse()函数是 ElementTree 库的主要入口,它使用文件名或者流对象作为参数。parse()函数会立即解析完整个文档。如果内存资源紧张,也可以增量式地解析 XML 文档parse()函数会返回一个能代表整篇文档的对象。这不是根元素。要获得根元素的引用可以调用getroot()方法。- 如预期的那样,根元素即
http://www.w3.org/2005/Atom名字空间中的feed。该字符串表示再次重申了非常重要的一点:XML 元素由名字空间和标签名(也称作本地名(local name))组成。这篇文档中的每个元素都在名字空间 Atom 中,所以根元素被表示为{http://www.w3.org/2005/Atom}feed。
☞ElementTree 使用
{namespace}localname``来表达 XML 元素。我们将会在 ElementTree 的 API 中多次见到这种形式。
元素即列表
在 ElementTree API 中,元素的行为就像列表一样。列表中的项即该元素的子元素。
# continued from the previous example
'{http://www.w3.org/2005/Atom}feed'
8
...
<Element {http://www.w3.org/2005/Atom}title at e2b5d0>
<Element {http://www.w3.org/2005/Atom}subtitle at e2b4e0>
<Element {http://www.w3.org/2005/Atom}id at e2b6c0>
<Element {http://www.w3.org/2005/Atom}updated at e2b6f0>
<Element {http://www.w3.org/2005/Atom}link at e2b4b0>
<Element {http://www.w3.org/2005/Atom}entry at e2b720>
<Element {http://www.w3.org/2005/Atom}entry at e2b510>
<Element {http://www.w3.org/2005/Atom}entry at e2b750>
- 紧接前一例子,根元素为
{http://www.w3.org/2005/Atom}feed。 - 根元素的“长度”即子元素的个数。
- 我们可以像使用迭代器一样来遍历其子元素。
- 从输出可以看到,根元素总共有 8 个子元素:所有 feed 级的元数据(
title,subtitle,id,updated和link),还有紧接着的三个entry元素。
也许你已经注意到了,但我还是想要指出来:该列表只包含直接子元素。每一个entry元素都有其子元素,但是并没有包括在这个列表中。这些子元素本可以包括在entry元素的列表中,但是确实不属于feed的子元素。但是,无论这些元素嵌套的层次有多深,总是有办法定位到它们的;在这章的后续部分我们会介绍两种方法。
属性即字典
XML 不只是元素的集合;每一个元素还有其属性集。一旦获取了某个元素的引用,我们可以像操作 Python 的字典一样轻松获取到其属性。
# continuing from the previous example
{'{http://www.w3.org/XML/1998/namespace}lang': 'en'}
<Element {http://www.w3.org/2005/Atom}link at e181b0>
{'href': 'http://diveintomark.org/',
'type': 'text/html',
'rel': 'alternate'}
<Element {http://www.w3.org/2005/Atom}updated at e2b4e0>
{}
attrib是一个代表元素属性的字典。这个地方原来的标记语言是这样描述的:<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>。前缀xml:指示一个内置的名字空间,每一个 XML 不需要声明就可以使用它。- 第五个子元素 — 以 0 为起始的列表中即
[4]— 为元素link。 link元素有三个属性:href,type,和rel。- 第四个子元素 —
[3]— 为updated。 - 元素
updated没有子元素,所以.attrib是一个空的字典对象。
在 XML 文档中查找结点
到目前为止,我们已经“自顶向下“地从根元素开始,一直到其子元素,走完了整个文档。但是许多情况下我们需要找到 XML 中特定的元素。Etree 也能完成这项工作。
>>> import xml.etree.ElementTree as etree
>>> tree = etree.parse('examples/feed.xml')
>>> root = tree.getroot()
[<Element {http://www.w3.org/2005/Atom}entry at e2b4e0>,
<Element {http://www.w3.org/2005/Atom}entry at e2b510>,
<Element {http://www.w3.org/2005/Atom}entry at e2b540>]
>>> root.tag
'{http://www.w3.org/2005/Atom}feed'
[]
[]
findfall()方法查找匹配特定格式的子元素。(关于查询的格式稍后会讲到。)- 每个元素 — 包括根元素及其子元素 — 都有
findall()方法。它会找到所有匹配的子元素。但是为什么没有看到任何结果呢?也许不太明显,这个查询只会搜索其子元素。由于根元素feed中不存在任何叫做feed的子元素,所以查询的结果为一个空的列表。 - 这个结果也许也在你的意料之外。在这篇文档中确实存在
author元素;事实上总共有三个(每个entry元素中都有一个)。但是那些author元素不是根元素的直接子元素。我们可以在任意嵌套层次中查找author元素,但是查询的格式会有些不同。
[<Element {http://www.w3.org/2005/Atom}entry at e2b4e0>,
<Element {http://www.w3.org/2005/Atom}entry at e2b510>,
<Element {http://www.w3.org/2005/Atom}entry at e2b540>]
[]
- 为了方便,对象
tree(调用etree.parse()的返回值)中的一些方法是根元素中这些方法的镜像。在这里,如果调用tree.getroot().findall(),则返回值是一样的。 - 也许有些意外,这个查询请求也没有找到文档中的
author元素。为什么没有呢?因为它只是tree.getroot().findall('{http://www.w3.org/2005/Atom}author')的一种简洁表示,即“查询所有是根元素的子元素的author”。因为这些author是entry元素的子元素,所以查询没有找到任何匹配的。
find()方法用来返回第一个匹配到的元素。当我们认为只会有一个匹配,或者有多个匹配但我们只关心第一个的时候,这个方法是很有用的。
>>> len(entries)
3
>>> title_element.text
'Dive into history, 2009 edition'
>>> foo_element
>>> type(foo_element)
<class 'NoneType'>
- 在前一样例中已经看到。这一句返回所有的
atom:entry元素。 find()方法使用 ElementTree 作为参数,返回第一个匹配到的元素。- 在
entries[0]中没有叫做foo的元素,所以返回值为None。
☞可逮住你了,在这里
find()方法非常容易被误解。在布尔上下文中,如果 ElementTree 元素对象不包含子元素,其值则会被认为是False(即如果len(element)等于 0)。这就意味着if element.find('...')并非在测试是否find()方法找到了匹配项;这条语句是在测试匹配到的元素是否包含子元素!想要测试find()方法是否返回了一个元素,则需使用if element.find('...') is not None。
也可以在所有*派生(descendant)*元素中搜索,即任意嵌套层次的子元素,孙子元素等…
>>> all_links
[<Element {http://www.w3.org/2005/Atom}link at e181b0>,
<Element {http://www.w3.org/2005/Atom}link at e2b570>,
<Element {http://www.w3.org/2005/Atom}link at e2b480>,
<Element {http://www.w3.org/2005/Atom}link at e2b5a0>]
{'href': 'http://diveintomark.org/',
'type': 'text/html',
'rel': 'alternate'}
{'href': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
'type': 'text/html',
'rel': 'alternate'}
>>> all_links[2].attrib
{'href': 'http://diveintomark.org/archives/2009/03/21/accessibility-is-a-harsh-mistress',
'type': 'text/html',
'rel': 'alternate'}
>>> all_links[3].attrib
{'href': 'http://diveintomark.org/archives/2008/12/18/give-part-1-container-formats',
'type': 'text/html',
'rel': 'alternate'}
//{http://www.w3.org/2005/Atom}link与前一样例很相似,除了开头的两条斜线。这两条斜线告诉findall()方法“不要只在直接子元素中查找;查找的范围可以是任意嵌套层次”。- 查询到的第一个结果是根元素的直接子元素。从它的属性中可以看出,它是一个指向该 feed 的 HTML 版本的备用链接。
- 其他的三个结果分别是低一级的备用链接。每一个
entry都有单独一个link子元素,由于在查询语句前的两条斜线的作用,我们也能定位到他们。
总的来说,ElementTree 的findall()方法是其一个非常强大的特性,但是它的查询语言却让人有些出乎意料。官方描述它为“有限的 XPath 支持。”XPath是一种用于查询 XML 文档的 W3C 标准。对于基础地查询来说,ElementTree 与 XPath 语法上足够相似,但是如果已经会 XPath 的话,它们之间的差异可能会使你感到不快。现在,我们来看一看另外一个第三方 XML 库,它扩展了 ElementTree 的 API 以提供对 XPath 的全面支持。
深入 lxml
lxml是一个开源的第三方库,以流行的libxml2 解析器为基础开发。提供了与 ElementTree 完全兼容的 API,并且扩展它以提供了对 XPath 1.0 的全面支持,以及改进了一些其他精巧的细节。提供Windows 的安装程序;Linux 用户推荐使用特定发行版自带的工具比如yum或者apt-get从它们的程序库中安装预编译好了的二进制文件。要不然,你就得手工安装他们了。
[<Element {http://www.w3.org/2005/Atom}entry at e2b4e0>,
<Element {http://www.w3.org/2005/Atom}entry at e2b510>,
<Element {http://www.w3.org/2005/Atom}entry at e2b540>]
- 导入
lxml以后,可以发现它与内置的 ElementTree 库提供相同的 API。 parse()函数:与 ElementTree 相同。getroot()方法:相同。findall()方法:完全相同。
对于大型的 XML 文档,lxml明显比内置的 ElementTree 快了许多。如果现在只用到了 ElementTree 的 API,并且想要使用其最快的实现(implementation),我们可以尝试导入lxml,并且将内置的 ElementTree 作为备用。
try:
from lxml import etree
except ImportError:
import xml.etree.ElementTree as etree
但是lxml不只是一个更快速的 ElementTree。它的findall()方法能够支持更加复杂的表达式。
>>> tree = lxml.etree.parse('examples/feed.xml')
[<Element {http://www.w3.org/2005/Atom}link at eeb8a0>,
<Element {http://www.w3.org/2005/Atom}link at eeb990>,
<Element {http://www.w3.org/2005/Atom}link at eeb960>,
<Element {http://www.w3.org/2005/Atom}link at eeb9c0>]
[<Element {http://www.w3.org/2005/Atom}link at eeb930>]
>>> NS = '{http://www.w3.org/2005/Atom}'
[<Element {http://www.w3.org/2005/Atom}author at eeba80>,
<Element {http://www.w3.org/2005/Atom}author at eebba0>]
- 在这个样例中,我使用了
import lxml.etree(而非from lxml import etree),以强调这些特性只限于lxml。 - 这一句在整个文档范围内搜索名字空间 Atom 中具有
href属性的所有元素。在查询语句开头的//表示“搜索的范围为整个文档(不只是根元素的子元素)。”{http://www.w3.org/2005/Atom}指示“搜索范围仅在名字空间 Atom 中。”*表示“任意本地名(local name)的元素。”[@href]表示“含有href属性。” - 该查询找出所有包含
href属性并且其值为http://diveintomark.org/的 Atom 元素。 - 在简单的字符串格式化后(要不然这条复合查询语句会变得特别长),它搜索名字空间 Atom 中包含
uri元素作为子元素的author元素。该条语句只返回了第一个和第二个entry元素中的author元素。最后一个entry元素中的author只包含有name属性,没有uri。
仍然不够用?lxml也集成了对任意 XPath 1.0 表达式的支持。我们不会深入讲解 XPath 的语法;那可能需要一整本书!但是我会给你展示它是如何集成到lxml去的。
>>> import lxml.etree
>>> tree = lxml.etree.parse('examples/feed.xml')
... namespaces=NSMAP)
[<Element {http://www.w3.org/2005/Atom}entry at e2b630>]
>>> entry = entries[0]
['Accessibility is a harsh mistress']
- 要查询名字空间中的元素,首先需要定义一个名字空间前缀映射。它就是一个 Python 字典对象。
- 这就是一个 XPath 查询请求。这个 XPath 表达式目的在于搜索
category元素,并且该元素包含有值为accessibility的term属性。但是那并不是查询的结果。请看查询字符串的尾端;是否注意到了/..这一块?它的意思是,“然后返回已经找到的category元素的父元素。”所以这条 XPath 查询语句会找到所有包含<category term='accessibility'>作为子元素的条目。 xpath()函数返回一个 ElementTree 对象列表。在这篇文档中,只有一个category元素,并且它的term属性值为accessibility。- XPath 表达式并不总是会返回一个元素列表。技术上说,一个解析了的 XML 文档的 DOM 模型并不包含元素;它只包含结点(node)。依据它们的类型,结点可以是元素,属性,甚至是文本内容。XPath 查询的结果是一个结点列表。当前查询返回一个文本结点列表:
title元素(atom:title)的文本内容(text()),并且title元素必须是当前元素的子元素(./)。
生成 XML
Python 对 XML 的支持不只限于解析已存在的文档。我们也可以从头来创建 XML 文档。
>>> import xml.etree.ElementTree as etree
<ns0:feed xmlns:ns0='http://www.w3.org/2005/Atom' xml:lang='en'/>
- 实例化
Element类来创建一个新元素。可以将元素的名字(名字空间 + 本地名)作为其第一个参数。当前语句在 Atom 名字空间中创建一个feed元素。它将会成为我们文档的根元素。 - 将属性名和值构成的字典对象传递给
attrib参数,这样就可以给新创建的元素添加属性。请注意,属性名应该使用标准的 ElementTree 格式,{namespace}localname``。 - 在任何时候,我们可以使用 ElementTree 的
tostring()函数序列化任意元素(还有它的子元素)。
这种序列化结果有使你感到意外吗?技术上说,ElementTree 使用的序列化方法是精确的,但却不是最理想的。在本章开头给出的 XML 样例文档中定义了一个默认名字空间(default namespace)(xmlns='http://www.w3.org/2005/Atom')。对于每个元素都在同一个名字空间中的文档 — 比如 Atom feeds — 定义默认的名字空间非常有用,因为只需要声明一次名字空间,然后在声明每个元素的时候只需要使用其本地名即可(<feed>,<link>,<entry>)。除非想要定义另外一个名字空间中的元素,否则没有必要使用前缀。
对于 XML 解析器来说,它不会“注意”到使用默认名字空间和使用前缀名字空间的 XML 文档之间有什么不同。当前序列化结果的 DOM 为:
<ns0:feed xmlns:ns0='http://www.w3.org/2005/Atom' xml:lang='en'/>
与下列序列化的 DOM 是一模一样的:
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'/>
实际上唯一不同的只是第二个序列化短了几个字符长度。如果我们改动整个样例 feed,使每一个起始和结束标签都有一个ns0:前缀,这将为每个起始标签增加 4 个字符 × 79 个标签 + 4 个名字空间声明本身用到的字符,总共 320 个字符。假设我们使用 UTF-8 编码,那将是 320 个额外的字节。(使用 gzip 压缩以后,大小可以降到 21 个字节,但是,21 个字节也是字节。)也许对个人来说这算不了什么,但是对于像 Atom feed 这样的东西,只要稍有改变就有可能被下载上千次,每一个请求节约的几个字节就会迅速累加起来。
内置的 ElementTree 库没有提供细粒度地对序列化时名字空间内的元素的控制,但是lxml有这样的功能。
>>> import lxml.etree
<feed xmlns='http://www.w3.org/2005/Atom'/>
>>> print(lxml.etree.tounicode(new_feed))
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'/>
- 首先,定义一个用于名字空间映射的字典对象。其值为名字空间;字典中的键即为所需要的前缀。使用
None作为前缀来定义默认的名字空间。 - 现在我们可以在创建元素的时候,给
lxml专有的nsmap参数传值,并且lxml会参照我们所定义的名字空间前缀。 - 如所预期的那样,该序列化使用 Atom 作为默认的名字空间,并且在声明
feed元素的时候没有使用名字空间前缀。 - 啊噢… 我们忘了加上
xml:lang属性。我们可以使用set()方法来随时给元素添加所需属性。该方法使用两个参数:标准 ElementTree 格式的属性名,然后,属性值。(该方法不是lxml特有的。在该样例中,只有nsmap 参数是lxml特有的,它用来控制序列化输出时名字空间的前缀。)
难道每个 XML 文档只能有一个元素吗?当然不了。我们可以创建子元素。
>>> print(lxml.etree.tounicode(new_feed))
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'><title type='html'/></feed>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'><title type='html'>dive into &hellip;</title></feed>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
<title type='html'>dive into&hellip;</title>
</feed>
- 给已有元素创建子元素,我们需要实例化
SubElement类。它只要求两个参数,父元素(即该样例中的new_feed)和子元素的名字。由于该子元素会从父元素那儿继承名字空间的映射关系,所以这里不需要再声明名字空间前缀。 - 我们也可以传递属性字典给它。字典的键即属性名;值为属性的值。
- 如预期的那样,新创建的
title元素在 Atom 名字空间中,并且它作为子元素插入到feed元素中。由于title元素没有文件内容,也没有其子元素,所以lxml将其序列化为一个空元素(使用/>)。 - 设定元素的文本内容,只需要设定其
.text属性。 - 当前
title元素序列化的时候就使用了其文本内容。任何包含了<或者&符号的内容在序列化的时候需要被转义。lxml会自动处理转义。 - 我们也可以在序列化的时候应用“漂亮的输出(pretty printing)”,这会在每个结束标签的末尾,或者含有子元素但没有文本内容的标签的末尾添加换行符。用术语说就是,
lxml添加“无意义的空白(insignificant whitespace)”以使输出更具可读性。
☞你也许也想要看一看xmlwitch,它也是用来生成 XML 的另外一个第三方库。它大量地使用了
with语句来使生成的 XML 代码更具可读性。
解析破损的 XML
XML 规范文档中指出,要求所有遵循 XML 规范的解析器使用“严厉的(draconian)错误处理”。即,当它们在 XML 文档中检测到任何编排良好性(wellformedness)错误的时候,应当立即停止解析。编排良好性错误包括不匹配的起始和结束标签,未定义的实体(entity),非法的 Unicode 字符,还有一些只有内行才懂的规则(esoteric rules)。这与其他的常见格式,比如 HTML,形成了鲜明的对比 — 即使忘记了封闭 HTML 标签,或者在属性值中忘了转义&字符,我们的浏览器也不会停止渲染一个 Web 页面。(通常大家认为 HTML 没有错误处理机制,这是一个常见的误解。HTML 的错误处理实际上被很好的定义了,但是它比“遇见第一个错误即停止”这种机制要复杂得多。)
一些人(包括我自己)认为 XML 的设计者强制实行这种严格的错误处理本身是一个失误。请不要误解我;我当然能看到简化错误处理机制的优势。但是在现实中,“编排良好性”这种构想比乍听上去更加复杂,特别是对 XML(比如 Atom feeds)这种发布在网络上,通过 HTTP 传播的文档。早在 1997 年 XML 就标准化了这种严厉的错误处理,尽管 XML 已经非常成熟,研究一直表明,网络上相当一部分的 Atom feeds 仍然存在着编排完整性错误。
所以,从理论上和实际应用两种角度来看,我有理由“不惜任何代价”来解析 XML 文档,即,当遇到编排良好性错误时,不会中断解析操作。如果你认为你也需要这样做,lxml可以助你一臂之力。
以下是一个破损的 XML 文档的片断。其中的编排良好性错误已经被高亮标出来了。
<?xml version='1.0' encoding='utf-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
<title>dive into <mark>…</mark></title>
...
</feed>
因为实体…并没有在 XML 中被定义,所以这算作一个错误。(它在 HTML 中被定义。)如果我们尝试使用默认的设置来解析该破损的 feed,lxml会因为这个未定义的实体而停下来。
>>> import lxml.etree
>>> tree = lxml.etree.parse('examples/feed-broken.xml')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "lxml.etree.pyx", line 2693, in lxml.etree.parse (src/lxml/lxml.etree.c:52591)
File "parser.pxi", line 1478, in lxml.etree._parseDocument (src/lxml/lxml.etree.c:75665)
File "parser.pxi", line 1507, in lxml.etree._parseDocumentFromURL (src/lxml/lxml.etree.c:75993)
File "parser.pxi", line 1407, in lxml.etree._parseDocFromFile (src/lxml/lxml.etree.c:75002)
File "parser.pxi", line 965, in lxml.etree._BaseParser._parseDocFromFile (src/lxml/lxml.etree.c:72023)
File "parser.pxi", line 539, in lxml.etree._ParserContext._handleParseResultDoc (src/lxml/lxml.etree.c:67830)
File "parser.pxi", line 625, in lxml.etree._handleParseResult (src/lxml/lxml.etree.c:68877)
File "parser.pxi", line 565, in lxml.etree._raiseParseError (src/lxml/lxml.etree.c:68125)
lxml.etree.XMLSyntaxError: Entity 'hellip' not defined, line 3, column 28
为了解析该破损的 XML 文档,忽略它的编排良好性错误,我们需要创建一个自定义的 XML 解析器。
examples/feed-broken.xml:3:28:FATAL:PARSER:ERR_UNDECLARED_ENTITY: Entity 'hellip' not defined
>>> tree.findall('{http://www.w3.org/2005/Atom}title')
[<Element {http://www.w3.org/2005/Atom}title at ead510>]
>>> title = tree.findall('{http://www.w3.org/2005/Atom}title')[0]
'dive into '
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
<title>dive into </title>
.
. [rest of serialization snipped for brevity]
.
- 实例化
lxml.etree.XMLParser类来创建一个自定义的解析器。它可以使用许多不同的命名参数。在此,我们感兴趣的为recover参数。当它的值被设为True,XML 解析器会尽力尝试从编排良好性错误中“恢复”。 - 为使用自定的解析器来处理 XML 文档,将对象
parser作为第二个参数传递给parse()函数。注意,lxml没有因为那个未定义的…实体而抛出异常。 - 解析器会记录它所遇到的所有编排良好性错误。(无论它是否被设置为需要从错误中恢复,这个记录总会存在。)
- 由于不知道如果处理该未定义的
…实体,解析器默认会将其省略掉。title元素的文本内容变成了'dive into '。 - 从序列化的结果可以看出,实体
…并没有被移到其他地方去;它就是被省略了。
在此,必须反复强调,这种“可恢复的”XML 解析器没有互用性(interoperability)保证。另一个不同的解析器可能就会认为…来自 HTML,然后将其替换为&hellip;。这样“更好”吗?也许吧。这样“更正确”吗?不,两种处理方法都不正确。正确的行为(根据 XML 规范)应该是终止解析操作。如果你已经决定不按规范来,你得自己负责。