18.3. 优化正则表达式

Soundex 函数首先检查输入是否是一个非空的字母字符串。 最好的方法是什么?

如果你回答“正则表达式”,去角落里反省一下你糟糕的直觉吧。 正则表达式几乎从来都不是正确的答案; 应该尽可能避免使用它们。 不仅仅是出于性能原因,还因为它们难以调试和维护。 当然,还有性能原因。

这段来自 soundex/stage1/soundex1a.py 的代码片段检查函数参数 source 是否是一个完全由字母组成的单词,并且至少包含一个字母(不是空字符串)

    allChars = string.uppercase + string.lowercase
    if not re.search('^[%s]+$' % allChars, source):
        return "0000"

soundex1a.py 的性能如何? 为了方便起见,脚本的 __main__ 部分包含以下代码,该代码调用 timeit 模块,使用三个不同的名称设置计时测试,对每个名称测试三次,并显示每个名称的最小时间


if __name__ == '__main__':
    from timeit import Timer
    names = ('Woo', 'Pilgrim', 'Flingjingwaller')
    for name in names:
        statement = "soundex('%s')" % name
        t = Timer(statement, "from __main__ import soundex")
        print name.ljust(15), soundex(name), min(t.repeat())

那么使用此正则表达式时, soundex1a.py 的性能如何?

C:\samples\soundex\stage1>python soundex1a.py
Woo             W000 19.3356647283
Pilgrim         P426 24.0772053431
Flingjingwaller F452 35.0463220884

正如你所料,当使用较长的名称调用时,算法需要更长的时间。 我们可以做一些事情来缩小这种差距(使函数在处理较长输入时花费更少的相对时间),但算法的性质决定了它永远不会在恒定时间内运行。

另一件要记住的事情是,我们正在测试一个具有代表性的姓名样本。 Woo 是一种琐碎的情况,因为它被缩短为一个字母,然后用零填充。 Pilgrim 是一种正常情况,长度平均,并且包含重要字母和被忽略字母的混合。 Flingjingwaller 非常长,并且包含连续的重复字符。 其他测试可能也有帮助,但这涵盖了各种不同的情况。

那么那个正则表达式呢? 好吧,效率很低。 由于表达式正在测试字符范围(大写的 A-Z 和小写的 a-z),因此我们可以使用简写的正则表达式语法。 这是 soundex/stage1/soundex1b.py

    if not re.search('^[A-Za-z]+$', source):
        return "0000"

timeit 表示 soundex1b.pysoundex1a.py 略快,但没什么可兴奋的

C:\samples\soundex\stage1>python soundex1b.py
Woo             W000 17.1361133887
Pilgrim         P426 21.8201693232
Flingjingwaller F452 32.7262294509

我们在 第 15.3 节“重构” 中看到,正则表达式可以被编译和重用以获得更快的结果。 由于此正则表达式在函数调用中永远不会改变,因此我们可以编译一次并使用编译后的版本。 这是 soundex/stage1/soundex1c.py

isOnlyChars = re.compile('^[A-Za-z]+$').search
def soundex(source):
    if not isOnlyChars(source):
        return "0000"

soundex1c.py 中使用编译后的正则表达式要快得多

C:\samples\soundex\stage1>python soundex1c.py
Woo             W000 14.5348347346
Pilgrim         P426 19.2784703084
Flingjingwaller F452 30.0893873383

但这方法错了吗? 这里的逻辑很简单:输入 source 不能为空,并且必须完全由字母组成。 编写一个循环来检查每个字符,并完全不用正则表达式,会不会更快?

这是 soundex/stage1/soundex1d.py

    if not source:
        return "0000"
    for c in source:
        if not ('A' <= c <= 'Z') and not ('a' <= c <= 'z'):
            return "0000"

事实证明, soundex1d.py 中的这种技术 并不 比使用编译后的正则表达式快(尽管它比使用未编译的正则表达式快)

C:\samples\soundex\stage1>python soundex1d.py
Woo             W000 15.4065058548
Pilgrim         P426 22.2753567842
Flingjingwaller F452 37.5845122774

为什么 soundex1d.py 不更快? 答案在于 Python 的解释性质。 正则表达式引擎是用 C 语言编写的,并编译为在本机计算机上运行。 另一方面,此循环是用 Python 编写的,并通过 Python 解释器运行。 即使循环相对简单,但它也不足以弥补被解释的开销。 正则表达式从来都不是正确的答案……除非它们是。

事实证明, Python 提供了一个鲜为人知的字符串方法。 你可以原谅不知道它,因为它在这本书中从未被提及过。 该方法称为 isalpha(),它检查字符串是否仅包含字母。

这是 soundex/stage1/soundex1e.py

    if (not source) or (not source.isalpha()):
        return "0000"

我们在 soundex1e.py 中使用这种特定方法获得了多少收益? 相当多。

C:\samples\soundex\stage1>python soundex1e.py
Woo             W000 13.5069504644
Pilgrim         P426 18.2199394057
Flingjingwaller F452 28.9975225902

示例 18.3. 迄今为止的最佳结果: soundex/stage1/soundex1e.py


import string, re

charToSoundex = {"A": "9",
                 "B": "1",
                 "C": "2",
                 "D": "3",
                 "E": "9",
                 "F": "1",
                 "G": "2",
                 "H": "9",
                 "I": "9",
                 "J": "2",
                 "K": "2",
                 "L": "4",
                 "M": "5",
                 "N": "5",
                 "O": "9",
                 "P": "1",
                 "Q": "2",
                 "R": "6",
                 "S": "2",
                 "T": "3",
                 "U": "9",
                 "V": "1",
                 "W": "9",
                 "X": "2",
                 "Y": "9",
                 "Z": "2"}

def soundex(source):
    if (not source) or (not source.isalpha()):
        return "0000"
    source = source[0].upper() + source[1:]
    digits = source[0]
    for s in source[1:]:
        s = s.upper()
        digits += charToSoundex[s]
    digits2 = digits[0]
    for d in digits[1:]:
        if digits2[-1] != d:
            digits2 += d
    digits3 = re.sub('9', '', digits2)
    while len(digits3) < 4:
        digits3 += "0"
    return digits3[:4]

if __name__ == '__main__':
    from timeit import Timer
    names = ('Woo', 'Pilgrim', 'Flingjingwaller')
    for name in names:
        statement = "soundex('%s')" % name
        t = Timer(statement, "from __main__ import soundex")
        print name.ljust(15), soundex(name), min(t.repeat())