列表推导式:简洁高效更具 Python 风格的列表创建方法

我们在《Python 中的列表和元组》中已经详细介绍了列表(list)的基本特性和使用方法,本文将着重介绍一种 Python 中用于创建 list 的简洁高效的语法形式:列表推导式。Python 之所以广受欢迎,有一个重要的原因是 Python 代码风格优雅,容易编写,并且几乎和普通英语一样易读(这当然是针对大多数母语为英语的开发者)。列表推导式就是 Python 中体现这一因素的语言特性。

列表推导式允许你使用一行代码就可以实现用于创建 list 的复杂逻辑。看起来是件美妙的事情,然而,就像“劲酒虽好,可不要贪杯哦”(呃,这不是广告哦),过度或不当地使用列表推导式,反而会降低代码的可读性,甚至导致程序效率的降低。

话不多说,我们现在开始步入正题。


【Python 中如何创建 list】

Python 中有几种不同的方法可以创建 list。为更好地理解使用列表推导式时的权衡取舍,我们先看一下如何使用这些方法创建 list。

1,使用 for 循环

for 循环是 Python 中最常用的循环类型。我们可以使用 for 循环来创建一个 list,只需三步:

  • 初始化一个空的 list 对象
  • 循环遍历一个包含元素的 iterable 或 range
  • 将各元素追加到 list 尾部

比如,我们可以使用以下三行代码创建一个包含 10 个平方数的 list。

>>> squares = []
>>> for i in range(10): 
...     squares.append(i * i)
... 
>>> squares 
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

2,使用 map() 对象

map() 提供了一个基于函数式编程的用于创建 list 的可选方法。你向其中传递一个函数 f 和一个 iterable,map() 将创建一个对象,该对象包含了 iterable 中的每个元素经由函数 f 计算后的全部输出结果。

这里给出一个使用 map() 的例子。假设我们有一批交易业务,办理这些业务需要缴纳一定的税额。我们需要计算这些业务的最终费用(业务费用 + 税费)。

>>> txns = [1.09, 23.56, 57.84, 4.56, 6.78]
>>> TAX_RATE = .08
>>> def get_price_with_tax(txn):
...     return txn * (1 + TAX_RATE)
...
>>> final_price = map(get_price_with_tax, txns)
>>> final_price
<map object at 0x0000021AF4B421C8>
>>> list(final_price)
[1.1772000000000002, 25.4448, 62.467200000000005, 4.9248, 7.322400000000001]

我们在 txns 中存储业务费用,并定义了一个计算单项业务最终费用的函数 get_price_with_tax,将这个函数和 txns 作为参数传递给 map() 函数。map() 的计算结果 final_price 是一个 map 对象,我们可以使用 list() 将其转换为 list 对象。


3,使用列表推导式

现在轮到列表推导式了。我们先用它来重写上边 for 循环那个例子:
>>> squares = [i*i for i in range(10)]
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
不需要单独定义一个空的 list,也无需逐个追加元素到 list 尾部,你只需要在定义 list 对象的同时使用下边的语法格式就可以将元素添加到 list 中:
new_list = [expression for member in iterable]
是不是少写了很多代码?提升了开发效率?
每个列表推导式包含三个元素:
  • expressionexpression 的结果就是新 list 中的元素。expression 可以是一个函数调用,或者其他任何合法的可返回一个值的表达式。
  • membermember 代表 iterable 中的每个对象或值。
  • iterableiterable 可以是一个 list、集合(set)、序列(sequence)、生成器(generator)或其他任何每次访问就返回一个元素的对象。
expression 可以很灵活,这使得列表推导式可以应用在许多使用 map() 的场合。我们重写一下上边 map() 那个例子:
>>> txns = [1.09, 23.56, 57.84, 4.56, 6.78]
>>> TAX_RATE = .08
>>> def get_price_with_tax(txn):
...     return txn * (1 + TAX_RATE)
...
>>> final_prices = [get_price_with_tax(i) for i in txns]
>>> final_prices
[1.1772000000000002, 25.4448, 62.467200000000005, 4.9248, 7.322400000000001]
使用列表推导式得到的结果是一个 list 对象,而 map() 返回的是一个 map 对象。

4,使用列表推导式的好处
使用列表推导式的一个主要好处是:它是一个可用于许多不同场景的工具。
除了用于创建 list,列表推导式还可用于映射(mapping)和过滤(filtering)计算场景。有了列表推导式,我们不需要为每个场景使用不同的计算方法。
这也是列表推导式为什么更具 Python 风格的原因,Python 拥抱简单而强大的可用于多种场景的工具。
还有个额外的好处。相比于 map(),使用列表推导式免去了记忆函数参数顺序的烦恼。
和循环比起来,列表推导式更具声明性,这意味着它们更易于阅读和理解。
从上边的对比中,我们已经看到了使用 for 循环创建 list 的“繁琐”:创建空 list,遍历源对象,追加新元素。列表推导式让我们专注于结果 list 中的内容,至于这个 list 如何创建出来,交由 Python 解释器来处理好了。

【功能丰富的列表推导式】

理解列表推导式的功能范围有助于理解其带来的全部价值。我们还将了解到 Python 3.8 中列表推导式的一些新变化。

1,使用条件逻辑

我们已经知道了使用列表推导式的基本语法:

new_list = [expression for member in iterable]

这个语法是正确但不完整的。更完整的语法格式支持可选的条件语句(conditional),常见的形式为将条件逻辑追加到上边格式的尾部。

new_list = [expression for member in iterable (if conditional)]

条件语句允许列表推导式选择性地保留符合要求的值,过滤掉那些不想要的值,这在很多时候是有用的。这和 filter() 提供的功能类似。

看个例子。

>>> sentence = 'the rocket came back from mars'
>>> vowels = [i for i in sentence if i in 'aeiou']
>>> vowels
['e', 'o', 'e', 'a', 'e', 'a', 'o', 'a']

这个示例用于从 sentence 中提取所有的元音字符。

条件语句可用来测试任何合法的逻辑表达式。你甚至可以将更复杂的过滤规则封装到一个函数中。

>>> sentence = 'The rocket, who was named Ted, came back from Mars because he missed his friends.'
>>> def is_consonant(letter):
...     vowels = 'aeiou'
...     return letter.isalpha() and letter.lower() not in vowels
...
>>> consonants = [i for i in sentence if is_consonant(i)]
>>> consonants
['T', 'h', 'r', 'c', 'k', 't', 'w', 'h', 'w', 's', 'n', 'm', 'd', 'T', 'd', 'c', 'm', 'b', 'c', 'k', 'f', 'r', 'm', 'M', 'r', 's', 'b', 'c', 's', 'h', 'm', 's', 's', 'd', 'h', 's', 'f', 'r', 'n', 'd', 's']

这个稍复杂些的例子用于提取 sentence 中的小写辅音字母。我们将过滤函数 is_consonant() 放到了列表推导式的条件语句中,并为其传递了参数 i。

条件语句不仅能过滤掉 list 中的非法元素,还支持对这些元素进行修改,使其合乎要求。这时,我们需要将条件语句前置到列表推导式的 expression 之后。

new_list = [expression (if conditional) for member in iterable]

此语法格式允许我们从条件逻辑的多个分支中提取数据到 list 中。

比如,有一组数据,我们需要将其中的正数翻倍,将其中的负数取绝对值。

>>> original_datas = [1.25, -9.45, 10.22, 3.78, -5.92, 1.16]
>>> prices = [i*2 if i>0 else -i for i in original_datas]
>>> prices
[2.5, 9.45, 20.44, 7.56, 5.92, 2.32]

请注意:我们在语法格式中标注的 (if conditional) 指的是一个合法的条件判断语句,不仅仅只包含一条 if 语句。


2,使用 set 和 dict 推导式

列表推导式是 Python 中常见的语法工具,与其相似,我们还可以创建集合(set)和字典(dict)推导式。
set 推导式几乎和列表推导式一样,区别在于:
  • set 推导式输出结果中不含重复元素,且不保证元素的输出顺序
  • set 推导式使用大括号 ({}) 来定义
>>> quote = "life, uh, finds a way"
>>> unique_vowels = {i for i in quote if i in 'aeiou'}
>>> unique_vowels
{'e', 'u', 'a', 'i'}
此示例用于提取 quote 中的元音字符。每个字符只保留一份,且顺序和在原 list 中出现的顺序不一致。
dict 推导式 和 set 推导式类似,并且多一条要求:你需要定义元素的 key。
>>> squares = {i: i * i for i in range(10)}
>>> squares
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}

3,使用 海象(walrus)运算符(:=)
赋值表达式是 Python 3.8 引入的一个新特性,允许计算表达式的同时将其结果赋值给一个变量。它使用海象运算符(:=)来完成这一操作。
比如:
while True:
    data = f.read(1024)
    if not data:
        break
    use(data)
这个代码片段可以用赋值表达式重写为:
while data := f.read(1024):
    use(data)
赋值表达式的一个用途就是扩大了列表推导式的使用范围,某些之前无法使用列表推导式实现的逻辑现在可以了。
比如,我们需要通过调用一个接口来获取气温,并仅输出高于 100 华氏度的气温。
使用传统的列表推导式无法实现这个目的,因为 expression for member in iterable (if conditional) 这种形式无法将数据赋值给一个 expression 可以访问的变量。
赋值表达式可以解决这个问题。下边的代码片段展示了如何实现这个需求,其中 get_weather_data() 用于产生模拟的温度数据。
>>> import random
>>> def get_weather_data():
...     return random.randrange(90, 110)
>>> hot_temps = [temp for _ in range(20) if (temp := get_weather_data()) >= 100]
>>> hot_temps
[107, 102, 109, 104, 107, 109, 108, 101, 104]
赋值表达式并不会经常应用在列表推导式中,但若恰好需要,它会是一个不错的选择。


【哪些场景不适合使用列表推导式】

列表推导式很有用,可以帮助我们编写易于阅读和调试的优雅代码,但它们不是所有情况下的正确选择。它们可能会使代码运行得更慢或占用更多内存。如果你的代码性能较差或较难理解,那么最好选择一个替代方案。

1,谨慎使用嵌套的推导式

推导式可以嵌套使用,从而创建一个混合着 list、dict 或 set 的集合。

比如,某个气候实验室正在跟踪 5 个城市六月份首周的最高气温。存储气温数据的数据结构可以是一个嵌套了列表推导式的字典推导式。

>>> cities = ['Austin', 'Tacoma', 'Topeka', 'Sacramento', 'Charlotte']
>>> temps = {city: [0 for _ in range(7)] for city in cities}
>>> temps
{    'Austin': [0, 0, 0, 0, 0, 0, 0],    'Tacoma': [0, 0, 0, 0, 0, 0, 0],    'Topeka': [0, 0, 0, 0, 0, 0, 0],    'Sacramento': [0, 0, 0, 0, 0, 0, 0],    'Charlotte': [0, 0, 0, 0, 0, 0, 0]}

temps 是一个由字典推导式生成的外层数据集合,字典推导式的 expression 部分为一个 key-value 键值对,其中的 value 部分是一个列表推导式。

嵌套的 list 也是一种创建矩阵的常见方法。比如下边这段代码创建了一个 6 行 5 列的矩阵:

>>> matrix = [[i for i in range(5)] for _ in range(6)]
>>> matrix
[    [0, 1, 2, 3, 4],    [0, 1, 2, 3, 4],    [0, 1, 2, 3, 4],    [0, 1, 2, 3, 4],    [0, 1, 2, 3, 4],    [0, 1, 2, 3, 4]]

外层的列表推导式用于生成矩阵的行,内层的列表推导式用于生成矩阵的列。

上边这两个例子中,嵌套推导式的用途很简单。而有些场景下使用嵌套的推导式会使你的代码看起来非常令人困惑。

比如,下边的代码用于扁平化一个矩阵:

>>> matrix = [
...     [0, 0, 0],
...     [1, 1, 1],
...     [2, 2, 2],
... ]
>>> flat = [num for row in matrix for num in row]
>>> flat
[0, 0, 0, 1, 1, 1, 2, 2, 2]

这段代码看起来很简单,但理解起来有些吃力。

相反,如果使用 for 循环来扁平化一个矩阵,那代码会直接明了得多。

>>> matrix = [
...     [0, 0, 0],
...     [1, 1, 1],
...     [2, 2, 2],
... ]
>>> flat = []
>>> for row in matrix:
...     for num in row:
...         flat.append(num)
...
>>> flat
[0, 0, 0, 1, 1, 1, 2, 2, 2]

外层 for 循环读取每一行,内层 for 循环读取每行中的每个元素。

单行嵌套的列表推导式固然更具 Python 风格,但比起风格更重要的是,你应该编写可以让其他人容易理解和修改的代码。你需要在编码风格和代码可读性方面做好平衡。


2,对于大数据集,请使用生成器

Python 中的列表推导式会将整个输出结果加载到内存中。对于小的或中等规模的 list,这没多大问题。如果你想计算前 1000 个数的平方和,列表推导式可以轻松胜任。

>>> sum([i * i for i in range(1000)])
332833500

但假如你想计算 10 亿个整数的平方和呢?如果你尝试在你的计算机上以上边的方式来执行这个运算,你可能会发现你的电脑变得没反应了。这是因为,Python 正在尝试创建一个包含 10 亿个整数的 list,而这可能耗尽你电脑的内存。

当 list 的大小成为瓶颈时,我们通常使用生成器来替代列表推导式。生成器不会在内存中创建一个很大块的数据结构,它会返回一个 iterable。你可以使用代码从这个 iterable 中逐次取出下一个值,不限次数或者直到取完全部数据,而每次只需要存储一个值即可。

如果你打算使用生成器计算前 10 亿个整数的平方和,你的程序需要花一段时间来完成这个计算,但它不会导致你的电脑僵死。

下边是使用生成器的示例代码:

>>> sum(i * i for i in range(1000000000))
333333332833333333500000000

从代码中可以看出这是个生成器,因为表达式使用的是圆括号,而非方括号或大括号。

上边这段代码仍需要做很多工作,但它执行的是惰性操作。由于是惰性求值,生成器只在被显式请求时才会计算这些平方值。当生成器返回一个值(比如,567*567),它会将此值加到正在运行的 sum 上,然后丢弃这个值,接着生成下一个值(568*568)。当 sum 函数向生成器请求下一个值时,这个过程重新开始。而此过程仅使用很少的内存。

map() 同样执行惰性操作,这意味着你可以在此场景中使用 map() 而无需担心内存问题。

>>> sum(map(lambda i: i*i, range(1000000000)))
333333332833333333500000000

你可以自行决定使用生成器还是 map()。


3,性能分析方法

我们对比了 for 循环、map()、列表推导式、生成器等几种创建 list 的方式,那么哪种方式更快呢?应该使用列表推导式还是其他可选的方法?
没有一种方法是放之四海而皆准的。问问自己性能是否对你而言至关重要,如果不是,最好的选择通常是那个能让代码最清晰的方法。
如果你处在一个对性能要求很高的场景中,最好对各种方法做下性能分析,让数据说话。
timeit 是一个有用的库,可用来测量执行代码片段所花费的时间。我们可以使用 timeit 来对比 map()、for 循环和列表推导式的运行时间。
>>> import random
>>> import timeit
>>> TAX_RATE = .08
>>> txns = [random.randrange(100) for _ in range(100000)]
>>> def get_price(txn):
...     return txn * (1 + TAX_RATE)
...
>>> def get_prices_with_map():
...     return list(map(get_price, txns))
...
>>> def get_prices_with_comprehension():
...     return [get_price(txn) for txn in txns]
...
>>> def get_prices_with_loop():
...     prices = []
...     for txn in txns:
...         prices.append(get_price(txn))
...     return prices
...
>>> timeit.timeit(get_prices_with_map, number=100)
2.0554370979998566
>>> timeit.timeit(get_prices_with_comprehension, number=100)
2.3982384680002724
>>> timeit.timeit(get_prices_with_loop, number=100)
3.0531821520007725
我们定义了三个函数,每个函数使用不同的方法来创建 list。然后使用 timeit 分别运行这三个函数 100 次。timeit 会返回执行 100 次函数调用耗费的总时长。
从运行结果中可以看到,for 循环和 map() 之间的差别最大。这个差别是否重要取决于你的应用要求。

【结语】

在这篇文章中,我们了解了如何使用列表推导式来实现复杂的任务而不增加代码的复杂度。

现在你应该可以:

  • 使用更具声明性的列表推导式来简化循环和 map() 调用
  • 使用条件逻辑增强列表推导式
  • 创建 set 和 dict 列表推导式
  • 在代码可读性和程序性能间做出权衡,选择合适的方法

欢迎关注本站公众号【python学与思】

python 学与思