定义函数
引言
到目前为止,你的程序都还是一条直线式的脚本:这里一个列表,那里一个循环,再加一个条件判断决定走哪条路。这样写没问题,直到你发现自己在一遍又一遍地写同样的那几行——换算这个温度、清洗那个字符串、给又一个学生打分。函数(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 = 1、b = 2,你想要它们各自的平方。没有函数的话,你每一次都得把配方亲手写一遍——先 a * a,再 b * b——既重复自己,又在每一处都可能敲错。定义 square 把那份配方只写下一次;调用它——square(a),然后 square(b)——就在任何需要的地方运行同一份配方。定义一次,想调用多少次就多少次。 这种拆分——先写配方,再按需运行——正是函数存在的全部理由。
易错点:是括号在调用函数
square 和 square(3) 不是一回事。square 是函数对象本身——那份配方;
square(3) 才调用它并求值为 9。写 square 而不加括号,你什么都没运行:
print(square) 只会显示 <function square at 0x...>。要真正使用一个函数,
你必须调用它,带上括号。
下面的示例定义了一个单行函数并调用它两次。注意:定义只运行一次;每次调用都会用给它的那个实参重新运行函数体。
示例:定义与调用
最后那一行值得停一停:square 是一个有类型的普通对象,跟 3 或 "hi" 一样。我们会在 2.3 里大力回到这个想法。
课堂练习:你的第一批函数
- 写一个函数
double(x),返回x * 2,并打印double(21)。 - 写一个函数
greet(name),返回字符串"Hello, " + name + "!",并对两个不同的名字打印结果。 - 先预测
square(2.5)返回什么,再运行。明明我们从没提过浮点数,它为什么照样能算?
2. 形参就是局部赋值
这里有一个让其余一切都豁然开朗的心智模型:调用一个函数,就是把它的实参赋给它的形参。写 square(3),效果上等于在函数内部做了 n = 3,然后运行函数体。形参不过是局部名称(local names)——只在这次调用期间存在的名称。
下面的示例给出一个形参和一个辅助名称。n 和 result 都是局部的:它们在调用开始时被创建,调用结束时就消失。
示例:形参与一个局部辅助名称
把最后一行取消注释再运行:在函数外面,result 根本不存在。这不是 bug——这正是重点所在。每次调用都拿到自己私有的一套名称,于是各次调用不会彼此误伤。可是这些私有名称在调用运行期间住在哪里呢?这个问题把我们径直带向调用栈。
不是每个函数都需要输入。一个函数可以完全没有形参——它的括号就空着。你仍然用 () 来定义它,仍然用 () 来调用它;只是没有东西要传而已。
示例:一个没有形参的函数
这是 §1 那条规则最清楚的一个例子:空的 () 仍然必不可少。greet() 调用函数并给你那个字符串;greet 本身只是那个函数对象,搁在那里没被使用。
3. 一次调用是怎么运行的:调用栈上的帧
当你调用一个函数时,Python 依次做三件事:
- 它为这次调用创建一个崭新的帧(frame)——一个保存本次调用局部名称的私有命名空间——并把它放到调用栈(call stack)的顶上。
- 它在这个新帧里把形参绑定到实参(也就是 §2 的那些局部赋值)。
- 它在这个帧里运行函数体。当函数体结束(或遇到
return)时,帧被丢弃,控制权交回给当初发起调用的那一方。
所以在任意一刻,调用栈不过是当前正在进行的那些调用的帧叠成的一摞。最底下的帧是模块本身(你在顶层定义的全局名称);你每发起一次调用,就在上面叠一个帧,每一次 return 又弹掉一个。
核心概念:帧、调用栈、堆
帧是某一次正在运行的调用的私有命名空间;它保存这次调用的局部名称。帧叠在 调用栈上——每个进行中的调用一个。帧里的名称行为和第 1 章里完全一样:每个 都是一张指向某个对象的标签,而这些对象住在堆(heap)里,被大家共享。 名称住在帧里;对象住在堆里。
下面的图展示了调用 square(4) 运行时的样子,它由顶层的 number = 4、再 answer = square(number) 发起。图分三部分,并排放着。中间是堆,装着真正的对象——那个函数,以及整数 4 和 16。左边是全局命名空间(模块自己的名称);右边,调用栈里放着一个虚线的帧,对应正在运行的那次 square 调用。每个名称,不管住在哪儿,都只是一张指向中间某个对象的标签。注意 number 和 n 指向同一个 4:传一个实参,就是把一个新的局部名称绑定到那个一模一样的对象上——什么都没复制。而 answer 目前什么都没指向:这次调用还没返回。
square 帧特意画成虚线边框:它是临时的。它只在这次调用运行期间存在,而下一步——return——正是让它消失的那一步。
深入了解:堆——以及少数几个真正静态的对象
那块共享的对象区域就是堆:解释器在对象被创建时分配出去的内存,并在没有谁
再指向它时(通过引用计数和垃圾回收)把它收回。几乎一切都住在这里——列表、字符
串、函数、你的 16。例外是 CPython 在启动时一次性预先创建、之后从不释放的少数
几个对象:None、True、False,以及从 −5 到 256 的小整数。那些确实坐落在
静态存储里,这正是为什么 id(4) 永远不变、4 is 4 永远为 True——也就是我们在
1.1 见过的现象。其余一切,就想成堆。
延伸阅读:CPython 如何管理堆
选读,远超本课程——如果你想真切看看堆:
- Memory Management — Python/C API —— 官方综述:“一个容纳所有 Python 对象和数据结构的私有堆。”
Objects/obmalloc.c—— CPython 真正的对象分配器(pymalloc):arena、pool 与 block。- Python 内存管理(视频) —— 一段可视化讲解。
小技巧:查看实时的调用栈
Python 能把当前实时的帧栈展示给你看。你很少需要这个,但它能把概念变得具体—— 运行它,从当前正在运行的那次调用、由内向外读出各个函数名直到模块:
我们不会纠缠于其机械细节——不需要你亲手压栈、弹栈。要带走的唯一一个想法就是:一次正在运行的调用有它自己的、装着局部名称的帧,而这些名称指向共享内存里的对象。 这就是理解 return 所需要的全部。
4. return 语句
现在我们能精确地说出 return 到底做了什么了,因为它正是我们这幅图的两半——调用栈与堆——交汇的唯一地点。
当一次调用运行 return result 时,result 所指向的那个对象被交还给调用者,随后这次调用的帧被丢弃。调用者会把它自己的某个名称绑定到同一个对象上。所以 return 既不搬动也不复制那个对象——它仍稳稳待在堆里——它只是让调用者帧里的一个名称指向它。
下面的图把 answer = square(number) 返回的那一瞬间定格下来。那条绿色箭头就是返回本身:全局命名空间里的 answer 此刻被绑定到了 result 所指的、一模一样的那个 16。再过一瞬,square 帧——连同 n、result——就被丢弃;但 16 在堆里活了下来,因为 answer 仍指着它。
这就是为什么一个在函数内部算出的结果能够“逃出”,即便它的帧被销毁了:return 交还的不是名称,而是那个对象(更准确地说,是堆中那个对象的一个引用),调用者把这个引用留住了。帧只是临时的脚手架;它产出的对象比它活得更久。这正是图所揭示的那种相互作用——调用栈保存着干活的名称,堆保存着长存的对象,而 return 正是让幸存的调用者里的一个名称,够到那个最初在即将消失的帧里建起来的对象。
一个函数可以返回任意对象,包括一个元组——这正是一次性交还多个值的惯用写法(回忆 1.2 的元组打包)。
示例:返回多个值
那如果一个函数从不写 return 呢?它仍然会返回点东西:对象 None(来自 1.1、那个唯一的“没有值”标量)。一个只做事——打印、修改一个列表——的函数,交回的就是 None。
易错点:打印不等于返回
一个把值 print 出来的函数,并没有把它返回——那个值去了屏幕,而这次调用交回的
仍是 None。如果调用者需要这个结果(要存起来、要相加、要往下传),函数就必须把它
return。初学者常常在函数里 print,然后纳闷为什么 total = compute() 让 total
成了 None。用我们的帧图来说:print 把字符送上了屏幕,却没有绑定任何对象带回跨越
边界,于是回来的是 None。
课堂练习:return
- 写
add(a, b),返回a + b。再写第二个函数,改为打印a + b。分别调用,把结果存进一个变量并打印那个变量。解释其中的差别。 - 写
divmod2(a, b),把a // b和a % b作为一个元组一起返回,并在调用处解包。 - 一次调用返回后,它的帧就被丢弃了。用一句话解释返回的对象为何仍能存活。
5. 默认形参值
一个形参可以带一个默认值(default):当调用者省略那个实参时就用它。这非常适合一个你通常想固定、偶尔才想改的设置——一个误差容限、一个分隔符、一个底数。
示例:一个默认形参
但默认值藏着一个著名的意外,而它正是从我们的内存图里直接推出来的。默认值只在 def 运行时被求值一次——而不是每次调用都求值——得到的对象被连同函数对象一起存在堆里。于是每一次用到这个默认值的调用,共享的都是那同一个对象。如果默认值是不可变的(比如字符串 "Hello"),你永远不会察觉。如果它是可变的(比如一个列表),每次调用都修改那同一个共享对象,改动就越积越多。
示例:被共享的可变默认值
你大概以为每次调用都会有一个新的 []。可只有一个列表对象——它在 def 运行时诞生,存在函数上,每次取用默认值时都被重复使用。每次调用都往堆里那同一个对象上追加。
易错点:永远不要用可变默认值;用 None 作哨兵
因为可变默认值只被创建一次又被共享,用 []、{} 或 set() 作默认值几乎总是
一个 bug。标准的修法是:默认用 None,在函数体里再造一个新对象——这样每次调用
都新建一个对象,而不是在定义时只造一次:
课堂练习:默认值
- 写
power(base, exponent=2),使power(5)返回25,power(5, 3)返回125。 - 运行那个有问题的
append_to,并用默认列表住在哪里来解释,为什么第二次调用会显示两个元素。 - 用
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。把它们当作精确的
文档,而不是保证。
小结
一个函数把一段有名字、可复用的行为打包起来,用 def 创建、靠调用运行。每次调用都在调用栈上拿到一个私有的帧:Python 压入帧,把实参作为局部名称绑定到形参,运行函数体——然后丢弃这个帧。名称住在帧里;它们所指的对象住在共享的堆里。这道分界正是 return 所跨接的:它把一个对象带过帧的边界,让调用者里的一个名称指向它,之后被调用者的帧消失、而对象存活下来。一个形参可以带一个默认值——但可变默认值只被创建一次又被共享,所以请默认用 None、在内部新建。函数本身也是一个普通的对象,这一点我们会在 2.3 里充分利用——那里也会细看实参抵达形参的不同方式(位置、关键字,以及变长的 *args/**kwargs)。
接下来,2.2 命名空间与作用域 会聚焦于这些帧:Python 如何把名称组织进各个命名空间(局部、外层、全局、内置),以及当好几个 x 同时在场时,它用什么规则决定你指的是哪一个。