您当前位置: 首页 深入 Python 3

难度等级: ♦♦♦♦♢

重构

在演奏了大量音符之后,最终的艺术回报是简单。
— 弗雷德里克·肖邦

 

深入

无论您是否喜欢,错误总会发生。尽管您尽力编写全面的 单元测试,但错误总会发生。我所说的“错误”是什么意思?错误是指您尚未编写的测试用例。

>>> import roman7
>>> roman7.from_roman('') 
0
  1. 这是一个错误。空字符串应该引发 InvalidRomanNumeralError 异常,就像任何其他不代表有效罗马数字的字符序列一样。

在重现错误并修复错误之前,您应该编写一个失败的测试用例,以说明该错误。

class FromRomanBadInput(unittest.TestCase):  
    .
    .
    .
    def testBlank(self):
        '''from_roman should fail with blank string'''
        self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, '') 
  1. 这里的内容相当简单。用空字符串调用 from_roman() 并确保它引发 InvalidRomanNumeralError 异常。难点在于找到错误;现在您知道了,测试它就变得容易了。

由于您的代码存在错误,并且您现在有一个测试该错误的测试用例,因此测试用例将失败。

you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... FAIL
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
FAIL: from_roman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest8.py", line 117, in test_blank
    self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, '')
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 11 tests in 0.171s

FAILED (failures=1)

现在您可以修复错误了。

def from_roman(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: {}'.format(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 语句。
  2. 我认为我在本书中尚未提及过这一点,因此请将其作为您关于 字符串格式化 的最后一次课程。从 Python 3.1 开始,您可以在格式说明符中使用位置索引时跳过数字。也就是说,您可以使用格式说明符 {0} 来引用 format() 方法的第一个参数,也可以直接使用 {},Python 将为您填写正确的位置索引。这适用于任何数量的参数;第一个 {}{0},第二个 {}{1},依此类推。
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... ok 
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 11 tests in 0.156s

OK 
  1. 现在空字符串测试用例通过了,因此错误已修复。
  2. 所有其他测试用例仍然通过,这意味着此错误修复没有破坏其他任何内容。停止编码。

以这种方式编码不会使修复错误变得更容易。简单的错误(像这个)需要简单的测试用例;复杂的错误将需要复杂的测试用例。在以测试为中心的开发环境中,看起来修复错误需要更长时间,因为您需要用代码准确地说明错误是什么(编写测试用例),然后修复错误本身。然后,如果测试用例没有立即通过,则需要确定是修复错误还是测试用例本身存在错误。但是,从长远来看,测试代码和被测试代码之间的这种来回往复是值得的,因为它可以更可能确保错误第一次就被正确修复。此外,由于您可以轻松地重新运行所有测试用例以及新的测试用例,因此在修复新代码时,破坏旧代码的可能性要小得多。今天的单元测试就是明天的回归测试。

处理不断变化的需求

尽管您尽力将客户固定在地面上,并以使用剪刀和热蜡进行可怕的令人不快的事情为代价,从他们那里获取精确的需求,但需求会发生变化。大多数客户在看到之前不知道自己想要什么,即使他们知道,他们也不擅长将自己想要的东西表达得足够精确以至于有用。即使他们知道,他们还是希望在下一个版本中获得更多。因此,请做好准备随着需求的变化更新您的测试用例。

例如,假设您想要扩展罗马数字转换函数的范围。通常,罗马数字中的任何字符在连续出现的次数不能超过三次。但罗马人愿意对此规则做出例外,在罗马数字中使用 4 个 M 字符来代表 4000。如果您进行此更改,您将能够将可转换数字的范围从 1..3999 扩展到 1..4999。但首先,您需要对测试用例进行一些更改。

[下载 roman8.py]

class KnownValues(unittest.TestCase):
    known_values = ( (1, 'I'),
                      .
                      .
                      .
                     (3999, 'MMMCMXCIX'),
                     (4000, 'MMMM'),                                      
                     (4500, 'MMMMD'),
                     (4888, 'MMMMDCCCLXXXVIII'),
                     (4999, 'MMMMCMXCIX') )

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman8.OutOfRangeError, roman8.to_roman, 5000)  

    .
    .
    .

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):     
            self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)

    .
    .
    .

class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 5000):                                    
            numeral = roman8.to_roman(integer)
            result = roman8.from_roman(numeral)
            self.assertEqual(integer, result)
  1. 现有的已知值没有改变(它们仍然是测试的合理值),但您需要在 4000 范围内添加几个值。这里我包含了 4000(最短)、4500(第二短)、4888(最长)和 4999(最大)。
  2. “大输入”的定义发生了改变。此测试以前使用 4000 调用 to_roman() 并期望出现错误;现在 4000-4999 是有效值,您需要将其提高到 5000
  3. “重复数字过多”的定义也发生了变化。此测试以前使用 'MMMM' 调用 from_roman() 并期望出现错误;现在 MMMM 被认为是一个有效的罗马数字,您需要将其提高到 'MMMMM'
  4. 健全性检查循环遍历从 13999 的每个数字。由于范围现在已经扩展,因此此 for 循环也需要更新,以达到 4999

现在您的测试用例已更新到最新需求,但您的代码尚未更新,因此您预计几个测试用例会失败。

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ERROR          
to_roman should give known result with known input ... ERROR            
from_roman(to_roman(n))==n for all n ... ERROR                          
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
ERROR: from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 82, in test_from_roman_known_values
    result = roman9.from_roman(numeral)
  File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman
    raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM

======================================================================
ERROR: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 76, in test_to_roman_known_values
    result = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)

======================================================================
ERROR: from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 131, in testSanity
    numeral = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)

----------------------------------------------------------------------
Ran 12 tests in 0.171s

FAILED (errors=3)
  1. from_roman() 已知值测试将从遇到 'MMMM' 时开始失败,因为 from_roman() 仍然认为这是一个无效的罗马数字。
  2. to_roman() 已知值测试将从遇到 4000 时开始失败,因为 to_roman() 仍然认为这超出了范围。
  3. 往返检查也将从遇到 4000 时开始失败,因为 to_roman() 仍然认为这超出了范围。

现在您已经拥有了由于新需求而失败的测试用例,您可以考虑修复代码,使其与测试用例保持一致。(当您第一次开始编写单元测试时,您可能会觉得被测试的代码永远不会“领先”于测试用例。当它落后时,您仍然有一些工作要做,一旦它追上测试用例,您就停止编码。在您习惯之后,您会想知道如何在没有测试的情况下进行编程。)

[下载 roman9.py]

roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 Ms  
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not isinstance(n, int):
        raise NotIntegerError('non-integers can not be converted')
    if not (0 < n < 5000):                        
        raise OutOfRangeError('number out of range (must be 1..4999)')

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result

def from_roman(s):
    .
    .
    .
  1. 您根本不需要对 from_roman() 函数进行任何更改。唯一需要更改的是 roman_numeral_pattern。如果您仔细观察,您会注意到我在正则表达式的第一部分中将可选 M 字符的最大数量从 3 更改为 4。这将允许罗马数字等效于 4999 而不是 3999。实际的 from_roman() 函数是完全通用的;它只是查找重复的罗马数字字符并将它们加起来,而不关心它们重复了多少次。它以前没有处理 'MMMM' 的唯一原因是您使用正则表达式模式匹配显式地阻止了它。
  2. to_roman() 函数只需要进行一项小更改,即在范围检查中。您以前检查的是 0 < n < 4000,现在您检查的是 0 < n < 5000。并且您更改了 raise 的错误消息,以反映新的可接受范围(1..4999 而不是 1..3999)。您不需要对函数的其余部分进行任何更改;它已经处理了新情况。(它会愉快地为它找到的每个千位数添加 'M';给定 4000,它将输出 'MMMM'。它以前没有这样做唯一的理由是您使用范围检查显式地阻止了它。)

您可能会怀疑这两个小更改是否就是您所需要的。嘿,别相信我的话;自己看看。

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.203s

OK  
  1. 所有测试用例都通过了。停止编码。

全面的单元测试意味着永远不需要依赖说“相信我”的程序员。

重构

全面的单元测试最好的地方不在于当您所有测试用例最终通过时所产生的感觉,甚至不在于当其他人因破坏他们的代码而责怪您时,您实际上可以证明您没有破坏他们的代码时所产生的感觉。单元测试最好的地方在于它让您能够肆无忌惮地进行重构。

重构是将工作代码改进的过程。通常,“更好”意味着“更快”,但它也可以意味着“使用更少的内存”,“使用更少的磁盘空间”或仅仅是“更优雅”。无论对您、您的项目,在您的环境中意味着什么,重构对任何程序的长期健康都是至关重要的。

这里,“更好”意味着“更快”和“更容易维护”。具体来说,from_roman() 函数比我希望的慢且复杂,因为您用来验证罗马数字的正则表达式又大又难看。现在,您可能会想,“当然,正则表达式又大又难看,但我该怎么验证一个任意字符串是否是一个有效的罗马数字?”

答案:只有 5000 个;为什么不建立一个查找表?当您意识到您根本不需要使用正则表达式时,这个想法就更好了。当您构建用于将整数转换为罗马数字的查找表时,您可以构建反向查找表,将罗马数字转换为整数。当您需要检查一个任意字符串是否是一个有效的罗马数字时,您将已经收集了所有有效的罗马数字。“验证”被简化为一次字典查找。

最棒的是,您已经拥有了一套完整的单元测试。您可以更改模块中一半以上的代码,但单元测试会保持不变。这意味着您可以证明——对自己和其他人——新代码的工作原理与原始代码一样好。

[下载 roman10.py]

class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass

roman_numeral_map = (('M',  1000),
                     ('CM', 900),
                     ('D',  500),
                     ('CD', 400),
                     ('C',  100),
                     ('XC', 90),
                     ('L',  50),
                     ('XL', 40),
                     ('X',  10),
                     ('IX', 9),
                     ('V',  5),
                     ('IV', 4),
                     ('I',  1))

to_roman_table = [ None ]
from_roman_table = {}

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

def build_lookup_tables():
    def to_roman(n):
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)
        to_roman_table.append(roman_numeral)
        from_roman_table[roman_numeral] = integer

build_lookup_tables()

让我们将其分解为可理解的部分。可以说,最重要的行是最后一行

build_lookup_tables()

您会注意到这是一个函数调用,但它周围没有 if 语句。这不是 if __name__ == '__main__' 块;它是在导入模块时被调用的。(重要的是要理解,模块只被导入一次,然后被缓存。如果您导入一个已经导入的模块,它什么也不做。所以这段代码只会在您第一次导入此模块时被调用。)

那么 build_lookup_tables() 函数做了什么?我很高兴您问这个问题。

to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
    def to_roman(n):                                
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)          
        to_roman_table.append(roman_numeral)       
        from_roman_table[roman_numeral] = integer
  1. 这是一段巧妙的编程……也许太巧妙了。to_roman() 函数是在上面定义的;它在查找表中查找值并返回它们。但 build_lookup_tables() 函数重新定义了 to_roman() 函数,使其真正发挥作用(就像您添加查找表之前一样)。在 build_lookup_tables() 函数中,调用 to_roman() 将调用这个重新定义的版本。一旦 build_lookup_tables() 函数退出,重新定义的版本就会消失——它只在 build_lookup_tables() 函数的局部范围内定义。
  2. 这行代码将调用重新定义的 to_roman() 函数,该函数实际上计算罗马数字。
  3. 获得结果(来自重新定义的 to_roman() 函数)后,您将整数及其等效的罗马数字添加到两个查找表中。

查找表构建完成后,其余代码既简单又快速。

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]                                            

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]                                          
  1. 在进行与之前相同的边界检查后,to_roman() 函数只需从查找表中找到相应的值并返回即可。
  2. 类似地,from_roman() 函数也被简化为一些边界检查和一行代码。不再需要正则表达式。不再需要循环。O(1) 的罗马数字转换。

但它真的能工作吗?当然可以,它确实能工作。我可以证明。

you@localhost:~/diveintopython3/examples$ python3 romantest10.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.031s                                                  

OK
  1. 虽然你没有问,但它也非常快!几乎快了 10 倍。当然,这并不是完全公平的比较,因为这个版本在导入时需要更长时间(因为它需要构建查找表)。但由于导入只执行一次,所以启动成本会在所有对 to_roman()from_roman() 函数的调用中进行分摊。由于测试进行了数千次函数调用(往返测试 alone 进行了 10,000 次),因此这种节省很快就能累积起来!

故事的寓意是什么?

总结

单元测试是一个强大的概念,如果实施得当,它既可以降低维护成本,又可以提高任何长期项目的灵活性。同样重要的是要理解,单元测试并不是万能药,不是神奇的解决问题的工具,也不是银弹。编写好的测试用例很困难,而且保持测试用例的更新需要纪律(尤其是在客户急于解决严重错误时)。单元测试不能替代其他形式的测试,包括功能测试、集成测试和用户验收测试。但它确实可行,而且确实有效,一旦你看到了它的效果,你就会想知道以前是怎么没有它就活下来的。

这几个章节涵盖了很多内容,而且其中很多内容甚至不是 Python 特定的。许多语言都有单元测试框架,所有这些框架都需要你了解相同的基本概念。

© 2001–11 Mark Pilgrim