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

难度级别:♦♦♦♦♢

高级迭代器

大跳蚤背上长着小跳蚤,专门咬它们,
小跳蚤背上还有更小的跳蚤,如此无穷无尽。
— 奥古斯都·德·摩根

 

深入探讨

就像正则表达式字符串注入了活力,itertools 模块也为迭代器注入了活力。但首先,我想向你展示一个经典的谜题。

HAWAII + IDAHO + IOWA + OHIO == STATES
510199 + 98153 + 9301 + 3593 == 621246

H = 5
A = 1
W = 0
I = 9
D = 8
O = 3
S = 6
T = 2
E = 4

这种谜题被称为字谜字母运算。字母拼出了实际的单词,但如果你用0–9之间的数字替换每个字母,它也会“拼出”算术方程式。诀窍是找出哪个字母对应哪个数字。每个字母的所有出现都必须映射到同一个数字,任何数字都不能重复,并且任何“单词”都不能以数字 0 开头。

在本章中,我们将深入研究一个最初由雷蒙德·赫廷格编写的不可思议的 Python 程序。该程序仅用 14 行代码解决字母运算谜题。

[下载 alphametics.py]

import re
import itertools

def solve(puzzle):
    words = re.findall('[A-Z]+', puzzle.upper())
    unique_characters = set(''.join(words))
    assert len(unique_characters) <= 10, 'Too many letters'
    first_letters = {word[0] for word in words}
    n = len(first_letters)
    sorted_characters = ''.join(first_letters) + \
        ''.join(unique_characters - first_letters)
    characters = tuple(ord(c) for c in sorted_characters)
    digits = tuple(ord(c) for c in '0123456789')
    zero = digits[0]
    for guess in itertools.permutations(digits, len(characters)):
        if zero not in guess[:n]:
            equation = puzzle.translate(dict(zip(characters, guess)))
            if eval(equation):
                return equation

if __name__ == '__main__':
    import sys
    for puzzle in sys.argv[1:]:
        print(puzzle)
        solution = solve(puzzle)
        if solution:
            print(solution)

您可以从命令行运行该程序。在 Linux 上,它看起来像这样。(这些可能需要一些时间,具体取决于您的计算机速度,并且没有进度条。请耐心等待!)

you@localhost:~/diveintopython3/examples$ python3 alphametics.py "HAWAII + IDAHO + IOWA + OHIO == STATES"
HAWAII + IDAHO + IOWA + OHIO = STATES
510199 + 98153 + 9301 + 3593 == 621246
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "I + LOVE + YOU == DORA"
I + LOVE + YOU == DORA
1 + 2784 + 975 == 3760
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "SEND + MORE == MONEY"
SEND + MORE == MONEY
9567 + 1085 == 10652

查找模式的所有出现

此字母运算求解器首先要做的是查找谜题中所有字母(A–Z)。

>>> import re
>>> re.findall('[0-9]+', '16 2-by-4s in rows of 8') 
['16', '2', '4', '8']
>>> re.findall('[A-Z]+', 'SEND + MORE == MONEY') 
['SEND', 'MORE', 'MONEY']
  1. re 模块是 Python 对正则表达式的实现。它有一个很方便的函数叫做findall(),它接受一个正则表达式模式和一个字符串,并在字符串中查找模式的所有出现。在本例中,该模式匹配数字序列。findall() 函数返回一个包含所有与模式匹配的子字符串的列表。
  2. 这里正则表达式模式匹配字母序列。同样,返回值是一个列表,列表中的每个项目都是与正则表达式模式匹配的字符串。

这里还有一个例子,它会稍微锻炼一下你的大脑。

>>> re.findall(' s.*? s', "The sixth sick sheikh's sixth sheep's sick.")
[' sixth s', " sheikh's s", " sheep's s"]

惊讶吗?正则表达式查找一个空格、一个s,然后是最短的任何字符序列(.*?),然后是一个空格,然后是另一个s。嗯,看看那个输入字符串,我看到了五个匹配项

  1. 第六个生病的 sheikh 的第六只羊病了。
  2. 第六个生病的 sheikh 的第六只羊病了。
  3. 第六个生病的sheikh 的 sixth 只羊病了。
  4. 第六个生病的 sheikh 的第六个 sheep 病了。
  5. 第六个生病的 sheikh 的第六个羊的 sick。

但是re.findall() 函数只返回了三个匹配项。具体来说,它返回了第一个、第三个和第五个。为什么呢?因为它不返回重叠的匹配项。第一个匹配项与第二个匹配项重叠,所以返回第一个,而第二个匹配项被跳过。然后第三个匹配项与第四个匹配项重叠,所以返回第三个,而第四个匹配项被跳过。最后,返回第五个匹配项。三个匹配项,而不是五个。

这与字母运算求解器无关;我只是觉得它很有趣。

查找序列中唯一的项目

集合使查找序列中唯一的项目变得微不足道。

>>> a_list = ['The', 'sixth', 'sick', "sheik's", 'sixth', "sheep's", 'sick']
>>> set(a_list) 
{'sixth', 'The', "sheep's", 'sick', "sheik's"}
>>> a_string = 'EAST IS EAST'
>>> set(a_string) 
{'A', ' ', 'E', 'I', 'S', 'T'}
>>> words = ['SEND', 'MORE', 'MONEY']
>>> ''.join(words) 
'SENDMOREMONEY'
>>> set(''.join(words)) 
{'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
  1. 给定一个包含多个字符串的列表,set() 函数将返回一个包含列表中唯一字符串的集合。如果你把它想象成一个for 循环,就很容易理解了。从列表中取第一个项目,把它放到集合中。第二个。第三个。第四个。第五个 - 等等,那已经在集合中了,所以它只列出一次,因为 Python 集合不允许重复。第六个。第七个 - 又是一个重复,所以它只列出一次。最终结果是什么?原始列表中的所有唯一项目,没有任何重复。原始列表甚至不需要先排序。
  2. 同样的技巧适用于字符串,因为字符串只是一系列字符。
  3. 给定一个字符串列表,''.join(a_list) 将所有字符串连接在一起形成一个字符串。
  4. 因此,给定一个字符串列表,这行代码将返回所有字符串中所有唯一的字符,没有任何重复。

字母运算求解器使用这种技术来构建谜题中所有唯一字符的集合。

unique_characters = set(''.join(words))

此列表稍后用于在求解器遍历可能的解决方案时,将数字分配给字符。

断言

像许多编程语言一样,Python 也有一个assert 语句。以下是它的工作原理。

>>> assert 1 + 1 == 2 
>>> assert 1 + 1 == 3 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError
>>> assert 2 + 2 == 5, "Only for very large values of 2" 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: Only for very large values of 2
  1. assert 语句后面可以是任何有效的 Python 表达式。在本例中,表达式1 + 1 == 2 评估为True,因此assert 语句不执行任何操作。
  2. 但是,如果 Python 表达式评估为Falseassert 语句将引发一个AssertionError
  3. 您还可以包含一个可读的消息,如果引发了AssertionError,则会打印该消息。

因此,这行代码

assert len(unique_characters) <= 10, 'Too many letters'

… 等同于这

if len(unique_characters) > 10:
    raise AssertionError('Too many letters')

字母运算求解器使用此确切的assert 语句,如果谜题中包含超过十个唯一字母,则提前退出。由于每个字母都分配了一个唯一的数字,并且只有十个数字,因此包含超过十个唯一字母的谜题不可能有解。

生成器表达式

生成器表达式就像一个生成器函数,但没有函数。

>>> unique_characters = {'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
>>> gen = (ord(c) for c in unique_characters) 
>>> gen 
<generator object <genexpr> at 0x00BADC10>
>>> next(gen) 
69
>>> next(gen)
68
>>> tuple(ord(c) for c in unique_characters) 
(69, 68, 77, 79, 78, 83, 82, 89)
  1. 生成器表达式就像一个匿名函数,它会生成值。表达式本身看起来像一个列表推导,但它用括号而不是方括号括起来。
  2. 生成器表达式返回… 一个迭代器。
  3. 调用next(gen) 将返回迭代器中的下一个值。
  4. 如果您愿意,可以通过将生成器表达式传递给tuple()list()set(),来遍历所有可能的值并返回一个元组、列表或集合。在这些情况下,您不需要额外的括号 - 只需将“裸”表达式ord(c) for c in unique_characters 传递给tuple() 函数,Python 会识别出这是一个生成器表达式。

使用生成器表达式而不是列表推导可以节省CPURAM。如果您正在构建一个列表只是为了丢弃它(例如,传递给tuple()set()),请使用生成器表达式!

以下是用生成器函数完成相同操作的另一种方法

def ord_map(a_string):
    for c in a_string:
        yield ord(c)

gen = ord_map(unique_characters)

生成器表达式更紧凑,但功能上等效。

计算排列… 懒惰的方式!

首先,排列究竟是什么?排列是一个数学概念。(实际上有几个定义,具体取决于您在做什么样的数学。这里我说的是组合学,但如果这对你来说毫无意义,别担心。和往常一样,维基百科是你的朋友。)

这个想法是,你取一个事物的列表(可以是数字、字母、也可以是跳舞的熊),然后找出将它们拆分成较小列表的所有可能方式。所有较小的列表都具有相同的尺寸,可以小到 1,也可以大到总项目数。哦,而且不能重复任何东西。数学家会说“让我们找出 3 个不同项目取 2 个的排列”,这意味着你有一个 3 个项目的序列,并且你想找出所有可能的排序对。

>>> import itertools 
>>> perms = itertools.permutations([1, 2, 3], 2) 
>>> next(perms) 
(1, 2)
>>> next(perms)
(1, 3)
>>> next(perms)
(2, 1) 
>>> next(perms)
(2, 3)
>>> next(perms)
(3, 1)
>>> next(perms)
(3, 2)
>>> next(perms) 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
  1. itertools 模块包含各种有趣的东西,包括一个permutations() 函数,它完成了查找排列的所有繁重工作。
  2. permutations() 函数接受一个序列(这里是一个包含三个整数的列表)和一个数字,该数字是每个较小组中想要包含的项目数。该函数返回一个迭代器,您可以在for 循环或任何进行迭代的地方使用它。这里我将手动遍历迭代器以显示所有值。
  3. [1, 2, 3] 取 2 个的第一个排列是(1, 2)
  4. 请注意,排列是有序的:(2, 1)(1, 2) 不同。
  5. 就是这样!这些都是[1, 2, 3] 取 2 个的所有排列。像(1, 1)(2, 2) 这样的对永远不会出现,因为它们包含重复项,因此不是有效的排列。当没有更多排列时,迭代器将引发StopIteration 异常。

permutations() 函数不必接受一个列表。它可以接受任何序列 - 甚至是字符串。

>>> import itertools
>>> perms = itertools.permutations('ABC', 3) 
>>> next(perms)
('A', 'B', 'C') 
>>> next(perms)
('A', 'C', 'B')
>>> next(perms)
('B', 'A', 'C')
>>> next(perms)
('B', 'C', 'A')
>>> next(perms)
('C', 'A', 'B')
>>> next(perms)
('C', 'B', 'A')
>>> next(perms)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> list(itertools.permutations('ABC', 3)) 
[('A', 'B', 'C'), ('A', 'C', 'B'),
 ('B', 'A', 'C'), ('B', 'C', 'A'),
 ('C', 'A', 'B'), ('C', 'B', 'A')]
  1. 字符串只是一系列字符。为了查找排列的目的,字符串'ABC' 等效于列表['A', 'B', 'C']
  2. ['A', 'B', 'C'] 这 3 个项目取 3 个的第一个排列是('A', 'B', 'C')。还有五个其他排列 - 同三个字符以所有可能的顺序排列。
  3. 由于permutations() 函数始终返回一个迭代器,因此调试排列的一种简单方法是将该迭代器传递给内置的list() 函数,以便立即查看所有排列。

itertools 模块中的其他有趣内容

>>> import itertools
>>> list(itertools.product('ABC', '123')) 
[('A', '1'), ('A', '2'), ('A', '3'), 
 ('B', '1'), ('B', '2'), ('B', '3'), 
 ('C', '1'), ('C', '2'), ('C', '3')]
>>> list(itertools.combinations('ABC', 2)) 
[('A', 'B'), ('A', 'C'), ('B', 'C')]
  1. itertools.product() 函数返回一个包含两个序列的笛卡尔积的迭代器。
  2. itertools.combinations() 函数返回一个迭代器,包含给定序列的给定长度的所有可能组合。这就像itertools.permutations() 函数一样,不同之处在于组合不包括按不同顺序重复其他项目的项目。因此,itertools.permutations('ABC', 2) 将返回('A', 'B')('B', 'A')(以及其他),但itertools.combinations('ABC', 2) 不会返回('B', 'A'),因为它按不同顺序重复了('A', 'B')

[下载 favorite-people.txt]

>>> names = list(open('examples/favorite-people.txt', encoding='utf-8')) 
>>> names
['Dora\n', 'Ethan\n', 'Wesley\n', 'John\n', 'Anne\n',
'Mike\n', 'Chris\n', 'Sarah\n', 'Alex\n', 'Lizzie\n']
>>> names = [name.rstrip() for name in names] 
>>> names
['Dora', 'Ethan', 'Wesley', 'John', 'Anne',
'Mike', 'Chris', 'Sarah', 'Alex', 'Lizzie']
>>> names = sorted(names) 
>>> names
['Alex', 'Anne', 'Chris', 'Dora', 'Ethan',
'John', 'Lizzie', 'Mike', 'Sarah', 'Wesley']
>>> names = sorted(names, key=len) 
>>> names
['Alex', 'Anne', 'Dora', 'John', 'Mike',
'Chris', 'Ethan', 'Sarah', 'Lizzie', 'Wesley']
  1. 此习惯用法返回文本文件中的行列表。
  2. 不幸的是(对于此示例),list(open(filename)) 习惯用法还包括每行末尾的回车符。此列表推导使用rstrip() 字符串方法从每行中删除尾随空格。(字符串还有lstrip() 方法来删除前导空格,以及strip() 方法,它删除两者。)
  3. sorted() 函数接受一个列表并返回已排序的列表。默认情况下,它按字母顺序排序。
  4. sorted() 函数也可以接受一个函数作为key 参数,并按该键排序。在本例中,排序函数是len(),因此它按len(每个项目) 排序。较短的名称排在前面,然后是较长的名称,最后是最长的名称。

这与itertools 模块有什么关系?我很高兴你问。

…continuing from the previous interactive shell…
>>> import itertools
>>> groups = itertools.groupby(names, len) 
>>> groups
<itertools.groupby object at 0x00BB20C0>
>>> list(groups)
[(4, <itertools._grouper object at 0x00BA8BF0>),
 (5, <itertools._grouper object at 0x00BB4050>),
 (6, <itertools._grouper object at 0x00BB4030>)]
>>> groups = itertools.groupby(names, len) 
>>> for name_length, name_iter in groups: 
...     print('Names with {0:d} letters:'.format(name_length))
...     for name in name_iter:
...         print(name)
... 
Names with 4 letters:
Alex
Anne
Dora
John
Mike
Names with 5 letters:
Chris
Ethan
Sarah
Names with 6 letters:
Lizzie
Wesley
  1. itertools.groupby() 函数接受一个序列和一个键函数,并返回一个迭代器,该迭代器生成对。每对包含key_function(每个项目) 的结果以及另一个迭代器,该迭代器包含所有共享该键结果的项目。
  2. 调用list() 函数“耗尽”了迭代器,即您已经生成了迭代器中的每个项目来创建列表。迭代器上没有“重置”按钮;一旦耗尽它,您就无法重新开始。如果您想再次循环遍历它(比如,在即将到来的for 循环中),您需要再次调用itertools.groupby() 来创建一个新的迭代器。
  3. 在本例中,给定一个已经按长度排序的名称列表,itertools.groupby(names, len) 将把所有 4 个字母的名称放到一个迭代器中,所有 5 个字母的名称放到另一个迭代器中,等等。groupby() 函数是完全通用的;它可以按第一个字母对字符串进行分组,按因子数量对数字进行分组,或者任何其他你能想到的键函数。

itertools.groupby() 函数仅在输入序列已按分组函数排序时才有效。在上面的示例中,您按 len() 函数对名称列表进行分组。之所以有效,是因为输入列表已按长度排序。

你仔细看吗?

>>> list(range(0, 3))
[0, 1, 2]
>>> list(range(10, 13))
[10, 11, 12]
>>> list(itertools.chain(range(0, 3), range(10, 13))) 
[0, 1, 2, 10, 11, 12]
>>> list(zip(range(0, 3), range(10, 13))) 
[(0, 10), (1, 11), (2, 12)]
>>> list(zip(range(0, 3), range(10, 14))) 
[(0, 10), (1, 11), (2, 12)]
>>> list(itertools.zip_longest(range(0, 3), range(10, 14))) 
[(0, 10), (1, 11), (2, 12), (None, 13)]
  1. itertools.chain() 函数接受两个迭代器,并返回一个包含第一个迭代器中所有项目、接着是第二个迭代器中所有项目的迭代器。(实际上,它可以接受任意数量的迭代器,并按它们传递给函数的顺序将它们全部链接在一起。)
  2. zip() 函数做了一些平淡无奇的事情,但事实证明非常有用:它接受任意数量的序列,并返回一个迭代器,该迭代器返回每个序列中第一个项目的元组,然后是第二个项目,然后是第三个,依此类推。
  3. zip() 函数在最短序列结束时停止。range(10, 14) 有 4 个项目 (10、11、12 和 13),但 range(0, 3) 只有 3 个,因此 zip() 函数返回一个包含 3 个项目的迭代器。
  4. 另一方面,itertools.zip_longest() 函数在最长序列结束时停止,为短序列结束后的项目插入 None 值。

好吧,这一切都很有趣,但这与字母算术求解器有什么关系?下面是答案。

>>> characters = ('S', 'M', 'E', 'D', 'O', 'N', 'R', 'Y')
>>> guess = ('1', '2', '0', '3', '4', '5', '6', '7')
>>> tuple(zip(characters, guess)) 
(('S', '1'), ('M', '2'), ('E', '0'), ('D', '3'),
 ('O', '4'), ('N', '5'), ('R', '6'), ('Y', '7'))
>>> dict(zip(characters, guess)) 
{'E': '0', 'D': '3', 'M': '2', 'O': '4',
 'N': '5', 'S': '1', 'R': '6', 'Y': '7'}
  1. 给定一个字母列表和一个数字列表(这里每个数字都用 1 个字符的字符串表示),zip 函数将按顺序创建字母和数字的配对。
  2. 为什么这很酷?因为这种数据结构恰好是传递给 dict() 函数以创建字典的正确结构,该字典使用字母作为键,并将关联的数字作为值。(当然,这不是唯一的方法。您可以使用 字典推导式 直接创建字典。)虽然字典的打印表示形式以不同的顺序列出配对(字典本身没有“顺序”),但您可以看到每个字母都与数字相关联,基于原始 charactersguess 序列的排序。

字母算术求解器使用此技术为每个可能的解创建将谜题中的字母映射到解中的数字的字典。

characters = tuple(ord(c) for c in sorted_characters)
digits = tuple(ord(c) for c in '0123456789')
...
for guess in itertools.permutations(digits, len(characters)):
    ...
    equation = puzzle.translate(dict(zip(characters, guess)))

但这个 translate() 方法是什么?啊,你现在要进入真正有趣的部分了。

一种新的字符串操作方式

Python 字符串有很多方法。您在 字符串章节 中了解了其中一些方法:lower()count()format()。现在我想向您介绍一种强大但鲜为人知的字符串操作技术:translate() 方法。

>>> translation_table = {ord('A'): ord('O')} 
>>> translation_table 
{65: 79}
>>> 'MARK'.translate(translation_table) 
'MORK'
  1. 字符串转换从转换表开始,转换表只是一个将一个字符映射到另一个字符的字典。实际上,“字符”是不正确的——转换表实际上将一个字节映射到另一个字节。
  2. 请记住,在 Python 3 中,字节是整数。ord() 函数返回字符的 ASCII 值,对于 A–Z,它始终是 65 到 90 之间的字节。
  3. 字符串上的 translate() 方法接受一个转换表,并将字符串通过它运行。也就是说,它将转换表中所有键的出现替换为相应的 value。在本例中,“转换”MARKMORK

这与解决字母算术谜题有什么关系?事实证明,一切都有关系。

>>> characters = tuple(ord(c) for c in 'SMEDONRY') 
>>> characters
(83, 77, 69, 68, 79, 78, 82, 89)
>>> guess = tuple(ord(c) for c in '91570682') 
>>> guess
(57, 49, 53, 55, 48, 54, 56, 50)
>>> translation_table = dict(zip(characters, guess)) 
>>> translation_table
{68: 55, 69: 53, 77: 49, 78: 54, 79: 48, 82: 56, 83: 57, 89: 50}
>>> 'SEND + MORE == MONEY'.translate(translation_table) 
'9567 + 1085 == 10652'
  1. 使用 生成器表达式,我们可以快速计算字符串中每个字符的字节值。charactersalphametics.solve() 函数中 sorted_characters 值的示例。
  2. 使用另一个生成器表达式,我们可以快速计算此字符串中每个数字的字节值。结果 guessitertools.permutations() 函数返回的形式,在 alphametics.solve() 函数中。
  3. 此转换表是通过 charactersguess 压缩在一起 并从生成的配对序列构建字典生成的。这正是 alphametics.solve() 函数在 for 循环中所做的操作。
  4. 最后,我们将此转换表传递给原始谜题字符串的 translate() 方法。这将字符串中的每个字母转换为相应的数字(基于 characters 中的字母和 guess 中的数字)。结果是一个有效的 Python 表达式,作为字符串。

这很令人印象深刻。但是,您如何处理一个恰好是有效 Python 表达式的字符串呢?

将任意字符串评估为 Python 表达式

这是谜题的最后一块(或者更确切地说,是谜题求解器的最后一块)。在所有这些花哨的字符串操作之后,我们剩下一个类似 '9567 + 1085 == 10652' 的字符串。但这是一个字符串,字符串有什么用?输入 eval(),通用的 Python 评估工具。

>>> eval('1 + 1 == 2')
True
>>> eval('1 + 1 == 3')
False
>>> eval('9567 + 1085 == 10652')
True

但是等等,还有更多!eval() 函数不限于布尔表达式。它可以处理任何 Python 表达式,并返回任何数据类型。

>>> eval('"A" + "B"')
'AB'
>>> eval('"MARK".translate({65: 79})')
'MORK'
>>> eval('"AAAAA".count("A")')
5
>>> eval('["*"] * 5')
['*', '*', '*', '*', '*']

但是等等,这还不是全部!

>>> x = 5
>>> eval("x * 5") 
25
>>> eval("pow(x, 2)") 
25
>>> import math
>>> eval("math.sqrt(x)") 
2.2360679774997898
  1. eval() 接受的表达式可以引用在 eval() 外部定义的全局变量。如果在函数中调用,它也可以引用局部变量。
  2. 以及函数。
  3. 以及模块。

嘿,等等……

>>> import subprocess
>>> eval("subprocess.getoutput('ls ~')") 
'Desktop         Library         Pictures \
 Documents       Movies          Public   \
 Music           Sites'
>>> eval("subprocess.getoutput('rm /some/random/file')") 
  1. subprocess 模块允许您运行任意 shell 命令并将结果作为 Python 字符串获取。
  2. 任意 shell 命令可能产生永久性后果。

更糟糕的是,还有一个全局 __import__() 函数,它以字符串形式接受模块名称,导入该模块,并返回对该模块的引用。结合 eval() 的强大功能,您可以构建一个表达式来删除所有文件

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')") 
  1. 现在想象一下 'rm -rf ~' 的输出。实际上,不会有任何输出,但您也不会剩下任何文件了。

eval() 是邪恶的

好吧,邪恶的部分是评估来自不可信来源的任意表达式。您只应将 eval() 用于受信任的输入。当然,诀窍在于弄清楚什么是“受信任的”。但有一件事我确定:您不应将此字母算术求解器放在互联网上作为有趣的网络服务。不要犯这种错误,认为“Gosh,该函数在获取要评估的字符串之前进行了大量的字符串操作;我无法想象有人如何利用它。”有人将会找到方法偷偷摸摸地将恶意可执行代码偷偷摸摸地绕过所有这些字符串操作(更奇怪的事情都发生过),然后您就可以和您的服务器说再见了。

但肯定有一些方法可以安全地评估表达式?将 eval() 放在一个沙盒中,它无法访问或破坏外部世界?嗯,既是也不是。

>>> x = 5
>>> eval("x * 5", {}, {}) 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'x' is not defined
>>> eval("x * 5", {"x": x}, {}) 
25
>>> import math
>>> eval("math.sqrt(x)", {"x": x}, {}) 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'math' is not defined
  1. 传递给 eval() 函数的第二个和第三个参数充当评估表达式的全局和局部命名空间。在本例中,它们都是空的,这意味着当评估字符串 "x * 5" 时,全局或局部命名空间中都没有对 x 的引用,因此 eval() 会抛出异常。
  2. 您可以通过单独列出它们来有选择地将特定值包含在全局命名空间中。然后这些变量(也只有这些变量)将在评估过程中可用。
  3. 即使您刚刚导入了 math 模块,但您没有将其包含在传递给 eval() 函数的命名空间中,因此评估失败。

天哪,太容易了。现在让我创建一个字母算术网络服务吧!

>>> eval("pow(5, 2)", {}, {}) 
25
>>> eval("__import__('math').sqrt(5)", {}, {}) 
2.2360679774997898
  1. 即使您为全局和局部命名空间传递了空字典,所有 Python 的内置函数在评估过程中仍然可用。因此 pow(5, 2) 可以正常工作,因为 52 是字面量,而 pow() 是内置函数。
  2. 不幸的是(如果您不明白为什么不幸,请继续阅读),__import__() 函数也是内置函数,因此它也可以正常工作。

是的,这意味着您仍然可以做一些不好的事情,即使您在调用 eval() 时明确将全局和局部命名空间设置为空字典。

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')", {}, {})

糟糕。我很庆幸没有创建字母算术网络服务。有没有任何方法可以安全地使用 eval()?嗯,既是也不是。

>>> eval("__import__('math').sqrt(5)",
...  {"__builtins__":None}, {}) 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
>>> eval("__import__('subprocess').getoutput('rm -rf /')",
...  {"__builtins__":None}, {}) 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
  1. 要安全地评估不受信任的表达式,您需要定义一个全局命名空间字典,该字典将 "__builtins__" 映射到 None,即 Python 的空值。在内部,“内置”函数包含在一个名为 "__builtins__" 的伪模块中。除非您明确地覆盖它,否则此伪模块(即内置函数集)将对评估的表达式可用。
  2. 确保您覆盖了 __builtins__。不是 __builtin____built-ins__ 或其他一些可以正常工作但会让您面临灾难性风险的变体。

所以 eval() 现在安全了吗?嗯,既是也不是。

>>> eval("2 ** 2147483647",
...  {"__builtins__":None}, {}) 
  1. 即使无法访问 __builtins__,您仍然可以发起拒绝服务攻击。例如,尝试将 2 提高到 2147483647th 次方会将您的服务器的 CPU 利用率提高到 100%,并且会持续相当长的时间。(如果您在交互式 shell 中尝试此操作,请按几下 Ctrl-C 以退出。)从技术上讲,此表达式最终会返回一个值,但在此期间,您的服务器将无所事事。

最终,可以安全地评估不受信任的 Python 表达式,对于“安全”的某种定义,它在现实生活中并没有太大用处。如果您只是玩玩,或者只传递受信任的输入,那就可以了。但是,任何其他情况都是自找麻烦。

将它们全部组合在一起

总结一下:该程序通过暴力破解(即对所有可能的解进行穷举搜索)来解决字母算术谜题。为此,它…

  1. 使用 re.findall() 函数找到谜题中的所有字母
  2. 使用集合和 set() 函数找到谜题中所有唯一的字母
  3. 使用 assert 语句检查是否有超过 10 个唯一的字母(这意味着谜题肯定无法解决)
  4. 使用生成器对象将字母转换为其 ASCII 等效项
  5. 使用 itertools.permutations() 函数计算所有可能的解
  6. 使用 translate() 字符串方法将每个可能的解转换为 Python 表达式
  7. 使用 eval() 函数通过评估 Python 表达式来测试每个可能的解
  8. 返回第一个评估为 True 的解

…只需 14 行代码。

进一步阅读

非常感谢 Raymond Hettinger 同意重新授权他的代码,以便我可以将其移植到 Python 3 并将其用作本章的基础。

© 2001–11 Mark Pilgrim