跳转至

定义函数 课件

函数也是一种对象——一个知道怎么做事的对象。

引言

到目前为止,你的程序都还是一条直线式的脚本:这里一个列表,那里一个循环,再加一个条件判断决定走哪条路。这样写没问题,直到你发现自己在一遍又一遍地写同样的那几行——换算这个温度、清洗那个字符串、给又一个学生打分。函数(function)让你把这样一段行为只写一次,给它起个名字,然后在任何需要的地方它,想用多少次就用多少次。

这其实就是我们在 1.1 见过的抽象思想,只是又往前迈了一步。在那里,一个名称代表一个值,于是你可以谈论“数 x”而不必先确定它到底是几。函数则让一个名称代表一段计算——“把它平方”“向那个人问好”——而不必每次都把步骤重写一遍。而且,正如本章格言所说,函数本身也不过是又一个对象:它有类型、有标识,可以被传来传去、被存起来,跟一个数或一个列表别无二致。到本章末尾我们会重重地依赖这一点;眼下,先从简单处起步。

一路上我们还会遇到第二个、悄悄支撑着函数一切行为的概念:当一个函数运行时,Python 会给它一块私有的工作空间,叫做帧(frame)。我们会在一需要它时就非正式地引入帧——正是帧让 return 变得讲得通——之后凡是有帮助的地方就反复用它,一直用到本章后面的递归。

和之前一样,这里大多数代码都是可运行的——按 Run(或 Ctrl/Cmd+Enter)执行、修改、再运行。

1. 定义与调用一个函数 课件

你用 def 关键字创建一个函数:一个名字,一对括号里列出的形参(parameters),一个冒号,再加上缩进的函数体(body)。写下 def不会运行函数体——它只是创建那个函数对象,并把名字绑定到它上面。函数体只有在你调用(call)这个函数时才运行,调用的写法是写出它的名字、后面跟一对括号。

一个函数定义永远是同一个形状——一行头部,再加缩进的函数体。在下面的模板里,用 红色 标出的部分是固定的 Python 语法,必须一字不差地照写;用 蓝色斜体 标出的是占位符,你要用自己的内容替换它们:

def 名称(形参):
    函数体
    return 返回值

从左到右读这一行头部:关键字 def、函数的名字、一对装着形参括号(列表可以是空的)、以及收尾的冒号。其下所有缩进的内容就是函数体。红色的那些部分——def():return——不归你改:把 def 拼错、漏掉冒号、或忘了括号,Python 都会以 SyntaxError 停下。

之后要调用这个函数,你写出它的名字、跟上一对括号,括号里放任意实参(arguments)

名称(实参)

那对括号不是可选的:调用永远名称(...)。哪怕函数不接收任何参数,括号也照样要写——你仍然写出空的 (),比如 greet()。光秃秃的名字 greet、不带括号,只是那个函数对象本身;不加 () 它什么都不会运行。

核心概念:函数、形参、实参

函数是一段可复用的、有名字的行为。定义里列出的名称是它的形参 (parameters);你调用时实际传入的值是实参(arguments)。一条 return 语句把一个结果交还给调用者,并结束这次调用。

定义 vs. 调用:两个新动词

定义(define)调用(call)这两个词是新的,值得放慢脚步说清楚,因为本章其余的一切都取决于你能不能把它们分开。

定义一个函数,其实只是一种变量创建。在第 1 章里,x = 5 创建了一个名字 x,把它绑定到一个对象——数 5——上。写 def square(n): ... 做的是同一件事:它创建一个名字 square,绑定到一个对象上。唯一改变的是对象的种类square 绑定到的不是一个数或一个列表,而是一个函数对象——一个存着配方的对象:“给定某个 n,把它自己乘以自己。”所以定义无非就是把一份配方写下来、贴上一个名字。此刻什么都还没算;没有谁被平方。你只是为以后准备好了那份配方。

这正是函数为何是一种如此强大的“变量”。一个数只是静静地拿着一个值;一个函数拿着的是行为——一段你可以按需运行的计算。

调用才是你真正使用那段行为的方式。回想一下你在第 1 章里怎么用普通变量:大多数时候你读取它们(打印它们的值)或修改它们。函数的用法不一样。你不会靠打印一个函数来让它干活——打印 square 只会显示 <function ...>,也就是那份配方本身。你要调用它:请这个函数执行它的配方,并交回一个结果。括号就是那个信号——square(3) 的意思是“用 n 等于 3 运行那份平方配方,把答案给我”。

这一区分为什么值得记牢,看个例子。假设 a = 1b = 2,你想要它们各自的平方。没有函数的话,你每一次都得把配方亲手写一遍——先 a * a,再 b * b——既重复自己,又在每一处都可能敲错。定义 square 把那份配方只写下一次;调用它——square(a),然后 square(b)——就在任何需要的地方运行同一份配方。定义一次,想调用多少次就多少次。 这种拆分——先写配方,再按需运行——正是函数存在的全部理由。

易错点:是括号在调用函数

squaresquare(3) 不是一回事。square 是函数对象本身——那份配方; square(3)调用它并求值为 9。写 square 而不加括号,你什么都没运行: print(square) 只会显示 <function square at 0x...>。要真正使用一个函数, 你必须调用它,带上括号。

下面的示例定义了一个单行函数并调用它两次。注意:定义只运行一次;每次调用都会用给它的那个实参重新运行函数体。

示例:定义与调用
def square(n):
    return n * n

print(square(3))    # 9
print(square(10))   # 100

print(type(square)) # <class 'function'> —— 函数也是对象

最后那一行值得停一停:square 是一个有类型的普通对象,跟 3"hi" 一样。我们会在 2.3 里大力回到这个想法。

名称指向对象:`def square(...)` 把名字 `square` 绑定到一个函数对象上——这与 `x = 3` 是同一种“名称—对象”绑定,只不过这个对象恰好是可调用的。
课堂练习:你的第一批函数
  1. 写一个函数 double(x),返回 x * 2,并打印 double(21)
  2. 写一个函数 greet(name),返回字符串 "Hello, " + name + "!",并对两个不同的名字打印结果。
  3. 先预测 square(2.5) 返回什么,再运行。明明我们从没提过浮点数,它为什么照样能算?

2. 形参就是局部赋值

这里有一个让其余一切都豁然开朗的心智模型:调用一个函数,就是把它的实参赋给它的形参。写 square(3),效果上等于在函数内部做了 n = 3,然后运行函数体。形参不过是局部名称(local names)——只在这次调用期间存在的名称。

下面的示例给出一个形参和一个辅助名称。nresult 都是局部的:它们在调用开始时被创建,调用结束时就消失。

示例:形参与一个局部辅助名称
def square(n):
    result = n * n     # 'result' 是一个新的局部名称
    return result

print(square(4))       # 16
# print(result)        # NameError:在外面 'result' 没有定义

把最后一行取消注释再运行:在函数外面,result 根本不存在。这不是 bug——这正是重点所在。每次调用都拿到自己私有的一套名称,于是各次调用不会彼此误伤。可是这些私有名称在调用运行期间住在哪里呢?这个问题把我们径直带向调用栈。

不是每个函数都需要输入。一个函数可以完全没有形参——它的括号就空着。你仍然用 ()定义它,仍然用 ()调用它;只是没有东西要传而已。

示例:一个没有形参的函数
def greet():
    return "Hello, class!"

print(greet())   # Hello, class! —— 是 () 在运行这个函数
print(greet)     # <function greet at 0x...> —— 不带括号,什么都不运行

这是 §1 那条规则最清楚的一个例子:空的 () 仍然必不可少。greet() 调用函数并给你那个字符串;greet 本身只是那个函数对象,搁在那里没被使用。

3. 一次调用是怎么运行的:调用栈上的帧 课件

当你调用一个函数时,Python 依次做三件事:

  1. 它为这次调用创建一个崭新的帧(frame)——一个保存本次调用局部名称的私有命名空间——并把它放到调用栈(call stack)的顶上。
  2. 它在这个新帧里把形参绑定到实参(也就是 §2 的那些局部赋值)。
  3. 它在这个帧里运行函数体。当函数体结束(或遇到 return)时,帧被丢弃,控制权交回给当初发起调用的那一方。

所以在任意一刻,调用栈不过是当前正在进行的那些调用的帧叠成的一摞。最底下的帧是模块本身(你在顶层定义的全局名称);你每发起一次调用,就在上面叠一个帧,每一次 return 又弹掉一个。

核心概念:帧、调用栈、堆

是某一次正在运行的调用的私有命名空间;它保存这次调用的局部名称。帧叠在 调用栈上——每个进行中的调用一个。帧里的名称行为和第 1 章里完全一样:每个 都是一张指向某个对象的标签,而这些对象住在堆(heap)里,被大家共享。 名称住在帧里;对象住在堆里。

下面的图展示了调用 square(4) 运行时的样子,它由顶层的 number = 4、再 answer = square(number) 发起。图分三部分,并排放着。中间是堆,装着真正的对象——那个函数,以及整数 416左边是全局命名空间(模块自己的名称);右边,调用栈里放着一个虚线的,对应正在运行的那次 square 调用。每个名称,不管住在哪儿,都只是一张指向中间某个对象的标签。注意 numbern 指向同一个 4:传一个实参,就是把一个新的局部名称绑定到那个一模一样的对象上——什么都没复制。而 answer 目前什么都没指向:这次调用还没返回。

memory: 堆 stack: 调用栈 objects: fn: 一个函数 i4: 4 i16: 16 globals: 全局命名空间 square -> fn number -> i4 answer frame: square(n) n -> i4 result -> i16
名称指向对象:一个帧不过是一个命名空间——一组名称——这些名称像第 1 章里一样指向共享的堆。全局命名空间没什么特别的;它只是最底下那个帧、也就是模块本身的命名空间。

square 帧特意画成虚线边框:它是临时的。它只在这次调用运行期间存在,而下一步——return——正是让它消失的那一步。

深入了解:堆——以及少数几个真正静态的对象

那块共享的对象区域就是:解释器在对象被创建时分配出去的内存,并在没有谁 再指向它时(通过引用计数和垃圾回收)把它收回。几乎一切都住在这里——列表、字符 串、函数、你的 16。例外是 CPython 在启动时一次性预先创建、之后从不释放的少数 几个对象:NoneTrueFalse,以及从 −5 到 256 的小整数。那些确实坐落在 静态存储里,这正是为什么 id(4) 永远不变、4 is 4 永远为 True——也就是我们在 1.1 见过的现象。其余一切,就想成

延伸阅读:CPython 如何管理堆

选读,远超本课程——如果你想真切看看堆:

小技巧:查看实时的调用栈

Python 能把当前实时的帧栈展示给你看。你很少需要这个,但它能把概念变得具体—— 运行它,从当前正在运行的那次调用、由内向外读出各个函数名直到模块:

import inspect

def inner():
    for frame in inspect.stack():
        print("frame:", frame.function)

def outer():
    inner()

outer()

我们不会纠缠于其机械细节——不需要你亲手压栈、弹栈。要带走的唯一一个想法就是:一次正在运行的调用有它自己的、装着局部名称的帧,而这些名称指向共享内存里的对象。 这就是理解 return 所需要的全部。

4. return 语句

现在我们能精确地说出 return 到底做了什么了,因为它正是我们这幅图的两半——调用栈——交汇的唯一地点。

当一次调用运行 return result 时,result 所指向的那个对象被交还给调用者,随后这次调用的帧被丢弃。调用者会把它自己的某个名称绑定到同一个对象上。所以 return 既不搬动也不复制那个对象——它仍稳稳待在堆里——它只是让调用者帧里的一个名称指向它。

下面的图把 answer = square(number) 返回的那一瞬间定格下来。那条绿色箭头就是返回本身:全局命名空间里的 answer 此刻被绑定到了 result 所指的、一模一样的那个 16。再过一瞬,square 帧——连同 nresult——就被丢弃;但 16 在堆里活了下来,因为 answer 仍指着它。

memory: 堆 stack: 调用栈 objects: fn: 一个函数 i4: 4 i16: 16 globals: 全局命名空间 square -> fn number -> i4 answer -> i16 @return frame: square(n) n -> i4 result -> i16

这就是为什么一个在函数内部算出的结果能够“逃出”,即便它的帧被销毁了:return 交还的不是名称,而是那个对象(更准确地说,是堆中那个对象的一个引用),调用者把这个引用留住了。帧只是临时的脚手架;它产出的对象比它活得更久。这正是图所揭示的那种相互作用——调用栈保存着干活的名称,保存着长存的对象,而 return 正是让幸存的调用者里的一个名称,够到那个最初在即将消失的帧里建起来的对象。

格言的现身:`16` 从来就没被困在 `square` 里——它是堆里的一个对象。`return` 不过是让另一个帧里的名称 `answer` 指向了它。

一个函数可以返回任意对象,包括一个元组——这正是一次性交还多个值的惯用写法(回忆 1.2 的元组打包)。

示例:返回多个值
def min_max(numbers):
    return min(numbers), max(numbers)   # 打包成一个元组

low, high = min_max([4, 9, 1, 7])       # 把它解包
print(low, high)                        # 1 9

那如果一个函数从不写 return 呢?它仍然会返回点东西:对象 None(来自 1.1、那个唯一的“没有值”标量)。一个只做事——打印、修改一个列表——的函数,交回的就是 None

易错点:打印不等于返回

一个把值 print 出来的函数,并没有把它返回——那个值去了屏幕,而这次调用交回的 仍是 None。如果调用者需要这个结果(要存起来、要相加、要往下传),函数就必须把它 return。初学者常常在函数里 print,然后纳闷为什么 total = compute()total 成了 None。用我们的帧图来说:print 把字符送上了屏幕,却没有绑定任何对象带回跨越 边界,于是回来的是 None

课堂练习:return
  1. add(a, b),返回 a + b。再写第二个函数,改为打印 a + b。分别调用,把结果存进一个变量并打印那个变量。解释其中的差别。
  2. divmod2(a, b),把 a // ba % b 作为一个元组一起返回,并在调用处解包。
  3. 一次调用返回后,它的帧就被丢弃了。用一句话解释返回的对象为何仍能存活。

5. 一个像样的函数:牛顿法 课件

到这里,一个真正的函数所需要的东西我们都有了——形参、函数体、return——所以来写一个真正派得上用场的。这个问题是数学的,也是全部计算中最有用的过程之一:给定一个函数,它在哪里穿过零?

假设你想要 \(\sqrt{2}\),手边却没有开方键。这等价于求下面这个函数的根

\[f(x) = x^2 - 2,\]

因为 \(f(x) = 0\) 恰好在 \(x = \sqrt{2}\) 时成立。牛顿法(Newton's method)用反复改进来找这样的根。从任意一个猜测 \(x\) 出发,注意到该点处的切线穿过零的位置是

\[x_{\text{new}} = x - \frac{f(x)}{f'(x)}.\]

这个交点是比你出发时更好的猜测。重复下去,猜测就以惊人的速度逼近真正的根。对 \(f(x) = x^2 - a\)\(f'(x) = 2x\),所以每一步不过是 \(x - (x^2 - a) / (2x)\)

剩下的唯一问题是何时停下。我们在猜测足够好时停下——当 \(|x^2 - a|\) 落到我们所选的一个容限(tolerance)以下。这个容限和别的形参没有两样,所以由调用者提供:

示例:用牛顿法求平方根
def sqrt_newton(a, tolerance):
    x = a / 2                              # 任何起始猜测都可以
    while abs(x * x - a) > tolerance:
        x = x - (x * x - a) / (2 * x)      # 走到切线的根
    return x

print(sqrt_newton(2, 1e-10))    # 1.4142135623746899
print(sqrt_newton(9, 1e-10))    # 3.0

对照 §3 和 §4 来读这段。每次调用都拿到自己的帧,里面装着 atolerancex;循环在那个帧里一遍遍重新绑定局部的 x;而 return x 在帧被丢弃之前,把最后那个对象交回给调用者。这里没有任何新东西——还是同一套机制,只不过在做一件值得做的事。

名称指向对象:`x` 不是一个被改写的盒子。每一次 `x = x - ...` 都在堆里造一个*新*的浮点数,并把局部名称 `x` 重新指过去。旧的值就被留在原地了。
课堂练习:牛顿法
  1. 在循环里加一行 print(x),运行 sqrt_newton(2, 1e-10)。它走了几步?每一步大约为你换来几位正确数字?
  2. 如果调用 sqrt_newton(2, 0) 会发生什么?用浮点数取值的道理解释,为什么这个循环可能永远停不下来。
  3. cbrt_newton(a, tolerance) 求立方根,其中 \(f(x) = x^3 - a\)\(f'(x) = 3x^2\)

这个函数我们还会再见两次。2.2 里它将展示函数内部的名称是如何被解析的;2.4 里我们会把它改写成递归——因为“改进猜测,然后再做同样的事”,正是递归所表达的。

6. 默认形参值 课件

牛顿法给我们留下了一个小小的麻烦。tolerance 这个实参每次调用都得敲一遍,尽管我们几乎总是想用同一个值——而且为了各次结果可比,我们通常也应该保持它不变。只有偶尔才想改动它。我们想要的,是先预置一个合理的值,需要时再覆盖它。

一个形参可以带一个默认值(default):当调用者省略那个实参时就用它。这非常适合一个你通常想固定、偶尔才想改的设置——一个误差容限、一个分隔符、一个底数。

示例:带默认容限的牛顿法
def sqrt_newton(a, tolerance=1e-10):       # 把常用的那个值预置好
    x = a / 2
    while abs(x * x - a) > tolerance:
        x = x - (x * x - a) / (2 * x)
    return x

print(sqrt_newton(2))            # 1.4142135623746899 —— 用了默认值
print(sqrt_newton(2, 1e-2))      # 1.4166666666666667 —— 更粗糙,也更快

同样的想法离开数学也一样自然——一句问候语通常是 "Hello",但不必非是:

示例:一个默认形参
def greet(name, greeting="Hello"):
    return greeting + ", " + name + "!"

print(greet("Ada"))                 # Hello, Ada!
print(greet("Bob", "Welcome"))      # Welcome, Bob!

有一条规则由语法本身推出:带默认值的形参必须排在不带默认值的形参后面。 def f(a, b=2) 没问题;def f(a=1, b)SyntaxError,因为 Python 无从判断你打算省略哪一个实参。

但默认值藏着一个著名的意外,而它正是从我们的内存图里直接推出来的。默认值只在 def 运行时被求值一次——而不是每次调用都求值——得到的对象被连同函数对象一起存在堆里。于是每一次用到这个默认值的调用,共享的都是那同一个对象。如果默认值是不可变的(比如字符串 "Hello"),你永远不会察觉。如果它是可变的(比如一个列表),每次调用都修改那同一个共享对象,改动就越积越多。

示例:被共享的可变默认值
def append_to(element, to=[]):     # 这个 [] 在 def 时被创建“一次”
    to.append(element)
    return to

print(append_to(12))   # [12]
print(append_to(42))   # [12, 42] —— 还是同一个列表,东西还在!
print(append_to(7))    # [12, 42, 7]

你大概以为每次调用都会有一个新的 []。可只有一个列表对象——它在 def 运行时诞生,存在函数上,每次取用默认值时都被重复使用。每次调用都往堆里那同一个对象上追加。

易错点:永远不要用可变默认值;用 None 作哨兵

因为可变默认值只被创建一次又被共享,用 []{}set() 作默认值几乎总是 一个 bug。标准的修法是:默认用 None,在函数体里再造一个新对象——这样每次调用 都新建一个对象,而不是在定义时只造一次:

def append_to(element, to=None):
    if to is None:
        to = []            # 每次调用都新建一个列表
    to.append(element)
    return to

print(append_to(12))   # [12]
print(append_to(42))   # [42] —— 如预期那样彼此独立
课堂练习:默认值
  1. power(base, exponent=2),使 power(5) 返回 25power(5, 3) 返回 125
  2. 运行那个有问题的 append_to,并用默认列表住在哪里来解释,为什么第二次调用会显示两个元素。
  3. None 哨兵的写法重写它,并确认各次调用现在彼此独立。
深入了解:文档字符串与类型提示

有两个习惯能让函数自带说明,也是现代 Python 的标准做法。文档字符串 (docstring)——函数体最开头的一个字符串字面量——记录这个函数做什么;help() 和编辑器会读它。类型提示(type hints)为形参和结果加上注解;它们不改变代码 的运行方式,但能记录意图、让工具提前发现错误:

def square(n: int) -> int:
    """Return the square of n."""
    return n * n

print(square.__doc__)   # 'Return the square of n.'

提示在运行时不被强制——square(2.5) 照样能用,返回 6.25。把它们当作精确的 文档,而不是保证。

7. 一个函数工厂,和一个让你意外的名称 课件

这是第二个特殊情形,而且它是个货真价实的谜题。因为函数是对象,我们可以在循环里造出好几个函数、把它们放进一个字典——一个小小的函数工厂(function factory)。每一个都本应记住自己的编号。

先读代码,预测 ff[3]() 会打印什么,然后再运行。

示例:从一个循环里造出五个函数
ff = {}

for i in range(5):
    def f():
        return i          # 这是哪一个 i?
    ff[i] = f

print(ff[3]())            # 不是 3
print(ff[0]())            # 也不是 0

五个函数每一个都返回 4。工厂确实造出了五个不同的函数对象——ff[0]ff[3] 真的是不同的对象——但它们对答案却众口一词,而这个答案是错的。

原因,正是关于函数内部名称最重要的那一件事。f 没有属于自己的 i f 的函数体里没有任何地方给 i 赋值,所以 i 不是 f 的局部名称之一。当 f 运行时,Python 只好到别处去找 i——于是它找到了循环遗留下来的那个 i,在全局命名空间里。等你调用 ff[3]() 的时候,那个循环早已结束,i 就停在 4 上。

所以这次查找并不发生在函数被定义时;它发生在函数被调用时。f 里面的名称 i 和循环里的名称 i同一个名称,被推迟到很晚才解析——而不是在定义时拍下的一份私有副本。

易错点:函数内部的名称是在函数运行时才查找的

只要一个函数在一处被定义、却在另一处被调用,这个坑就会出现——在循环里、在一串 回调里、在一部处理器字典里。函数并不会在 def 时把周围的值拍成照片。它只留下 那个名称,之后再对着那时的世界去解析。

第一个修法用的正是 §6 刚教过你的东西:把 i 做成一个带默认值的形参,让 f 有一个自己的局部 i——而默认值确实def 时求值,每轮循环各求一次:

ff = {}
for i in range(5):
    def f(i=i):           # 局部的 i 此刻就捕获了这一轮的值
        return i
    ff[i] = f

print(ff[3]())            # 3

注意变了什么:f 现在有了一个叫 i 的形参,所以 i 局部的,外面也就没有 什么可查的了。

这个修法管用,但它是个小花招——它借形参列表把一个值偷渡了进去。诚实的修法需要一个我们还没有的词汇:一种说清某个名称属于哪一个命名空间的办法,以及一种让内层函数抓住外层函数变量的办法。这恰好就是下一页的主题。

名称指代对象——而一个名称只有在某个命名空间里才有意义。`f` 里的 `i` 和循环里的 `i` 看起来一模一样,而这正是问题所在:它们*就是*同一个,因为 `f` 从没造过属于自己的那一个。

小结

一个函数把一段有名字、可复用的行为打包起来,用 def 创建、靠调用运行。每次调用都在调用栈上拿到一个私有的:Python 压入帧,把实参作为局部名称绑定到形参,运行函数体——然后丢弃这个帧。名称住在帧里;它们所指的对象住在共享的里。这道分界正是 return 所跨接的:它把一个对象带过帧的边界,让调用者里的一个名称指向它,之后被调用者的帧消失、而对象存活下来。

牛顿法(§5)把这一切用在了一个真问题上,而它的容限实参又引出了默认形参(§6)——连同那条告诫:可变默认值只被创建一次又被共享,所以请默认用 None、在内部新建。接着 §7 的函数工厂给我们留下一个没解完的谜题:在循环里造出的五个函数全都返回同一个数,因为函数内部的名称是在函数运行时、而不是定义时被解析的。

于是有两条线索从这一页引出。2.2 命名空间与作用域直接接手那个谜题:Python 如何把名称组织进各个命名空间(局部、外层、全局、内置)、它用什么规则判断你说的是哪一个 i,以及那个工厂的正确修法。而函数本身也是一个普通的对象——这一点 2.3 会充分利用,那里也会细看实参如何抵达形参(位置、关键字,以及解包运算符 ***)。牛顿法则会在 2.4 里作为递归重新登场。