函数是一等对象 课件
引言
我们已经两次说过,函数也不过是一个对象——一次在 2.1 的格言里,又一次在每当我们把 square 像任何别的值一样画进堆里的时候。本页终于要把这一点兑现。因为函数是一个普通对象,凡是你能对一个数或一个列表做的事,都能对它做:把它绑定到一个名字、把它存进列表或字典、把它传给另一个函数、以及从一个函数里把它返回。这种自由就是“一等(first-class)”的含义,而它解锁了一个强大的想法——高阶函数(higher-order function)——整个 2.4 都建立在它之上。
和往常一样,代码可运行。
1. 函数是一个对象 课件 课件
第 1 章里我们用 id() 和 type() 查看一个对象的标识和类型。一个函数对这两者都答得上来,跟一个整数一样——而且没有什么能拦着你给它起第二个名字,或把它塞进一个列表或一个字典。
示例:函数是可以搬来搬去的值
核心概念:一等对象
当一门语言对一个对象不施加任何限制时,它就是一等的(first-class):它能被 命名、被存进数据结构、被作为实参传入、被作为结果返回。在 Python 里,函数是一等的 ——本页其余部分利用的正是这一点。
2. 高阶函数 课件 课件
因为函数是一个值,所以一个函数可以把另一个函数当作它的实参之一。一个把函数作为实参、或把函数作为结果返回的函数,叫做高阶函数。有些你已经用过了:sorted 接受一个 key 函数,max 也一样。
下面的示例写了我们自己的一个小小高阶函数,并且把内置的 len 作为实参传给 sorted。
示例:把函数作为实参传入
核心概念:高阶函数
高阶函数是把函数作为实参、或把函数作为结果返回的函数。普通函数作用于数据; 高阶函数作用于行为。
下面这个更有用,而且它会陪我们走完这一页。假设我们想数一个函数被调用了多少次——一个粗糙的性能分析器。这个计数必须在多次调用之间存活,而 2.2 已经给了我们两种安排的办法。还有第三种,你现在应该能认出它的真面目:
示例:call_count,用共享默认值来计数
那个 x=[0] 就是 2.1 §6 里的被共享的可变默认值——我们称之为 bug 的那个东西——在这里被有意用上了。因为这个列表在 def 时被创建一次、之后每次调用都重复使用,改动 x[0] 就成了一种在多次调用之间记住某样东西的办法。它能用,你在真实代码里也会遇见它,但它是个花招:状态藏在函数签名里,任何一个调用者只要自己传一个 x,计数就被悄悄清零。
2.2 §6 里那个计数器把同样的活儿干得光明正大:计数绑定在一个外层帧上,而不是被偷渡进形参里。等闭包有了名字,我们会在 §4 回到它。
更要紧的限制是另一个。看函数体的最后一行:func()——一对空括号。call_count 只能数那些不取实参的函数。 call_count(print) 打印一个空行,算不上什么演示;而 call_count(max) 会直接出错。我们想要的,是给 print 一点东西去打印。
显而易见的修法是再接一个形参、把它传下去:
示例:把一个实参传过去
好一些了——但也只对恰好取一个实参的函数管用。两个实参我们就又卡住了。我们可以再加 arg2、arg3 并给它们默认值,可这显然是条歪路:我们事先并不知道被包住的函数需要多少个实参,也不知道它们都叫什么。
要真正解决它,我们得更仔细地看看实参究竟是如何抵达形参的——而首先,要看一段和函数毫无关系的 Python 语法。
3. 实参如何抵达形参 课件 课件
3.1 位置实参与关键字实参
当一个函数有好几个形参时,你可以用两种方式提供实参。位置(positional)实参按顺序匹配形参。关键字(keyword)实参显式地点名形参,于是顺序不再要紧、调用也读得更清楚。
示例:位置实参 vs 关键字实参
易错点:位置实参必须在前
一旦一次调用里出现了一个关键字实参,它之后的每一个实参也都必须是关键字。
power(base=2, 10) 是一个 SyntaxError。把位置实参放在前面,然后才是关键字。
3.2 星号:打包与解包
在解决 call_count 之前,先在 * 运算符自己的地盘上认识它。它一开始和函数毫无关系——它讲的是把好几个值打包(packing)成一个对象,以及把一个对象解包(unpacking)回好几个名称。这两件事你其实都做过,只是没留意。
从最朴素的情形开始。在 = 右边写上好几个值,就把它们打包成一个元组;在左边写上好几个名称,就再把它解包开来:
示例:不带星号的打包与解包
到这里规则还很严格——名称有几个,值就得有几个。个数给错,Python 就拒绝,因为它无从猜测你想漏掉哪一个:
星号放松的正是这一条。一个带 * 的名称表示“剩下多少都算”,并把它们收进一个列表:
示例:一个星号吸收剩余部分
从“剩下多少都算”能推出两件事。带星号的那个名称最后总是一个列表,哪怕它只接到一项、或者一项也没接到。而星号至多只能有一个,因为两个会有歧义——*a, *b = 1, 2, 3 是 SyntaxError,因为没有任何东西能决定第一段在哪里结束、第二段从哪里开始。
还有一条边界值得试探。解包需要有东西可以迭代,所以 *a, b = [1] 可以,而 *a, b = 1 不行——光秃秃的 1 不是序列,Python 会直说:TypeError: cannot unpack non-iterable int object。
课堂练习:预测解包结果
先在纸上做出来,然后再运行。对每一条,给出每个名称的值和类型,或者说明它为什么失败。
a = 1, 2, 3a, b, c = 1, 2, 3a, b = 1, 2, 3a, *b, c = 1, 2, 3, 4, 5*a, b, c = 1, 2, 3, 4, 5*a, *b = 1, 2, 3, 4, 5*a, b = 1
现在看对调用真正要紧的那个方向。把 * 放在一个已有序列的前面,它做的是相反的事:把那一个对象重新铺开成一个个独立的值。
示例:星号把一个序列铺开
留意 print(nums) 和 print(*nums) 的差别:前者交给 print 一个列表对象,后者交给它五个独立的实参。所以 * 放在名称上是打包,放在值上是解包——一个符号,两个相反的方向,得到哪一个取决于它站在 =(或调用)的哪一边。
双星号对字典做同样的事。* 把一个序列铺成位置值,** 则把一个映射铺成 key=value 对——在合并两个字典时看得最清楚:
示例:** 解包一个映射
3.3 变长形参:*args 与 **kwargs
§3.2 讲的全是普通的值。现在把这同样的两个运算符对准一个形参列表,call_count 的难题就迎刃而解了。
把个数未知的实参传给一个函数要两步,而它们正是你刚看过的那两个方向:
- 打包——在定义里,
*args把抵达的、无论多少个位置实参收进一个元组。 - 解包——在函数体里那次调用中,
*args又把那个元组铺开成一个个独立的实参,交给被包住的函数。
示例:用 * 和 ** 收集实参
args 和 kwargs 这两个名字纯属约定——干活的是 * 和 **。也要注意它们各自是什么:args 是一个元组,kwargs 是一个字典,所以第 1 章里关于元组和字典的一切,在函数体内部全都适用。
现在 call_count 什么都数得了。它把抵达的一切打包进 args,又在传出去的路上把它解开:
示例:call_count,完成版
那个 sep=", " 正是用来证明这一点的。如果 args 是作为一个元组整体传过去的,print 会带着它自己的括号和逗号把它显示出来;正因为星号把它解开了,print 收到的是三个独立的字符串,并用我们要求的分隔符把它们连起来。试着删掉那个星号再比较一下。
一个用 *args, **kwargs 接收并转发两者的包装器,可以挡在任何函数前面——这正是 2.4 里装饰器的形状。
示例:一个透传的高阶函数
核心概念:*args 与 **kwargs
在定义里,*args 把多出来的位置实参聚成一个元组,**kwargs 把多出来的关键字
实参聚成一个字典。在调用里,* 和 ** 把一个序列或字典重新铺开成一个个独立的
实参。这和 §3.2 的打包/解包是同一回事,只不过用在了形参列表上。
有两条顺序规则,两条都来自 Python 需要把实参彼此区分开:一次调用里位置实参
必须排在关键字实参之前;而在定义里,顺序是普通形参、然后 *args、然后
**kwargs,带默认值的形参排在不带默认值的之后。
课堂练习:变长实参
- 写
my_sum(*args),返回它收到的、无论多少个数字之和,用两个、再用五个数字测试。 **kwargs是一个字典,所以它有.keys()、.values()和.items()。写sum_of_kwargs,使sum_of_kwargs(Alice=5, Bob=3, Charlie=4)返回12,再写一个版本同时报告谁贡献得最多。- 给定
pair = (3, 4),用power(*pair)调用 §3.1 的power,确认它返回81。 - 重写
call_count,让它不再需要x=[0]默认值,改用 2.2 §6 的那个计数器。你更愿意把哪个版本交给别人?为什么?
4. 把函数作为返回值:闭包 课件 课件
“高阶”的另一半是返回一个函数。当一个函数定义了一个内层函数并把它返回时,内层那个会带着它的外层(enclosing)作用域一起走——也就是造出它的那个函数的局部名称(回忆 2.2 的外层作用域)。一个内层函数连同它仍在引用的那些外层名称捆在一起,就叫做闭包(closure)。
示例:一个造函数的函数
下面的图把 make_linear(2, 1) 返回的那一瞬间定格下来。它的帧里有 a → 2、b → 1,以及刚刚建好的内层函数 line;那条绿色箭头把这个函数交回给全局名称 double_plus_one。
现在看看什么存活了下来。make_linear 返回的那一刻,上面那个帧就被丢弃——可等到你调用 double_plus_one(5) 时,a 和 b 还在那儿。这正是 2.1 那个堆的想法被推到了尽头:帧是临时的,但对象 2 和 1 没有被释放,因为返回的那个闭包仍在引用它们。闭包恰恰就是这个——一个函数,连同它一直让其存活的、堆里的一小块私有空间。
同样的想法也修好了 2.1 §7 的那个函数工厂。2.2 §6.1 里的 make_f(i) 一直就是一个闭包——每个被返回的 f 都让自己捕获的那个 i 存活着,方式和 double_plus_one 留住 a、b 一模一样。现在这个名字终于有了。
要让内层函数改变一个被捕获的名称(而不只是读它),用 nonlocal——正是 2.2 那个关键字。这就给出了我们在那里造过的计数器,现在总算能叫出它的正式名字:
示例:一个会计数的闭包
把它和 §2 里 x=[0] 版本的 call_count 并排放着看。两者都在多次调用之间记住一个计数;差别在于这份记忆住在哪里。闭包把它放在一个被捕获的外层名称里,私有,且每个计数器各有一份。默认实参那个花招把它放在函数签名里,对每个调用者都可见,也能被任何一个调用者重置。行为相同,而其中一个对此是坦白的。
核心概念:闭包
闭包是一个内层函数,连同它仍在引用的外层作用域名称。闭包在外层调用返回之后 依然让那些对象存活,从而给内层函数一份私有的、持久的记忆。
闭包不只是一个计数小把戏——它们让你把数据和作用于它的行为捆在一起,同时把数据保持私有。下面的示例造了一些简单的游戏角色。每一次 make_player 调用都捕获它自己的 hp 和 damage,并返回一小束共享它们的函数。注意 hp 不是返回出来的键之一:改变一个角色血量的唯一途径是经由它自己的 take_damage——外面没有谁能伸手进来把 hp 设成一个荒唐的值。
示例:带私有状态的闭包(游戏角色)
def make_player(name, hp, damage):
def attack(other):
other["take_damage"](damage) # 把我的伤害用到别人身上
def take_damage(amount):
nonlocal hp
hp -= amount
def status():
print(f"{name}: {hp} hp")
return {"attack": attack, "take_damage": take_damage, "status": status}
bob = make_player("Bob", 100, 10)
charlie = make_player("Charlie", 100, 5)
bob["attack"](charlie)
charlie["status"]() # Charlie: 90 hp
bob["status"]() # Bob: 100 hp —— 没被动过
这三个内层函数都闭合在来自同一次 make_player 调用的同一个 hp 和 damage 之上,于是它们彼此协作:attack 触发对方的 take_damage,后者更新那个 status 稍后会读取的 hp。两个不同的玩家带着两套独立的、被捕获的值。如果这听起来像一个带私有字段和方法的对象——它确实是。闭包是获得封装最古老的方式之一,也是你将在第 3 章见到的类的种子。
深入了解:被捕获的变量到底住在哪里
闭包总得把它捕获的变量存在某处,而你可以直接看到它们。Python 把它们放在附着于
函数对象上的单元(cell)里,经由 __closure__ 可达:
print(charlie["take_damage"].__closure__) # 一个由 cell 对象组成的元组
print(charlie["take_damage"].__closure__[0].cell_contents) # 90 —— 当前的 hp
这是堆的想法被坐实了:被捕获的 hp 是一个由内层函数上的一个单元持有的对象,这正是
它为何能比创建它的那次 make_player 调用活得更久。而且因为 attack、take_damage
和 status 来自同一次调用,它们为 hp 共享同一个单元——通过其中之一改动它,
其余的立刻就看见。同胞闭包靠它们共同持有的堆单元被绑在一起。
深入了解:记住一个值的三种办法,以及如何取舍
到这里,第 2 章已经展示了三种让一个值在多次调用之间存活的办法。把它们并排放着 看很值得,因为这个取舍是实打实的。
全局(2.2 §6)最生硬:计数是一个模块层的名称,任何东西都能读它、覆盖它,
而这个函数也只有挨着它才说得通。可变默认值——call_count 的 x=[0]——
利用 2.1 §6 那个共享默认值的陷阱,把计数藏进函数签名;它紧凑,在真实代码里也
确实出现,但它会漏,因为任何调用者都可以自己传一个 x,悄悄把计数清零。
用 nonlocal 的闭包把计数放进一个被捕获的外层帧里:私有、每个计数器各一份,
而且它说的就是它的意思。
优先用闭包。认识另外两种,是为了读懂别人的代码——也为了看出这三者其实是关于 一个名称住在哪里的同一个想法,而这条线索贯穿了 2.1、2.2 和这一页。
5. lambda:没有名字的函数 课件
很多时候,你想传到某处去的那个函数小到给它一个 def 加一个名字都显得笨重。lambda 把一个函数就地作为一个表达式造出来,没有名字。它的函数体是单个表达式,其值会被自动返回。
语法是 lambda 形参: 表达式。下面两种定义是等价的:
示例:lambda 不过是一个紧凑的函数值
lambda 大放异彩之处,是把一个一次性的函数传给一个高阶函数,这样你就永远不必给它起名。
示例:lambda 作为 key 函数
易错点:让 lambda 保持小巧
一个 lambda 只能装单个表达式——没有语句,没有多行。这是有意为之:如果一个函数
需要的不止一个简短表达式,就给它一个真正的 def 和一个有描述性的名字。把 lambda
留给那些你传给 sorted、max、map 之流的、用完即弃的小函数。
小结
Python 里的函数是一等对象,所以它们能像任何值一样被命名、存储、传递、返回:
| 想法 | 含义 |
|---|---|
| 一等 | 函数可以被起别名、放进列表/字典、传入、返回 |
| 高阶函数 | 把函数作为实参、或返回一个函数 |
*args / **kwargs |
定义时收集多出来的实参;调用时把它们铺开 |
| 闭包 | 一个内层函数,加上它一直保活着的外层名称 |
lambda |
一个小巧的匿名函数,作为单个表达式就地写出 |
这不是五个各自独立的小把戏,而是同一个想法的不同侧面:函数是一个值。接下来,2.4 实践用例 会把这个想法用在你课程围绕展开的五个模式里——装饰器、递归、map/filter/reduce、生成器、错误处理——它们每一个都是乔装打扮的高阶函数。