极其简单的 Python:Lambda 表达式、装饰器和其他魔法
重温函数
什么是函数式编程?
递归
嵌套函数
闭包
Lambda 表达式
装饰器
审查
喜欢这些文章吗?那就买本书吧! Jason C. McDonald 的《Dead Simple Python》现已由 No Starch Press 出版。
Python 以看起来像魔术而闻名,这可能部分归功于函数的多种形式:lambda、装饰器、闭包等等。一个恰当的函数调用可以实现令人惊叹的效果,而无需编写任何类!
您可能会说函数很神奇。
重温函数
我们已经在数据类型和不可变性中讨论过函数。如果你还没有读过那篇文章,我建议你现在回去看看。
让我们看一个函数的简单示例,只是为了确保我们理解一致。
def cheer(volume=None):
if volume is None:
print("yay.")
elif volume == "Louder!":
print("yay!")
elif volume == "LOUDER!":
print("*deep breath*")
print("yay!")
cheer() # prints "yay."
cheer("Louder!") # prints "yay!"
cheer("LOUDER!") # prints "*deep breath* ...yay!"
这没什么奇怪的。该函数cheer()
只接受一个参数volume
。如果我们不传递参数volume
,它将默认为该值None
。
什么是函数式编程?
我们这些学习过面向对象编程语言的人,已经学会了用类和对象来思考一切。数据被组织成对象,同时还有负责访问和修改数据的函数。有时这种方法有效,但有时,类会让人觉得太过繁琐。
函数式编程几乎与此相反。我们围绕函数进行组织,并通过函数传递数据。我们必须遵循一些规则:
-
函数应该只接受输入,并且只产生输出。
-
函数不应该有副作用;它们不应该修改自身外部的任何事物。
-
理想情况下,函数应该始终对相同的输入产生相同的输出。函数内部不应该存在任何会破坏此模式的状态。
现在,在你开始将所有 Python 代码重写为纯函数式之前,请停下来!别忘了 Python 的一大优点在于它是一种多范式语言。你不必只选择一种范式并坚持使用;你可以在代码中混合搭配各种范式的精华。
事实上,我们已经这样做了!迭代器和生成器都借鉴了函数式编程,它们与对象配合得很好。您也可以随意加入 lambda、装饰器和闭包。关键在于选择最合适的工具。
在实践中,我们很少能够完全避免副作用。在将函数式编程的概念应用于 Python 代码时,你应该更加注重对副作用的谨慎和深思熟虑,而不是一味地避免它们。将它们限制在那些没有更好方法解决问题的情况下。这里没有硬性规定可以依赖;你需要培养自己的判断力。
递归
当一个函数调用自身时,这被称为递归。当我们需要重复一个函数的整个逻辑,但循环不合适(或者感觉太混乱)时,递归会很有帮助。
注意:下面的示例经过简化,以突出递归本身。实际上,这并非递归的最佳方案;当你需要对不同的数据片段反复调用复杂的语言时,例如遍历树形结构时,递归会更好。
import random
random.seed()
class Villain:
def __init__(self):
self.defeated = False
def confront(self):
# Roll of the dice.
if random.randint(0,10) == 10:
self.defeated = True
def defeat(villain):
villain.confront()
if villain.defeated:
print("Yay!")
return True
else:
print("Keep trying...")
return defeat(villain)
starlight = Villain()
victory = defeat(starlight)
if victory:
print("YAY!")
相关的内容random
可能看起来很新。它实际上与本主题无关,但简而言之,我们可以通过在程序开始时首先为随机数生成器设置种子(random.seed()
),然后调用来生成随机整数random.randint(min, max)
,其中min
和max
定义了可能值的包含范围。
这里逻辑的关键部分是defeat()
函数。只要反派没有被打败,函数就会调用自身,并传递villain
变量。这种情况会一直持续,直到其中一个函数调用返回值。在本例中,该值会沿着递归调用堆栈向上返回,最终存储在 中victory
。
无论需要多长时间*
,我们最终都会打败那个恶棍。
小心无限递归
递归可能是一个强大的工具,但它也会带来一个问题:如果我们没有办法停止怎么办?
def mirror_pool(lookers):
reflections = []
for looker in lookers:
reflections.append(looker)
lookers.append(reflections)
print(f"We have {len(lookers) - 1} duplicates.")
return mirror_pool(lookers)
duplicates = mirror_pool(["Pinkie Pie"])
显然,这会一直运行下去!有些语言没有提供简洁的方法来处理这个问题——函数会无限递归,直到崩溃。
Python 可以更优雅地阻止这种疯狂的行为。一旦达到设定的递归深度(通常为 997-1000 次),它就会停止整个程序并引发错误:
RecursionError:调用 Python 对象时超出最大递归深度
与所有错误一样,我们可以在事情失控之前发现它:
try:
duplicates = mirror_pool(["Pinkie Pie"])
except RecursionError:
print("Time to watch paint dry.")
值得庆幸的是,由于我编写这段代码的方式,我实际上不需要做任何特殊的事情来清理这 997 个重复项。递归函数从未返回,因此duplicates
在这种情况下仍然是未定义的。
然而,我们可能想用另一种方式控制递归,这样就不必使用try-except
来防止灾难了。在我们的递归函数中,我们可以通过添加一个参数来跟踪它被调用的次数calls
,并在参数过大时立即中止。
def mirror_pool(lookers, calls=0):
calls += 1
reflections = []
for looker in lookers:
reflections.append(looker)
lookers.append(reflections)
print(f"We have {len(lookers) - 1} duplicates.")
if calls < 20:
lookers = mirror_pool(lookers, calls)
return lookers
duplicates = mirror_pool(["Pinkie Pie"])
print(f"Grand total: {len(duplicates)} Pinkie Pies!")
我们仍然需要弄清楚如何在不丢失原始文件的情况下删除 20 个重复文件,但至少程序没有崩溃。
注意:您可以使用覆盖最大递归级别sys.setrecursionlimit(n)
,其中n
是您想要的最大值。
嵌套函数
有时,我们可能会有一段想要在函数内重用的逻辑,但我们不想通过创建另一个函数来弄乱我们的代码。
def use_elements(target):
elements = ["Honesty", "Kindness", "Laughter",
"Generosity", "Loyalty", "Magic"]
def use(element, target):
print(f"Using Element of {element} on {target}.")
for element in elements:
use(element, target)
use_elements("Nightmare Moon")
当然,这么简单的例子的问题在于,它的实用性并非立竿见影。当我们有大量逻辑需要抽象成一个函数以实现可复用性,但又不想在主函数之外定义时,嵌套函数就变得非常有用。如果函数use()
非常复杂,并且可能不止在循环中调用,那么这种设计就显得合理了。
尽管如此,这个例子虽然简单,却展现了底层概念。但它也带来了另一个难题。你会注意到,每次调用target
内部函数use()
时,我们都会将 传递给它,这感觉毫无意义。难道我们不能直接使用target
已经在局部作用域中的变量吗?
事实上,我们可以。
def use_elements(target):
elements = ["Honesty", "Kindness", "Laughter",
"Generosity", "Loyalty", "Magic"]
def use(element):
print(f"Using Element of {element} on {target}.")
for element in elements:
use(element)
use_elements("Nightmare Moon")
然而,一旦我们尝试修改该变量,就会遇到麻烦:
def use_elements(target):
elements = ["Honesty", "Kindness", "Laughter",
"Generosity", "Loyalty", "Magic"]
def use(element):
print(f"Using Element of {element} on {target}.")
target = "Luna"
for element in elements:
use(element)
print(target)
use_elements("Nightmare Moon")
运行该代码会引发错误:
UnboundLocalError:局部变量“target”在赋值前被引用
显然,它不再能看到我们的局部变量target
。这是因为默认情况下,赋值给一个名称会遮蔽其封闭作用域内所有现有的名称。因此,这行代码target == "Luna"
试图创建一个限定在 作用域内的新变量use()
,并将该变量隐藏(遮蔽)target
在 封闭作用域内use_elements()
。Python 看到了这一点,并假设,由于我们target
在函数 中定义use()
,所有对该变量的引用都与该局部名称相关。这可不是我们想要的!
该nonlocal
关键字允许我们告诉内部函数,我们正在使用target
来自封闭局部范围的变量。
def use_elements(target):
elements = ["Honesty", "Kindness", "Laughter",
"Generosity", "Loyalty", "Magic"]
def use(element):
nonlocal target
print(f"Using Element of {element} on {target}.")
target = "Luna"
for element in elements:
use(element)
print(target)
use_elements("Nightmare Moon")
现在,一切就绪,我们可以看到Luna
打印出来的值。我们的工作完成了!
注意:如果您希望允许函数能够修改全局范围(所有函数之外)定义的变量,请使用global
关键字而不是nonlocal
。
闭包
基于嵌套函数的思想,并回想一下函数与其他对象的处理方式没有什么不同,我们可以创建一个实际上构建并返回另一个函数的函数,称为闭包。
def harvester(pony):
total_trees = 0
def applebucking(trees):
nonlocal pony, total_trees
total_trees += trees
print(f"{pony} harvested from {total_trees} trees so far.")
return applebucking
apple_jack = harvester("Apple Jack")
big_mac = harvester("Big Macintosh")
apple_bloom = harvester("Apple Bloom")
north_orchard = 120
west_orchard = 80 # watch out for fruit bats
east_orchard = 135
south_orchard = 95
near_house = 20
apple_jack(west_orchard)
big_mac(east_orchard)
apple_bloom(near_house)
big_mac(north_orchard)
apple_jack(south_orchard)
在这个例子中,applebucking()
就是闭包,因为它封闭了非局部变量pony
和total_trees
。即使在外部函数终止后,闭包仍然保留对这些变量的引用。
闭包由函数返回harvester()
,可以像其他对象一样存储在变量中。它“封闭”了一个非局部变量,这使得它本身就是一个闭包;否则,它就只是一个函数。
在这个例子中,我使用闭包来有效地创建带有状态的对象。换句话说,每个伐木工人都会记住自己已经伐了多少棵树。这种用法并不严格遵循函数式编程,但如果你不想为了存储一个函数的状态而创建一个完整的类,那么它就非常有用!
apple_jack
、big_macintosh
和apple_bloom
现在是三个不同的函数,每个函数都有各自的状态;它们各自有不同的名称,并记住它们从多少棵树上砍伐了树木。一个闭包的状态发生的变化不会影响其他闭包的状态。
当我们运行代码时,我们会看到以下状态:
Apple Jack harvested from 80 trees so far.
Big Macintosh harvested from 135 trees so far.
Apple Bloom harvested from 20 trees so far.
Big Macintosh harvested from 255 trees so far.
Apple Jack harvested from 175 trees so far.
非常简单。
闭包的问题
闭包本质上是“隐式类”,因为它们将功能及其持久信息(状态)放在同一个对象中。然而,闭包也有一些独特的缺点:
-
你无法访问所谓的“成员变量”。在我们的例子中,我永远无法访问闭包
total_trees
中的变量apple_jack
!我只能在闭包自身代码的上下文中使用该变量。 -
闭包的状态完全不透明。除非你知道闭包是怎么写的,否则你根本不知道它到底跟踪了哪些信息。
-
由于前两点,我们不可能直接知道闭包何时具有任何状态。
使用闭包时,你需要做好准备来处理这些问题以及它们带来的所有调试难题。我建议仅当你需要一个函数在调用之间存储少量私有状态,并且只在代码中只使用有限的一段时间,以至于感觉没有必要编写整个类时才使用闭包。(另外,别忘了生成器和协程,它们可能更适合许多类似的场景。)
基本上,它就在那里。只要你小心使用,闭包仍然是你的 Python 库中一个有用的一部分。
Lambda 表达式
lambda是由单个表达式组成的匿名函数(没有名称)。
光是这个定义就足以让许多程序员无法想象自己为什么需要它。编写一个没有名字的函数有什么意义呢?这基本上让复用变得完全不切实际。当然,你可以将 lambda表达式赋值给变量,但那样的话,难道不应该直接写一个函数吗?
为了理解这一点,我们先来看一个没有lambda 的例子:
class Element:
def __init__(self, element, color, pony):
self.element = element
self.color = color
self.pony = pony
def __repr__(self):
return f"Element of {self.element} ({self.color}) is attuned to {self.pony}"
elements = [
Element("Honesty", "Orange", "Apple Jack"),
Element("Kindness", "Pink", "Fluttershy"),
Element("Laughter", "Blue", "Pinkie Pie"),
Element("Generosity", "Violet", "Rarity"),
Element("Loyalty", "Red", "Rainbow Dash"),
Element("Magic", "Purple", "Twilight Sparkle")
]
def sort_by_color(element):
return element.color
elements = sorted(elements, key=sort_by_color)
print(elements)
我主要想让你注意的是sort_by_color()
我必须编写的函数,它的作用是按颜色对列表中的 Element 对象进行排序。实际上,这有点烦人,因为我以后再也不需要这个函数了。
这就是 lambda 的用武之地。我可以删除整个函数,并将该elements = sorted(...)
行更改为:
elements = sorted(elements, key=lambda e: e.color)
使用 lambda 表达式可以让我准确地在使用它的地方描述我的逻辑,而不是在其他地方。(这key=
部分只是表明我将 lambda 表达式传递给了key
on 的参数sorted()
。)
Lambda 具有以下结构lamba <parameters>: <return expression>
。它可以收集任意数量的参数,以逗号分隔,但只能有一个表达式,该表达式的值是隐式返回的。
注意事项:与常规函数不同,Lambdas 不支持类型注释(类型提示)。
如果我想重写该 lambda 以按元素名称而不是颜色排序,我只需要更改表达式部分:
elements = sorted(elements, key=lambda e: e.name)
就这么简单。
再次强调,lambda 主要在需要将一个包含单个表达式的函数传递给另一个函数时有用。下面是另一个示例,这次 lambda 使用了更多参数。
为了设置这个例子,让我们从 Flyer 的类开始,它存储名称和最大速度,并返回传单的随机速度。
import random
random.seed()
class Flyer:
def __init__(self, name, top_speed):
self.name = name
self.top_speed = top_speed
def get_speed(self):
return random.randint(self.top_speed//2, self.top_speed)
我们希望能够让任何给定的 Flyer 对象执行任何飞行技巧,但将所有这些逻辑放入类本身是不切实际的......可能有成千上万的飞行技巧和变体!
Lambda 表达式是定义这些技巧的一种方法。我们首先向此类添加一个可以接受函数作为参数的函数。我们假设该函数始终只接受一个参数:执行技巧的速度。
def perform(self, trick):
performed = trick(self.get_speed())
print(f"{self.name} perfomed a {performed}")
为了使用它,我们创建一个 Flyer 对象,然后将函数传递给它的perform()
方法。
rd = Flyer("Rainbow Dash", 780)
rd.perform(lambda s: f"barrel-roll at {s} mph.")
rd.perform(lambda s: f"flip at {s} mph.")
因为 lambda 的逻辑是在函数调用中,所以更容易看出发生了什么。
回想一下,你可以将 lambda 表达式存储在变量中。当你希望代码简洁,但又需要一定的复用性时,这实际上会很有帮助。例如,假设我们有另一个 Flyer,我们希望它们都执行一个桶滚动作。
spitfire = Flyer("Spitfire", 650)
barrelroll = lambda s: f"barrel-roll at {s} mph."
spitfire.perform(barrelroll)
rd.perform(barrelroll)
当然,我们也可以写成barrelroll
一个正式的单行函数,但这样做可以省去一些样板代码。而且,既然写完这段代码后我们不会再用到这些逻辑,那就没必要再写一个完整的函数了。
再次强调,可读性很重要。Lambda 表达式非常适合表达简短、清晰的逻辑片段,但如果有更复杂的内容,则绝对应该编写一个合适的函数。
装饰器
想象一下,我们想要修改任何函数的行为,但实际上并不改变函数本身。
让我们从一个相当基本的功能开始:
def partial_transfiguration(target, combine_with):
result = f"{target}-{combine_with}"
print(f"Transfiguring {target} into {result}.")
return result
target = "frog"
target = partial_transfiguration(target, "orange")
print(f"Target is now a {target}.")
运行后,我们可以得到:
Transfiguring frog into frog-orange.
Target is now a frog-orange.
很简单。但如果我们想添加一些额外的功能呢?如你所知,我们真的不应该把这些逻辑放在函数里partial_transfiguration
。
这时装饰器就派上用场了。装饰器会将额外的逻辑“包裹”在函数周围,这样我们实际上并不会修改原始函数本身。这使得代码更易于维护。
我们先来为 fanfare 创建一个装饰器。这里的语法乍一看可能有点复杂,不过放心,我会逐步讲解的。
import functools
def party_cannon(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("Waaaaaaaait for it...")
r = func(*args, **kwargs)
print("YAAY! *Party cannon explosion*")
return r
return wrapper
你可能已经意识到,这wrapper()
实际上是一个闭包,它由我们的函数创建并返回party_cannon()
。我们传递了我们正在“包装”的函数func
。
然而,我们对被包装的函数一无所知!它可能有参数,也可能没有。闭包的参数列表(*args, **kwargs)
可以接受任意数量的参数,从零到(实际上)无穷大。我们以与调用闭包时相同的方式传递这些参数func()
。
当然,如果参数列表和通过func()
装饰器传递给它的参数之间存在某种不匹配,则会出现通常和预期的错误(这显然是一件好事。)
在 中wrapper()
,我们可以随时随地以任何方式调用我们的函数func()
。我选择在打印两条消息之间这样做。
我不想丢弃func()
返回的值,因此我将返回的值分配给r
,并确保在包装器的末尾使用返回它return r
。
请注意,对于调用包装器中的函数的方式,甚至是否调用或调用多少次,实际上并没有硬性规定。您还可以按照自己认为合适的方式处理参数和返回值。关键在于确保包装器不会以某种意外的方式破坏它所包装的函数。
包装器前面那行奇怪的代码,@functools.wraps(func)
,实际上本身就是一个装饰器。如果没有它,被包装的函数就会对自己的身份产生混淆,从而影响我们对诸如__doc__
文档字符串和等重要函数属性的外部访问__name__
。这个特殊的装饰器确保了这种情况不会发生;被包装的函数保留了它自己的身份,可以通过所有常见的方式从函数外部访问。(要使用这个特殊的装饰器,我们必须import functools
先这样做。)
现在我们已经party_cannon
写好了装饰器,我们可以用它来给函数添加我们想要的效果了partial_transfiguration()
。操作非常简单,如下所示:
@party_cannon
def partial_transfiguration(target, combine_with):
result = f"{target}-{combine_with}"
print(f"Transfiguring {target} into {result}.")
return result
第一行@party_cannon
是我们唯一需要做的修改!该partial_transfiguration
函数现在被修饰了。
注意:你甚至可以将多个装饰器堆叠在一起,一个接一个。只需确保每个装饰器都紧挨着它所包装的函数或装饰器。
我们的用法与以前没有任何改变:
target = "frog"
target = partial_transfiguration(target, "orange")
print(f"Target is now a {target}.")
但输出确实发生了变化:
Waaaaaaaait for it...
Transfiguring frog into frog-orange.
YAAY! *Party cannon explosion*
Target is now a frog-orange.
审查
我们已经介绍了 Python 函数式“魔法”的四个方面。让我们花点时间回顾一下。
-
递归是指函数调用自身。请注意“无限递归”;Python 不允许递归堆栈的深度超过大约一千次递归调用。
-
嵌套函数是在另一个函数中定义的函数。
-
嵌套函数可以读取其封闭范围内的变量,但不能修改它们,除非您
nonlocal
在嵌套函数中将该变量指定为第一个变量。 -
闭包是一个嵌套函数,它封闭一个或多个非局部变量,然后由封闭函数返回。
-
Lambda是一个匿名函数,由单个表达式组成,并返回其值。Lambda 可以像其他对象一样传递或赋值给变量。
-
装饰器“包裹”另一个函数来扩展其行为,而无需直接修改所包裹的函数。
您可以阅读文档以获取有关这些主题的更多信息。(实际上,您会注意到,官方文档中很少提及嵌套函数和闭包;它们是设计模式,而不是正式定义的语言结构。)
感谢@deniska、@asdf、@SnoopDeJi (Freenode IRC) 和@sandodargo (DEV) 提出的改进建议。
文章来源:https://dev.to/codemouse92/dead-simple-python-lambdas-decorators-and-other-magic-5gbf