Dive-Into-Python-中文版-五-

63 阅读1小时+

Dive Into Python 中文版(五)

第十四章 测试优先编程

第十四章 测试优先编程

  • 14.1. roman.py, 第 1 阶段
  • 14.2. roman.py, 第 2 阶段
  • 14.3. roman.py, 第 3 阶段
  • 14.4. roman.py, 第 4 阶段
  • 14.5. roman.py, 第 5 阶段

14.1. roman.py, 第 1 阶段

14.1. roman.py, 第 1 阶段

到目前为止,单元测试已经完成,是时候开始编写被单元测试测试的代码了。你将分阶段地完成这个工作,因此开始时所有的单元测试都是失败的,但在逐步完成 roman.py 的同时你会看到它们一个个地通过测试。

例 14.1. roman1.py

这个程序可以在例子目录下的 py/roman/stage1/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

"""Convert to and from Roman numerals"""
#Define exceptions
class RomanError(Exception): pass                 class OutOfRangeError(RomanError): pass           class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass 
def toRoman(n):
    """convert integer to Roman numeral"""
    pass                                         
def fromRoman(s):
    """convert Roman numeral to integer"""
    pass 
[1]这就是如何定义你自己的 Python 异常。异常 (Exception) 也是类,通过继承已有的异常,你可以创建自定义的异常。强烈建议 (但不是必须) 你继承 Exception 来定义自己的异常,因为它是所有内建异常的基类。这里我定义了 RomanError (从 Exception 继承而来) 作为我所有自定义异常的基类。这是一个风格问题,我也可以直接从 Exception 继承建立每一个自定义异常。
[2]OutOfRangeErrorNotIntegerError 异常将会最终被用于 toRoman 以标示不同类型的无效输入,更具体而言就是 ToRomanBadInput 测试的那些。
[3]InvalidRomanNumeralError 将被最终用于 fromRoman 以标示无效输入,具体而言就是 FromRomanBadInput测试的那些。
[4]在这一步中你只是想定义每个函数的 API ,而不想具体实现它们,因此你以 Python 关键字 pass 姑且带过。

重要的时刻到了 (请打起鼓来):你终于要对这个简陋的小模块开始运行单元测试了。目前而言,每一个测试用例都应该失败。事实上,任何测试用例在此时通过,你都应该回头看看 romantest.py ,仔细想想为什么你写的测试代码如此没用,以至于连什么都不作的函数都能通过测试。

用命令行选项 -v 运行 romantest1.py 可以得到更详细的输出信息,这样你就可以看到每一个测试用例的具体运行情况。如果幸运,你的结果应该是这样的:

例 14.2. 以 romantest1.py 测试 roman1.py 的输出

fromRoman should only accept uppercase input ... ERROR
toRoman should always return uppercase ... ERROR
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... FAIL
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... FAIL
toRoman should fail with negative input ... FAIL
toRoman should fail with large input ... FAIL
toRoman should fail with 0 input ... FAIL
======================================================================
ERROR: fromRoman should only accept uppercase input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 154, in testFromRomanCase
    roman1.fromRoman(numeral.upper())
AttributeError: 'None' object has no attribute 'upper' ======================================================================
ERROR: toRoman should always return uppercase
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 148, in testToRomanCase
    self.assertEqual(numeral, numeral.upper())
AttributeError: 'None' object has no attribute 'upper' ======================================================================
FAIL: fromRoman should fail with malformed antecedents
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 127, in testRepeatedPairs
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with too many repeated numerals
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should give known result with known input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None ======================================================================
FAIL: toRoman should give known result with known input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 93, in testToRomanKnownValues
    self.assertEqual(numeral, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: I != None ======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None ======================================================================
FAIL: toRoman should fail with non-integer input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 116, in testNonInteger
    self.assertRaises(roman1.NotIntegerError, roman1.toRoman, 0.5)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: NotIntegerError ======================================================================
FAIL: toRoman should fail with negative input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 112, in testNegative
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, -1)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError ======================================================================
FAIL: toRoman should fail with large input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 104, in testTooLarge
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, 4000)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError ======================================================================
FAIL: toRoman should fail with 0 input  ---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 108, in testZero
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, 0)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError  ----------------------------------------------------------------------
Ran 12 tests in 0.040s  FAILED (failures=10, errors=2) 
[1]运行脚本将会执行 unittest.main(),由它来执行每个测试用例,也就是每个在 romantest.py 中定义的方法。对于每个测试用例,无论测试通过与否,都会输出这个方法的 doc string。意料之中,没有通过一个测试用例。
[2]对于每个失败的测试用例,unittest 显示的跟踪信息告诉我们都发生了什么。就此处而言,调用 assertRaises (也称作 failUnlessRaises) 引发了一个 AssertionError 异常,因为期待 toRoman 所引发的 OutOfRangeError 异常没有出现。
[3]在这些细节后面,unittest 给出了一个关于被执行测试的个数和花费时间的总结。
[4]总而言之,由于至少一个测试用例没有通过,单元测试失败了。当某个测试用例没能通过时,unittest 会区分是失败 (failures) 还是错误 (errors)。失败是指调用 assertXYZ 方法,比如 assertEqual 或者 assertRaises 时,断言的情况没有发生或预期的异常没有被引发。而错误是指你测试的代码或单元测试本身发生了某种异常。例如:testFromRomanCase 方法 (“fromRoman 只接受大写输入”) 就是一个错误,因为调用 numeral.upper() 引发了一个 AttributeError 异常,因为 toRoman 的返回值不是期望的字符串类型。但是,testZero (“toRoman 应该在输入 0 时失败”) 是一个失败,因为调用 fromRoman 没有引发一个 assertRaises 期待的异常:InvalidRomanNumeral

14.2. roman.py, 第 2 阶段

14.2. roman.py, 第 2 阶段

现在你有了 roman 模块的大概框架,到了开始写代码以通过测试的时候了。

例 14.3. roman2.py

这个文件可以从 py/roman/stage2/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

"""Convert to and from Roman numerals"""
#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass
#Define digit mapping
romanNumeralMap = (('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))
def toRoman(n):
    """convert integer to Roman numeral"""
    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:      
            result += numeral
            n -= integer
    return result
def fromRoman(s):
    """convert Roman numeral to integer"""
    pass 
[1]romanNumeralMap 是一个用来定义三个内容的元组的元组:1. 代表大部分罗马数字的字符。注意不只是单字符的罗马数字,你同样在这里定义诸如 CM (“比一千少一百,即 900”) 的双字符,这可以让稍后编写的 toRoman 简单一些。2. 罗马数字的顺序。它们是以降序排列的,从M 一路到 I。3. 每个罗马数字所对应的数值。每个内部的元组都是一个 (_numeral_,_value_) 数值对。
[2]这里便显示出你丰富的数据结构带来的优势,你不需要什么特定的逻辑处理减法规则。你只需要通过搜寻 romanNumeralMap 寻找不大于输入数值的最大对应整数即可。只要找到,就在结果的结尾把这个整数对应的罗马字符添加到输出结果的末尾,从输入值中减去这个整数,一遍遍这样继续下去。

例 14.4. toRoman 如何工作

如果你不明了 toRoman 如何工作,在 while 循环的结尾添加一个 print 语句:

 while n >= integer:
            result += numeral
            n -= integer
            print 'subtracting', integer, 'from input, adding', numeral, 'to output' 
>>> import roman2
>>> roman2.toRoman(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' 

看来 toRoman 可以运转了,至少手工测试可以。但能通过单元测试吗?啊哈,不,不完全可以。

例 14.5. 以 romantest2.py 测试 roman2.py 的输出

要记得用 -v 命令行选项运行 romantest2.py 开启详细信息模式。

fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok  fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... ok  fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... FAIL  toRoman should fail with negative input ... FAIL
toRoman should fail with large input ... FAIL
toRoman should fail with 0 input ... FAIL 
[1]事实上,toRoman 的返回值总是大写的,因为 romanNumeralMap 定义的罗马字符都是以大写字母表示的。因此这个测试已经通过了。
[2]好消息来了:这个版本的 toRoman 函数能够通过已知值测试。记住,这并不能证明完全没问题,但至少通过测试多种有效输入考验了这个函数:包括每个单一字符的罗马数字,可能的最大输入 (3999),以及可能的最长的罗马数字 (对应于 3888)。从这点来看,你有理由相信这个函数对于任何有效输入都不会出问题。
[3]但是,函数还没办法处理无效输入,每个无效输入测试都失败了。这很好理解,因为你还没有对无效输入进行检查,测试用例希望捕捉到特定的异常 (通过 assertRaises),而你根本没有让这些异常引发。这是你下一阶段的工作。

下面是单元测试结果的剩余部分,列出了所有失败的详细信息,你已经让它降到了 10 个。

 ======================================================================
FAIL: fromRoman should only accept uppercase input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 156, in testFromRomanCase
    roman2.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with malformed antecedents
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 127, in testRepeatedPairs
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with too many repeated numerals
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should give known result with known input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None ======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None ======================================================================
FAIL: toRoman should fail with non-integer input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 116, in testNonInteger
    self.assertRaises(roman2.NotIntegerError, roman2.toRoman, 0.5)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: NotIntegerError ======================================================================
FAIL: toRoman should fail with negative input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 112, in testNegative
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, -1)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError ======================================================================
FAIL: toRoman should fail with large input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 104, in testTooLarge
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, 4000)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError ======================================================================
FAIL: toRoman should fail with 0 input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 108, in testZero
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, 0)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError ----------------------------------------------------------------------
Ran 12 tests in 0.320s
FAILED (failures=10) 

14.3. roman.py, 第 3 阶段

14.3. roman.py, 第 3 阶段

现在 toRoman 对于有效的输入 (13999 整数) 已能正确工作,是正确处理那些无效输入 (任何其他输入) 的时候了。

例 14.6. roman3.py

这个文件可以在例子目录下的 py/roman/stage3/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

"""Convert to and from Roman numerals"""
#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass
#Define digit mapping
romanNumeralMap = (('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))
def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):                                             
        raise OutOfRangeError, "number out of range (must be 1..3999)" 
    if int(n) <> n:                                                    
        raise NotIntegerError, "non-integers can not be converted"
    result = ""                                                        
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result
def fromRoman(s):
    """convert Roman numeral to integer"""
    pass 
[1]这个写法很 Pythonic:一次进行多个比较。这等价于if not ((0 &lt; n) and (n &lt; 4000)),但是更容易让人理解。这是在进行范围检查,可以将过大的数、负数和零查出来。
[2]你使用 raise 语句引发自己的异常。你可以引发任何内建异常或者已定义的自定义异常。第二个参数是可选的,如果给定,则会在异常未被处理时显示于追踪信息 (trackback) 之中。
[3]这是一个非整数检查。非整数无法转化为罗马数字表示。
[4]函数的其他部分未被更改。

例 14.7. 观察 toRoman 如何处理无效输入

>>> import roman3
>>> roman3.toRoman(4000)
Traceback (most recent call last):
  File "<interactive input>", line 1, in ?
  File "roman3.py", line 27, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
>>> roman3.toRoman(1.5)
Traceback (most recent call last):
  File "<interactive input>", line 1, in ?
  File "roman3.py", line 29, in toRoman
    raise NotIntegerError, "non-integers can not be converted"
NotIntegerError: non-integers can not be converted 

例 14.8. 用 romantest3.py 测试 roman3.py 的结果

fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... ok  fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... ok  toRoman should fail with negative input ... ok  toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok 
[1]toRoman 仍然能通过已知值测试,这很令人鼓舞。所有第 2 阶段通过的测试仍然能通过,这说明新的代码没有对原有代码构成任何负面影响。
[2]更令人振奋的是所有的无效输入测试现在都通过了。testNonInteger 这个测试能够通过是因为有了 int(n) &lt;&gt; n 检查。当一个非整数传递给 toRoman 时,int(n) &lt;&gt; n 检查出问题并引发 NotIntegerError 异常,这正是 testNonInteger 所期待的。
[3]testNegative 这个测试能够通过是因为 not (0 &lt; n &lt; 4000) 检查引发了 testNegative 期待的 OutOfRangeError 异常。
 ======================================================================
FAIL: fromRoman should only accept uppercase input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 156, in testFromRomanCase
    roman3.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with malformed antecedents
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 127, in testRepeatedPairs
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with too many repeated numerals
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should give known result with known input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None ======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None ----------------------------------------------------------------------
Ran 12 tests in 0.401s
FAILED (failures=6) 
[1]你已将失败降至 6 个,而且它们都是关于 fromRoman 的:已知值测试、三个独立的无效输入测试,大小写检查和完备性检查。这意味着 toRoman 通过了所有可以独立通过的测试 (完备性测试也测试它,但需要 fromRoman 编写后一起测试)。这就是说,你应该停止对 toRoman 的代码编写。不必再推敲,不必再做额外的检查 “恰到好处”。停下来吧!现在,别再敲键盘了。

注意 全面的单元测试能够告诉你的最重要的事情是什么时候停止编写代码。当一个函数的所有单元测试都通过了,停止编写这个函数。一旦整个模块的单元测试通过了,停止编写这个模块。

14.4. roman.py, 第 4 阶段

14.4. roman.py, 第 4 阶段

现在 toRoman 完成了,是开始编写 fromRoman 的时候了。感谢那个将每个罗马数字和对应整数关连的完美数据结构,这个工作不比 toRoman 函数复杂。

例 14.9. roman4.py

这个文件可以在例子目录下的 py/roman/stage4/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

"""Convert to and from Roman numerals"""
#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass
#Define digit mapping
romanNumeralMap = (('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))
# toRoman function omitted for clarity (it hasn't changed)
def fromRoman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral: 
            result += integer
            index += len(numeral)
    return result 
[1]这和 toRoman 的工作模式很相似。你遍历整个罗马数字数据结构 (一个元组的元组),与前面不同的是不去一个个搜寻最大的整数,而是搜寻 “最大的”罗马数字字符串。

例 14.10. fromRoman 如何工作

如果你不清楚 fromRoman 如何工作,在 while 结尾处添加一个 print 语句:

 while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print 'found', numeral, 'of length', len(numeral), ', adding', integer 
>>> import roman4
>>> roman4.fromRoman('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 

例 14.11. 用 romantest4.py 测试 roman4.py 的结果

fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... ok  toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok  toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok 
[1]这儿有两个令人激动的消息。一个是 fromRoman 对于所有有效输入运转正常,至少对于你测试的已知值是这样。
[2]第二个好消息是,完备性测试也通过了。与已知值测试的通过一起来看,你有理由相信 toRomanfromRoman 对于所有有效输入值工作正常。(尚不能完全相信,理论上存在这种可能性:toRoman 存在错误而导致一些特定输入会产生错误的罗马数字表示,并且 fromRoman 也存在相应的错误,把 toRoman 错误产生的这些罗马数字错误地转换为最初的整数。取决于你的应用程序和你的要求,你或许需要考虑这个可能性。如果是这样,编写更全面的测试用例直到解决这个问题。)
 ======================================================================
FAIL: fromRoman should only accept uppercase input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 156, in testFromRomanCase
    roman4.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with malformed antecedents
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 127, in testRepeatedPairs
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ======================================================================
FAIL: fromRoman should fail with too many repeated numerals
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ----------------------------------------------------------------------
Ran 12 tests in 1.222s
FAILED (failures=4) 

14.5. roman.py, 第 5 阶段

14.5. roman.py, 第 5 阶段

现在 fromRoman 对于有效输入能够正常工作了,是揭开最后一个谜底的时候了:使它正常工作于无效输入的情况下。这意味着要找出一个方法检查一个字符串是不是有效的罗马数字。这比 toRoman 中验证有效的数字输入困难,但是你可以使用一个强大的工具:正则表达式。

如果你不熟悉正则表达式,并且没有读过 第七章 正则表达式,现在是该好好读读的时候了。

如你在 第 7.3 节 “个案研究:罗马字母”中所见到的,构建罗马数字有几个简单的规则:使用字母 M, D, C, L, X, VI。让我们回顾一下:

  1. 字符是被“加”在一起的:I1II2III3VI6 (看上去就是 “51”),VII7VIII8
  2. 这些字符 (I, X, CM) 最多可以重复三次。对于 4,你则需要利用下一个能够被 5 整除的字符进行减操作得到。你不能把 4 表示为 IIII 而应该表示为 IV (“比 51 ”)。40 则被写作 XL (“比 5010”),41 表示为 XLI42 表示为 XLII43 表示为 XLIII44 表示为 XLIV (“比5010,加上 51”)。
  3. 类似地,对于数字 9,你必须利用下一个能够被 10 整除的字符进行减操作得到:8VIII,而 9IX (“比 101”),而不是 VIIII (由于 I 不能重复四次)。90 表示为 XC900 表示为 CM
  4. 含五的字符不能被重复:10 应该表示为 X,而不会是 VV100 应该表示为 C,而不是 LL
  5. 罗马数字一般从高位到低位书写,从左到右阅读,因此不同顺序的字符意义大不相同。DC600CD 是完全另外一个数 (400,“比 500100”)。CI101,而 IC 根本就不是一个有效的罗马数字 (因为你无法从100直接减1,应该写成 XCIX,意思是 “比 10010,然后加上数字 9,也就是比 101”)。

例 14.12. roman5.py

这个程序可以在例子目录下的py/roman/stage5/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

"""Convert to and from Roman numerals"""
import re
#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass
#Define digit mapping
romanNumeralMap = (('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))
def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):
        raise OutOfRangeError, "number out of range (must be 1..3999)"
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"
    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result
#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 
def fromRoman(s):
    """convert Roman numeral to integer"""
    if not re.search(romanNumeralPattern, s):                                    
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s
    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result 
[1]这只是 第 7.3 节 “个案研究:罗马字母” 中讨论的匹配模版的继续。十位上可能是XC (90),XL (40),或者可能是 L 后面跟着 0 到 3 个 X 字符。个位则可能是 IX (9),IV (4),或者是一个可能是 V 后面跟着 0 到 3 个 I 字符。
[2]把所有的逻辑编码成正则表达式,检查无效罗马字符的代码就很简单了。如果 re.search 返回一个对象则表示匹配了正则表达式,输入是有效的,否则输入无效。

这里你可能会怀疑,这个面目可憎的正则表达式是否真能查出错误的罗马字符表示。没关系,不必完全听我的,不妨看看下面的结果:

例 14.13. 用 romantest5.py 测试 roman5.py 的结果

 fromRoman should only accept uppercase input ... ok  toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... ok  fromRoman should fail with repeated pairs of numerals ... ok  fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 12 tests in 2.864s
OK 
[1]有件事我未曾讲过,那就是默认情况下正则表达式大小写敏感。由于正则表达式 romanNumeralPattern 是以大写字母构造的,re.search 将拒绝不全部是大写字母构成的输入。因此大写输入的检查就通过了。
[2]更重要的是,无效输入测试也通过了。例如,上面这个用例测试了 MCMC 之类的情形。正如你所见,这不匹配正则表达式,因此 fromRoman 引发一个测试用例正在等待的 InvalidRomanNumeralError 异常,所以测试通过了。
[3]事实上,所有的无效输入测试都通过了。正则表达式捕捉了你在编写测试用例时所能预见的所有情况。
[4]最终迎来了 “OK”这个平淡的“年度大奖”,所有测试都通过后 unittest 模块就会输出它。

注意 当所有测试都通过了,停止编程。

第十五章 重构

第十五章 重构

  • 15.1. 处理 bugs
  • 15.2. 应对需求变化
  • 15.3. 重构
  • 15.4. 后记
  • 15.5. 小结

15.1. 处理 bugs

15.1. 处理 bugs

尽管你很努力地编写全面的单元测试,但是 bug 还是会出现。我所说的 “bug” 是什么呢?Bug 是你还没有编写的测试用例。

例 15.1. 关于 Bug

>>> import roman5
>>> roman5.fromRoman("") 
0 
[1]在前面的章节中你注意到一个空字符串会匹配上那个检查罗马数字有效性的正则表达式了吗?对于最终版本中的正则表达式这一点仍然没有改变。这就是一个 Bug ,你希望空字符串能够像其他无效的罗马数字表示一样引发 InvalidRomanNumeralError 异常。

在重现这个 Bug 并修改它之前你应该编写一个会失败的测试用例来说明它。

例 15.2. 测试 bug (romantest61.py)

 class FromRomanBadInput(unittest.TestCase):                                      
    # previous test cases omitted for clarity (they haven't changed)
    def testBlank(self):
        """fromRoman should fail with blank string"""
        self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, "") 
[1]这里很简单。以空字符串调用 fromRoman 并确保它会引发一个 InvalidRomanNumeralError 异常。难点在于找出 Bug,既然你已经知道它了,测试就简单了。

因为你的代码存在一个 Bug,并且你编写了测试这个 Bug 的测试用例,所以测试用例将会失败:

例 15.3. 用 romantest61.py 测试 roman61.py 的结果

fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... FAIL
fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok
======================================================================
FAIL: fromRoman should fail with blank string
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage6\romantest61.py", line 137, in testBlank
    self.assertRaises(roman61.InvalidRomanNumeralError, roman61.fromRoman, "")
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError ----------------------------------------------------------------------
Ran 13 tests in 2.864s
FAILED (failures=1) 

现在 你可以修改这个 Bug 了。

例 15.4. 修改 Bug (roman62.py)

这个文件可以在例子目录下的 py/roman/stage6/ 目录中找到。

 def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s: 
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not re.search(romanNumeralPattern, s):
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s
    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result 
[1]只需要两行代码:一行直接检查空字符串和一行 raise 语句。

例 15.5. 用 romantest62.py 测试 roman62.py 的结果

fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... ok  fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 13 tests in 2.834s
OK 
[1]空字符串测试用例现在通过了,说明 Bug 被修正了。
[2]所有其他测试用例依然通过,证明这个 Bug 修正没有影响到其他部分。不需要再编程了。

这样编程,并没有令 Bug 修正变得简单。简单的 Bug (就像这一个) 需要简单的测试用例,复杂 Bug 则需要复杂的测试用例。以测试为核心的氛围好像 延长了修正 Bug 的时间,因为你需要先贴切地描述出 Bug (编写测试用例) 然后才去修正它。如果测试用例没能正确通过,你需要思量这个修改错了还是测试用例本身出现了 Bug。无论如何,从长远上讲,这样在测试代码和代码之间的反复是值得的,因为这样会使 Bug 在第一时间就被修正的可能性大大提高。而且不论如何更改,你都可以轻易地重新运行所有 测试用例,新代码破坏老代码的机会也变得微乎其微。今天的单元测试就是明天的回归测试 (regression test)。

15.2. 应对需求变化

15.2. 应对需求变化

尽管你竭尽努力地分析你的客户,并点灯熬油地提炼出精确的需求,但需求还是会是不断变化。大部分客户在看到产品前不知道他们想要什么。即便知道,也不擅于精确表述出他们的有效需求。即便能表述出来,他们在下一个版本一定会要求更多的功能。因此你需要做好更新测试用例的准备以应对需求的改变。

假设你想要扩展罗马数字转换函数的范围。还记得没有哪个字符可以重复三遍以上这条规则吗?呃,现在罗马人希望给这条规则来个例外,用连续出现 4 个 M 字符来表示 4000。如果这样改了,你就可以把转换范围从 1..3999 扩展到 1..4999。但你先要对测试用例进行修改。

例 15.6. 修改测试用例以适应新需求 (romantest71.py)

这个文件可以在例子目录下的 py/roman/stage7/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

 import roman71
import unittest
class KnownValues(unittest.TestCase):
    knownValues = ( (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'),
                    (3999, 'MMMCMXCIX'),
                    (4000, 'MMMM'),                                       
                    (4500, 'MMMMD'),
                    (4888, 'MMMMDCCCLXXXVIII'),
                    (4999, 'MMMMCMXCIX'))
    def testToRomanKnownValues(self):
        """toRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman71.toRoman(integer)
            self.assertEqual(numeral, result)
    def testFromRomanKnownValues(self):
        """fromRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman71.fromRoman(numeral)
            self.assertEqual(integer, result)
class ToRomanBadInput(unittest.TestCase):
    def testTooLarge(self):
        """toRoman should fail with large input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, 5000) 
    def testZero(self):
        """toRoman should fail with 0 input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, 0)
    def testNegative(self):
        """toRoman should fail with negative input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, -1)
    def testNonInteger(self):
        """toRoman should fail with non-integer input"""
        self.assertRaises(roman71.NotIntegerError, roman71.toRoman, 0.5)
class FromRomanBadInput(unittest.TestCase):
    def testTooManyRepeatedNumerals(self):
        """fromRoman should fail with too many repeated numerals"""
        for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):     
            self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, s)
    def testRepeatedPairs(self):
        """fromRoman should fail with repeated pairs of numerals"""
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, s)
    def testMalformedAntecedent(self):
        """fromRoman should fail with malformed antecedents"""
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, s)
    def testBlank(self):
        """fromRoman should fail with blank string"""
        self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, "")
class SanityCheck(unittest.TestCase):
    def testSanity(self):
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 5000):                                    
            numeral = roman71.toRoman(integer)
            result = roman71.fromRoman(numeral)
            self.assertEqual(integer, result)
class CaseCheck(unittest.TestCase):
    def testToRomanCase(self):
        """toRoman should always return uppercase"""
        for integer in range(1, 5000):
            numeral = roman71.toRoman(integer)
            self.assertEqual(numeral, numeral.upper())
    def testFromRomanCase(self):
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 5000):
            numeral = roman71.toRoman(integer)
            roman71.fromRoman(numeral.upper())
            self.assertRaises(roman71.InvalidRomanNumeralError,
                              roman71.fromRoman, numeral.lower())
if __name__ == "__main__":
    unittest.main() 
[1]原来的已知值没有改变 (它们仍然是合理的测试值) 但你需要添加几个大于 4000 的值。这里我添加了 4000 (最短的一个),4500 (次短的一个),4888 (最长的一个) 和 4999 (值最大的一个)。
[2]“最大输入”的定义改变了。以前是以 4000 调用 toRoman 并期待一个错误;而现在 4000-4999 成为了有效输入,需要将这个最大输入提升至 5000
[3]“过多字符重复” 的定义也改变了。这个测试以前是以 'MMMM' 调用 fromRoman 并期待一个错误;而现在 MMMM 被认为是一个有效的罗马数字表示,需要将这个“过多字符重复”改为 'MMMMM'
[4]完备测试和大小写测试原来在 13999 范围内循环。现在范围扩展了,这个 for 循环需要将范围也提升至 4999

现在你的测试用例和新需求保持一致了,但是你的程序代码还没有,因此几个测试用例的失败是意料之中的事。

例 15.7. 用 romantest71.py 测试 roman71.py 的结果

 fromRoman should only accept uppercase input ... ERROR  toRoman should always return uppercase ... ERROR
fromRoman should fail with blank string ... ok
fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ERROR  toRoman should give known result with known input ... ERROR  fromRoman(toRoman(n))==n for all n ... ERROR  toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok 
[1]我们的大小写检查是因为循环范围是 14999,而 toRoman 只接受 13999 之间的数,因此测试循环到 4000 就会失败。
[2]fromRoman 的已知值测试在遇到 'MMMM' 就会失败,因为 fromRoman 还认为这是一个无效的罗马数字表示。
[3]toRoman 的已知值测试在遇到 4000 就会失败,因为 toRoman 仍旧认为这超出了有效值范围。
[4]完备测试在遇到 4000 也会失败,因为 toRoman 也会认为这超出了有效值范围。
 ======================================================================
ERROR: fromRoman should only accept uppercase input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 161, in testFromRomanCase
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999) ======================================================================
ERROR: toRoman should always return uppercase
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 155, in testToRomanCase
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999) ======================================================================
ERROR: fromRoman should give known result with known input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 102, in testFromRomanKnownValues
    result = roman71.fromRoman(numeral)
  File "roman71.py", line 47, in fromRoman
    raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s
InvalidRomanNumeralError: Invalid Roman numeral: MMMM ======================================================================
ERROR: toRoman should give known result with known input
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 96, in testToRomanKnownValues
    result = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999) ======================================================================
ERROR: fromRoman(toRoman(n))==n for all n
---------------------------------------------------------------------- Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 147, in testSanity
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999) ----------------------------------------------------------------------
Ran 13 tests in 2.213s
FAILED (errors=5) 

既然新的需求导致了测试用例的失败,你该考虑修改代码以便它能再次通过测试用例。(在你开始编写单元测试时要习惯一件事:被测试代码永远不会在编写测试用例“之前”编写。正因为如此,你还有一些工作要做,一旦可以通过所有的测试用例,停止编码。)

例 15.8. 为新的需求编写代码 (roman72.py)

这个文件可以在例子目录下的 py/roman/stage7/ 目录中找到。

"""Convert to and from Roman numerals"""
import re
#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass
#Define digit mapping
romanNumeralMap = (('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))
def toRoman(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"
    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result
#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 
def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not re.search(romanNumeralPattern, s):
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s
    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result 
[1]toRoman 只需要在取值范围检查一处做个小改动。将原来的 0 &lt; n &lt; 4000,更改为现在的检查 0 &lt; n &lt; 5000。你还要更改你 raise 的错误信息以反映接受新取值范围 (1..4999 而不再是 1..3999)。你不需要改变函数的其他部分,它们已经适用于新的情况。(它们会欣然地为新的 1000 添加 'M',以 4000 为例,函数会返回 'MMMM' 。之前没能这样做是因为到范围检查时就被停了下来。)
[2]你对 fromRoman 也不需要做过多的修改。唯一的修改就在 romanNumeralPattern:如果你注意的话,你会发现你只需在正则表达式的第一部分增加一个可选的 M 。这就允许最多 4 个 M 字符而不再是 3 个,意味着你允许代表 4999 而不只是 3999 的罗马数字。fromRoman 函数本身是普遍适用的,它并不在意字符被多少次的重复,只是根据重复的罗马字符对应的数值进行累加。以前没能处理 'MMMM' 是因为你通过正则表达式的检查强行停止了。

你可能会怀疑只需这两处小改动。嘿,不相信我的话,你自己看看吧:

例 15.9. 用 romantest72.py 测试 roman72.py 的结果

fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... ok
fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 13 tests in 3.685s
OK 
[1]所有的测试用例都通过了,停止编写代码。

全面的单元测试意味着不必依赖于程序员的一面之词:“相信我!”

15.3. 重构

15.3. 重构

全面的单元测试带来的最大好处不是你的全部测试用例最终通过时的成就感;也不是被责怪破坏了别人的代码时能够证明 自己的自信。最大的好处是单元测试给了你自由去无情地重构。

重构是在可运行代码的基础上使之工作得更好的过程。通常,“更好”意味着“更快”,也可能意味着 “使用更少的内存”,或者 “使用更少的磁盘空间”,或者仅仅是“更优雅的代码”。不管对你,对你的项目意味什么,在你的环境中,重构对任何程序的长期良性运转都是重要的。

这里,“更好” 意味着 “更快”。更具体地说,fromRoman 函数可以更快,关键在于那个丑陋的、用于验证罗马数字有效性的正则表达式。尝试不用正则表达式去解决是不值得的 (这样做很难,而且可能也快不了多少),但可以通过预编译正则表达式使函数提速。

例 15.10. 编译正则表达式

>>> import re
>>> pattern = '^M?M?M?$'
>>> re.search(pattern, 'M')               
<SRE_Match object at 01090490>
>>> compiledPattern = re.compile(pattern) 
>>> compiledPattern
<SRE_Pattern object at 00F06E28>
>>> dir(compiledPattern)                  
['findall', 'match', 'scanner', 'search', 'split', 'sub', 'subn']
>>> compiledPattern.search('M')           
<SRE_Match object at 01104928> 
[1]这是你看到过的 re.search 语法。把一个正则表达式作为字符串 (pattern) 并用这个字符串来匹配 ('M')。如果能够匹配,函数返回 一个 match 对象,可以用来确定匹配的部分和如何匹配的。
[2]这里是一个新的语法:re.compile 把一个正则表达式作为字符串参数接受并返回一个 pattern 对象。注意这里没去匹配字符串。编译正则表达式和以特定字符串 ('M') 进行匹配不是一回事,所牵扯的只是正则表达式本身。
[3]re.compile 返回的已编译的 pattern 对象有几个值得关注的功能:包括了几个 re 模块直接提供的功能 (比如:searchsub)。
[4]'M' 作参数来调用已编译的 pattern 对象的 search 函数与用正则表达式和字符串 'M' 调用 re.search 可以得到相同的结果,只是快了很多。 (事实上,re.search 函数仅仅将正则表达式编译,然后为你调用编译后的 pattern 对象的 search 方法。)

注意 在需要多次使用同一个正则表达式的情况下,应该将它进行编译以获得一个 pattern 对象,然后直接调用这个 pattern 对象的方法。

例 15.11. roman81.py 中已编译的正则表达式

这个文件可以在例子目录下的 py/roman/stage8/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

# toRoman and rest of module omitted for clarity
romanNumeralPattern = \
    re.compile('^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$') 
def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not romanNumeralPattern.search(s):                                    
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s
    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result 
[1]看起来很相似,但实质却有很大改变。romanNumeralPattern 不再是一个字符串了,而是一个由 re.compile 返回的 pattern 对象。
[2]这意味着你可以直接调用 romanNumeralPattern 的方法。这比每次调用 re.search 要快很多。模块被首次导入 (import) 之时,正则表达式被一次编译并存储于 romanNumeralPattern。之后每次调用 fromRoman 时,你可以立刻以正则表达式匹配输入的字符串,而不需要在重复背后的这些编译的工作。

那么编译正则表达式可以提速多少呢?你自己来看吧:

例 15.12. 用 romantest81.py 测试 roman81.py 的结果

.............  ----------------------------------------------------------------------
Ran 13 tests in 3.385s  OK 
[1]有一点说明一下:这里,我在运行单元测试时没有 使用 -v 选项,因此输出的也不再是每个测试完整的 doc string,而是用一个圆点来表示每个通过的测试。(失败的测试标用 F 表示,发生错误则用 E 表示,你仍旧可以获得失败和错误的完整追踪信息以便查找问题所在。)
[2]运行 13 个测试耗时 3.385 秒,与之相比是没有预编译正则表达式时的 3.685 秒。这是一个 8% 的整体提速,记住单元测试的大量时间实际上花在做其他工作上。(我单独测试了正则表达式部分的耗时,不考虑单元测试的其他环节,正则表达式编译可以让匹配 search 平均提速 54%。)小小修改还真是值得。
[3]对了,不必顾虑什么,预先编译正则表达式并没有破坏什么,你刚刚证实这一点。

我还想做另外一个性能优化工作。就正则表达式语法的复杂性而言,通常有不止一种方法来构造相同的表达式是不会令人惊讶的。在 comp.lang.python 上对该模块进行一些讨论后,有人建议我使用 {_m_,_n_} 语法来查找可选重复字符。

例 15.13. roman82.py

这个文件可以在例子目录下的 py/roman/stage8/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

# rest of program omitted for clarity
#old version
#romanNumeralPattern = \
#   re.compile('^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$')
#new version
romanNumeralPattern = \
    re.compile('^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$') 
[1]你已经将 M?M?M?M? 替换为 M{0,4}。它们的含义相同:“匹配 0 到 4 个 M 字符”。类似地,C?C?C? 改成了 C{0,3} (“匹配 0 到 3 个 C 字符”) 接下来的 XI 也一样。

这样的正则表达简短一些 (虽然可读性不太好)。核心问题是,是否能加快速度?

例 15.14. 以 romantest82.py 测试 roman82.py 的结果

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s  OK 
[1]总体而言,这种正则表达使单元测试提速 2%。这不太令人振奋,但记住 search 函数只是整体单元测试的一个小部分,很多时间花在了其他方面。(我另外的测试表明这个应用了新语法的正则表达式使 search 函数提速 11% 。) 通过预先编译和使用新语法重写可以使正则表达式的性能提升超过 60%,令单元测试的整体性能提升超过 10%
[2]比任何的性能提升更重要的是模块仍然运转完好。这便是我早先提到的自由:自由地调整、修改或者重写任何部分并且保证在此过程中没有把事情搞得一团糟。这并不是给无休止地为了调整代码而调整代码以许可;你有很切实的目标 (“让 fromRoman 更快”),而且你可以实现这个目标,不会因为考虑在改动过程中是否会引入新的 Bug 而有所迟疑。

还有另外一个我想做的调整,我保证这是最后一个,之后我会停下来,让这个模块歇歇。就像你多次看到的,正则表达式越晦涩难懂越快,我可不想在六个月内再回头试图维护它。是呀!测试用例通过了,我便知道它工作正常,但如果我搞不懂它是如何 工作的,添加新功能、修正新 Bug,或者维护它都将变得很困难。正如你在 第 7.5 节 “松散正则表达式” 看到的,Python 提供了逐行注释你的逻辑的方法。

例 15.15. roman83.py

该文件可以在例子目录下的 py/roman/stage8/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

# rest of program omitted for clarity
#old version
#romanNumeralPattern = \
#   re.compile('^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$')
#new version
romanNumeralPattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 M's
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 C's),
                        #            or 500-800 (D, followed by 0 to 3 C's)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 X's),
                        #        or 50-80 (L, followed by 0 to 3 X's)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 I's),
                        #        or 5-8 (V, followed by 0 to 3 I's)
    $                   # end of string
    ''', re.VERBOSE) 
[1]re.compile 函数的第二个参数是可选的,这个参数通过一个或一组标志 (flag) 来控制预编译正则表达式的选项。这里你指定了 re.VERBOSE 选项,告诉 Python 正则表达式里有内联注释。注释和它们周围的空白 会被认做正则表达式的一部分,在编译正则表达式时 re.compile 函数会忽略它们。这个新 “verbose” 版本与老版本完全一样,只是更具可读性。

例 15.16. 用 romantest83.py 测试 roman83.py 的结果

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s  OK 
[1]新 “verbose” 版本和老版本的运行速度一样。事实上,编译的 pattern 对象也一样,因为 re.compile 函数会剔除掉所有你添加的内容。
[2]新 “verbose” 版本可以通过所有老版本通过的测试。什么都没有改变,但在六个月后重读该模块的程序员却有了理解功能如何实现的机会。

15.4. 后记

15.4. 后记

聪明的读者在学习前一节时想得会更深入一层。现在写的这个程序中最令人头痛的性能负担是正则表达式,但它是必需的,因为没有其它方法来识别罗马数字。但是,它们只有 5000 个,为什么不一次性地构建一个查询表来读取?不必用正则表达式凸现了这个主意的好处。你建立了整数到罗马数字查询表的时候,罗马数字到整数的逆向查询表也构建了。

更大的好处在于,你已经拥有一整套完全的单元测试。你修改了多半的代码,但单元测试还是一样的,因此你可以确定你的新代码与来的代码一样可以正常工作。

例 15.17. roman9.py

这个文件可以在例子目录下的 py/roman/stage9/ 目录中找到。

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass
#Roman numerals must be less than 5000
MAX_ROMAN_NUMERAL = 4999
#Define digit mapping
romanNumeralMap = (('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))
#Create tables for fast conversion of roman numerals.
#See fillLookupTables() below.
toRomanTable = [ None ]  # Skip an index since Roman numerals have no zero
fromRomanTable = {}
def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n <= MAX_ROMAN_NUMERAL):
        raise OutOfRangeError, "number out of range (must be 1..%s)" % MAX_ROMAN_NUMERAL
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"
    return toRomanTable[n]
def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, "Input can not be blank"
    if not fromRomanTable.has_key(s):
        raise InvalidRomanNumeralError, "Invalid Roman numeral: %s" % s
    return fromRomanTable[s]
def toRomanDynamic(n):
    """convert integer to Roman numeral using dynamic programming"""
    result = ""
    for numeral, integer in romanNumeralMap:
        if n >= integer:
            result = numeral
            n -= integer
            break
    if n > 0:
        result += toRomanTable[n]
    return result
def fillLookupTables():
    """compute all the possible roman numerals"""
    #Save the values in two global tables to convert to and from integers.
    for integer in range(1, MAX_ROMAN_NUMERAL + 1):
        romanNumber = toRomanDynamic(integer)
        toRomanTable.append(romanNumber)
        fromRomanTable[romanNumber] = integer
fillLookupTables() 

这样有多快呢?

例 15.18. 用 romantest9.py 测试 roman9.py 的结果

 .............
----------------------------------------------------------------------
Ran 13 tests in 0.791s
OK 

还记得吗?你原有版本的最快速度是 13 个测试耗时 3.315 秒。当然,这样的比较不完全公平,因为这个新版本需要更长的时间来导入 (当它填充查询表时)。但是导入只需一次,在运行过程中可以忽略。

这个重构的故事的寓意是什么?

  • 简洁是美德。
  • 特别是使用正则表达式时。
  • 并且单元测试给了你大规模重构的信心……即使原有的代码不是你写的。

15.5. 小结

15.5. 小结

单元测试是一个强大的概念,使用得当的话既可以减少维护成本又可以增加长期项目的灵活性。同样重要的是要意识到单元测试并不是“灵丹妙药”,也不是“银弹”。编写好的测试用例很困难,保持其更新更需要磨练 (特别是当顾客对修复严重的 Bug 大呼小叫之时)。单元测试不是其它形式测试的替代品,比如说功能性测试、集成测试以及可用性测试。但它切实可行且功效明显,一旦相识,你会反问为什么以往没有应用它。

这一章涵盖了很多内容,有很多都不是 Python 所特有的。很多语言都有单元测试框架,都要求你理解相同的基本概念:

  • 测试用例的设计方针是目的单一、可以自动运行、互不干扰。
  • 在被测试代码编写之前 编写测试用例。
  • 编写测试有效输入的测试用例")并检查正确的结果。
  • 编写测试无效输入的测试用例")并检查正确的失败。
  • 为描述 Bug 或反映新需求而编写和升级测试用例。
  • 为改进性能、可伸缩性、可读性、可维护性和任何缺少的特性而无情地重构。

另外,你应该能够自如地做到如下 Python 的特有工作:

  • 继承 unittest.TestCase 生成子类并为每个单独的测试用例编写方法。
  • 使用 assertEqual 检查已知结果的返回。
  • 使用 assertRaises 检查函数是否引发已知异常。
  • if __name__ 子句中调用 unittest.main() 来一次性运行所有测试用例。
  • 以详细 (verbose) 或者普通 (regular) 模式运行单元测试

进一步阅读

第十六章 函数编程

第十六章 函数编程

  • 16.1. 概览
  • 16.2. 找到路径
  • 16.3. 重识列表过滤
  • 16.4. 重识列表映射
  • 16.5. 数据中心思想编程
  • 16.6. 动态导入模块
  • 16.7. 全部放在一起
  • 16.8. 小结

16.1. 概览

16.1. 概览

在 第十三章 单元测试 中,你学会了单元测试的哲学。在 第十四章 测试优先编程 中你步入了 Python 基本的单元测试操作,在 第十五章 重构 部分,你看到单元测试如何令大规模重构变得容易。本章将在这些程序样例的基础上,集中关注于超越单元测试本身的更高级的 Python 特有技术。

下面是一个作为简单回归测试 (regression test) 框架运行的完整 Python 程序。它将你前面编写的单独单元测试模块组织在一起成为一个测试套件并一次性运行。实际上这是本书的构建代码的一部分;我为几个样例程序都编写了单元测试 (不是只有 第十三章 单元测试 中的 roman.py 模块),我的自动构建代码的第一个工作便是确保我所有的例子可以正常工作。如果回归测试程序失败,构建过程当即终止。我可不想因为发布了不能工作的样例程序而让你在下载他们后坐在显示器前抓耳挠腮地为程序不能运转而烦恼。

例 16.1. regression.py

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

"""Regression testing framework
This module will search for scripts in the same directory named
XYZtest.py.  Each such script should be a test suite that tests a
module through PyUnit.  (As of Python 2.1, PyUnit is included in
the standard library as "unittest".)  This script will aggregate all
found test suites into one big test suite and run them all at once.
"""
import sys, os, re, unittest
def regressionTest():
    path = os.path.abspath(os.path.dirname(sys.argv[0]))   
    files = os.listdir(path)                               
    test = re.compile("test\.py$", re.IGNORECASE)          
    files = filter(test.search, files)                     
    filenameToModuleName = lambda f: os.path.splitext(f)[0]
    moduleNames = map(filenameToModuleName, files)         
    modules = map(__import__, moduleNames)                 
    load = unittest.defaultTestLoader.loadTestsFromModule  
    return unittest.TestSuite(map(load, modules))          
if __name__ == "__main__":                   
    unittest.main(defaultTest="regressionTest") 

把这段代码放在本书其他样例代码相同的目录下运行之,_module_test.py 中的所有单元测试将被找到并一起被运行。

例 16.2. regression.py 的样例输出

[you@localhost py]$ python regression.py -v
help should fail with no object ... ok  help should return known result for apihelper ... ok
help should honor collapse argument ... ok
help should honor spacing argument ... ok
buildConnectionString should fail with list input ... ok  buildConnectionString should fail with string input ... ok
buildConnectionString should fail with tuple input ... ok
buildConnectionString handles empty dictionary ... ok
buildConnectionString returns known result with known input ... ok
fromRoman should only accept uppercase input ... ok  toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... ok
fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok
kgp a ref test ... ok
kgp b ref test ... ok
kgp c ref test ... ok
kgp d ref test ... ok
kgp e ref test ... ok
kgp f ref test ... ok
kgp g ref test ... ok
----------------------------------------------------------------------
Ran 29 tests in 2.799s
OK 
[1]前五个测试来自于 apihelpertest.py,用以测试 第四章 自省的威力 中的样例代码。
[2]接下来的五个测试来自于 odbchelpertest.py,用以测试 第二章 第一个 Python 程序 中的样例代码。
[3]其他的测试来自于 romantest.py,你在 第十三章 单元测试 中深入学习过。

16.2. 找到路径

16.2. 找到路径

从命令行运行 Python 代码时,知道所运行代码在磁盘上的存储位置有时候是有必要的。

这是一个不那么容易想起,但一想起就很容易解决的小麻烦。答案是 sys.argv。正如你在 第九章 XML 处理 中看到的,它包含了很多命令行参数。它也同样记录了运行脚本的名字,和你调用它时使用的命令一摸一样。这些信息足以令我们确定文件的位置。

例 16.3. fullpath.py

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

 import sys, os
print 'sys.argv[0] =', sys.argv[0]             
pathname = os.path.dirname(sys.argv[0])         print 'path =', pathname
print 'full path =', os.path.abspath(pathname) 
[1]无论如何运行一段脚本,sys.argv[0] 总是包含脚本的名字,和调用时使用的命令一摸一样。你很快会发现,它不一定包含任何路径信息。
[2]os.path.dirname 接受作为字符串传来的文件名并返回路径部分。如果给定的文件名不包含任何路径信息,os.path.dirname 返回空字符串。
[3]os.path.abspath 是这里的关键。它接受的路径名可以是部分的甚至是完全空白,但总能返回完整有效的路径名。

进一步地解释 os.path.abspath 是有必要的。它非常灵活,可以接受任何类型的路径名。

例 16.4. os.path.abspath 的进一步解释

>>> import os
>>> os.getcwd()                        
/home/you
>>> os.path.abspath('')                
/home/you
>>> os.path.abspath('.ssh')            
/home/you/.ssh
>>> os.path.abspath('/home/you/.ssh') 
/home/you/.ssh
>>> os.path.abspath('.ssh/../foo/')    
/home/you/foo 
[1]os.getcwd() 返回当前的工作路径。
[2]用空字符串调用 os.path.abspath 将返回当前的工作路径,与 os.getcwd()的效果相同。
[3]以不完整的路径名调用 os.path.abspath 可以构建一个基于当前工作路径且完整有效的路径名。
[4]以完整的路径名调用 os.path.abspath 则简单地将其直接返回。
[5]os.path.abspath格式化 返回的路径名。注意这个例子在我根本没有‘foo’目录时同样奏效。os.path.abspath 从不检查你的磁盘,而仅仅是字符串操作。

注意 传递给 os.path.abspath 的路径名和文件名可以不存在。

注意 os.path.abspath 不仅构建完整路径名,还能格式化路径名。这意味着如果你正工作于 /usr/ 目录,os.path.abspath('bin/../local/bin') 将会返回 /usr/local/bin。它把路径名格式化为尽可能简单的形式。如果你只是希望简单地返回这样的格式化路径名而不需要完整路径名,可以使用 os.path.normpath

例 16.5. fullpath.py 的样例输出

[you@localhost py]$ python /home/you/diveintopython/common/py/fullpath.py 
sys.argv[0] = /home/you/diveintopython/common/py/fullpath.py
path = /home/you/diveintopython/common/py
full path = /home/you/diveintopython/common/py
[you@localhost diveintopython]$ python common/py/fullpath.py               
sys.argv[0] = common/py/fullpath.py
path = common/py
full path = /home/you/diveintopython/common/py
[you@localhost diveintopython]$ cd common/py
[you@localhost py]$ python fullpath.py                                     
sys.argv[0] = fullpath.py
path = 
full path = /home/you/diveintopython/common/py 
[1]在第一种情况下,sys.argv[0] 包含代码的完整路径。你可以通过 os.path.dirname 函数将文件名从其中剥离出来并返回完整的路径,os.path.abspath 则是简单地把你传递给它的值返回。
[2]如果脚本是以不完整路名被运行的,sys.argv[0] 还是会包含命令行中出现的一切。os.path.dirname 将会给你一个 (相对于当前工作路径的) 不完整的路径名,os.path.abspath 将会以不完整路径名为基础构建一个完整的路径名。
[3]如果没有给定任何路径,而是从当前目录运行脚本,os.path.dirname 将简单地返回一个空字符串。由于是从当前目录运行脚本,os.path.abspath 将针对给定的空字符串给出你所希望获知的当前目录。

注意 就像 osos.path 模块的其他函数,os.path.abspath 是跨平台的。如果你是在 Windows (使用反斜杠作为路径符号) 或 Mac OS (使用冒号) 上运行,它们同样工作,只是将获得与我稍有不同的结果。os 的所有函数都是这样的。

补充. 一位读者对这个结果并不满意,他希望能够从当前路径运行所有单元测试,而不是从 regression.py 所在目录运行。他建议以下面的代码加以取代:

例 16.6. 在当前目录运行脚本

import sys, os, re, unittest
def regressionTest():
    path = os.getcwd()       
    sys.path.append(path)    
    files = os.listdir(path) 
[1]不是将 path 设置为运行代码所在的路径,而是将它设置为当前目录。可以是你在运行脚本之前所在的任何路径,而不需要是运行脚本所在的路径。(多次体味这句话,直到你真正理解了它。)
[2]将这个目录添加到 Python 库搜索路径中,你稍后动态导入单元测试模块时,Python 就能找到它们了。如果 path 就是正在运行代码的存储目录,你就不需要这样做了,因为 Python 总会查找这个目录。
[3]函数的其他部分不变。

这个技术允许你在多个项目中重用 regression.py 代码。只需要将这个代码放在一个普通目录中,在运行项目前将路径更改为项目的目录。项目中所有的单元测试被找到并运行,而不仅仅局限于 regression.py 所在目录的单元测试。

16.3. 重识列表过滤

16.3. 重识列表过滤

你已经熟识了应用列表解析来过滤列表。这里介绍的是达到相同效果的另一种令很多人感觉清晰的实现方法。

Python 有一个内建 filter 函数,它接受两个参数:一个函数和一个列表,返回一个列表。[12] 作为第一个参数传递给 filter 的函数本身应接受一个参数,filter 返回的列表将会包含被传入列表参数传递给 filter 所有可以令函数返回真 (true) 的元素。

都明白了吗?并没有听起来那么难。

例 16.7. filter 介绍

>>> def odd(n):                 
... return n % 2
... 
>>> li = [1, 2, 3, 5, 9, 10, 256, -3]
>>> filter(odd, li)             
[1, 3, 5, 9, -3]
>>> [e for e in li if odd(e)]   
>>> filteredList = []
>>> for n in li:                
... if odd(n):
...         filteredList.append(n)
... 
>>> filteredList
[1, 3, 5, 9, -3] 
[1]odd 使用内建的取模 (mod) 函数 “%” 对于为奇数的 n 返回 1;为偶数的返回 0
[2]filter 接受两个参数:一个函数 (odd) 和一个列表 (li)。它依列表循环为每个元素调用 odd 函数。如果 odd 返回的是真 (记住,Python 认为所有非零值为真),则该元素被放在返回列表中,如若不然则被过滤掉。结果是一个只包含原列表中奇数的列表,出现顺序则和原列表相同。
[3]你可以通过遍历的方式完成相同的工作,正如在 第 4.5 节 “过滤列表” 中看到的。
[4]你可以通过 for 循环的方式完成相同的工作。取决于你的编程背景,这样也许更“直接”,但是像 filter 函数这样的实现方法更清晰。不但编写简单,而且易于读懂。for 循环就好比近距离的绘画:你可以看到所有的细节,但是或许你应该花几秒时间退后几步看一看图画的全景:“啊,你仅仅是要过滤列表!”

例 16.8. regression.py 中的 filter

 files = os.listdir(path)                                
    test = re.compile("test\.py$", re.IGNORECASE)           
    files = filter(test.search, files) 
[1]正如你在 第 16.2 节 “找到路径” 中看到的,path 可能包括正在运行脚本的完全或者部分路径名,或者当脚本运行自当前目录时包含一个空的字符串。任何一种情况下,files 都会获得正运行脚本所在目录的文件名。
[2]这是一个预编译的正则表达式。正如你在 第 15.3 节 “重构”中看到的,如果你需要反复使用同一个正则表达式,你应该编译它已获得更快的性能。编译后的对象将含有接受一个待寻找字符串作为参数的 search 方法。如果这个正则表达式匹配字符串,search 方法返回一个包含正则表达式匹配信息的 Match 对象;否则返回 None,这是 Python 空 (null) 值。
[3]对于 files 列表中的每个元素,你将会调用正则表达式编译对象 testsearch 方法。如果正则表达匹配,方法将会返回一个被 Python 认定为真 (true) 的 Match 对象;如果正则表达不匹配,search 方法将会返回被认定为假 (false) 的 None,元素将被排除。

历史注释. Python 2.0 早期的版本不包含 列表解析,因此不能 以列表解析方式过滤,filter 函数是当时唯一的方法。即便是在引入列表解析的 2.0 版,有些人仍然钟情于老派的 filter (和这章稍后将见到的它的伴侣函数 map )。两种方法并存于世,使用哪种方法只是风格问题,mapfilter 将在未来的 Python 版本中被废止的讨论尚无定论。

例 16.9. 以列表解析法过滤

 files = os.listdir(path)                               
    test = re.compile("test\.py$", re.IGNORECASE)          
    files = [f for f in files if test.search(f)] 
[1]这种方法将完成和 filter 函数完全相同的工作。哪种方法更清晰完全取决于你自己。

Footnotes

[12] 从技术层面上讲,filter 的第二个参数可以是任意的序列,包括列表、元组以及定义了 __getitem__ 特殊方法而能像列表一样工作的自定义类。在可能情况下,filter 会返回与输入相同的数据类型,也就是过滤一个列表返回一个列表,过滤一个元组返回一个元组。

16.4. 重识列表映射

16.4. 重识列表映射

你对使用列表解析映射列表的做法已经熟知。另一种方法可以完成同样的工作:使用内建 map 函数。它的工作机理和 filter 函数类似。

例 16.10. map 介绍

>>> def double(n):
... return n*2
... 
>>> li = [1, 2, 3, 5, 9, 10, 256, -3]
>>> map(double, li)                       
[2, 4, 6, 10, 18, 20, 512, -6]
>>> [double(n) for n in li]               
[2, 4, 6, 10, 18, 20, 512, -6]
>>> newlist = []
>>> for n in li:                          
... newlist.append(double(n))
... 
>>> newlist
[2, 4, 6, 10, 18, 20, 512, -6] 
[1]map 接受一个函数和一个列表作为参数,[13] 并对列表中的每个元素依次调用函数返回一个新的列表。在这个例子中,函数仅仅是将每个元素乘以 2。
[2]使用列表解析的方法你可以做到相同的事情。列表解析是在 Python 2.0 版时被引入的;而 map 则古老得多。
[3]你如果坚持以 Visual Basic 程序员自居,通过 for 循环的方法完成相同的任务也完全可以。

例 16.11. map 与混合数据类型的列表

>>> li = [5, 'a', (2, 'b')]
>>> map(double, li)                       
[10, 'aa', (2, 'b', 2, 'b')] 
[1]作为一个旁注,我想指出只要提供的那个函数能够正确处理各种数据类型,map 对于混合数据类型列表的处理同样出色。在这里,double 函数仅仅是将给定参数乘以 2,Python 则会根据参数的数据类型决定正确操作的方法。对整数而言,这意味着乘 2;对字符串而言,意味着把自身和自身连接;对于元组,意味着构建一个包括原始元组全部元素和原始元组组合在一起的新元组。

好了,玩够了。让我们来看一些真实代码。

例 16.12. regression.py 中的 map

 filenameToModuleName = lambda f: os.path.splitext(f)[0] 
    moduleNames = map(filenameToModuleName, files) 
[1]正如你在 第 4.7 节 “使用 lambda 函数” 中所见,lambda 定义一个内联函数。也正如你在 例 6.17 “分割路径名” 中所见,os.path.splitext 接受一个文件名并返回一个元组 (_name_, _extension_)。因此 filenameToModuleName 是一个接受文件名,剥离出其扩展名,然后只返回文件名称的函数。
[2]调用 map 将把 files 列出的所有文件名传递给 filenameToModuleName 函数,并且返回每个函数调用结果所组成的列表。换句话说,你剔除掉文件名的扩展名,并将剔除后的文件名存于 moduleNames 之中。

如你在本章剩余部分将看到的,你可以将这种数据中心思想扩展到定义和执行一个容纳来自很多单个测试套件的测试的一个测试套件的最终目标。

Footnotes

[13] 同前,我需要指出 map 可以接受一个列表、元组,或者一个像序列一样的对象。参见前面的关于 filter 的脚注。

16.5. 数据中心思想编程

16.5. 数据中心思想编程

现在的你,可能正抓耳挠腮地狠想,为什么这样比使用 for 循环和直接调用函数好。这是一个非常好的问题。通常这是一个程序观问题。使用 mapfilter 强迫你围绕数据进行思考。

就此而言,你从没有数据开始,你所做的第一件事是获得当前脚本的目录路径,并获得该目录中的文件列表。这就是关键的一步,使你有了待处理的真实数据:文件名列表。

当然,你知道你并不关心所有的文件,而只关心测试套件。你有太多数据,因此你需要过滤(filter)数据。你如何知道哪些数据应该保留?你需要一个测试来确定,因此你定义一个测试并把它传给 filter 函数。这里你应用了一个正则表达式来确定,但无论如何构建测试,原则是一样的。

现在你有了每个测试套件的文件名 (且局限于测试套件,因为所有其他内容都被过滤掉了),但是你还需要以模块名来替代之。你有正确数量的数据,只是格式不正确。因此,你定义了一个函数来将文件名转换为模块名,并使用这个函数映射整个列表。从一个文件名,你可以获得一个模块名,从一个文件名列表,你可以获得一个模块名列表。

如果不应用 filter,你也可以使用 for 循环结合一个 if 语句的方法。map 的使用则可以由一个 for 循环和一个函数调用来取代。但是 for 循环看起来像是个繁重的工作。至少,简单讲是在浪费时间,糟糕的话还会隐埋 Bug。例如,你需要弄清楚如何测试这样一个条件:“这个文件是测试套件吗?”这是应用特定的逻辑,没有哪个语言能自动为我们写出其代码。但是一旦你搞清楚了,你还需要费尽周折地定义一个新的空列表,写一个 for 循环以及一个 if 语句并手工地调用 append 将符合条件的元素一个个添加到新列表中,然后一路上注意区分哪个变量里放着过滤后的数据,哪个变量里放着未过滤的老数据。为什么不直接定义测试条件,然后由 Python 为你完成接下来的工作呢?

当然啦,你可以尝试眩一点的做法,去删除列表中的元素而不新建一个列表。但是你以前吃过这样的亏。试图在循环中改变数据结构是很容易出问题的。Python 是一个这样工作的语言吗?用多长时间你才能搞清这一点?你能确定记得你第二次这样尝试的安全性?程序员在和这类纯技术课题较劲的过程中,花费了太多的时间,犯了太多的错误,却并没有什么意义。这样并不可能令你的程序有所进步,只不过是费力不讨好。

我在第一次学习 Python 时是抵触列表解析的,而且我抗拒 filtermap 的时间更长。我坚持着我更艰难的生活,固守着类似于 for 循环和 if 语句以及一步步地以代码为中心的编程方式。而且我的 Python 程序看起来很像是 Visual Basic 程序,细化每一个函数中的每一个操作步骤。它们却有着同样的小错误和隐蔽的 Bug。这一切其实都没有意义。

让这一切都远去吧。费力不讨好的编程不重要,数据重要。并且数据并不麻烦,它们不过就是数据。如果多了,就过滤。如果不是我们要的,就映射。聚焦在数据上,摒弃费力的劳作。

16.6. 动态导入模块

16.6. 动态导入模块

好了,大道理谈够了。让我们谈谈动态导入模块吧。

首先,让我们看一看正常的模块导入。import _module_ 语法查看搜索路径,根据给定的名字寻找模块并导入它们。你甚至可以这样做:以逗号分割同时导入多个模块,本章代码前几行就是这样做的。

例 16.13. 同时导入多个模块

 import sys, os, re, unittest 
[1]这里同时导入四个模块:sys (为系统函数和得到命令行参数)、os (为目录列表之类的操作系统函数)、re (为正则表达式),以及 unittest (为单元测试)。

现在让我们用动态导入做同样的事。

例 16.14. 动态导入模块

>>> sys = __import__('sys')           
>>> os = __import__('os')
>>> re = __import__('re')
>>> unittest = __import__('unittest')
>>> sys                               
>>> <module 'sys' (built-in)>
>>> os
>>> <module 'os' from '/usr/local/lib/python2.2/os.pyc'> 
[1]内建 __import__ 函数与 import 语句的既定目标相同,但它是一个真正的函数,并接受一个字符串参数。
[2]变量 sys 现在是 sys 模块,和 import sys 的结果完全相同。变量 os 现在是一个 os 模块,等等。

因此 __import__ 导入一个模块,但是是通过一个字符串参数来做到的。依此处讲,你用以导入的仅仅是一个硬编码性的字符串,但它可以是一个变量,或者一个函数调用的结果。并且你指向模块的变量也不必与模块名匹配。你可以导入一系列模块并把它们指派给一个列表。

例 16.15. 动态导入模块列表

>>> moduleNames = ['sys', 'os', 're', 'unittest'] 
>>> moduleNames
['sys', 'os', 're', 'unittest']
>>> modules = map(__import__, moduleNames)        
>>> modules                                       
[<module 'sys' (built-in)>,
<module 'os' from 'c:\Python22\lib\os.pyc'>,
<module 're' from 'c:\Python22\lib\re.pyc'>,
<module 'unittest' from 'c:\Python22\lib\unittest.pyc'>]
>>> modules[0].version                            
'2.2.2 (#37, Nov 26 2002, 10:24:37) [MSC 32 bit (Intel)]'
>>> import sys
>>> sys.version
'2.2.2 (#37, Nov 26 2002, 10:24:37) [MSC 32 bit (Intel)]' 
[1]moduleNames 只是一个字符串列表。没什么特别的,只是这些名字刚好是你可应需而用的可导入模块名。
[2]简单得令人惊奇,通过映射 __import__ 就实现了导入。记住,列表 (moduleNames) 的每个元素将被用来一次次调用函数 (__import__) 并以一个返回值构成的列表作为返回结果。
[3]所以现在你已经由一个字符串列表构建起了一个实际模块的列表。(你的路径可能不同,这取决于你的操作系统、你安装 Python 的位置、月亮残缺的程度等等。)
[4]从这些是真实模块这一点出发,让我们来看一些模块属性。记住,modules[0] sys 模块,因此,modules[0].version sys.version。所有模块的其他属性和方法也都可用。import 语句没什么神奇的,模块也没什么神奇的。模块就是对象,一切都是对象。

现在,你应该能够把这一切放在一起,并搞清楚本章大部分样例代码是做什么的。

16.7. 全部放在一起

16.7. 全部放在一起

你已经学习了足够的知识,现在来分析本章样例代码的前七行:读取一个目录并从中导入选定的模块。

例 16.16. regressionTest 函数

 def regressionTest():
    path = os.path.abspath(os.path.dirname(sys.argv[0]))   
    files = os.listdir(path)                               
    test = re.compile("test\.py$", re.IGNORECASE)          
    files = filter(test.search, files)                     
    filenameToModuleName = lambda f: os.path.splitext(f)[0]
    moduleNames = map(filenameToModuleName, files)         
    modules = map(__import__, moduleNames)                 
load = unittest.defaultTestLoader.loadTestsFromModule  
return unittest.TestSuite(map(load, modules)) 

让我们一行行交互地看。假定当前目录是 c:\diveintopython\py,其中有包含本章脚本在内的本书众多样例。正如在 第 16.2 节 “找到路径” 中所见,脚本目录将存于 path 变量,因此让我们从这里开始以实打实的代码起步。

例 16.17. 步骤 1:获得所有文件

>>> import sys, os, re, unittest
>>> path = r'c:\diveintopython\py'
>>> files = os.listdir(path) 
>>> files 
['BaseHTMLProcessor.py', 'LICENSE.txt', 'apihelper.py', 'apihelpertest.py',
'argecho.py', 'autosize.py', 'builddialectexamples.py', 'dialect.py',
'fileinfo.py', 'fullpath.py', 'kgptest.py', 'makerealworddoc.py',
'odbchelper.py', 'odbchelpertest.py', 'parsephone.py', 'piglatin.py',
'plural.py', 'pluraltest.py', 'pyfontify.py', 'regression.py', 'roman.py', 'romantest.py',
'uncurly.py', 'unicode2koi8r.py', 'urllister.py', 'kgp', 'plural', 'roman',
'colorize.py'] 
[1]files 是由脚本所在目录的所有文件和目录构成的列表。(如果你已经运行了其中的一些样例,可能还会看到一些 .pyc 文件。)

例 16.18. 步骤 2:找到你关注的多个文件

>>> test = re.compile("test\.py$", re.IGNORECASE)           
>>> files = filter(test.search, files)                      
>>> files                                                   
['apihelpertest.py', 'kgptest.py', 'odbchelpertest.py', 'pluraltest.py', 'romantest.py'] 
[1]这个正则表达式将匹配以 test.py 结尾的任意字符串。注意,你必须转义这个点号,因为正则表达式中的点号通常意味着 “匹配任意单字符”,但是你实际上想匹配的事一个真正的点号。
[2]被编译的正则表达式就像一个函数,因此你可以用它来过滤文件和目录构成的大列表,找寻符合正则表达式的所有元素。
[3]剩下的是一个单元测试脚本列表,因为只有它们是形如 SOMETHINGtest.py 的文件。

例 16.19. 步骤 3:映射文件名到模块名

>>> filenameToModuleName = lambda f: os.path.splitext(f)[0] 
>>> filenameToModuleName('romantest.py')                    
'romantest'
>>> filenameToModuleName('odchelpertest.py')
'odbchelpertest'
>>> moduleNames = map(filenameToModuleName, files)          
>>> moduleNames                                             
['apihelpertest', 'kgptest', 'odbchelpertest', 'pluraltest', 'romantest'] 
[1]正如你在 第 4.7 节 “使用 lambda 函数” 中所见,lambda 快餐式地创建内联单行函数。这里应用你在 例 6.17 “分割路径名” 中已经见过的,标准库的 os.path.splitext 将一个带有扩展名的文件名返回成只包含文件名称的那部分。
[2]filenameToModuleName 是一个函数。lambda 函数并不比你以 def 语句定义的普通函数神奇。你可以如其他函数一样地调用 filenameToModuleName,它也将如你所愿:从参数中剔除扩展名。
[3]现在你可以通过 map 把这个函数应用于单元测试文件列表中的每一个文件。
[4]结果当然如你所愿:以指代模块的字符串构成的一个列表。

例 16.20. 步骤 4:映射模块名到模块

>>> modules = map(__import__, moduleNames)                  
>>> modules                                                 
[<module 'apihelpertest' from 'apihelpertest.py'>,
<module 'kgptest' from 'kgptest.py'>,
<module 'odbchelpertest' from 'odbchelpertest.py'>,
<module 'pluraltest' from 'pluraltest.py'>,
<module 'romantest' from 'romantest.py'>]
>>> modules[-1]                                             
<module 'romantest' from 'romantest.py'> 
[1]正如你在 第 16.6 节 “动态导入模块” 中所见,你可以通过 map__import__ 的协同工作,将模块名 (字符串) 映射到实际的模块 (像其他模块一样可以被调用和使用)。
[2]modules 现在是一个模块列表,其中的模块和其他模块一样。
[3]该列表的最后一个模块 romantest 模块,和通过 import romantest 导入的模块完全等价。

例 16.21. 步骤 5:将模块载入测试套件

>>> load = unittest.defaultTestLoader.loadTestsFromModule 
>>> map(load, modules)                     
[<unittest.TestSuite tests=[
  <unittest.TestSuite tests=[<apihelpertest.BadInput testMethod=testNoObject>]>,
  <unittest.TestSuite tests=[<apihelpertest.KnownValues testMethod=testApiHelper>]>,
  <unittest.TestSuite tests=[
    <apihelpertest.ParamChecks testMethod=testCollapse>, 
    <apihelpertest.ParamChecks testMethod=testSpacing>]>, 
    ...
  ]
]
>>> unittest.TestSuite(map(load, modules)) 
[1]模块对象的存在,使你不但可以像其他模块一样地使用它们;通过类的实例化和函数的调用,你还可以内省模块,从而弄清楚已经有了那些类和函数。这正是 loadTestsFromModule 方法的工作:内省每一个模块并为每个模块返回一个 unittest.TestSuite 对象。每个 TestSuite (测试套件) 对象都包含一个 TestCase 对象的列表,每个对象对应着你的模块中的一个测试方法。
[2]最后,你将TestSuite列表封装成一个更大的测试套件。unittest 模块会很自如地遍历嵌套于测试套件中的树状结构,最后深入到独立测试方法,一个个加以运行并判断通过或是失败。

自省过程是 unittest 模块经常为我们做的一项工作。还记得我们的独立测试模块仅仅调用了看似神奇的 unittest.main() 函数就大刀阔斧地完成了全部工作吗?unittest.main() 实际上创建了一个 unittest.TestProgram 的实例,而这个实例实际上创建了一个 unittest.defaultTestLoader 的实例并以调用它的模块启动它。 (如果你不给出,如何知道调用它的模块是哪一个?通过使用同样神奇的 __import__('__main__') 命令,动态导入正在运行的模块。我可以就 unittest 模块中使用的所有技巧和技术写一本书,但那样我就没法写完这本了。)

例 16.22. 步骤 6:告知 unittest 使用你的测试套件

 if __name__ == "__main__":                   
    unittest.main(defaultTest="regressionTest") 
[1]在不使用 unittest 模块来为我们做这一切的神奇工作的情况下,你实际上已自己做到了。你已经创建了一个自己就能导入模块、调用 unittest.defaultTestLoader 并封装于一个测试套件的 regressionTest 函数。现在你所要做的不是去寻找测试并以通用的方法构建一个测试套件,而是告诉 unittest 前面那些,它将调用 regressionTest 函数,而它会返回可以直接使用的 TestSuite

16.8. 小结

16.8. 小结

regression.py 程序及其输出到现在应该很清楚了。

你现在应该能够很自如地做到如下事情:

  • 从命令行操作路径信息。
  • 不使用列表解析,使用 filter 过滤列表。
  • 不使用列表解析,使用 map 映射列表。
  • 动态导入模块。

第十七章 动态函数

第十七章 动态函数

  • 17.1. 概览
  • 17.2. plural.py, 第 1 阶段
  • 17.3. plural.py, 第 2 阶段
  • 17.4. plural.py, 第 3 阶段
  • 17.5. plural.py, 第 4 阶段
  • 17.6. plural.py, 第 5 阶段
  • 17.7. plural.py, 第 6 阶段
  • 17.8. 小结

17.1. 概览

17.1. 概览

我想谈谈名词复数。还有,返回其它函数的函数,高级的正则表达式和生成器 (Generator)。生成器是 Python 2.3 新引入的。但首先还是让我们先来谈谈如何生成名词复数。

如果你还没有看过 第七章 正则表达式,现在是个绝佳的机会。这章中假定你已理解了正则表达式的基础内容并迅速深入更高级的应用。

英语是一个吸收很多外来语而令人疯掉的语言,把单数名词变成复数的规则则是复杂而又多变的。有规则,有例外,更有例外的例外。

如果你在英语国家长大或是在正规学校学习了英语,你可能对下面的基本规则很熟悉:

  1. 如果一个词以 S, X 或 Z 结尾,加 ES。如 “Bass” 变成 “basses”,“fax” 变成 “faxes”,还有 “waltz” 变成 “waltzes”。
  2. 如果一个词以发音的 H 结尾,加 ES;若以不发音的 H 结尾,加 S。什么是发音的 H?和其他字母混合在一起发出一个你可以听到的声音。那么,“coach” 变成 “coaches” ,“rash” 变成 “rashes”,因为在读出来时,你可以听到 CH 和 SH 的声音。但是,“cheetah” 变成 “cheetahs”,因为 H 不发音。
  3. 如果一个词以发 I 音的 Y 结尾,把 Y 变成 IES;如果 Y 与元音搭配在一起发出其他声音则只添加 S。因此,“vacancy” 变成 “vacancies”,但 “day” 变成 “days”。
  4. 如果一切规则都不适用,就只添加 S 并祈祷不会错。

(我知道有很多例外情况,比如:“Man” 变成 “men”,“woman” 变成 “women”,但是,“human” 却变成 “humans”。“Mouse” 变成 “mice”,“louse” 变成 “lice”,但是,“house” 却变成 “houses”。“Knife” 变成 “knives”,“wife” 变成 “wives”,但是 “lowlife” 却变成 “lowlifes”。更不要说那些复数根本就不需要变化的词了,比如 “sheep”, “deer” 和 “haiku”。)

其他的语言当然完全不同。

让我们来设计一个复数化名词的模块吧!从英语名词开始,仅考虑上面的四种规则,但是记得你将来需要不断添加规则,更可能最后添加进更多的语言。

17.2. plural.py, 第 1 阶段

17.2. plural.py, 第 1 阶段

你所针对的单词 (至少在英语中) 是字符串和字符。你还需要规则来找出不同的字符 (字母) 组合,并对它们进行不同的操作。这听起来像是正则表达式的工作。

例 17.1. plural1.py

 import re
def plural(noun):                            
    if re.search('[sxz]$', noun):             
        return re.sub('$', 'es', noun)        
    elif re.search('[^aeioudgkprt]h$', noun):
        return re.sub('$', 'es', noun)       
    elif re.search('[^aeiou]y$', noun):      
        return re.sub('y$', 'ies', noun)     
    else:                                    
        return noun + 's' 
[1]好啦,这是一个正则表达式,但是它使用了你在 第七章 正则表达式 中未曾见过的语法。方括号的意思是 “完全匹配这些字符中的一个”。也就是说,[sxz] 意味着 “s,或者 x,再或者 z”,但只是其中的一个。$ 应该不陌生,它意味着匹配字符串的结尾。也就是说,检查 noun 是否以 sx,或者 z 结尾。
[2]re.sub 函数进行以正则表达式为基础的替换工作。让我们更具体地看看它。

例 17.2. re.sub 介绍

>>> import re
>>> re.search('[abc]', 'Mark')   
<_sre.SRE_Match object at 0x001C1FA8>
>>> re.sub('[abc]', 'o', 'Mark') 
'Mork'
>>> re.sub('[abc]', 'o', 'rock') 
'rook'
>>> re.sub('[abc]', 'o', 'caps') 
'oops' 
[1]Mark 包含 ab,或者 c吗?是的,含有 a
[2]好的,现在找出 ab,或者 c 并以 o 取代之。Mark 就变成 Mork 了。
[3]同一方法可以将 rock 变成 rook
[4]你可能认为它可以将 caps 变成 oaps,但事实并非如此。re.sub 替换所有 的匹配项,并不只是第一个匹配项。因此正则表达式将会把 caps 变成 oops,因为 ca 都被转换为 o了。

例 17.3. 回到 plural1.py

 import re
def plural(noun):                            
    if re.search('[sxz]$', noun):            
        return re.sub('$', 'es', noun)        
    elif re.search('[^aeioudgkprt]h$', noun): 
        return re.sub('$', 'es', noun)        
    elif re.search('[^aeiou]y$', noun):      
        return re.sub('y$', 'ies', noun)     
    else:                                    
        return noun + 's' 
[1]回到 plural 函数。你在做什么?你在以 es 取代字符串的结尾。换句话说,追加 es 到字符串。你可以通过字符串拼合做到相同的事,例如 noun + 'es',但是我使用正则表达式做这一切,既是为了保持一致,也是为了本章稍后你会明白的其它原因。
[2]仔细看看,这是另一个新的内容。^ 是方括号里面的第一个字符,这有特别的含义:否定。[^abc] 意味着 “ 除 ab、 和 c 以外的 任意单字符”。所以,[^aeioudgkprt] 意味着除 aeioudgkprt 以外的任意字符。这个字符之后应该跟着一个 h,然后是字符串的结尾。你在寻找的是以发音的 H 结尾的单词。
[3]这是一个相似的表达:匹配 Y 前面不是 aeiou,并以这个 Y 结尾的单词。你在查找的是以发 I 音的 Y 结尾的单词。

例 17.4. 正则表达式中否定的更多应用

>>> import re
>>> re.search('[^aeiou]y$', 'vacancy') 
<_sre.SRE_Match object at 0x001C1FA8>
>>> re.search('[^aeiou]y$', 'boy')     
>>> 
>>> re.search('[^aeiou]y$', 'day')
>>> 
>>> re.search('[^aeiou]y$', 'pita')    
>>> 
[1]vacancy 匹配这个正则表达式,因为它以 cy 结尾,并且 c 不在 aeiou 之列。
[2]boy 不能匹配,因为它以 oy 结尾,并且你特别指出 y 之前的字符不可以是 oday 不能匹配是因为以 ay 结尾。
[3]pita 不匹配是因为不以 y 结尾。

例 17.5. 更多的 re.sub

>>> re.sub('y$', 'ies', 'vacancy')              
'vacancies'
>>> re.sub('y$', 'ies', 'agency')
'agencies'
>>> re.sub('([^aeiou])y$', r'\1ies', 'vacancy') 
'vacancies' 
[1]正则表达式把 vacancy 变为 vacancies,把 agency 变为 agencies,这正是你想要的。注意,将 boy 变成 boies 是可行的,但是永远不会发生,因为 re.search 首先确定是否应该应用 re.sub
[2]顺便提一下,可以将两个正则表达式 (一个确定规则适用与否,一个应用规则) 合并在一起成为一个正则表达式。这便是合并后的样子。它的大部分已经很熟悉:你应用的是在 第 7.6 节 “个案研究:解析电话号码” 学过的记忆组 (remembered group) 记住 y 之前的字符。然后再替换字符串,你使用一个新的语法 \1,这意味着:“嘿!记得前面的第一个组吗?把它放这儿”。就此而言,记住了 y 之前的 c ,然后你做替换工作,你将 c 替换到 c 的位置,并将 ies 替换到 y 的位置。(如果你有不止一个组则可以使用 \2 或者 \3 等等。)

正则表达式替换非常强大,并且 \1 语法使之更加强大。但是将整个操作放在一个正则表达式中仍然晦涩难懂,也不能与前面描述的复数规则直接呼应。你原来列出的规则,比如 “如果单词以 S,X 或者 Z 结尾,结尾追加 ES”。如果你在函数中看到两行代码描述 “如果单词以 S,X 或者 Z 结尾,结尾追加 ES”,更加直观些。

17.3. plural.py, 第 2 阶段

17.3. plural.py, 第 2 阶段

现在你将增加一个抽象过程。你从定义一个规则列表开始:如果这样,就做那个,否则判断下一规则。让我们暂时将程序一部分复杂化以便使另一部分简单化。

例 17.6. plural2.py

 import re
def match_sxz(noun):                          
    return re.search('[sxz]$', noun)          
def apply_sxz(noun):                          
    return re.sub('$', 'es', noun)            
def match_h(noun):                            
    return re.search('[^aeioudgkprt]h$', noun)
def apply_h(noun):                            
    return re.sub('$', 'es', noun)            
def match_y(noun):                            
    return re.search('[^aeiou]y$', noun)      
def apply_y(noun):                            
    return re.sub('y$', 'ies', noun)          
def match_default(noun):                      
    return 1                                  
def apply_default(noun):                      
    return noun + 's'                         
rules = ((match_sxz, apply_sxz),
         (match_h, apply_h),
         (match_y, apply_y),
         (match_default, apply_default)
         )                                     
def plural(noun):                             
    for matchesRule, applyRule in rules:       
        if matchesRule(noun):                  
            return applyRule(noun) 
[1]这个版本看起来更加复杂 (至少是长了),但做的工作没有变化:试图顺序匹配四种不同规则,并在匹配时应用恰当的正则表达式。不同之处在于,每个独立的匹配和应用规则都在自己的函数中定义,并且这些函数列于 rules 变量这个元组的元组之中。
[2]使用一个 for 循环,你可以根据 rules 元组一次性进行匹配和应用规则两项工作 (一个匹配和一个应用)。for 循环第一轮中,matchesRule 将使用 match_sxzapplyRule 将使用 apply_sxz;在第二轮中 (假设真走到了这么远),matchesRule 将被赋予 match_happlyRule 将被赋予 apply_h
[3]记住 Python 中的一切都是对象,包括函数。rules 包含函数;不是指函数名,而是指函数本身。当 matchesRuleapplyRulefor 循环中被赋值后,它们就成了你可以调用的真正函数。因此,在 for 循环第一轮中,这就相当于调用 matches_sxz(noun)
[4]for 循环第一轮中,这就相当于调用 apply_sxz(noun),等等。

这个抽象过程有些令人迷惑,试着剖析函数看看实际的等价内容。这个 for 循环相当于:

例 17.7. 剖析 plural 函数

 def plural(noun):
    if match_sxz(noun):
        return apply_sxz(noun)
    if match_h(noun):
        return apply_h(noun)
    if match_y(noun):
        return apply_y(noun)
    if match_default(noun):
        return apply_default(noun) 

这里的好处在于 plural 函数现在被简化了。它以普通的方法反复使用其它地方定义的规则。获得一个匹配规则,匹配吗?调用并应用规则。规则可以在任意地方以任意方法定义,plural 函数对此并不关心。

现在,添加这个抽象过程值得吗?嗯……还不值。让我们看看如何向函数添加一个新的规则。啊哈,在先前的范例中,需要向 plural 函数添加一个 if 语句;在这个例子中,需要增加两个函数:match_fooapply_foo,然后更新 rules 列表指定在什么相对位置调用这个新匹配和新规则应用。

这其实不过是步入下一节的一个基石。让我们继续。

17.4. plural.py, 第 3 阶段

17.4. plural.py, 第 3 阶段

将每个匹配和规则应用分别制作成函数没有必要。你从来不会直接调用它们:你把它们定义于 rules 列表之中并从那里调用它们。让我们隐去它们的函数名而抓住规则定义的主线。

例 17.8. plural3.py

 import re
rules = \
  (
    (
     lambda word: re.search('[sxz]$', word),
     lambda word: re.sub('$', 'es', word)
    ),
    (
     lambda word: re.search('[^aeioudgkprt]h$', word),
     lambda word: re.sub('$', 'es', word)
    ),
    (
     lambda word: re.search('[^aeiou]y$', word),
     lambda word: re.sub('y$', 'ies', word)
    ),
    (
     lambda word: re.search('$', word),
     lambda word: re.sub('$', 's', word)
    )
   )                                           
def plural(noun):                             
    for matchesRule, applyRule in rules:       
        if matchesRule(noun):                 
            return applyRule(noun) 
[1]这与第 2 阶段定义的规则是一样的。惟一的区别是不再定义 match_sxzapply_sxz 之类的函数,而是以 lambda 函数 法将这些函数的内容直接 “嵌入” rules 列表本身。
[2]注意 plural 函数完全没有变化,还是反复于一系列的规则函数,检查第一个匹配规则,如果返回真则调用第二个应用规则并返回值。和前面一样,给定单词返回单词。唯一的区别是规则函数被内嵌定义,化名作 lambda 函数。但是 plural 函数并不在乎它们是如何定义的,只是拿到规则列表,闭着眼睛干活。

现在添加一条新的规则,所有你要做的就是直接在 rules 列表之中定义函数:一个匹配规则,一个应用规则。这样内嵌的规则函数定义方法使得没必要的重复很容易被发现。你有四对函数,它们采用相同的模式。匹配函数就是调用 re.search,应用函数就是调用 re.sub。让我们提炼出这些共同点。

17.5. plural.py, 第 4 阶段

17.5. plural.py, 第 4 阶段

让我们精炼出代码中的重复之处,以便更容易地定义新规则。

例 17.9. plural4.py

 import re
def buildMatchAndApplyFunctions((pattern, search, replace)):  
    matchFunction = lambda word: re.search(pattern, word)      
    applyFunction = lambda word: re.sub(search, replace, word) 
    return (matchFunction, applyFunction) 
[1]buildMatchAndApplyFunctions 是一个动态生成其它函数的函数。它将 patternsearchreplace (实际上是一个元组,我们很快就会提到这一点),通过使用 lambda 语法构建一个接受单参数 (word) 并以传递给 buildMatchAndApplyFunctionspattern 和传递给新函数的 word 调用 re.search 的匹配函数!哇塞!
[2]构建应用规则函数的方法相同。应用规则函数是一个接受单参数并以传递给 buildMatchAndApplyFunctionssearchreplace 以及传递给这个应用规则函数的 word 调用 re.sub 的函数。在一个动态函数中应用外部参数值的技术被称作闭合 (closures)。你实际上是在应用规则函数中定义常量:它只接受一个参数 (word),但用到了定义时设置的两个值 (searchreplace)。
[3]最终,buildMatchAndApplyFunctions 函数返回一个包含两个值的元组:你刚刚创建的两个函数。你在这些函数中定义的常量 (matchFunction 中的 pattern 以及 applyFunction 中的 searchreplace) 保留在这些函数中,由 buildMatchAndApplyFunctions 一同返回。这简直太酷了。

如果这太费解 (它应该是这样,这是个怪异的东西),可能需要通过了解它的使用来搞明白。

例 17.10. plural4.py 继续

patterns = \
  (
    ('[sxz]$', '$', 'es'),
    ('[^aeioudgkprt]h$', '$', 'es'),
    ('(qu|[^aeiou])y$', 'y$', 'ies'),
    ('$', '$', 's')
  )                                                 
rules = map(buildMatchAndApplyFunctions, patterns) 
[1]我们的复数化规则现在被定义成一组字符串 (不是函数)。第一个字符串是你在调用 re.search 时使用的正则表达式;第二个和第三个字符串是你在通过调用 re.sub 来应用规则将名词变为复数时使用的搜索和替换表达式。
[2]这很神奇。把传进去的 patterns 字符串转换为传回来的函数。如何做到的呢?将这些字符串映射给 buildMatchAndApplyFunctions 函数之后,三个字符串参数转换成了两个函数组成的元组。这意味着 rules 被转换成了前面范例中相同的内容:由许多调用 re.search 函数的匹配函数和调用 re.sub 的规则应用函数构成的函数组组成的一个元组。

我发誓这不是我信口雌黄:rules 被转换成了前面范例中相同的内容。剖析 rules 的定义,你看到的是:

例 17.11. 剖析规则定义

rules = \
  (
    (
     lambda word: re.search('[sxz]$', word),
     lambda word: re.sub('$', 'es', word)
    ),
    (
     lambda word: re.search('[^aeioudgkprt]h$', word),
     lambda word: re.sub('$', 'es', word)
    ),
    (
     lambda word: re.search('[^aeiou]y$', word),
     lambda word: re.sub('y$', 'ies', word)
    ),
    (
     lambda word: re.search('$', word),
     lambda word: re.sub('$', 's', word)
    )
   ) 

例 17.12. plural4.py 的完成

 def plural(noun):                                  
    for matchesRule, applyRule in rules:            
        if matchesRule(noun):                      
            return applyRule(noun) 
[1]由于 rules 列表和前面的范例是相同的,plural 函数没有变化也就不令人诧异了。记住,这没什么特别的,按照顺序调用一系列函数。不必在意规则是如何定义的。在第 2 阶段,它们被定义为各具名称的函数。在第 3 阶段,他们被定义为匿名的 lambda 函数。现在第 4 阶段,它们通过 buildMatchAndApplyFunctions 映射原始的字符串列表被动态创建。无所谓,plural 函数的工作方法没有变。

还不够兴奋吧!我必须承认,在定义 buildMatchAndApplyFunctions 时我跳过了一个微妙之处。让我们回过头再看一下。

例 17.13. 回头看 buildMatchAndApplyFunctions

 def buildMatchAndApplyFunctions((pattern, search, replace)): 
[1]注意到双括号了吗?这个函数并不是真的接受三个参数,实际上只接受一个参数:一个三元素元组。但是在函数被调用时元组被展开了,元组的三个元素也被赋予了不同的变量:pattern, searchreplace。乱吗?让我们在使用中理解。

例 17.14. 调用函数时展开元组

>>> def foo((a, b, c)):
... print c
... print b
... print a
>>> parameters = ('apple', 'bear', 'catnap')
>>> foo(parameters) 
catnap
bear
apple 
[1]调用 foo 的正确方法是使用一个三元素元组。函数被调用时,元素被分别赋予 foo 中的多个局部变量。

现在,让我们回过头看一看这个元组自动展开技巧的必要性。patterns 是一个元组列表,并且每个元组都有三个元素。调用 map(buildMatchAndApplyFunctions, patterns),这并 意味着是以三个参数调用 buildMatchAndApplyFunctions。使用 map 映射一个列表到函数时,通常使用单参数:列表中的每个元素。就 patterns 而言,列表的每个元素都是一个元组,所以 buildMatchAndApplyFunctions 总是是以元组来调用,在 buildMatchAndApplyFunctions 中使用元组自动展开技巧将元素赋值给可以被使用的变量。

17.6. plural.py, 第 5 阶段

17.6. plural.py, 第 5 阶段

你已经精炼了所有重复代码,也尽可能地把复数规则提炼到定义一个字符串列表。接下来的步骤是把这些字符串提出来放在另外的文件中,从而可以和使用它们的代码分开来维护。

首先,让我们建立一个包含你需要的所有规则的文本文件。没有什么特别的结构,不过是以空格 (或者制表符) 把字符串列成三列。你把它命名为 rules.en,“en” 是英语的意思。这些是英语名词复数的规则,你以后可以为其它语言添加规则文件。

例 17.15. rules.en

[sxz]$                  $               es
[^aeioudgkprt]h$        $               es
[^aeiou]y$              y$              ies
$                       $               s 

现在来看看如何使用规则文件。

例 17.16. plural5.py

 import re
import string                                                                     
def buildRule((pattern, search, replace)):                                        
    return lambda word: re.search(pattern, word) and re.sub(search, replace, word) 
def plural(noun, language='en'):                             
    lines = file('rules.%s' % language).readlines()          
    patterns = map(string.split, lines)                      
    rules = map(buildRule, patterns)                         
    for rule in rules:                                      
        result = rule(noun)                                  
        if result: return result 
[1]在这里你还将使用闭合技术 (动态构建函数时使用函数外部定义的变量),但是现在你把原来分开的匹配函数和规则应用函数合二为一 (你将在下一节中明了其原因)。你很快会看到,这与分别调用两个函数效果相同,只是调用的方法稍有不同。
[2]咱们的 plural 函数现在接受的第二个参数是默认值为 en 的可选参数 language
[3]你使用 language 参数命名一个文件,打开这个文件并读取其中的内容到一个列表。如果 languageen,那么你将打开 rules.en 文件,读取全部内容,以其中的回车符作为分隔构建一个列表。文件的每一行将成为列表的一个元素。
[4]如你所见,文件的每一行都有三个值,但是它们是以空白字符 (制表符或者空格符,这没什么区别) 分割。用 string.split 函数映射列表来创建一个每个元素都是三元素元组的新列表。因此,像 [sxz]$ $ es 这样的一行将被打碎并放入 ('[sxz]$', '$', 'es') 这样的元组。这意味着 patterns 将最终变成元组列表的形式,就像第 4 阶段实打实编写的那样。
[5]如果 patterns 是一个元组列表,那么 rules 就可以通过一个个调用 buildRule 动态地生成函数列表。调用 buildRule(('[sxz]$', '$', 'es')) 返回一个接受单参数 word 的函数。当返回的函数被调用,则将执行 re.search('[sxz]$', word) and re.sub('$', 'es', word)
[6]因为你现在构建的是一个匹配和规则应用合一的函数,你需要分别调用它们。仅仅是调用函数,如果返回了内容,那么返回的便是复数;如果没有返回 (也就是返回了None),那么该规则未能匹配,就应该尝试其他规则。

这里的进步是你把复数规则完全分离到另外的文件中。不但这个文件可以独立于代码单独维护,而且你建立了一个命名规划使 plural 函数可以根据 language 参数使用不同的规则文件。

这里的缺陷是每次调用 plural 函数都需要去读取一次文件。我想我可以在整本书中都不使用 “留给读者去练习”,但是这里:为特定的语言规则文件建立一个缓存机制,并在调用期间规则文件改变时自动刷新留给读者作为练习。祝你顺利。

17.7. plural.py, 第 6 阶段

17.7. plural.py, 第 6 阶段

现在你已准备好探讨生成器 (Generator) 了。

例 17.17. plural6.py

 import re
def rules(language):                                                                 
    for line in file('rules.%s' % language):                                         
        pattern, search, replace = line.split()                                      
        yield lambda word: re.search(pattern, word) and re.sub(search, replace, word)
def plural(noun, language='en'):      
    for applyRule in rules(language): 
        result = applyRule(noun)      
        if result: return result 

这里使用了被称作生成器的技术,我不打算在你看过一个简单例子之前试图解释它。

例 17.18. 介绍生成器

>>> def make_counter(x):
... print 'entering make_counter'
... while 1:
...         yield x               
...         print 'incrementing x'
...         x = x + 1
... 
>>> counter = make_counter(2) 
>>> counter                   
<generator object at 0x001C9C10>
>>> counter.next()            
entering make_counter
2
>>> counter.next()            
incrementing x
3
>>> counter.next()            
incrementing x
4 
[1]make_counter 中出现关键字 yield 意味着这不是一个普通的函数。它是一种每次生成一个值的特殊函数。你可以把它看成是一个可恢复函数。调用它会返回一个生成器,它可以返回 x 的连续值。
[2]想要创建一个 make_counter 生成器的实例,只要像其它函数一样调用。注意这并没有真正执行函数代码。你可以分辨出这一点,因为 make_counter 的第一行是 print 语句,然而没有任何内容输出。
[3]make_counter 函数返回一个生成器对象。
[4]你第一次调用生成器对象的 next() 方法,将执行 make_counter 中的代码执行到第一个 yield 语句,然后返回生产 (yield) 出来的值。在本例中,这个值是 2,因为你是通过 make_counter(2) 来创建最初的生成器的。
[5]不断调用生成器对象的 next() 将从你上次离开的位置重新开始 并继续下去直到你又一次遇到 yield 语句。接下来执行 print 语句来打印 incrementing x,然后执行 x = x + 1 语句来真正地增加。然后你进入 while 的又一次循环,你所做的第一件事是 yield x,返回目前的 x 值 (现在是 3)。
[6]第二次你调用 counter.next() 时,你又做一遍相同的事情,但是这次 x4。如此继续。因为 make_counter 设置的是一个无限循环,理论上你可以永远这样继续下去,不断地递增并弹出 x 值。现在让我们看看生成器更具意义的应用。

例 17.19. 使用生成器替代递归

 def fibonacci(max):
    a, b = 0, 1       
    while a < max:
        yield a       
        a, b = b, a+b 
[1]斐波纳契数列 (Fibonacci sequence) 是每个数都是前面两个数值和的一个数列。它从 01 开始,开始增长得很慢,但越来越快。开始这个数列你需要两个变量:a0开始,b1 开始。
[2]a 是数列的当前值,弹出它。
[3]b 是数列的下一个数,把它赋值给 a,同时计算出 (a+b) 并赋值给 b 放在一边稍后使用。注意这是并行发生的,如果 a3b5,那么 a, b = b, a+b 将会设置 a5 (b 的原值),b8 (ab 之和)。

这样你就有了生成连续的 Fibonacci 数的函数了。当然你也可以通过递归做到,但是这里的方法更加易读。并且也与 for 工作得很好。

例 17.20. for 循环中的生成器

>>> for n in fibonacci(1000): 
... print n,              
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 
[1]你可以在 for 循环中直接使用 fibonacci 这样的生成器。for 循环将会创建一个生成器对象并连续调用其 next() 方法获得值并赋予 for 循环变量 (n)。
[2]每轮 for 循环 n 都从 fibonacciyield 语句获得一个新的值。当 fibonacci 超出数字限定 (a 超过 max,你在这里限定的是 1000) 很自然地退出 for 循环。

好了,让我们回到 plural 函数看看如何可以把它用起来。

例 17.21. 生成器生成动态函数

 def rules(language):                                                                 
    for line in file('rules.%s' % language):                                          
        pattern, search, replace = line.split()                                       
        yield lambda word: re.search(pattern, word) and re.sub(search, replace, word) 
def plural(noun, language='en'):      
    for applyRule in rules(language):  
        result = applyRule(noun)      
        if result: return result 
[1]for line in file(...) 是从文件中一行行读取的通用方法,每次一行。它能正常工作是因为 file 实际上返回一个生成器,它的 next() 方法返回文件中的下一行。简直太酷了,光是想想就让我满头大汗。
[2]这没有什么神奇之处。还记得规则文件的每一行都用空白分开三个值吗?所以 line.split() 返回一个三元素元组,你把这些值赋给了 3 个局部变量。
[3]然后你不断地弹出。 你弹出什么呢?一个使用 lambda 动态生成的函数,而这个函数实际上是一个闭合 (把本地变量 patternsearchreplace 作为常量)。换句话说,rules 是一个弹出规则函数的生成器。
[4]既然 rules 是一个生成器,你就可以在 for 循环中直接使用它。for 循环的第一轮你调用 rules 函数,打开规则文件,读取第一行,动态构建一个根据规则文件第一行匹配并应用规则的函数。for 循环的第二轮将会从上一轮 rules 中停下的位置 (for line in file(...) 循环内部) 读取规则文件的第二行,动态构建根据规则文件第二行匹配并应用规则的另一个函数。如此继续下去。

你在第 5 阶段得到的是什么?第 5 阶段中,你读取整个规则文件并在使用第一条规则之前构建一个所有规则组成的列表。现在有了生成器,你可以更舒适地做到这一切:你打开并读取第一条规则,根据它创建函数并使用之,如果它适用则根本不去读取规则文件剩下的内容,也不去建立另外的函数。

进一步阅读

17.8. 小结

17.8. 小结

这一章中我们探讨了几个不同的高级技术。它们并不都适用于任何情况。

你现在应该能自如应用如下技术:

  • 应用正则表达式进行字符串替换。
  • 将函数当作对象,把它们存于列表中,把它们赋值给变量,并通过变量来调用它们。
  • 构建应用 lambda 的动态函数。
  • 构建闭合,将外部变量作为常量构建动态函数。
  • 构建生成器,进行逻辑递增操作并在每次调用时返回不同值的恢复执行函数。

抽象化,动态构建函数,构建闭合以及应用生成器能够使你的代码更加简单化、可读化、灵活化。你需要在简洁和功能实现两方面进行平衡。