15.3. 重构

全面的单元测试最大的好处不是当你所有的测试用例最终都通过时的那种感觉,甚至也不是当别人指责你破坏了他们的代码而你实际上可以证明你没有做的时候的那种感觉。单元测试最大的好处是,它给了你无情重构的自由。

重构是指在不改变代码功能的情况下,对其进行改进的过程。通常,“更好”意味着“更快”,尽管它也可以意味着“使用更少的内存”,或“使用更少的磁盘空间”,或者仅仅是“更优雅”。无论对你、对你的项目、在你的环境中意味着什么,重构对任何程序的长期健康发展都很重要。

在这里,“更好”意味着“更快”。具体来说,fromRoman 函数比它需要的速度慢,因为它使用了那个又大又笨重的正则表达式来验证罗马数字。试图完全抛弃正则表达式可能不值得(这将是困难的,而且最终可能不会更快),但你可以通过预编译正则表达式来加快函数的速度。

示例 15.10. 编译正则表达式

>>> import re
>>> pattern = '^M?M?M?$'
>>> re.search(pattern, 'M')               1
<SRE_Match object at 01090490>
>>> compiledPattern = re.compile(pattern) 2
>>> compiledPattern
<SRE_Pattern object at 00F06E28>
>>> dir(compiledPattern)                  3
['findall', 'match', 'scanner', 'search', 'split', 'sub', 'subn']
>>> compiledPattern.search('M')           4
<SRE_Match object at 01104928>
1 这是你以前见过的语法:re.search 接受一个字符串形式的正则表达式 (pattern) 和一个要与之匹配的字符串 ('M')。如果模式匹配,该函数将返回一个匹配对象,可以查询该对象以确切了解匹配的内容和方式。
2 这是新的语法:re.compile 接受一个字符串形式的正则表达式并返回一个模式对象。注意这里没有要匹配的字符串。编译正则表达式与将其与任何特定字符串(如 'M')匹配无关;它只涉及正则表达式本身。
3 re.compile 返回的已编译模式对象有几个看起来很有用的函数,包括几个(如 searchsub)可以直接在 re 模块中使用。
4 使用字符串 'M' 调用已编译模式对象的 search 函数与使用正则表达式和字符串 'M' 调用 re.search 的效果相同。只是速度要快得多,快得多。(事实上,re.search 函数只是简单地编译正则表达式并为你调用生成的模式对象的 search 方法。)
Note
每当你打算多次使用一个正则表达式时,你都应该编译它以获得一个模式对象,然后直接调用该模式对象上的方法。

示例 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?)$') 1

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not romanNumeralPattern.search(s):                                    2
        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 返回的模式对象。
2 这意味着你可以直接在 romanNumeralPattern 上调用方法。这将比每次都调用 re.search 快得多,快得多。正则表达式在模块首次导入时被编译一次并存储在 romanNumeralPattern 中;然后,每次调用 fromRoman 时,你都可以立即将输入字符串与正则表达式进行匹配,而无需在幕后进行任何中间步骤。

那么编译正则表达式到底能快多少呢?你自己看看

示例 15.12. romantest81.py 针对 roman81.py 的输出

.............          1
----------------------------------------------------------------------
Ran 13 tests in 3.385s 2

OK                     3
1 这里顺便说一句:这一次,我运行单元测试时没有 使用 -v 选项,所以你不会看到每个测试的完整 文档字符串,而只是看到每个通过的测试都有一个点。(如果一个测试失败,你会看到一个 F,如果它有一个错误,你会看到一个 E。你仍然会看到每个失败和错误的完整回溯,所以你可以追踪任何问题。)
2 你在 3.385 秒内运行了 13 个测试,相比之下,没有预编译正则表达式需要 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
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 1

OK                     2
1 总的来说,使用这种形式的正则表达式,单元测试的运行速度提高了 2%。这听起来并不令人兴奋,但请记住,search 函数只是整个单元测试的一小部分;大部分时间都花在了其他事情上。(另外,我单独对正则表达式进行了时间测试,发现使用这种语法,search 函数的速度提高了 11%。)通过预编译正则表达式并将其中的一部分重写为使用这种新语法,你已经将正则表达式的性能提高了 60% 以上,并将整个单元测试的整体性能提高了 10% 以上。
2 比任何性能提升更重要的是,该模块仍然可以完美地工作。这就是我之前所说的自由:自由地调整、更改或重写任何一部分代码,并验证你在此过程中没有搞砸任何东西。这不是让你为了调整代码而无休止地调整代码的许可证;你有一个非常具体的目標(“fromRoman 更快”),并且你能够在没有任何挥之不去的疑虑的情况下完成这个目標,而不用担心你是否在这个过程中引入了新的错误。

我还想做另一个调整,然后我保证我会停止重构,并把这个模块放到一边。正如你反复看到的那样,正则表达式会很快变得非常复杂和难以阅读。我不希望六个月后回到这个模块,并试图维护它。当然,测试用例通过了,所以我知道它可以工作,但如果我无法弄清楚它是如何工作的,那么添加新功能、修复新错误或以其他方式维护它仍然很困难。正如你在 第 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
1 re.compile 函数可以接受一个可选的第二个参数,它是一组一个或多个标志,用于控制已编译正则表达式的各种选项。在这里,你指定了 re.VERBOSE 标志,它告诉 Python 在正则表达式本身中存在内联注释。注释及其周围的所有空格 被视为正则表达式的一部分;re.compile 函数在编译表达式时只是简单地将它们全部删除。这个新的“详细”版本与旧版本相同,但它的可读性要高得多。

例 15.16. 使用 roman83.py 运行 romantest83.py 的输出结果

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s 1

OK                     2
1 这个新的“详细”版本与旧版本的运行速度完全相同。事实上,编译后的模式对象是相同的,因为 re.compile 函数会去除您添加的所有内容。
2 这个新的“详细”版本通过了与旧版本相同的所有测试。除了六个月后回到这个模块的程序员有机会理解这个函数是如何工作的之外,其他什么都没有改变。