跳转至

函数是一等对象 课件

引言

我们已经两次说过,函数也不过是一个对象——一次在 2.1 的格言里,又一次在每当我们把 square 像任何别的值一样画进堆里的时候。本页终于要把这一点兑现。因为函数是一个普通对象,凡是你能对一个数或一个列表做的事,都能对它做:把它绑定到一个名字、把它存进列表或字典、把它传给另一个函数、以及从一个函数里把它返回。这种自由就是“一等(first-class)”的含义,而它解锁了一个强大的想法——高阶函数(higher-order function)——整个 2.4 都建立在它之上。

和往常一样,代码可运行。

1. 函数是一个对象 课件 课件

第 1 章里我们用 id()type() 查看一个对象的标识和类型。一个函数对这两者都答得上来,跟一个整数一样——而且没有什么能拦着你给它起第二个名字,或把它塞进一个列表或一个字典。

示例:函数是可以搬来搬去的值
def square(n):
    return n * n

print(type(square))      # <class 'function'>

sq = square              # 同一个函数对象的第二个名字
print(sq(5))             # 25 —— 同一个函数,新名字

ops = {"sq": square, "neg": lambda n: -n}   # 把函数存进一个字典
print(ops["sq"](4))      # 16
名称指向对象:那个函数对象像 2.1 里的每一个值一样住在**堆**里,而 `sq = square` 正是 1.2 里那同一种别名——两个名字,一个对象。这个对象只不过恰好是可调用的,所以两个名字都能被调用。
核心概念:一等对象

当一门语言对一个对象不施加任何限制时,它就是一等的(first-class):它能被 命名、被存进数据结构、被作为实参传入、被作为结果返回。在 Python 里,函数是一等的 ——本页其余部分利用的正是这一点。

2. 高阶函数 课件 课件

因为函数是一个值,所以一个函数可以把另一个函数当作它的实参之一。一个把函数作为实参、或把函数作为结果返回的函数,叫做高阶函数。有些你已经用过了:sorted 接受一个 key 函数,max 也一样。

下面的示例写了我们自己的一个小小高阶函数,并且把内置的 len 作为实参传给 sorted

示例:把函数作为实参传入
def apply_twice(func, x):
    return func(func(x))        # 把传进来的函数调用两次

def increment(n):
    return n + 1

print(apply_twice(increment, 5))   # 7

words = ["python", "is", "great"]
print(sorted(words, key=len))      # ['is', 'great', 'python'] —— 按长度排序
核心概念:高阶函数

高阶函数是把函数作为实参、或把函数作为结果返回的函数。普通函数作用于数据; 高阶函数作用于行为

下面这个更有用,而且它会陪我们走完这一页。假设我们想数一个函数被调用了多少次——一个粗糙的性能分析器。这个计数必须在多次调用之间存活,而 2.2 已经给了我们两种安排的办法。还有第三种,你现在应该能认出它的真面目:

示例:call_count,用共享默认值来计数
def call_count(func, x=[0]):
    print(f"calling {x[0] + 1} times")
    x[0] += 1
    func()

call_count(print)          # calling 1 times
call_count(print)          # calling 2 times
call_count(print)          # calling 3 times

那个 x=[0] 就是 2.1 §6 里的被共享的可变默认值——我们称之为 bug 的那个东西——在这里被有意用上了。因为这个列表在 def 时被创建一次、之后每次调用都重复使用,改动 x[0] 就成了一种在多次调用之间记住某样东西的办法。它能用,你在真实代码里也会遇见它,但它是个花招:状态藏在函数签名里,任何一个调用者只要自己传一个 x,计数就被悄悄清零。

2.2 §6 里那个计数器把同样的活儿干得光明正大:计数绑定在一个外层帧上,而不是被偷渡进形参里。等闭包有了名字,我们会在 §4 回到它。

更要紧的限制是另一个。看函数体的最后一行:func()——一对空括号。call_count 只能数那些不取实参的函数。 call_count(print) 打印一个空行,算不上什么演示;而 call_count(max) 会直接出错。我们想要的,是给 print 一点东西去打印。

显而易见的修法是再接一个形参、把它传下去:

示例:把一个实参传过去
def call_count(func, arg_to_call, x=[0]):
    print(f"calling {x[0] + 1} times")
    x[0] += 1
    func(arg_to_call)

call_count(print, "hello")     # calling 1 times / hello
call_count(print, "python")    # calling 2 times / python

好一些了——但也只对恰好取一个实参的函数管用。两个实参我们就又卡住了。我们可以再加 arg2arg3 并给它们默认值,可这显然是条歪路:我们事先并不知道被包住的函数需要多少个实参,也不知道它们都叫什么。

要真正解决它,我们得更仔细地看看实参究竟是如何抵达形参的——而首先,要看一段和函数毫无关系的 Python 语法。

3. 实参如何抵达形参 课件 课件

3.1 位置实参与关键字实参

当一个函数有好几个形参时,你可以用两种方式提供实参。位置(positional)实参按顺序匹配形参。关键字(keyword)实参显式地点名形参,于是顺序不再要紧、调用也读得更清楚。

示例:位置实参 vs 关键字实参
def power(base, exponent):
    return base ** exponent

print(power(2, 10))                # 1024 —— 位置,按顺序匹配
print(power(exponent=10, base=2))  # 1024 —— 关键字,与顺序无关
print(power(2, exponent=10))       # 1024 —— 先位置后关键字
易错点:位置实参必须在前

一旦一次调用里出现了一个关键字实参,它之后的每一个实参也都必须是关键字。 power(base=2, 10) 是一个 SyntaxError。把位置实参放在前面,然后才是关键字。

3.2 星号:打包与解包

在解决 call_count 之前,先在 * 运算符自己的地盘上认识它。它一开始和函数毫无关系——它讲的是把好几个值打包(packing)成一个对象,以及把一个对象解包(unpacking)回好几个名称。这两件事你其实都做过,只是没留意。

从最朴素的情形开始。在 = 右边写上好几个值,就把它们打包成一个元组;在左边写上好几个名称,就再把它解包开来:

示例:不带星号的打包与解包
a = 1, 2, 3           # 打包:a 是元组 (1, 2, 3)
print(a, type(a))

a, b, c = 1, 2, 3     # 解包:一个名称对一个值
print(a, b, c)        # 1 2 3

到这里规则还很严格——名称有几个,值就得有几个。个数给错,Python 就拒绝,因为它无从猜测你想漏掉哪一个:

a, b = 1, 2, 3            # ValueError: too many values to unpack (expected 2)

星号放松的正是这一条。一个带 * 的名称表示“剩下多少都算”,并把它们收进一个列表

示例:一个星号吸收剩余部分
a, *b, c = 1, 2, 3, 4, 5
print(a, b, c)        # 1 [2, 3, 4] 5   —— b 拿走了中间那段

*a, b = 1, 2, 3, 4, 5
print(a, b)           # [1, 2, 3, 4] 5

a, *b = 1, 2, 3, 4, 5
print(a, b)           # 1 [2, 3, 4, 5]

*a, b = [1]
print(a, b)           # [] 1           —— “多少都算”也可以是零个

从“剩下多少都算”能推出两件事。带星号的那个名称最后总是一个列表,哪怕它只接到一项、或者一项也没接到。而星号至多只能有一个,因为两个会有歧义——*a, *b = 1, 2, 3SyntaxError,因为没有任何东西能决定第一段在哪里结束、第二段从哪里开始。

还有一条边界值得试探。解包需要有东西可以迭代,所以 *a, b = [1] 可以,而 *a, b = 1 不行——光秃秃的 1 不是序列,Python 会直说:TypeError: cannot unpack non-iterable int object

课堂练习:预测解包结果

先在纸上做出来,然后再运行。对每一条,给出每个名称的值和类型,或者说明它为什么失败。

  1. a = 1, 2, 3
  2. a, b, c = 1, 2, 3
  3. a, b = 1, 2, 3
  4. a, *b, c = 1, 2, 3, 4, 5
  5. *a, b, c = 1, 2, 3, 4, 5
  6. *a, *b = 1, 2, 3, 4, 5
  7. *a, b = 1

现在看对调用真正要紧的那个方向。把 * 放在一个已有序列的前面,它做的是相反的事:把那一个对象重新铺开成一个个独立的值。

示例:星号把一个序列铺开
nums = [3, 1, 4, 1, 5]
print(nums)            # [3, 1, 4, 1, 5] —— 一个实参,一个列表
print(*nums)           # 3 1 4 1 5       —— 五个独立的实参
print(max(*nums))      # 5 —— 等同于 max(3, 1, 4, 1, 5)

留意 print(nums)print(*nums) 的差别:前者交给 print 一个列表对象,后者交给它五个独立的实参。所以 * 放在名称上是打包,放在上是解包——一个符号,两个相反的方向,得到哪一个取决于它站在 =(或调用)的哪一边。

双星号对字典做同样的事。* 把一个序列铺成位置值,** 则把一个映射铺成 key=value 对——在合并两个字典时看得最清楚:

示例:** 解包一个映射
dict1 = {"a": 1, "b": 2, "c": 3}
dict2 = {"d": 4, "e": 5, "f": 6}

combined = {**dict1, **dict2}       # 两个都铺进一个新字典
print(combined)                     # {'a': 1, 'b': 2, ..., 'f': 6}

3.3 变长形参:*args**kwargs

§3.2 讲的全是普通的值。现在把这同样的两个运算符对准一个形参列表call_count 的难题就迎刃而解了。

把个数未知的实参传给一个函数要两步,而它们正是你刚看过的那两个方向:

  1. 打包——在定义里,*args 把抵达的、无论多少个位置实参收进一个元组。
  2. 解包——在函数体里那次调用中,*args 又把那个元组铺开成一个个独立的实参,交给被包住的函数。
示例:用 *** 收集实参
def total(*args):
    print("args 是", args)      # 一个装着所有传入项的元组
    return sum(args)

print(total(1, 2, 3, 4))        # args 是 (1, 2, 3, 4) -> 10

def show(**kwargs):
    for key, value in kwargs.items():
        print(key, "=", value)

show(a=1, b=2)                  # a = 1 / b = 2  (kwargs 是一个字典)

argskwargs 这两个名字纯属约定——干活的是 ***。也要注意它们各自是什么args 是一个元组,kwargs 是一个字典,所以第 1 章里关于元组和字典的一切,在函数体内部全都适用。

现在 call_count 什么都数得了。它把抵达的一切打包进 args,又在传出去的路上把它解开:

示例:call_count,完成版
def call_count(func, *args, x=[0]):
    print(f"calling {x[0] + 1} times")
    x[0] += 1
    func(*args, sep=", ")           # 把元组重新铺开

call_count(print, "hello", "python", "world")
# calling 1 times
# hello, python, world

那个 sep=", " 正是用来证明这一点的。如果 args 是作为一个元组整体传过去的,print 会带着它自己的括号和逗号把它显示出来;正因为星号把它解开了,print 收到的是三个独立的字符串,并用我们要求的分隔符把它们连起来。试着删掉那个星号再比较一下。

一个用 *args, **kwargs 接收并转发两者的包装器,可以挡在任何函数前面——这正是 2.4 里装饰器的形状。

示例:一个透传的高阶函数
def call_it(func, *args, **kwargs):
    print("正在调用", func.__name__)
    return func(*args, **kwargs)        # 原样转发一切

print(call_it(max, 3, 7, 2))            # 正在调用 max -> 7
call_it(print, "a", "b", "c", sep=", ") # 正在调用 print -> a, b, c
核心概念:*args**kwargs

定义里,*args 把多出来的位置实参聚成一个元组,**kwargs 把多出来的关键字 实参聚成一个字典。在调用里,*** 把一个序列或字典重新铺开成一个个独立的 实参。这和 §3.2 的打包/解包是同一回事,只不过用在了形参列表上。

有两条顺序规则,两条都来自 Python 需要把实参彼此区分开:一次调用里位置实参 必须排在关键字实参之前;而在定义里,顺序是普通形参、然后 *args、然后 **kwargs,带默认值的形参排在不带默认值的之后。

课堂练习:变长实参
  1. my_sum(*args),返回它收到的、无论多少个数字之和,用两个、再用五个数字测试。
  2. **kwargs 是一个字典,所以它有 .keys().values().items()。写 sum_of_kwargs,使 sum_of_kwargs(Alice=5, Bob=3, Charlie=4) 返回 12,再写一个版本同时报告谁贡献得最多。
  3. 给定 pair = (3, 4),用 power(*pair) 调用 §3.1 的 power,确认它返回 81
  4. 重写 call_count,让它不再需要 x=[0] 默认值,改用 2.2 §6 的那个计数器。你更愿意把哪个版本交给别人?为什么?

4. 把函数作为返回值:闭包 课件 课件

“高阶”的另一半是返回一个函数。当一个函数定义了一个内层函数并把它返回时,内层那个会带着它的外层(enclosing)作用域一起走——也就是造出它的那个函数的局部名称(回忆 2.2 的外层作用域)。一个内层函数连同它仍在引用的那些外层名称捆在一起,就叫做闭包(closure)

示例:一个造函数的函数
def make_linear(a, b):
    def line(x):
        return a * x + b      # 'a' 和 'b' 来自外层作用域
    return line

double_plus_one = make_linear(2, 1)
triple = make_linear(3, 0)

print(double_plus_one(5))     # 11
print(triple(5))              # 15

下面的图把 make_linear(2, 1) 返回的那一瞬间定格下来。它的帧里有 a → 2b → 1,以及刚刚建好的内层函数 line;那条绿色箭头把这个函数交回给全局名称 double_plus_one

memory: 堆 stack: 调用栈 objects: ln: 一个函数 i2: 2 i1: 1 globals: 全局命名空间 double_plus_one -> ln @return frame: make_linear(a, b) a -> i2 b -> i1 line -> ln

现在看看什么存活了下来。make_linear 返回的那一刻,上面那个帧就被丢弃——可等到你调用 double_plus_one(5) 时,ab 还在那儿。这正是 2.1 那个堆的想法被推到了尽头:是临时的,但对象 21 没有被释放,因为返回的那个闭包仍在引用它们。闭包恰恰就是这个——一个函数,连同它一直让其存活的、堆里的一小块私有空间。

格言的现身:`make_linear` 的帧消失了,但 `a` 和 `b` 是堆里的对象,被那个指着它们的闭包保活着。每一次调用 `make_linear` 都会造出一个*新*闭包,带着它自己捕获的值。

同样的想法也修好了 2.1 §7 的那个函数工厂。2.2 §6.1 里的 make_f(i) 一直就是一个闭包——每个被返回的 f 都让自己捕获的那个 i 存活着,方式和 double_plus_one 留住 ab 一模一样。现在这个名字终于有了。

要让内层函数改变一个被捕获的名称(而不只是读它),用 nonlocal——正是 2.2 那个关键字。这就给出了我们在那里造过的计数器,现在总算能叫出它的正式名字:

示例:一个会计数的闭包
def make_counter():
    calls = 0
    def step():
        nonlocal calls        # 重新绑定外层的 'calls',而不是新建一个局部
        calls += 1
        return calls
    return step

c = make_counter()
print(c(), c(), c())          # 1 2 3
d = make_counter()
print(d())                    # 1 —— 一个全新的、独立的计数器

把它和 §2 里 x=[0] 版本的 call_count 并排放着看。两者都在多次调用之间记住一个计数;差别在于这份记忆住在哪里。闭包把它放在一个被捕获的外层名称里,私有,且每个计数器各有一份。默认实参那个花招把它放在函数签名里,对每个调用者都可见,也能被任何一个调用者重置。行为相同,而其中一个对此是坦白的。

核心概念:闭包

闭包是一个内层函数,连同它仍在引用的外层作用域名称。闭包在外层调用返回之后 依然让那些对象存活,从而给内层函数一份私有的、持久的记忆。

闭包不只是一个计数小把戏——它们让你把数据和作用于它的行为捆在一起,同时把数据保持私有。下面的示例造了一些简单的游戏角色。每一次 make_player 调用都捕获它自己的 hpdamage,并返回一小束共享它们的函数。注意 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 调用的同一个 hpdamage 之上,于是它们彼此协作: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 调用活得更久。而且因为 attacktake_damagestatus 来自同一次调用,它们为 hp 共享同一个单元——通过其中之一改动它, 其余的立刻就看见。同胞闭包靠它们共同持有的堆单元被绑在一起。

深入了解:记住一个值的三种办法,以及如何取舍

到这里,第 2 章已经展示了三种让一个值在多次调用之间存活的办法。把它们并排放着 看很值得,因为这个取舍是实打实的。

全局(2.2 §6)最生硬:计数是一个模块层的名称,任何东西都能读它、覆盖它, 而这个函数也只有挨着它才说得通。可变默认值——call_countx=[0]—— 利用 2.1 §6 那个共享默认值的陷阱,把计数藏进函数签名;它紧凑,在真实代码里也 确实出现,但它会漏,因为任何调用者都可以自己传一个 x,悄悄把计数清零。 用 nonlocal闭包把计数放进一个被捕获的外层帧里:私有、每个计数器各一份, 而且它说的就是它的意思。

优先用闭包。认识另外两种,是为了读懂别人的代码——也为了看出这三者其实是关于 一个名称住在哪里的同一个想法,而这条线索贯穿了 2.1、2.2 和这一页。

5. lambda:没有名字的函数 课件

很多时候,你想传到某处去的那个函数小到给它一个 def 加一个名字都显得笨重。lambda 把一个函数就地作为一个表达式造出来,没有名字。它的函数体是单个表达式,其值会被自动返回。

语法是 lambda 形参: 表达式。下面两种定义是等价的:

示例:lambda 不过是一个紧凑的函数值
def square_def(n):
    return n * n

square_lambda = lambda n: n * n      # 行为相同,就地写出

print(square_def(6), square_lambda(6))   # 36 36

lambda 大放异彩之处,是把一个一次性的函数传给一个高阶函数,这样你就永远不必给它起名。

示例:lambda 作为 key 函数
people = [("Ada", 36), ("Bob", 41), ("Cleo", 29)]

print(sorted(people, key=lambda person: person[1]))   # 按年龄排序
print(max(people, key=lambda person: person[1]))      # 最年长者 -> ('Bob', 41)
易错点:让 lambda 保持小巧

一个 lambda 只能装单个表达式——没有语句,没有多行。这是有意为之:如果一个函数 需要的不止一个简短表达式,就给它一个真正的 def 和一个有描述性的名字。把 lambda 留给那些你传给 sortedmaxmap 之流的、用完即弃的小函数。

小结

Python 里的函数是一等对象,所以它们能像任何值一样被命名、存储、传递、返回:

想法 含义
一等 函数可以被起别名、放进列表/字典、传入、返回
高阶函数 把函数作为实参、或返回一个函数
*args / **kwargs 定义时收集多出来的实参;调用时把它们铺开
闭包 一个内层函数,加上它一直保活着的外层名称
lambda 一个小巧的匿名函数,作为单个表达式就地写出

这不是五个各自独立的小把戏,而是同一个想法的不同侧面:函数是一个值。接下来,2.4 实践用例 会把这个想法用在你课程围绕展开的五个模式里——装饰器、递归、map/filter/reduce、生成器、错误处理——它们每一个都是乔装打扮的高阶函数。