超简单的Python教程系列——第11篇:Lambda、装饰器和其他
Python 以看起来像魔术而闻名,这可能部分归因于函数可以采用多种形式:lambda、装饰器、闭包等等。一个良好的函数调用可以做惊人的事情,而无需编写一个类!
你可能会说函数就是魔法。
重新认识函数
我们已经接触过数据类型和不变性中的函数。如果你还没有读过那篇文章,我建议你现在回去看看。
让我们看一个函数的简单示例,以确保我们在同一维度。
这里没有什么令人惊讶的。函数cheer()
接受单个参数volume
。如果我们不为volume
传递参数,它将默认为None
。
什么是函数式编程?
我们这些来自面向对象编程语言的人已经学会了从类和对象的角度来思考一切。数据被组织成对象,以及负责访问和修改该数据的函数。有时这可行,但有时,类开始感觉像太多模板。
函数式编程几乎与此相反。我们围绕函数进行组织,我们通过这些函数传递数据。我们必须遵守一些规则:
- 函数应该只接受输入,只产生输出。
- 函数不应该有副作用;他们不应该修改任何外部的东西。
- 函数应该(理想情况下)总是为相同的输入产生相同的输出。函数内部不应该有会破坏这种模式的状态。
现在,在你将所有 Python 代码重写为纯函数式之前,停止!不要忘记 Python 的一大优点是它是一种多范式语言。你不必选择一种范式并坚持下去;你可以在你的代码中混合搭配,这样是最好的。
事实上,我们已经这样做了!迭代器和生成器都是从函数式编程中参考而来的,它们与对象一起工作得很好。也可以随意合并 lambda、装饰器和闭包。这一切都是为了选择最适合这项工作的工具。
在实践中,我们很少能同时避免副作用。在将函数式编程的概念应用到 Python 代码中时,你应该更多地关注和慎重考虑副作用,而不是完全避免它们。将它们限制在没有更好方法解决问题的情况下。这里没有硬性规定可以依靠。你需要培养自己的洞察力。
递归
当一个函数调用自身时,这称为递归。当我们需要重复函数的整个逻辑,但循环不合适(或者感觉太混乱)时,这会很有帮助。
注意:我下面的示例被简化以突出递归本身。这实际上并不是递归是最佳方法的情况。当你需要对不同的数据块重复调用复杂的语言时,例如当你遍历树结构时,递归 会更好。
与random
相关的东西可能看起来很新。它与这个主题并没有真正相关,但简而言之,我们可以通过在程序开始时用随机数生成器生成随机数 (random.seed()
),然后调用random.randint(min, max)
, 函数里面的min
和max
定义可能值的包含范围。
这里逻辑的重要部分是defeat()
函数。只要villain 没有被打败,函数就会调用自己,传递villain
变量,直到其中一个函数调用返回一个值。在这种情况下,该值在递归调用堆栈中返回,最终存储在victory
.
不管花多长时间*
,我们最终都会打败那个villain 。
注意无限递归
递归可以是一个强大的工具,但它也会带来一个问题:如果我们没有办法停下来怎么办?
显然,这将永远运行!一些语言没有提供一种干净的方式来处理这个问题——函数只会无限递归,直到崩溃。
Python 更优雅地阻止了这种疯狂。一旦达到设定的递归深度(通常为 997-1000 次),它就会停止整个程序并引发错误:
RecursionError:调用 Python 对象时超出最大递归深度
像所有错误一样,我们可以在事情失控之前发现它:
值得庆幸的是,由于我编写这段代码的方式,我实际上不需要做任何特别的事情来清理 997 个重复项。递归函数从未返回,因此duplicates
在这种情况下保持未定义。
但是,我们可能希望以另一种方式控制递归,因此我们不必使用 try-except
来防止灾难。在我们的递归函数中,我们可以通过添加一个参数来跟踪它被调用的次数calls
,并在它变得太大时立即中止。
我们仍然需要弄清楚如何在不丢失原始数据的情况下删除 20 个重复项,但至少程序没有崩溃。
注意:你可以使用sys.setrecursionlimit(n)
覆盖最大递归级别,其中n
是你想要的最大值。
嵌套函数
有时,我们可能想要在一个函数中重用一段逻辑,但我们不想通过创建另一个函数来弄乱我们的代码。
当然,这个简单的例子的问题在于它的用处不是很明显。当我们想要将大量逻辑抽象为函数以实现可重用性但又不想在主函数之外定义时,嵌套函数会变得很有帮助。如果use()
函数要复杂得多,并且可能不仅仅是循环调用,那么这种设计将是合理的。
尽管如此,该示例的简单性仍然体现了基本概念。这也带来了另一个困难。你会注意到,每次我们调用它时use()
,我们都在传递target
给内部函数,这感觉毫无意义。我们不能只使用已经在本地范围内的target
变量吗?
事实上,我们可以:
然而,一旦我们尝试修改该变量,我们就会遇到麻烦:
运行该代码会引发错误:
UnboundLocalError: local variable 'target' referenced before assignment
显然,它不再认识我们的局部变量target
。这是因为默认情况下,分配给变量时会覆盖封闭范围中的任何已有相同的变量。因此,该行target == "Luna"
试图创建一个限制在use()
范围内的新变量,并在use_elements()
的封闭范围内隐藏已有的target
变量。与Python 看到了这一点并假设,因为我们在 use()
函数中定义了target
,所以对该变量的所有引用都与该本地名称相关。这不是我们想要的!
关键字nonlocal
允许我们告诉内部函数我们正在使用来自封闭局部范围的target
变量。
现在,运行结果,我们看到了打印出来的值Luna
。我们在这里的工作完成了!
注意:如果你希望函数能够修改定义为全局范围(在所有函数之外)的变量,请使用global
关键字而不是nonlocal
。
闭包
基于嵌套函数的思想,我们可以创建一个函数,该函数实际上构建并返回另一个函数(称为闭包)。
在此示例中,applebucking()
就是闭包,因为它关闭了非局部变量pony
和total_trees
。即使在外部函数终止后,闭包仍保留对这些变量的引用。
闭包从harvester()
函数返回,并且可以像任何其他对象一样存储在变量中。正是因为它“关闭”了一个非局部变量,这使得它本身就是一个闭包。否则,它只是一个函数。
在这个例子中,我使用闭包来有效地创建带有状态的对象。换句话说,每个收割机都记得他或她从多少棵树上收割过。这种特殊用法并不严格符合函数式编程,但如果你不想创建一个完整的类来存储一个函数的状态,它会非常有用!
apple_jack
, big_macintosh
, 和apple_bloom
现在是三个不同的函数,每个函数都有自己独立的状态;他们每个人都有不同的名字,并记住他们收获了多少棵树。在一个闭包状态中发生的事情对其他闭包状态没有影响,他们都是独立的个体。
当我们运行代码时,我们看到了这个状态:
闭包的问题
闭包本质上是“隐式类”,因为它们将功能及其持久信息(状态)放在同一个对象中。然而,闭包有几个独特的缺点:
- 你不能按原样访问“成员变量”。在我们的示例中,我永远无法访问闭包
apple_jack
上的变量total_trees
!我只能在闭包自己的代码的上下文中使用该变量。 - 关闭状态是完全不透明的。除非你知道闭包是如何编写的,否则你不知道它记录了哪些信息。
- 由于前面两点,根本不可能直接知道闭包何时具有任何状态。
使用闭包时,你需要准备好处理这些问题,以及它们带来的所有调试困难。我建议仅在你需要单个函数来存储调用之间的少量私有状态时才使用它们,并且仅在代码中如此有限的时间段内使用它们,以至于编写整个类并不合理。(另外,不要忘记生成器和协程,它们可能更适合许多此类场景。)
闭包仍然可以成为 Python中有用的部分,只要你非常小心地使用它们。
Lambdas
lambda是由单个表达式组成的匿名函数(无名称)。
仅此定义就是许多程序员无法想象他们为什么需要一个定义的原因。编写一个没有名称的函数有什么意义,基本上使重用完全不切实际?当然,你可以将lambda 分配给一个变量,但此时,你不应该刚刚编写了一个函数吗?
为了理解这一点,让我们先看一个没有lambdas 的例子:
我希望你注意的主要事情是sort_by_color()
函数,我必须编写该函数以明确按颜色对列表中的 Element 对象进行排序。实际上,这有点烦人,因为我再也不需要那个功能了。
这就是 lambdas 的用武之地。我可以删除整个函数,并将elements = sorted(...)
行更改为:
使用 lambda 可以让我准确地描述我的逻辑我在哪里使用它,而不是在其他任何地方。(这key=
部分只是表明我将 lambda 传递给sorted()
函数的key
的参数。)
一个 lambda 具有结构lamba
。它可以收集任意数量的参数,用逗号分隔,但它只能有一个表达式,其值是隐式返回的。
注意:与常规函数不同,Lambda 不支持类型注释(类型提示)。
如果我想重写那个 lambda 以按元素的名称而不是颜色排序,我只需要更改表达式部分:
就这么简单。
同样,lambda在需要将带有单个表达式的函数传递给另一个函数时非常有用。这是另一个示例,这次在 lambda 上使用了更多参数。
为了设置这个示例,让我们从 Flyer 的类开始,它存储名称和最大速度,并返回 Flyer 的随机速度。
我们希望能够让任何给定的 Flyer 对象执行任何飞行技巧,但是将所有这些逻辑放入类本身是不切实际的……可能有成千上万的飞行技巧和变体!
Lambdas是定义这些技巧的一种方式。我们将首先向该类添加一个函数,该函数可以接受函数作为参数。我们假设这个函数总是有一个参数:执行技巧的速度。
要使用它,我们创建一个 Flyer 对象,然后将函数传递给它的perform()
方法。
因为 lambda 的逻辑在函数调用中,所以更容易看到发生了什么。
回想一下,你可以将 lambdas 存储在变量中。当你希望代码如此简短但需要一些可重用性时,这实际上会很有帮助。例如,假设我们有另一个 Flyer,我们希望他们两个都进行barrelroll。
当然,我们可以将barrelroll
写成一个适当的单行函数,但是通过这种方式,我们为自己节省了一些样板文件。而且,由于在这段代码之后我们不会再次使用该逻辑,因此没有必要再使用一个成熟的函数。
再一次,可读性很重要。Lambda 非常适合用于简短、清晰的逻辑片段,但如果你有更复杂的事情,你还是应该编写一个合适的函数。
装饰器
假设我们想要修改任何函数的行为,而不实际更改函数本身。
让我们从一个相当基本的函数开始:
运行给我们:
很简单,对吧。但是,如果我们想为此添加一些额外的宣传呢?如你所知,我们真的不应该将这种逻辑放在我们的partial_transfiguration
函数中。
这就是装饰器的用武之地。装饰器“包装”了函数周围的附加逻辑,这样我们实际上不会修改原始函数本身。这使得代码更易于维护。
让我们从为大张旗鼓创建一个装饰器开始。这里的语法一开始可能看起来有点过于复杂,但请放心,我会详细介绍。
你可能已经认识到wrapper()
它实际上是一个闭包,它是由我们的party_cannon()
函数创建并返回的。我们传递我们正在“装饰”的函数,func
。
然而,我们真的对我们装饰的函数一无所知!它可能有也可能没有参数。闭包的参数列表(*args, **kwargs)
实际上可以接受任何数量的参数,从零到无穷大。调用func()
时,我们以相同的方式将这些参数传递给func()
。
当然,如果func()
上的参数列表与通过装饰器传递给它的参数之间存在某种不匹配,则会引发通常和预期的错误(这显然是一件好事)。
在wrapper()
内部,我们可以随时随地调用我们的函数func()
。我选择在打印我的两条消息之间这样做。
我不想丢弃func()
返回的值,所以我将返回的值分配给r
,并确保在装饰器的末尾用return r
.
请注意,对于在装饰器中调用函数的方式,或者即使调用它或调用多少次,实际上并没有硬性规定。你还可以以任何你认为合适的方式处理参数和返回值。关键是要确保装饰器实际上不会以某种意想不到的方式破坏它装饰的函数。
装饰器前的奇数行,@functools.wraps(func)
,实际上是一个装饰器本身。没有它,被装饰的函数本质上会混淆它自己的身份,弄乱我们对__doc__
(文档字符串)和__name__
.。这个特殊的装饰器确保不会发生这种情况;被包装的函数保留自己的身份,可以从函数外部以所有常用方式访问。(要使用那个特殊的装饰器,我们必须首先import functools
。)
现在我们已经编写了party_cannon
装饰器,我们可以使用它来添加我们想要的partial_transfiguration()
函数。这样做很简单:
第一行,@party_cannon
是我们做出的唯一改变!partial_transfiguration
函数现在已装饰。
注意:你甚至可以将多个装饰器堆叠在一起,一个在下一个之上。只需确保每个装饰器紧接在它所包装的函数或装饰器之前。
我们以前的用法根本没有改变:
然而输出确实发生了变化:
总结
我们已经介绍了 Python 中函数式“魔法”的四个方面。让我们花点时间回顾一下。
- 递归是函数调用自身的时候。当心“无限递归”;Python 不会让递归堆栈的深度超过大约一千次递归调用。
- 嵌套函数是在另一个函数中定义的函数。
- 嵌套函数可以读取其封闭范围内的变量,但它不能修改它们,除非你在嵌套函数中将变量指定为第一个
nonlocal
。 - 闭包是一个嵌套函数,它关闭一个或多个非局部变量,然后由封闭函数返回。
- lambda是一个匿名(未命名)函数,由单个表达式组成,并返回其值。Lambda 可以像任何其他对象一样被传递并分配给变量。
- 装饰器“环绕”另一个函数以扩展其行为,而无需直接修改要包装的函数。
有关这些主题的更多信息,请阅读文档。(你会注意到,官方文档中很少提到嵌套函数和闭包;它们是设计模式,而不是正式定义的语言结构。)