想理解Python的列表解析吗?Think in Excel or SQL.,pythonthink,未经许可,禁止转载!英文


本文由 编橙之家 - Sam Lin 翻译,艾凌风 校稿。未经许可,禁止转载!
英文出处:Reuven Lerner。欢迎加入翻译组。

推导式是 Python 中最有用的设计之一。它融合了老的、可靠的“map”和“filter” 函数到一段紧凑的代码,它语法优雅,允许我们在小段代码中表达复杂的想法。推导式是 Python 高手工具箱里面一个最重要的工具。

然而,我发现很多 Python 程序员,包括一些有经验的开发者,无法完全适应推导式。这有两个原因:第一,对于什么时候使用推导式和它们解决哪种问题并不明显。另外一个同样重要的原因是,它的语法很难被人记住和理解。

我已经开始在我的 Python 课程 中使用关于推导式的新的解释和介绍,并且发现这有助于使低年级学生的学习曲线变得不那么陡峭。在这篇文章中,我将公开我的讲解内容,希望有助于 Python 开发者理解什么时候、在哪里和如何使用推导式。

举个简单的例子:我想输入一个含有 5 个整数的列表,并且得到含有这 5 个数的平方的列表。如果将这个问题给一个初级(甚至是中级)Python 程序员,答案可能会类似这样:

Python
numbers = range(5)
output = [ ]
for number in numbers:
    output.append(number * number)
print(output)

现在的问题是,这种方法的确奏效。(在我的课堂中,我经常会使用这个短语:“很不幸,这种方法奏效。”)通常,当我讨论推导式时,我会讨论函数编程,不可变数据结构的理念,以及我们不愿意改变数据的这一理念。还有在 map–reduce 方面思考的好处。

让我们忘记上面的东西,并且问你一个更加简单的问题:如果你将这个问题给你的会计师,他们会如何解决该问题呢?

几乎可以肯定,一个会计师会打开 Excel,并且将数字放到同一列:

Python
A
-
0
1
2
3
4

假设上面的数字位于电子表格的 A 列。Excel 使用者会这样做,即告诉 Excel B 列应该是 A*A 的计算结果。问题就得到解决了:

Python
A  B
-  -
0  0
1  1
2  4
3  9
4  16

你可以说在这里,不同点是 Excel 有 GUI,而 Python 没有。但是这不是关键点。真正的差异是我们的会计师告诉 Excel 如何将第一列转化成第二列,Python 开发者则编写程序来描述如何执行这个转化。

我们也可以用不同的方式思考这个问题:会计师使用一种并行的方式,将一条表达式应用于一个大型数据集上,而不是串行地解决这个问题,如上面的 for 循环。Excel 使用者不关心,甚至不知道,传递给表达式的数字的顺序。重要的是对于每个数字,只会使用表达式一次,和最后的结果以正确的顺序呈现。

我们可能会取笑 Excel,并且视它的使用者为技术新手。当然,很多 Excel 使用者会否认他们拥有高级的编程能力。但是这种思维,对于 Excel 使用者是如此的基础和自然,而对于程序员是如此陌生。这很可惜,因为这种思维让我们用一种简单的方法表达大量的想法。

总结一下这种方法:

  • 把你的输入当作可迭代的数据源
  • 想一下对于数据源的每个元素,你要使用什么操作
  • 输出一个新的序列

这是传统的 “map” 函数做的事情。Python 的确有一个 “map” 函数,但是今天,我们有代表性地使用列表推导式。

更具体一点,使用我上面使用过的例子:假设我们有一个包含 5 个数字的列表,并且我们想要把那个列表变成它的平方的一个列表。那么列表推导式的语法看起来会像下面一样:

Python
[number * number for number in range(5) ]

呀。难怪大家会被这个语法吓跑。下面我们把上述语法拆分一下:

  • 首先,这样将返回一个列表。(这就是为什么它被叫做“列表推导式”。)那是因为方括号具有强制性,并且会告诉 Python 创建哪种对象。
  • 数据源是 “range(5)”,它会返回一个列表。
  • 数据源中的每个元素会依次赋值到可迭代变量 “number” 中。
  • 我们会对数据源的每个元素都调用 “number * number” 运算。

换句话说,我们正创建一个新的列表,该列表的元素是让数据源的每个元素都应用了表达式的结果。这听起来像前面我们的会计师所做的一样,使用 Excel:我们告诉 Python 我们想要什么,和如何将源转化成结果。至于内部是如何工作?如何创建列表?我们不知道也不关心。

列表推导式会让人感到畏惧而不容易被理解,部分原因是运算的顺序似乎不常见。我发现用下面的方法来重写列表推导式会对理解有所帮助:

Python
[number * number
 for number in range(5) ]

没错——现在我将列表推导式展开成两行;第一行描述了我想调用的运算,第二行描述了数据源。如果这似乎还不熟悉,那么尝试一下把它放到你可能有经验的一些场景中:

Python
[number * number           # SELECT
 for number in range(5) ]  # FROM

虽然他们不是直接等效,但是在一个 SQL 的 SELECT 查询(SELECT 表达式和 FROM 子句的位置)和我们的列表推导式之间有相当多的相似点。一个 SQL 查询的 FROM 子句描述数据源,通常会是一个表格,但是也可以是一个视图或者一个函数调用的结果。SELECT 的初始部分通常是列的名字,但是可以包括函数调用和运算符。

一方面,SELECT-FROM 组合似乎简单到不值得一提。因为你仅需从数据源中获取一组选择好的值即可。另一方面,这样的查询建立起数据库的主干。同样,这样的功能建立起很多 Python 程序的主干,遍历一个数据结构,取出数据结构的一部分,变换那部分,然后返回一个新的列表。

一个我喜爱的例子(也是我的电子书《Practice Makes Python》中的一个练习)是获取 Unix 中使用的 /etc/passwd 文件,然后获取包含在该文件中的用户名。/etc/passwd 文件每行含有一个记录,字段用冒号隔开。这是我电脑里 /etc/passwd 文件的几行:

Python
nobody:*:-2:-2::0:0:Unprivileged User:/var/empty:/usr/bin/false
root:*:0:0::0:0:System Administrator:/var/root:/bin/sh
daemon:*:1:1::0:0:System Services:/var/root:/usr/bin/false
_uucp:*:4:4::0:0:Unix to Unix Copy Protocol:/var/spool/uucp:/usr/sbin/uucico

我们通常会将一个文件看做字节的集合,当我们阅读它的时候,我们赋予它语义意义。但是在 Python 里,我们鼓励将一个文件看成一个有序的、可迭代的文本行的集合。没错,我可以根据字节阅读一个文件,但是想要阅读文件的行是那么的平常,Python 也提供了一些方法来读取文本行。

我们知道我们可以遍历一个文件的行:

Python
for line in open('/etc/passwd'):
    print(line)

这表明了一个文件时可迭代的,即意味着它可以充当一个列表推导式的数据源。这意味着上面的代码可以重写成:

Python
[line
 for line in open('/etc/passwd')]

再次,我们的列表推导式的第一行表示我们想要应用到数据源中每个元素的表达式。在这里,表达式就是 line。如果我们想要从这些行获取每一行中的用户名,我们只需使用 string 的 “split” 方法,返回一个列表——然后从结果列表中获取索引为 0 的值。例如:

Python
[line.split(":")[0]
 for line in open('/etc/passwd')]

再次,我们可以从一个 SQL 查询的角度来思考它:

Python
SELECT username
FROM users

当然,上面的“username”是一个列名。对于我的列表推导式,一个更加等效的查询是带有“info”列的“Users”表,如下:

Python
SELECT split_part(info, ':', 1)
FROM users;

注意到在这个例子中,我使用了内置 PostgreSQL “split_part” 运算符来执行等效于 Python 中 str.split 方法的操作。

记住在我的 SQL 查询例子中,一个查询的结果总是看起来和表现得像一个表。返回的列的数量和类型依赖于在 SELECT 语句中表达式的数量和类型。但是结果的集合会有一列或多列,零行或者多行。

同样,一个列表推导式的结果总是一个列表。在列表推导式里,你可以拥有任何你想要的表达式;表达式代表列表中的一项,不是列表本身。

例如,假设我想把 /etc/passwd 里的用户名变成一个字典列表。这里不需要一个创建单个字典的字典推导式。而是需要一个列表推导式,它的表达式创建一个字典。下面是符合上述内容的一个愚蠢的列表推导式:

Python
[ {'name':line.split(":")[0]}
   for line in open('/etc/passwd')]

上述的代码是奏效的,它创建了一个字典列表。每个字典有一对键-值对。但是上面的做法似乎有点愚蠢,而我很可能想得到一个包含用户名和数字用户 ID 的字典,该 ID 处于索引 2 的位置。那么,我就可以这样写:

Python
[ {'name':line.split(":")[0], 'id':line.split(":")[2]}
for line in open('/etc/passwd')]

再次,我们可以从 Excel 的角度去思考,或者是 SQL 的角度:现在,我的查询产生了一列结果,但是每列包含一个文本字符串。我们甚至可以说查询产生两列结果,这在 SQL 的世界里是非常正常的。

请忽略在一个推导式中调用两次 str.split 的效率(或没有):当我在 Mac 中运行这段代码时,它产生一个异常,抱怨一个索引超出了范围。

原因很简单:我根据 : 分离每一行,并将分离后的结果放到一个列表中。但是如果有一行没有包含任何 : 字符,那么将返回一个单元素列表。因此我需要除去那些不符合的行。特别地,至少在我的 Mac 中,我需要删除 /etc/passwd 里面注释的行,即以‘#’字符开头的行。

在列表推导式的世界里,我会像下面这样写:

Python
[ {'name':line.split(":")[0], 'id':line.split(":")[2]}
for line in open('/etc/passwd')
if not line.startswith("#")]

与先前的 SQL 做进一步类比,在 Python 代码中添加等效的 SQL 语句注释:

Python
[ {'name':line.split(":")[0], 'id':line.split(":")[2]}    # SELECT
for line in open('/etc/passwd')                           # FROM
if not line.startswith("#")]                              # WHERE

当然,当推导式首行变得很长的时候,使用函数来代替通常是个好主意。又因为首行可以是任意合法的 Python 表达式,所以使用函数是个好主意:

Python
def get_user_info(line):
    name, passwd, id, rest = line.split(":", 3)   # max 4 fields
    return {'name':name, 'id':id}

[ get_user_info(line)             # SELECT
for line in open('/etc/passwd')   # FROM
if not line.startswith("#")]      # WHERE

因此,列表推导式会给你类似一个 SQL SELECT 查询的能力——除非你不在一个表中查询数据,而是遵守 Python 的迭代协议,该协议包括大量内置和定制的对象。

那么,你想要何时使用列表推导式?还有它是如何区别于一个 for 循环的?

无论你想何时传输数据,使用列表推导式都是合适的。也就是说,你有一个可迭代的数据源,并且你想创建一个新的列表,该列表的元素是基于数据源产生的。例如,假设我想找到在 /etc/passwd 中每个字符使用的次数。我可以像下面一样,使用 collections.Counter:

Python
from collections import Counter
counts = [Counter(line)
          for line in open('/etc/passwd')
          if not line.startswith("#")]

我们知道“counts”是一个列表,因为我使用了一个列表推导式来创建它。它是一个包含很多 Counter 对象的列表,/etc/passwd 中每行非注释行。如果我想找出在每行中哪个字符出现次数最多将会怎样?我可以修改表达式,找出 Counter 对象中最常用的字符:

Python
counts = [Counter(line).most_common(1)
          for line in open('/etc/passwd')
          if not line.startswith("#")]

我可以再扩展表达式,来获得每行使用次数最多的字符(在一个单元素列表的双元素元组里面):

Python
counts = [Counter(line).most_common(1)[0][0]
          for line in open('/etc/passwd')
          if not line.startswith("#")]

现在我可以找出每个最常用的字符出现的次数:

Python
Counter([Counter(line).most_common(1)[0][0]
          for line in open('/etc/passwd')
          if not line.startswith("#")])

在我的电脑中,答案是:

Python
Counter({':': 71, 'e': 4, 's': 1})

意思是说在 71 行非注释行中,“:”是最常用的,但是有 4 行最常用的是“e”,有 1 行是“s”。现在,我可以用一个 for 循环来完成么?当然可以——但是因为我正在处理可迭代量,和因为我在这使用适用于这种可迭代量的对象,因此在某种程度上,我可以将它们连接起来,而不需要我告诉 Python 如何做这项工作。我正在做着与会计师一样的事情,回到这篇文章的开头——我说出我想要的,让 Python 做艰苦的工作来为我处理这些事情。

那么,我应该何时使用 for 循环?差别在于你是否想要获得一个列表,和你是否想执行一条命令很多次。如果你想要构建一个列表,和如果它建立于一个已经存在的可迭代量,那么我会说列表推导式绝对是最好的选择。但是如果你想执行某样东西多次,并且不需要创建一个列表,那么使用列表推导式是一种糟糕的方法;作为替代,你应该使用一个“for”循环。

列表推导式固然比 for 循环快。但是很多时候,使用 for 循环的场景不同于使用列表推导式的。当你想要将一个可迭代的结构转化成其它事物时,你不应该使用“for”循环;那要用推导式。你也不应该使用列表推导式来执行某项工作(如,print)很多次,即使你可以通过调用一个函数来做。何时使用“for”循环与何时使用推导式的界限,经验丰富的 Python 开发者可以在脑海中清晰地描述出来,但是对于新手来说,语言上和这些思想上面都是很模糊的。

所以,总结一下:

  • 如果你想执行一条命令多次,使用“for”循环。
  • 如果你有一个可迭代量,并且想创建一个新的可迭代量,那么列表推导式是你最好的选择。
  • 构建一个列表推导式有点像在 Excel 的工作:以一组数据开始,然后创建一组新的数据。可以使用任何表达式来将一个事物映射到另一个。你不需要关心 Python 在后台如何工作;你仅仅想要获取新的数据。
  • 列表推导式可以由两部分或者三部分组成,通常,将它们分成几行会更容易理解:(1)表达式,(2)数据源,(3)可选的“if”语句。
  • 这三行类似于 SQL 一个查询中的 SELECT、FROM 和 WHERE 子句。像 SELECT、FROM 和 WHERE 可以使用任意表达式一样,Python 的列表推导式也可以使用任意表达式。虽然 SELECT 总是返回一组表状的结果,而列表推导式总是返回一个列表。
  • 你想要创建一个集合,或者可能是一个字典,而不是一个列表吗?那么你可以使用集合推导式或者一个字典推导式。这个思想与我讲过的列表推导式一样,除了你的结果会变成一个集合或者一个字典。

你觉得使用列表推导式很难吗?如果是,使用它们的困难是什么?上面的内容对你使用列表推导式和记住它们的语法有帮助么?我很渴望听到你的回应,这样我就可以在未来改善这些解释了。

打赏支持我翻译更多好文章,谢谢!

打赏译者

打赏支持我翻译更多好文章,谢谢!

评论关闭