原文:
towardsdatascience.com/complex-list-comprehensions-can-be-readable-b6c657622910
PYTHON 编程
Python 推导式允许在循环中进行强大的计算——甚至嵌套循环。图片由Önder Örtel在Unsplash提供
Python 推导式——包括列表、字典和集合推导式以及生成器表达式——构成了强大的 Python 语法糖。你可以在以下文章中了解更多信息:
与相应的for循环相比,Python 推导式有两个显著优点:它们更快,而且可以读起来更加清晰。
注意短语“可以读起来更加清晰。”确实,它们并不总是更易读。这引发了一个问题:它们在什么时候才是?
这取决于你。是开发者——也就是你——使 Python 推导式变得易于阅读。尽管同样可以说for循环也可能因为实现不当而破坏可读性。
Python 中的推导式被设计成以非常类似于阅读英文句子的方式来阅读:你可以像阅读英文句子一样从左到右(或者如果它跨越多行,则从上到下)阅读一个推导式。
许多人说你不应该使用复杂的推导式,因为它们难以阅读和理解。在这篇文章中,我们将讨论这个广为人知的原理——如果不是一个神话。不幸的是,许多人过度强调这个原理,以至于在可以使用推导式成功的情况下,却过度避免使用 Python 推导式。
你可以像阅读英文句子一样从左到右(或者如果它跨越多行,则从上到下)阅读一个推导式。
阅读推导式
我喜欢将 Python 推导式视为算法:在一个或多个循环中执行数据操作,之后可以有一个可选的if条件或多个条件。这种看待推导式的方法在很大程度上有助于理解它,即使它相当长且复杂。
记住,Python 推导式始终遵循以下模式:数据操作 → 循环(s) → 可选条件(s)。每次分析推导式时,我们都会回到这个模式。
这条规则有一个例外。当你使用 walrus 运算符时,你必须稍微打破这个算法模式;我们稍后会讨论这个问题。然而,一旦你有一些实践经验,这种变化对你来说不会构成太大的挑战,算法的可读性也不会损失太多。
我喜欢把 Python 列表推导式看作是算法:在一个或多个循环中执行数据操作,后面可以有一个可选的
if条件或多个条件。
基本用例
让我们考虑一个非常简单的例子:给定一个数字列表x,我们想要创建一个包含x平方元素的列表。我们可以使用一个普通的for循环来完成这个任务:
>>> x = [1, 2, 5, 100]
>>> x_squared = []
>>> for xi in x:
... x_squared.append(xi**2)
>>> x_squared
[1, 4, 25, 10000]
让我们从第二行开始阅读代码:
-
我们首先创建一个空的输出列表,
x_squared。这个名字本身就说出了这个列表将包含什么。 -
执行一个
for循环,每次迭代都会为xi执行,xi的值构成了x列表的后续元素。 -
在每次迭代中,我们将
xi**2追加到输出列表x_squared中。
让我们考虑相应的列表推导式:
>>> x = [1, 2, 5, 100]
>>> x_squared = [xi**2 for xi in x]
>>> x_squared
[1, 4, 25, 10000]
正如你所见,在这个简单的例子中,推导式只需要一行。我们可以这样阅读它:
- 对
x列表中的每个xi计算xi**2,并将结果收集到输出列表中。
就这样!它很清晰,很明显,很容易阅读。你注意到我们使用的模式了吗?它简单到不能再简单了:数据操作 → 循环。
我知道对于初学者来说,这并不那么清晰、明显且易于阅读。但为了学习一门编程语言并利用其优势,你需要练习。只有那时,你才能真正从语言的优势和复杂性中受益,包括像 Python 列表推导式这样的语法糖。
面对现实:如果你想使用 Python,你必须能够使用和理解推导式。
因此,即使你不认为 Python 列表推导式非常自然和易于阅读,也不要停止尝试。迟早,你会在它们中看到高级 Python 程序员看到的东西:简洁与简洁性和可读性的结合。只需继续尝试。
这是一个非常简单的用例——但事实是,这样的推导式在 Python 的实际应用中非常常见。我们可以通过添加一个if检查使其稍微复杂一些,这是一个常见的实际场景。
因此,即使你不认为 Python 列表推导式非常自然和易于阅读,也不要停止尝试。迟早,你会在它们中看到高级 Python 程序员看到的东西:简洁与简洁性和可读性的结合。只需继续尝试。
例如,我们可以只取奇数,即xi % 2 != 0的xi。让我们重构for循环来实现这一点:
>>> x = [1, 2, 5, 100]
>>> x_squared = []
>>> for xi in x:
... if xi % 2 != 0:
... x_squared.append(xi**2)
>>> x_squared
[1, 25]
所以:
-
就像之前一样,首先创建一个空的输出列表,
x_squared。 -
执行一个
for循环,每次迭代都会为xi执行,xi的值构成了x列表的后续元素。 -
在每次迭代中,我们检查
xi是否是奇数。如果是,我们将xi**2追加到输出列表x_squared中。否则,忽略xi。
让我们使用相应的列表推导式:
>>> x = [1, 2, 5, 100]
>>> x_squared = [xi**2 for xi in x if xi % 2 != 0]
>>> x_squared
[1, 25]
让我们来看看:
-
计算
xi**2对于xi,其中xi是x列表的后续值。 -
收集输出列表中
xi的奇数值的计算结果。
事实是:如果你想使用 Python,你必须能够使用并理解推导式。
在上述两种场景中,我认为列表推导式的版本更容易阅读。for循环需要阅读整个代码,不同的操作分散在其中。列表推导式是一个整洁的单行代码,它收集了典型的模式中的所有操作:数据操作 → 循环 → 条件。
这些是简单的场景。然而,众所周知,如果 Python 列表推导式过于复杂,例如通过多层嵌套(即在循环中创建循环),其可读性会下降。我们将在下一节中考虑这样的例子。
高级用例
这次,我们将使用字典推导式,因为它通常比相应的列表推导式更难编写和阅读。此外,我们将使用一个循环和两个if检查。
我们将使用以下数据:
>>> products = [
... "Widget", "Gadget", "Thingamajig",
... "Doodad", "Whatsit",
... ]
>>> prices = [19.99, 25.50, 9.99, 20.00, 22.50]
>>> discounts = [0.10, 0.25, 0.05, 0.20, 0.15]
我们希望创建一个包含产品和其价格的字典,但仅限于折扣至少为 15%且价格在 20 到 30 美元之间的产品。
让我们从常规的for循环开始:
>>> discounted_products = {}
>>> prod_price_disc = zip(products, prices, discounts)
>>> for product, price, discount in prod_price_disc:
... if discount >= 0.15 and 20 <= price <= 30:
... discounted_products[product] = price
>>> discounted_products
{'Gadget': 25.5, 'Doodad': 20.0, 'Whatsit': 22.5}
这样我们可以阅读代码:
-
首先,我们需要初始化一个输出字典,
discounted_products。它将收集符合标准的产品。 -
然后,我们创建一个
for循环来同时迭代产品名称、价格和折扣。为此,我们需要创建一个zip对象,使用zip()函数。 -
在循环内部,我们检查两个条件:每个产品的折扣至少为 15%(
discount >= 0.15)以及价格在 20 到 30 美元之间(20 <= price <= 30)。 -
如果两个条件都满足,产品及其价格将被添加到
discounted_products字典中,其中产品作为键,价格作为值。
我认为这是一个相当简单的练习,但基于for循环的代码并不成比例地简单。因此,让我们检查相应的字典推导式:
>>> discounted_products = {
... product: price
... for product, price, discount
... in zip(products, prices, discounts)
... if discount >= 0.15 and 20 <= price <= 30
... }
>>> discounted_products
{'Gadget': 25.5, 'Doodad': 20.0, 'Whatsit': 22.5}
如您所见,两种方法都得到了相同的结果。让我们阅读代码:
-
整个过程被压缩成一个单一的字典推导式。然而,除非你愿意接受非常长的单行,否则它不会在一行中完成。在我看来,这样的一行长代码甚至可能比上面显示的
for循环更难以阅读。 -
这样我们可以阅读推导式:取一个产品(作为键)及其价格(作为其值),同时迭代产品、价格和折扣,使用相应的
zip对象,通过zip()函数——但只有当两个条件满足时:每个产品的折扣至少为 15%(discount >= 0.15)且价格在 20 到 30 美元之间(20 <= price <= 30)。 -
这样的键值对被保存在输出字典
discounted_products中。
对我来说,推导式代码要简单得多,因为它将字典构建、数据操作、循环和条件检查集成到单个可读命令中。它不再是单行代码了——但仍然,生成的代码非常可读,整个过程都是使用我们之前使用的算法模式实现的:数据操作 → 循环 → 条件。请注意,两个条件被压缩成一个if条件,尽管我们可以轻松地使用两个if检查(这适用于代码的两个版本)。
换句话说,在 Python 推导式中if a if b与if a and b的意思相同。选择哪一个应该取决于可读性,因为基准测试这两个解决方案并没有提供明确的结论。
让我们考虑一个更高级的场景,其中有两个嵌套的for循环。这是我们将要使用的数据:
>>> products = ['Apples', 'Bananas', 'Cherries', 'Dates']
>>> prices = [25, 15, 22, 35]
>>> discounts = [0.20, 0.10, 0.15, 0.25]
>>> locations = ['East', 'West', 'North', 'South']
>>> available_in = [
... ['East', 'North'],
... ['West'],
... ['South', 'East'],
... ['North']
... ]
虽然我们有四个商店的位置,但产品的可用性仅限于选定位置;它们以列表形式提供在available_in列表中。例如,苹果在东部和北部商店有售,而香蕉只在西部商店有售。在取产品和它们的价格时,我们需要考虑到这一点。
这就是for循环:
>>> discounted_products = {}
>>> zipped = zip(products, prices, discounts, available_in)
>>> for product, price, discount, locations in zipped:
... for location in locations:
... cond1 = discount >= 0.15
... cond2 = 20 <= price <= 30
... cond3 = location in ['East', 'North']
... if cond1 and cond2 and cond3:
... discounted_products[(product, location)] = price
>>> discounted_products
{('Apples', 'East'): 25, ('Apples', 'North'): 25, ('Cherries', 'East'): 22}
以及相应的字典推导式:
>>> zipped = zip(products, prices, discounts, available_in)
>>> discounted_products = {
... (product, location): price
... for product, price, discount, locations in zipped
... for location in locations
... if discount >= 0.15
... and 20 <= price <= 30
... and location in ['East', 'North']
... }
>>> discounted_products
{('Apples', 'East'): 25, ('Apples', 'North'): 25, ('Cherries', 'East'): 22}
我们可以使用三个if条件来代替:
>>> zipped = zip(products, prices, discounts, available_in)
>>> discounted_products = {
... (product, location): price
... for product, price, discount, locations in zipped
... for location in locations
... if discount >= 0.15
... if 20 <= price <= 30
... if location in ['East', 'North']
... }
>>> discounted_products
{('Apples', 'East'): 25, ('Apples', 'North'): 25, ('Cherries', 'East'): 22}
这次,我不会逐行解释代码。试着你自己做。然而,我想指出代码的以下方面:
-
在常规的
for循环中,我们定义了条件变量cond1、cond2和cond3。从理论上讲,这是不必要的,但我这样做是为了可读性。否则,带有条件的行将不得不非常长,或者分成几行。 -
在字典推导式中,我们不需要这样做,因为包含三个条件的代码是可读的——尽管它被分成了三行。然而,这种分割并没有降低可读性;相反,它表明我们有三个条件来满足数据。
-
推导式遵循之前的模式:操作、循环、条件。再次强调,你可以从上到下阅读它,就像从左到右阅读一个常规句子一样。
-
两种版本中额外的复杂性都是由嵌套的
for循环for location in locations引入的。在我看来,这一行并不包含太多复杂性,至少不是嵌套for循环中的那种复杂性。
本文的目的不是断言即使在复杂情况下,Python 列表推导也可以很简单。相反,我想展示的是,在某些复杂情况下,它们甚至比相应的传统 for 循环更易读。所以,如果你决定放弃列表推导因为它很复杂,记得还有另一种选择,那就是相应的 for 循环,而且它可能比我们刚刚放弃的列表推导更难读。
当然,代码是否易读取决于你。在更高级的情况下,代码很容易变得难以阅读。让我重写最后一个例子来说明这一点:
>>> discounted_products = {
... (product, location): price
... for product, price, discount, locations in zip(products, prices, discounts, available_in)
... for location in locations
... if discount >= 0.15 and 20 <= price <= 30 and location in ['East', 'North']
... }
>>> discounted_products
{('Apples', 'East'): 25, ('Apples', 'North'): 25, ('Cherries', 'East'): 22}
这个版本比之前的版本可读性差——但我会说,它仍然相当易读,尤其是在与传统 for 循环相比时。
真的是这样——并且是一个普遍的情况——嵌套列表推导难以阅读吗?在我看来,并不是。如果你只记得强大的 zip() 函数,你可以使事情变得相当易读——当然假设你知道 zip 对象是如何工作的。
考虑以下示例,包含多个 for 循环。它真的那么难以理解吗?
让我们根据 x 值的行和 y 值的列计算一个值矩阵,就像乘法表一样:
>>> multi_table = {(x, y): x * y for x in range(10) for y in range(10)}
>>> multi_table[(5, 6)]
30
我们可以通过将其拆分为多行来提高可读性:
>>> multi_table = {
... (x, y): x * y
... for x in range(10)
... for y in range(10)
... }
>>> multi_table[(5, 6)]
30
我肯定更喜欢后者版本,它是一个命令,5 行列表推导,每行展示过程的一个单独步骤。在这里,这是
-
数据处理操作:计算
x*y并存储为元组(x, y) -
循环:
for给定的x值,for给定的y值
这只是一个简单的乘法,但你可以将第一行替换为更复杂的计算,你会发现这样的列表推导可以使生活更轻松,代码更简单、更易读。
这不是比以下相应的代码更简单吗?
>>> multi_table = {}
>>> for x in range(10):
... for y in range(10):
... multi_table[(x, y)] = x * y
>>> multi_table
记住,当你需要计算矩阵,比如这里,你需要两个循环,而不是 zip 对象。这是因为后者不计算矩阵。比较:
>>> x, y = range(3), range(3)
>>> [(xi, yi) for xi, yi in zip(x, y)] # the same as list(zip(x, y))
[(0, 0), (1, 1), (2, 2)]
>>> [(xi, yi) for xi in x for yi in y]
[(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2)]
我不想断言列表推导在所有情况下都会更易读,无论什么情况。然而,当计算行不需要长代码时,通常会是这样的。
我们绝不能忘记海象操作符,它可以使理解代码更加强大。看这里(针对 Python 3.8 及更高版本):
>>> {
... (x, y): prod
... for x in range(7)
... for y in range(7)
... if (prod := x * y) % 2 != 0
... if y > x
... }
{(1, 3): 3, (1, 5): 5, (3, 5): 15}
这段代码应该是清晰的。我们以以下方式创建字典:
-
数据处理操作:对于
x, y的元组,我们计算x和y的乘积 -
循环:对于
x值从range(7),对于y值从range(7) -
条件:如果乘积是奇数且
y > x
注意这次条件是在数据处理操作的结果上,而不是像之前那样在原始数据上。这就是为什么我们使用了海象操作符。
使用它确实会在阅读过程中引入一些复杂性。我们需要从使用 prod 的那一行跳转到它的实际定义处,然后我们需要返回到同一行。那么,我们能否在不使用 walrus 操作符的情况下做到同样的事情?
是的,我们可以:
>>> {
... (x, y): x * y
... for x in range(1, 7)
... for y in range(1, 7)
... if (x * y) % 2 != 0
... if y > x
... }
{(1, 3): 3, (1, 5): 5, (3, 5): 15}
你是否发现了这段代码的问题?这次,问题不在于可读性,而在于优化:与 walrus 版本不同,这里的乘积 (x * y) 被计算了两次!这意味着没有使用 walrus 操作符的版本将比使用它的版本要慢。
让我们看看在常规 for 循环中它是如何工作的。它不需要 walrus 操作符:
>>> multi_table = {}
>>> for x in range(7):
... for y in range(7):
... prod = x * y
... if prod % 2 != 0 and y > x:
... multi_table[(x, y)] = prod
>>> multi_table
{(1, 3): 3, (1, 5): 5, (3, 5): 15}
坦白说,以下列表推导的部分:
... (x, y): x * y
... for x in range(1, 7)
... for y in range(1, 7)
... if (x * y) % 2 != 0
... if y > x
在我的看法中,比常规 for 循环的这一部分要易于阅读得多:
>>> for x in range(7):
... for y in range(7):
... prod = x * y
... if prod % 2 != 0 and y > x:
... multi_table[(x, y)] = prod
尽管前一个版本使用了 walrus 操作符!
简化复杂列表推导的管道
再次回到一般的列表推导模式:数据操作 → 循环(s) → 可选条件。第一步,数据操作,可以是简单或复杂的。有时,它可能由多个数据操作组成。
如果我们需要在一个列表推导中组合多个操作,我们有几种解决方案可供选择,其中最重要的是:
合并操作。例如,(x + 5)**2 实际上合并了两个操作,x + 5 然后计算输出的平方。然而,让我们来看另一个例子:我们需要合并三个字符串操作:str.lower()、str.strip() 和 str.replace(' ','_')。在这种情况下,我们这样做:
>>> texts = [
... "Text 1",
... "the Second text ",
... " and FINALLY, the THIRD text! t"]
>>> output_join = [
... t.lower().strip().replace(' ', '_')
... for t in texts
... ]
这种方法只适用于像这样的简单情况,当你可以以这种方式简单地将操作合并在一起,而不需要执行额外的中间计算。
使用一个函数。相反,我们可以将所有操作移动到一个函数中,并在列表推导中调用它:
>>> def preprocess(text: str) -> str:
... return text.lower().strip().replace(' ', '_')
>>> output_func = [preprocess(t) for t in texts]
这种解决方案甚至可以在非常复杂的情况下工作,即使是在数据上执行许多需要多个计算步骤的高级操作。
事实上,这样的列表推导本身会非常简单,因为数据操作逻辑被移动到了我们用来构建列表推导的函数中(这里,preprocess())。虽然通常定义一个只使用一次的函数不是一个好主意,但它在帮助组织代码时可以非常有效。
如果你选择这种方法,请记住为函数使用一个有信息量的名称。只有这样,这样的列表推导才能具有可读性——即使函数内部实现的数据操作逻辑很复杂。
使用列表推导管道。在这种情况下,我们不会使用单个列表推导,而是调用一系列列表推导,一个接一个。这被称为列表推导管道。让我们创建一个生成器管道:
>>> step1 = (t.lower() for t in texts)
>>> step2 = (t.strip() for t in step1)
>>> output_gen_pipe= (t.replace(' ', '_') for t in step2)
以及基于列表的相应管道(一个列表推导管道):
>>> step1 = [t.lower() for t in texts]
>>> step2 = [t.strip() for t in step1]
>>> output_list_pipe = [t.replace(' ', '_') for t in step2]
注意,前一个版本产生了一个生成器,因此我们需要评估它;我们将使用一个列表来做这件事——见下文。
注意,所有四种方法都会得到完全相同的结果:
>>> (output_join
... == output_func
... == list(output_gen_pipe)
... == output_list_pipe)
True
理解管道可以构成一个强大的解决方案。然而,并不总是适用于实际的管道。
这是一个高级主题,所以我们在这里不会涉及。如果你对这个主题感兴趣并想了解更多,你将在以下文章中找到很多相关信息:
结论
如果你已经使用 Python 了一段时间,你可能已经听到了只在使用简单情况时使用理解,否则使用 for 循环的警告。如何决定特定情况是否太复杂以至于无法实现理解?
对于这个,你需要练习和经验。经验丰富的 Python 开发者在做出这样的决定之前几乎从不犹豫。他们通常知道在给定上下文中哪种选择更好。
如果你不是高级 Python 开发者,你需要获得这样的技能。如果你还没有达到那里,不要担心;通过尽可能多地实现理解来练习这项技能——即使你的直觉感觉上下文对理解来说太难。除非上下文确实非常复杂,并且每个迭代都需要多个操作,否则尝试实现理解和 for 循环,并比较它们。
即使每个迭代都需要多个数据操作,你也可以使用一个理解,使用我们上面讨论的简单技巧:将数据操作移动到函数中,并在理解的循环的每个迭代中调用它。这种方法可以提供比在循环代码块内部实现所有这些操作的 for 循环更简单的代码。
当一个理解看起来对你来说太难编写时,不要过早放弃。尝试无论如何实现它,如果你成功了,可能会觉得这是一个相当整洁的解决方案:理解代码的难度不一定要与实现它的难度成比例。
通常,你可以在 for 循环和相应的理解之间进行选择。然而,有时你需要使用单个命令(即使它很长,因为它可以被分成多行),在这种情况下,你只需要一个理解。
以 Pytest 中 fixtures 的参数化为例。你可以创建一个参数列表,作为 params 参数传递,要么在调用 pytest.fixture() 之外,要么直接在它里面。通常,在内部做更好,因为这使代码更清晰、更有组织。只有当参数化代码变得太复杂,无法包含在调用 pytest.fixture() 之内时,我才将其移到外面。
Python 的语法糖,如生成器表达式(包括生成器表达式)、装饰器、walrus 运算符以及其他,使 Python 变得如此强大且易于阅读。因此,不要回避它们。它们的存在是为了使 Python 编程更容易。此外,生成器表达式还为 Python 增加了大量的美感。学会如何使用它们,你会发现没有它们时你享受 Python 的程度要低得多。
并非所有人都同意这样的推理。有些人声称你不应该使用这种语法糖,因为那些不太熟悉 Python 的人可能无法理解这样的代码。我完全不同意这种做法。如果你决定使用一种编程语言,为什么你不应该使用它的本地语法和语法糖呢?这些通常是这种语言中最强大的编程工具之一。
如果你想使用 C 语法,就用 C,而不是 Python。否则,即使你的 Python 代码能够正确运行,它也不会看起来像地道的 Python 代码。这样的代码可能会很长,不够优化,且难以理解。
应该将使用生成器表达式限制在最简单情况下的说法已经成为一个被过度使用的神话。你确实应该避免使用过于复杂的生成器表达式,但这是什么意思呢?两个 if 条件或嵌套循环会使生成器表达式变得过于复杂吗?不,它们不会!
因此,请遵循以下规则:如果与相应的 for 循环相比,Python 生成器表达式过于复杂,那么不要使用它。然而,如果生成器表达式比循环更容易理解,即使它跨越多行且看起来有挑战性,也要使用它。
换句话说,你应该基于生成器表达式的可读性以及相应 for 循环的可读性来决定是否使用它。如果性能是一个考虑因素,也要考虑这一点;这通常意味着你更倾向于使用生成器表达式而不是 for 循环。
897

被折叠的 条评论
为什么被折叠?



