📖 名词溯源:function 为什么叫「函数」? 英文 function 源自拉丁语 functio(执行、履行),本义是「做一件事」。数学里「函数」是「输入到输出的对应关系」(y = f(x)),编程里的函数继承了这层意思:给它输入(参数),它做处理,给出输出(返回值)。「函数」这个中文译名,正来自数学。
🎯 本章学习目标
- 理解函数「封装一段代码、起名、复用」的本质与价值。
- 掌握
def定义函数、以及调用函数的写法。 - 理解「参数」(喂进数据)和「返回值」(吐出结果)两个概念。
- 理解「作用域」:函数内外的变量为何互不相通。
- 理解函数调用的底层机制——调用栈。
9.1为什么需要函数:把重复代码打包
上一章的循环解决「同一段代码连续重复」,但还有一种重复:同一段逻辑,散落在程序的不同地方。
比如「计算长方形面积」这个逻辑,你可能在程序里三个地方都要用。没有函数,你只能把 长 × 宽 抄三遍:
area1 = 10 * 5
# ... 隔了很多行 ...
area2 = 8 * 3
# ... 又隔了很多行 ...
area3 = 12 * 6
问题很明显:
- 难维护:如果公式改成「长 × 宽 ÷ 2」(三角形),你得改三处,漏一处就出错。
- 难读:满屏都是
10 * 5,看不出「这是在算面积」。
函数(function)就是解决这个的:把一段代码打包,起个名字,之后用名字就能调用它。
核心思想 函数的本质是「封装 + 复用」:封装,把一段逻辑藏进一个名字里;复用,需要时喊名字就行,不必重写。这和第 2 章讲的「抽象」一脉相承——函数就是把「一段代码」抽象成一个「好记的名字」。
深入一步:函数的「接口契约」
把函数看成黑盒,使用它只需要一份契约:给它什么(参数)、还你什么(返回值)。契约之外的一切——内部变量叫什么、先算哪一步——都是黑盒的私事,使用者不必知道,作者可以随时重构。这份契约思维是工程协作的地基:航电软件按模块分工,每个团队只维护自己模块的「契约」,别人的黑盒拿来就用。与之配套的是一条经济学:一次修改,处处生效——面积公式若要改进(比如加入单位换算),只需要改函数体一处,几十个调用点自动全部升级。规模越大,函数的价值越大。
📐 例题 1 把重复的计算收进函数
机翼检查要分别计算三块检查区域的面积:10×5、8×3、12×6。不写函数时,同样的「宽乘高」表达式要抄三遍;请定义函数 rect_area(w, h),然后用三次调用打印三个面积(应输出 50、24、72)。
思路 找重复:三处都是「两个数相乘取结果」——把算式搬进函数体,把「会变的两个数」提升为参数 w、h。调用时把具体的数交过去,函数把算好的结果 return 回来。
解
def rect_area(w, h):
return w * h
print(rect_area(10, 5))
print(rect_area(8, 3))
print(rect_area(12, 6))
验证 机器实测输出三行:50、24、72——三次调用各得其所,乘法逻辑只写了一遍。
答:代码如上。⚠ 易错点:定义必须出现在第一次调用之前——Python 从上往下执行,调用写在 def 上面会报 NameError(名字还没被创建);以及 return 写成 print——调用处拿到的将是 None(本章后面细说)。
工程视角 一张检查单,本质就是一份「函数库」:每一条检查都是一次「输入当前状态 → 输出结论」的调用。把检查单条目写成函数,检查程序就从「一大段流程」变成「一串调用」——加一条检查只是加一行调用,单子怎么改,程序怎么变,文档与代码长成了同一个形状。
辨析:函数与循环,一对互补的「反重复」工具
第 8 章说循环消灭「时间的重复」(同一件事做 100 遍),函数消灭「空间的重复」(同一段逻辑写在 100 处)。判断口诀再进一层:「数据在变、逻辑不变」找循环;「位置在变、逻辑不变」找函数。工程上两者常常联手——循环体里调函数,一边重复处理每个数据,一边把每步的逻辑封装成黑盒,这正是真实数据处理代码的标准骨架。
再进半步:函数命名的三条行业惯例
函数做的是「动作」,所以名字习惯用动词开头:calculate_area、check_oil、print_report——读调用处就像读一句祈使句。其二,见名知义压倒一切:fuel_needed 好过 f,check_oil 好过 check——长一点不怕,含混才致命;唯一例外是数学味极浓的场景(sqr、fact),惯例单刀直入。其三,前缀表达意图:is_/has_ 开头多返回布尔(is_ready),check_ 开头多用于「检查并给出结论」,update_ 开头明示「会就地修改」——读名字就能预判函数的脾气。命名是写给未来读者的第一行注释,值得多想十秒钟。
9.2def:定义一个函数
在 Python 里,用 def(define 的缩写,意为「定义」)来定义一个函数:
def 函数名(参数):
函数体 # 缩进
看一个具体例子:
def say_hello():
print("你好!")
say_hello() # 调用函数,输出:你好!
say_hello() # 再调用一次,又输出一次
这里有个关键点:def 定义函数时,函数体并不会执行,只是「登记」了一个函数。只有当后面用 say_hello() 调用它时,函数体才真正运行。
把这点类比成生活:def 相当于「写了一张菜谱」;调用 才是「照着菜谱做一次菜」。写菜谱时不炒菜,照着做才炒。
要点 定义 ≠ 执行。写 def 只是「造工具」,调用(函数名())才是「用工具」。这也是为什么 print("Hello") 里的 print 要加括号——括号就是「调用」的信号。
🧠 一句话记忆 def 是「写菜谱」,调用是「照着做」。写了不调,等于没写。
深入一步:def 只造不跑,调用才跑
一个常见误解是「def 里的代码马上执行」。机器实测给出反证:
def check():
print("函数体执行了")
print("定义完成")
check()
输出顺序是「定义完成」在前、「函数体执行了」在后——执行到 def 那行时,Python 只做一件事:把函数体打包成一个函数对象,并把名字 check 绑到它上面;函数体一个字都没跑。只有调用 check() 那一刻,函数体才被「激活」。这解释了很多初学者困惑的现象:语法错误(比如函数体里少个冒号)在 def 行执行时就暴露——打包时要先通过语法检查;而 NameError(函数体里用了不存在的名字)要到调用时才炸——运行到那行才知道名字不存在。定义期管「语法」,调用期管「语义」,两个阶段各管一段。
📐 例题 2 函数名也是变量:不带括号时它是谁
定义 say_hello 后:①执行 say_hello();②执行 alias = say_hello 再 alias();③执行 print(say_hello)。预测三步各自的输出,再用机器验证。
思路 括号是「调用」的标志。带括号:执行函数体;不带括号:函数名就是一个普通变量,装着「函数对象」这个值——它可以赋给别的名字(alias),也可以被打印。
解
def say_hello():
print("你好!")
say_hello()
alias = say_hello
alias()
print(say_hello)
验证 机器实测输出三行:你好!、你好!、<function say_hello at 0x…>。前两行证明 alias 与 say_hello 绑定的是同一个函数对象——换名字照常调用;第三行打印的正是「函数对象」本身(0x… 是它在内存里的地址,每台机器不同)。
答:①你好!②你好!③函数对象的说明文字。⚠ 易错点:把「函数名」与「调用函数」混为一谈——写 alias = say_hello 时忘了加括号以为是笔误、随手补上——那就变成「先调用 say_hello,把返回值(None)赋给 alias」,随后 alias() 会报 TypeError:None 不能被调用。括号一念之差,语义天差地别。
辨析:为什么惯例是「定义在上、调用在下」
既然 Python 从上往下执行,函数定义必须先于第一次调用,于是文件自然长成「上半部分一堆 def、下半部分主流程」的形状——这不是审美,是执行顺序的必然。看一份新代码时也照这个路线读:先跳到文件底部看主流程(它像目录,列出程序要做哪几件事),再回头逐个读函数定义——主流程告诉你「做什么」,函数定义告诉你「怎么做」。
再进半步:函数是值,值能去的地方函数都能去
例题 2 的 alias 证明了一件比表面更深的事:函数是「一等公民」——它像数字、字符串一样是个值。值能做的事,函数都能做:存进变量(alias = say_hello)、放进列表、当参数传给另一个函数……最后这一项是函数式编程的大门:让「操作本身」成为被传递的数据——「排序时用哪个规则比较」「遍历时对每项做什么」,都可以把函数递进去。第 12 章讲排序时会真正用到它;现在先把「函数是值」这颗种子埋下,见到 key=len 这类写法时它自会发芽。
9.3参数:给函数「喂」数据
上面的 say_hello() 太死板了——它每次都说同一句话。真正有用的函数,应该能接收不同的数据,做出不同的结果。这个「接收数据」的通道,就是 参数(parameter)。
def greet(name):
print("你好," + name)
greet("小明") # 输出:你好,小明
greet("小红") # 输出:你好,小红
拆解一下:
def greet(name):定义时,name是参数,是一个「占位符」,表示「将来会有一个值从这里传进来」。greet("小明"):调用时,"小明"是实参(实际参数),它被「塞进」占位符 name。- 函数体里的
name,此时就等于"小明"。
一个函数可以有多个参数,用逗号隔开:
def add(a, b):
print(a + b)
add(3, 5) # 输出:8
🧠 一句话记忆 参数 = 占位符(定义时),实参 = 真值(调用时)。调用时实参按位置「对号入座」。
深入一步:三种传参写法,一张嘴说清
| 写法 | 样子 | 特点 |
|---|---|---|
| 位置参数 | f(1000, 4) | 按位置对号入座,最古老直接 |
| 关键字参数 | f(distance=800) | 指名道姓,顺序随意,可读性强 |
| 默认值参数 | def f(d, per_km=3) | 不传就用默认,最常用的可省 |
📐 例题 3 带默认值的燃油估算函数
定义 fuel_needed(distance, per_km=3):按「每公里 per_km 升」估算航段油耗。分别用四种方式调用并打印:①只传距离 1000;②传 1000 和 4;③只传关键字 distance=800;④关键字参数乱序 per_km=5, distance=200。
思路 默认值给参数兜底:per_km 不传就是 3。关键字参数「指名道姓」,跟位置无关——乱序也不怕。
解
def fuel_needed(distance, per_km=3):
return distance * per_km
print(fuel_needed(1000))
print(fuel_needed(1000, 4))
print(fuel_needed(distance=800))
print(fuel_needed(per_km=5, distance=200))
验证 机器实测输出四行:3000、4000、2400、1000——①默认每公里 3 升;②按 4 升覆盖默认;③关键字只给距离,per_km 仍走默认;④两个关键字乱序传入,各归各位。
答:代码如上,四个结果 3000/4000/2400/1000。⚠ 易错点两条:一是位置参数不许排在关键字参数后面——f(distance=800, 4) 直接 SyntaxError;二是默认值从右往左连续设置——def f(d=1000, per_km) 不合法(左边的 d 有默认、右边的 per_km 没有),要么都有,要么右边开始连续几个有。
深入一步:传的是「对象的引用」
「Python 到底是值传递还是引用传递」是经典争论,精确答案是:传对象引用(pass by object reference)。效果用一句口诀记住:函数里「原位修改」对象,外部看得见;「重新绑定」名字,只改了函数自己的局部。列表的 append 属于前者——在原对象上动手;x = 99 属于后者——让局部名字 x 指向新对象,外面的名字不受影响。
📐 例题 4 原位修改 vs 重新绑定
定义两个函数:add_item(items) 执行 items.append("坚果");set_new(x) 执行 x = 99。外部准备列表 snacks = ["能量棒"] 和整数 n = 5,分别调用两个函数后打印 snacks 与 n——哪个变了,哪个没变?
思路 add_item 拿到的是「同一个列表」的引用,append 在原对象末尾动手——外部自然看得见;set_new 里 x = 99 是把局部名字 x 换了个指向,n 与 x 从此无关。
解
def add_item(items):
items.append("坚果")
def set_new(x):
x = 99
snacks = ["能量棒"]
add_item(snacks)
print("调用后:", snacks)
n = 5
set_new(n)
print("调用后:", n)
验证 机器实测输出两行:调用后: ['能量棒', '坚果'];调用后: 5——列表被函数改了,整数没有。
答:snacks 变成 ['能量棒', '坚果'],n 仍是 5。⚠ 易错点:以为「传进去就能改一切」或「传进去什么都不能改」——都不对。能不能改,取决于函数体里做的是哪种操作:原位方法(append 等)影响外部,赋值重新绑定只留内部。第 10 章讲完列表的可变性后,这条规则的内涵还会再深一层。
工程视角 工业代码里的行规:能不动传入对象就别动——要「修改版」,就在函数里算好新值 return 回来,让调用者自己决定用哪个。这种「只读入参、只产出返回值」的函数叫纯函数,它最容易测试、最容易并行、也最不容易出诡异 bug。原位修改留给明确以「就地更新」为天职的函数,并在名字上明示(如 update_xxx)。
再进半步:形参、实参,一对常被混叫的名字
把术语钉牢:形参(parameter)是函数定义里那些占位的名字——def fuel_needed(distance, per_km=3) 里的 distance、per_km,它们像表格里的空栏;实参(argument)是调用时真正递过去的值——fuel_needed(1000) 里的 1000。「对号入座」说的就是实参填进形参的过程:位置参数按栏位顺序填,关键字参数直接报栏位名填。日常对话里「传个参数」两边都指,但读技术文档时分清这对词,很多句子会立刻通顺:「默认值绑定在形参上」「实参按位置求值」——说的都是同一套机制的两侧。
9.4返回值:函数「吐」结果
参数是「喂进去」的数据,返回值(return value)则是函数「吐出来」的结果。用 return 关键字:
def add(a, b):
return a + b # 把结果「吐」出去
result = add(3, 5) # result 拿到 8
print(result) # 输出:8
这里 return a + b 做了两件事:
- 算出
a + b的值(8)。 - 把这个值「送回」给调用者,并且函数到此结束。
于是 add(3, 5) 整个表达式就「变成了」8,就像它是个值一样。这就是函数调用能出现在 result = add(3, 5) 右边的原因。
| 动作 | 作用 | 结果去哪了 |
|---|---|---|
print(x) | 把值显示到屏幕 | 人看到,程序里没法继续用 |
return x | 把值交还给调用者 | 程序可以接着用它计算 |
⚠️ 新手最易混淆的点 print 和 return 完全不同!print 是「给人看」,return 是「给程序用」。一个函数可以打印很多东西,但真正的「产出」是 return 的值。想用函数的结果做后续计算,必须用 return,而不是 print。
深入一步:return 的三重身份
return 一词身兼三职:结束——执行到它,函数立刻收工,后面的语句一句不跑;交回——把右边的值送还给调用处;占位成值——「函数调用」这个表达式本身就是一个值,可以进运算、可以进 print、可以再当参数传。第三条常被初学者忽略:area = rect_area(10, 5) * 2 里,调用直接参与了乘法——函数调用就是值,与写 50 无异。
不写 return,函数返回什么?
机器实测:
def greet(name):
print("你好,", name)
result = greet("小明")
print(result)
输出两行:「你好, 小明」和「None」。greet 只打印、没 return——Python 规定这种函数返回 None(表示「没有值」的特殊值,第 5 章见过)。print 与 return 是两条完全不同的通道:print 是「说给人听」(往屏幕输出),return 是「递给程序」(把值交回调用处)。只打印不 return 的函数,调用处永远拿到 None——「程序里看起来有结果,代码里拿不到结果」的经典 bug 就是这么来的。
📐 例题 5 多 return 路径:成绩分档函数
定义 grade(score):不低于 90 返回「优秀」;不低于 60 返回「可以进入下一阶段训练」;其余返回「不及格,需补训」。验证 72、95、58 三个分数,并解释为什么中间档的条件只写 score >= 60 就够。
思路 return 一执行函数即结束——所以第二个 return 能被走到,说明第一个条件必然不成立。这正是第 7 章 elif 链「拦截」思想在函数里的翻版:排在前面的 return 替后面的挡掉了它已排除的区间。
解
def grade(score):
if score >= 90:
return "优秀"
if score >= 60:
return "可以进入下一阶段训练"
return "不及格,需补训"
print(grade(72))
print(grade(95))
print(grade(58))
验证 机器实测输出三行:可以进入下一阶段训练、优秀、不及格,需补训——72 落进第二档、95 第一档、58 直落兜底。
答:代码如上。⚠ 易错点:第二档照抄业务原文写成 score >= 60 and score < 90——不算错,但后半截是废话:走到这里必然已排除 90 分以上。让 return 的「提前结束」替你收缩条件,代码更短也更难写错。
辨析:返回值驱动决策,打印只是汇报
判断一个函数该 return 还是 print,问一句:「调用它的代码,要用这个结果做后续计算吗?」要用——必须 return;只是给人看的汇报——print。二者兼得的真实场景是「print 用于日志留痕,return 用于程序流转」:函数把结果 return 出去参与决策,顺带 print 一行记录在案。初学阶段最常见的坏味道,是把所有函数都写成「只 print 不 return」——程序每一步的产出都只存在于屏幕上,代码之间无法协作。函数之间递的是 return,不是屏幕。
再进半步:例题 5 的风格,学名叫「早退」
例题 5 那种「一串 if + return 置于函数开头」的写法,业界有个正式名字:早退模式(guard clause,卫语句)。它的对手是「大泥球嵌套」——把主逻辑整个裹进 if/else 的层层缩进里,读到第五层缩进时人已经晕了。早退把「不满足就先走人」的条件提到最前面,一次 return 一层筛,剩下的主干逻辑零嵌套、顶格写——函数读起来像安检口:不合格的旅客在门口就被请走,走到里面的都是「确定要处理」的情况。需要层层过滤的业务(参数校验、状态检查)优先用早退重写,可读性立竿见影。
9.5作用域:变量住在哪一层
函数会带来一个微妙的问题:函数里定义的变量,函数外能用吗?反过来呢?这就涉及 作用域(scope)——变量「可见、可用」的范围。
def my_func():
x = 10 # x 定义在函数内部
print(x) # 函数内能用,输出 10
my_func()
print(x) # 报错!函数外访问不到 x
原因在于:函数内部定义的变量,是「局部变量」,只在函数内有效。函数执行完,这些变量就「消失」了(它们的内存被回收)。函数就像一个小房间,房间里的东西,外面看不见。
反过来,函数可以读到外面(全局)的变量,但要修改它们需要特别声明。为了简单,先记住这条核心规则:
核心思想 函数内部 = 一个独立的小世界。里面定义的变量(局部变量)外界看不见、函数结束后就销毁;外面的变量(全局变量)函数可以读。这种隔离是好事——它让函数「自成一体」,不会互相干扰。
深入一步:LEGB——名字查找的四级路线
函数是独立房间,但房间不隔音:函数里用到的名字,Python 按固定的四级路线查找,一级找不到再下一级,找到即停:
| 层级 | 名字住在哪 | 例子 |
|---|---|---|
| L — Local | 当前函数内部 | 函数体里赋值过的变量 |
| E — Enclosing | 外层函数(套娃时才有) | 嵌套函数的外层函数变量 |
| G — Global | 模块最外层 | 文件顶层赋值的变量 |
| B — Built-in | 内置名字空间 | print、len、range…… |
四个字母由内向外排成 L→E→G→B,就是名字查找的路线图。反过来读更实用:内层看不见外层的「改」,但看得见外层的「读」——这正是「函数像独立小房间」的精确含义:墙是半透明的,光能进来,手伸不出去。
📐 例题 6 LEGB 查找与遮蔽
①嵌套函数版:模块层 x = "全局",outer 内 x = "外层函数的",inner 里只 print(x)——inner 打印哪个?②遮蔽版:msg = "全局",函数 shadow 内 msg = "局部" 并打印,函数外再打印——两次各输出什么?
思路 ①inner 自己没有 x,沿 LEGB 向外找:最近的一层是 outer(E 层)——在 E 命中,不再找 G。②shadow 里对 msg 赋值,msg 就成了 shadow 的局部变量(L 层),外部的同名变量被「遮蔽」但毫发无伤。
解
x = "全局"
def outer():
x = "外层函数的"
def inner():
print(x)
inner()
outer()
msg = "全局"
def shadow():
msg = "局部"
print("函数内读到:", msg)
shadow()
print("函数外读到:", msg)
验证 机器实测三行:外层函数的、函数内读到: 局部、函数外读到: 全局——①inner 拿到的是 E 层的值;②函数内外的 msg 是两个互不相干的名字。
答:①「外层函数的」;②函数内「局部」、函数外「全局」。⚠ 易错点:以为「函数里赋值会改到全局」——赋值只创建局部名字;也以为「同名就冲突」——同名变量分层共存,各回各家,谁也不覆盖谁。
global 与一个著名陷阱:UnboundLocalError
函数内确实需要修改全局变量时,得先声明 global count——机器实测:count 初值 0,函数里 global 后自增,调用两次,外部打印 count = 2,修改真实生效。但更大的价值在反例:
def broken():
y = y + 1 # UnboundLocalError!
机器实测报错 UnboundLocalError: cannot access local variable 'y' where it is not associated with a value。原因很微妙:函数体里只要出现对 y 的赋值,y 就被判定为局部变量——于是右边那个 y 在「局部 y 还没被赋值」时就被读取,炸了。哪怕外面明明有个全局 y,也帮不上忙。这个陷阱专挑「函数里给全局变量做自增」的写法——解法有二:小的共享计数用 return 接力(局部算好、外部接收),确需直接改再上 global。global 是重量级工具,能不用就不用:它把函数的「独立性」凿了个洞,用过 global 的函数从此和全局名字锁死,搬到别的程序就散架。
工程视角 作用域规则的本质是一场交易:用「看不见外面」换「不被外面打扰」。函数敢把辅助变量叫 temp、result、i,正是因为外界的同名名字与它无关;大型程序敢几万行并行开发,正是因为一个个函数房间各自隔音。理解 LEGB 不只为做题——它解释了「为什么函数能被安全地到处复制粘贴」。
再进半步:B 层也会被遮蔽——别给变量起名叫 print
LEGB 的 B 层(内置名)看起来遥不可及,其实一伸手就能盖住它:只要在函数里(或更糟,在全局)写一句 print = 5,print 这个名字就在你那一层「落户」了——之后调用 print("你好"),机器实测报 TypeError: 'int' object is not callable:名字找到了,但找到的是 5,而整数不能被调用。这类「把内置名占为己用」是新手最容易踩的隐形坑——代码在改那行之前全都正常,改完之后所有用到该名字的地方集体罢工。自查办法很简单:给变量起名时心里过一遍「这是不是 Python 自带的名字」——print、len、sum、max、min、list、str、int……这份黑名单会随着学习自然长在直觉里。
9.6底层原理:调用栈
最后,我们追到底:当程序调用一个函数时,内存里到底发生了什么?答案是——一个叫 调用栈(call stack)的结构。
调用栈是内存里专门开辟的一块区域,用来管理「函数调用的来龙去脉」。它像一叠盘子:每调用一个函数,就往栈顶放一个「栈帧」(stack frame);函数执行完,就把这个栈帧拿掉。
一个栈帧里存着什么?
- 参数:调用时传进来的值。
- 局部变量:函数里定义的变量。
- 返回地址:记住「函数执行完后,该回到哪里继续」。
看这个例子:
def add(a, b):
return a + b
def main():
result = add(3, 5) # 调用 add
print(result)
main()
底层原理 调用栈用「后进先出」的栈结构管理函数调用:最后调用的函数最先返回。栈帧里存的「返回地址」解释了第 9.5 节的一切——为什么函数结束能「回到原处」,为什么局部变量会消失。你写的每一个函数调用,背后都是栈帧的一次「压入」和「弹出」。
🧠 一句话记忆 调用函数 = 栈帧入栈,函数返回 = 栈帧出栈。局部变量住在栈帧里,所以函数一结束就消失。
深入一步:栈帧——调用栈上的「工作台」
每次函数被调用,调用栈上就摞起一块栈帧(stack frame)——函数专属的工作台,上面摆三样东西:参数的值(实参对号入座的落点)、局部变量(函数体里创建的名字)、返回地址(回哪儿继续执行的记号)。函数返回时,整块工作台连同行装一起被撤下——这就是「局部变量随函数结束而消失」的物理真相:不是清空,是整块工作台都拆了。后进先出的栈规则与「嵌套调用、逐层返回」天然绝配:最后被调用的函数一定最先返回——最上面的工作台先撤,撤一层,下面的正好接上。
📐 例题 7 三层调用的栈演变
函数 a 调用 b,b 调用 c;进入时各打印「进入 x」,b 在 c 返回后打印「离开 b」,a 在 b 返回后打印「离开 a」。不运行,先画出调用过程中的栈演变(每步栈里从底到顶有哪些帧),再运行对答案。
def c():
print("进入 c")
def b():
print("进入 b")
c()
print("离开 b")
def a():
print("进入 a")
b()
print("离开 a")
a()
思路 从 a() 启动起逐步追踪:进入 a(栈:a)→ a 调 b(栈:a, b)→ b 调 c(栈:a, b, c)→ c 返回(c 帧撤下,栈:a, b)→ b 返回(栈:a)→ a 返回(栈:空)。
验证 机器实测输出 5 行:进入 a、进入 b、进入 c、离开 b、离开 a——顺序与栈演变完全吻合:进入按 a→b→c(压栈序),离开按 b→a(弹栈序,后进先出)。「离开 a」必然最后,因为 a 的工作台在栈底,要等上面全部撤完才轮到它。
答:栈演变如上。⚠ 易错点:以为输出是「进入a/b/c 后按 a→b→c 顺序离开」——忘了栈是后进先出:谁最后被调用,谁最先返回。「进入是压栈序、离开是弹栈序」这一句,值得背下来。
递归入门:函数调用自己
函数能调用自己——这叫递归(recursion)。看似怪圈,放到调用栈上立刻讲得通:每递归一层,栈上多摞一块同名的帧,各帧的参数各不相同。经典例子是阶乘——数学定义本身就是递归的:fact(n) = n × fact(n-1),且 fact(1) = 1:
def fact(n):
if n == 1:
return 1 # 出口:不再继续递归
return n * fact(n - 1)
两行结构对应两个必备部件:出口(n == 1 时直接返回,不再往下钻)与递推(n 乘「下一层的结果」)。没有出口的递归会一层层无限制压栈——Python 默认把深度限制在约 1000 层,超限抛出 RecursionError(机器实测 sys.getrecursionlimit() 为 1000;fact(3000) 果然触发 RecursionError)。这个限制是栈保护的「安全带」——真实内存有限,不能让失控的递归把栈压爆。
📐 例题 8 递归追踪:fact(5) 的六帧人生
用 fact(5) 逐层展开:写出从调用到返回的全过程——每层传什么参数、返回什么值、栈最深时有几帧;并验证 fact(5) 的结果。
思路 分「下潜」与「回升」两段看。下潜:fact(5) 要等 fact(4),fact(4) 要等 fact(3)……一路压栈到 fact(1) 命中出口。回升:fact(1) 交回 1,fact(2) 算 2×1 交回 2,fact(3) 算 3×2 交回 6……逐层弹栈,每层的「乘法」其实发生在下一层返回之后。
| 阶段 | 动作 | 栈(底→顶) | 返回值 |
|---|---|---|---|
| 下潜 | 调用 fact(5)→fact(4)→…→fact(1) | 压到 5 帧 | — |
| 出口 | fact(1) 命中 n==1 | 5 帧(最深) | 1 |
| 回升 | fact(2)=2×1、fact(3)=3×2、fact(4)=4×6、fact(5)=5×24 | 逐帧弹栈 | 2→6→24→120 |
验证 机器实测 fact(5) = 120,与循环版(第 8 章例题 6 的累乘器)结果一致,也与 math.factorial(5) 一致。
答:fact(5) = 120;栈最深 5 帧;回升时依次交回 1、2、6、24、120。⚠ 易错点:把「n × fact(n-1)」理解为「先乘再递归」——恰恰相反:乘法在右边的调用返回之后才执行,先下潜到底,再逐层回升结账。写递归的军规只有一条:先把出口写出来,再写递推。
工程视角 程序崩溃时打印的 traceback,就是调用栈的临终快照——最上面一行是最深处正在执行的代码,越往下越是「谁调用谁」。读 traceback 的正确姿势是自下而上:先看最后一行「谁发起了整条调用链」,再逐层向上找到报错的那一层。调用栈不再是抽象概念,而是每次排错的地图。
辨析:递归 vs 循环——同一道题的两把刀
阶乘既可以用循环算(第 8 章累乘器),也可以用递归算(本章 fact)——结果相同,味道不同。循环是一根筋走到底、中途不停;递归是先拆到底、再逐层合并答案,天然适合「问题本身能切成同构小子问题」的形状:阶乘、文件夹里数文件(文件夹套文件夹)、表达式求值(括号套括号)。工程上的务实建议:能用循环就用循环——它没有栈深度的顾虑,也更好读;递归留给「结构天生递归」的问题,并永远先写出口。两把刀都挂腰上,按题选刀。
再进半步:深度上限是安全带,不是障碍物
Python 提供 sys.setrecursionlimit(n) 可以调高递归深度,但轻易别动:1000 层的上限是「安全带」——它在内存被压爆之前把程序拦下来,报一个还能看懂的 RecursionError,而不是让进程无声无息地崩掉。另一个必须知道的事实:有些语言对「递归调用恰好发生在函数最后一步」的情形会自动优化成循环(不压新帧,术语叫尾调用优化),Python 没有这个优化——同样的递归写法,在别的语言里能跑百万层,在 Python 里 1000 层就到顶。所以在 Python 里,「递归深度可能很大」的场合,一开始就该设计成循环——这不是退让,是对语言特性的尊重。
本章小结
- 函数把一段代码封装、起名、复用,是「抽象」思想的直接体现。
def是「定义」(写菜谱),调用(函数名())才真正执行。- 参数是喂进的数据(占位符),返回值(return)是吐出的结果。
- 作用域:函数内部是独立小世界,局部变量外界不可见、用完即销毁。
- 函数调用的底层机制是调用栈——栈帧的入栈(调用)与出栈(返回)。
- 参数有三种写法:位置、关键字、默认值(从右往左连续设);传对象引用口诀——原位修改影响外部,重新绑定只改内部。
- 名字按 LEGB 四级查找、找到即停;函数内赋值即局部(遮蔽不覆盖);递归先写出口,深度超约 1000 层抛 RecursionError。
读完本章,你应该能……
- 写出一个带参数的函数,并正确调用它。
- 写出一个带 return 的函数,并用它的结果做后续计算。
- 区分
print和return的作用。 - 解释为什么函数里定义的变量在函数外访问不到。
- 用自己的话描述「调用栈」的入栈与出栈过程。
- 画出 a 调 b、b 调 c 时的调用栈演变图,并解释输出为什么是「进入a→进入b→进入c→离开b→离开a」。
- 解释 fact() 去掉出口(
if n == 1)后为什么会报 RecursionError,以及这个限制保护的是什么。
思考题
1. 写一个函数 square(n),返回 n 的平方,并调用它打印 9 的平方。
提示:def square(n): return n * n,然后 print(square(9))。
2. print 和 return 的区别是什么?各举一个使用场景。
提示:print 是显示给人看;return 是把值交还给程序继续用。调试时用 print,计算产出用 return。
3. 为什么函数内部的局部变量在函数结束后就「消失」了?
提示:因为局部变量存在调用栈的栈帧里,函数返回时栈帧被弹出销毁。
4. 用自己的话解释:调用函数时,计算机是怎么「记住返回后该回哪」的?
提示:栈帧里存了「返回地址」;函数返回时,从这个地址恢复执行。
5. 例题 4 里 add_item 能让外部列表变长,set_new 却改不了外部的 n——同样是传参,差别在哪?
提示:差别不在传参,在函数体做的是哪种操作。append 是原位修改——函数内外拿的是同一个列表,改它外部当然看得见;x = 99 是重新绑定——局部名字 x 指向新对象,外部名字 n 与它再无关系(机器实测:列表变、整数不变)。
6. 全局 msg = "全局",函数内加一行 msg = "局部" 前后,函数内的 print(msg) 各读到什么?
提示:加上那行赋值后,msg 成为函数的局部变量——函数内读到「局部」、函数外仍是「全局」(机器实测);没加之前,函数内沿 LEGB 读到全局的「全局」。一行赋值,改变的是名字的「户口」。
7. 递归函数把出口 if n == 1: return 1 删掉会发生什么?这个报错保护的是什么?
提示:fact(n) 将无限调用 fact(n-1),帧一层层压栈——Python 默认深度约 1000 层,超限抛 RecursionError(机器实测 fact(3000) 即触发)。它保护的是真实的内存栈:无限制的递归会把栈压爆,这个「安全带」宁可报错也不硬撑。