把一段代码变成函数
本节目标:能把重复代码抽成带参数的函数,说清位置参数、默认参数、关键字参数与返回值,并知道函数内外各自能看见哪些名字。
前面三课把「算法要跑多久」这件事说清楚了:数基本操作、写 O 记号、按数据规模反推上限,也量过了列表与字典各操作的代价。这三课里写的代码有一个共同点:全部摊在一个文件里,从上往下跑一遍。样例只要三五个数,这样写没问题;数据范围一上到 10^6,同一段逻辑往往要在几个地方各写一遍——读数据时要扫一遍,统计时要扫一遍,输出前还要再数一次。
算法题的真实形态不是这样。深度优先搜索、广度优先搜索、二分查找这些算法,写下去必然要反复调用同一段逻辑,而且要在不同数据上调用它:小样例上自测一次,正式数据上再跑一次。同一段逻辑写两遍,就多了一处改一处忘一处的地方;改一处逻辑要翻三遍代码,正是这种写法带来的。
这一课要做的事只有一件:把一段代码包起来、给它起个名字、需要时用不同的数据调用它。这就是函数。它是后面每一课的地基——接下来学的每一种算法,都要先写成函数,才能在小样例和正式数据上共用同一份实现。
函数这个名字从哪来
函数这个词是从数学里借来的。数学上的函数 f(x) 描述一种对应关系:给一个输入 x,得到唯一确定的输出 f(x)。编程语言借用了这个意思,但把它落到代码上:函数是一段有名字的代码块,可以按名字调用,调用时把数据交进去,执行完把结果交出来。
把「一段代码」和「一个函数」对照着看,差别在三处:
| 一段摊开的代码 | 一个函数 | |
|---|---|---|
| 怎么找到它 | 靠记得它在这个文件的中段 | 靠名字,odd_count 一眼看出它做什么 |
| 换一份数据再用一次 | 把这段复制一遍、改里面的变量名 | 调用它,把新数据作为实参传进去 |
| 结果怎么给出去 | 直接打印,或者留在某个变量里 | 用 return 送到调用处,调用处决定怎么用 |
第三行是后面所有内容的关键。摊开的代码把结果打印出来,就结束了;函数把结果返回出来,调用处才能拿它继续算——小样例上比对答案、正式数据上写进输出,用的是同一份实现。
怎么写一个函数,怎么调用它
定义函数用关键字 def(define 的缩写),后面跟函数名、一对括号、一个冒号,函数体在下一行开始并缩进:
def odd_count(n):
count = (n + 1) // 2
print(count)
odd_count(10)
odd_count(1)
这段代码的读法:def odd_count(n): 把 odd_count 这个名字与一段代码关联起来,此后读到这个名字,指的就是那段代码;括号里的 n 是运行这段代码时需要交给它的数据;冒号与随后的缩进决定了这段代码到哪里结束。odd_count(10) 是调用:按名字找到那段代码,把 10 交给它,从头执行一遍。运行结果是两行 5 和 1。
5
1
括号不能省。定义时不带参数的函数写成 def show():,调用时写成 show(),两个括号都在。名字后面没有括号时,odd_count 只是这个名字本身,代码一行都不会执行。
定义与调用的次序也有要求:Python 从上往下读文件,读到 def 才把名字与代码块关联起来,所以调用必须写在定义之后。把 odd_count(10) 写在 def 之前,会报 NameError。
再来一个例子,它接收两个数并返回较大的那个:
def bigger(a, b):
if a > b:
return a
return b
print(bigger(3, 9))
print(bigger(9, 3))
输出是 9 和 9。两次调用传进去的实参顺序不同,函数内部按形参的名字取值,所以两种顺序都能得到正确结果。
传进去的数据叫什么
上一节的 n、a、b 出现在函数定义里,10、3、9 出现在调用处。这两类东西在教材里有各自的正式名字,混用会让后面的讨论没法进行。
形参(parameter)是函数定义括号里的变量名,表示这个函数完成任务需要哪些信息;实参(argument)是调用时括号里给出的具体值,表示这次调用真的交给了它什么。调用 bigger(3, 9) 时,实参 3 与 9 按位置分别赋给形参 a 与 b,函数体里读到的就是这两个值。
按位置对应,是最直接的传参方式。它的代价是顺序写错时不会报错,只会算出另一个结果:bigger(9, 3) 与 bigger(3, 9) 的返回值恰好一样,但如果换成一个「求 a 除以 b」的函数,两个参数写反就会算出 3/9 而不是 9/3。这类错误在数值上看着像对的一半,最难查。
给形参写上默认值,调用时可以省掉它:
def count_in_range(lo, hi, step=1):
count = 0
for x in range(lo, hi, step):
count += 1
return count
print(count_in_range(1, 9)) # step 用默认值 1,输出 8
print(count_in_range(1, 9, 2)) # 显式给出 step,输出 4
print(count_in_range(lo=1, hi=9)) # 两个都用关键字给,输出 8
三条调用规则,记下来能省很多调试时间:
- 没有默认值的形参必须在前。
def count_in_range(lo=1, hi)是语法错误,原因在位置参数按顺序匹配:count_in_range(9)交出的那个值该给谁,必须能唯一确定。 - 调用时先位置、后关键字。
count_in_range(1, step=2, 9)是语法错误;写成count_in_range(1, 9, step=2)合法,位置实参1、9按顺序给lo、hi,关键字实参step=2指名给step。 - 关键字实参必须匹配真实的形参名,名字拼错会报
TypeError。好处也在这里:count_in_range(lo=1, hi=9)这种写法把每个值对应谁写在调用处,读数的人不必回头查定义。
结果怎么交出来
函数把结果交回调用处,用 return 语句:
def odd_count(n):
count = (n + 1) // 2
return count
print(odd_count(10)) # 5
print(odd_count(10) * 2) # 10
return 做两件事:把右边的值送到调用处,并立刻结束函数的执行。所以它后面的语句不再运行。print(odd_count(10) * 2) 这一行能算出 10,说明 odd_count(10) 交回来的是可以继续参与运算的值;print 只是把文字写到屏幕上,交不回来任何东西。
return 只能带一个值。要一次交回两样东西(比如「正数有几个」和「最大的那个」),把它们写成一个元组——用逗号隔开的几个值:
def count_and_max(nums):
count = 0
best = nums[0]
for x in nums:
if x > 0:
count += 1
if x > best:
best = x
return count, best
print(count_and_max([3, -1, 4, 0, 7])) # (3, 7)
count, best = count_and_max([3, -1, 4, 0, 7])
print(count, best) # 3 7
return count, best 写起来是两样东西,实际交回来的是一个 (3, 7) 形式的元组。调用处可以直接整体接住当元组用,也可以写成 count, best = ... 一次拆成两个变量——这种写法叫解包赋值,元组里有几个元素,左边就写几个名字。
两个容易踩的空档:
- 不加
return的函数交回来的是None。None是 Python 里表示「没有值」的专用值,它不等于 0、也不是空字符串,拿它去参与运算会报TypeError。 - 有的分支有
return、有的分支没有。下面的maybe在参数不小于 0 时直接走到函数末尾,没有返回值,所以交回来的是None:
def maybe(x):
if x > 0:
return x
print(maybe(5)) # 5
print(maybe(-1)) # None
写带条件的函数时,回到每一个分支问一句:这条路交回来的值,调用处拿它继续算会不会出错。
函数里能看见哪些名字
函数体里用到的名字,Python 按固定的顺序去找。这个顺序是先在当前函数的局部名字里找,再往外找全局名字,最后找内置名字(print、len、range 这些就是内置名字)。同时有一条补充规定:函数体内部被赋值的名字,从函数开始执行起就算这个函数局部的名字。
这条规定的直接后果,是下面这段代码会报错:
count = 7
def broken():
print(count)
count = 1
broken()
报的是 UnboundLocalError: cannot access local variable 'count' where it is not associated with a value。原因:函数体里出现了 count = 1 这个赋值,count 因此成了局部的名字;而 print(count) 在它被赋值之前就要读它,此时局部还没有这个值。让函数只读全局名字,去掉那行赋值,代码就正常:
count = 7
def peek():
print(count) # 7
peek()
函数里改了局部名字,函数外的同名变量不受影响,反过来也一样。这就是作用域:一个名字在程序的哪一段里有效。参数与函数体内新建的变量都是局部的,函数一返回就消失;全局变量在文件顶层建立,能活到程序结束。
要让函数改到全局变量,得用 global 声明:
total = 0
def add(n):
global total
total += n
add(1000000)
print(total) # 1000000
这段代码能跑,但竞赛代码里应当躲开这种写法。全局变量是函数之间的暗线:看 add 的定义,完全看不出它动了哪个变量,也看不出调用它之前必须先把谁初始化好;等到程序里有两三个函数共用同一个全局变量时,谁在什么时候改了它就成了只能靠通读全文回答的问题。要交出去的东西写进 return,要传进来的东西写进形参——函数的输入输出都在定义那一行写得清清楚楚。
默认值只算一次
默认参数有一个与直觉相反的细节,官方文档把它单独标成「重要警告」:默认值只在函数定义的时候计算一次,之后每次调用都用同一个对象。
先看一个与陷阱无关、但把这条规则说得最白的例子:
i = 5
def f(arg=i):
print(arg)
i = 6
f() # 输出 5,不是 6
i 改成 6 之后再调用 f(),打印的仍是 5。原因是 def f(arg=i) 这一行在执行到的时候就把 i 当时的值(5)记下来了,形参的默认值与后来的全局变量 i 再也没有关系。
这条规则碰到可变对象就出问题。函数在定义时记下的是那个列表本身,于是所有「没给这个实参」的调用共用它:
def f(a, L=[]):
L.append(a)
return L
print(f(1)) # [1]
print(f(2)) # [1, 2]
print(f(3)) # [1, 2, 3]
三次调用都没有给 L,按「默认值是空列表」的想法,每次该拿到一个只装了自己那个新元素的列表;实际输出是累加的。f(2) 拿到的是已经装过 1 的那个列表,再往里追加 2。也就是说,三次调用共享了同一个列表对象,第一次调用做的修改留在了第二、三次调用里。
更糟的是,这个列表在函数之外仍然存在,所以从两次调用的返回值就能看出它们是同一个对象:
def f(a, L=[]):
L.append(a)
return L
r1 = f(1)
r2 = f(2)
r3 = f(3)
print(r1) # [1, 2, 3],不是 [1]
print(r1 is r2) # True
r1 打印出 [1, 2, 3]:第一次调用的返回值被后面两次调用改掉了。is 判断两个名字指的是不是一个对象,这里给出 True,与打印结果互相印证。
修法是把默认值写成 None,在函数体里判断:真的没给,才新建一个空列表。
def f(a, L=None):
if L is None:
L = []
L.append(a)
return L
print(f(1), f(2), f(3)) # [1] [2] [3]
None 是不可变对象,无论定义时记下多少次都是同一个「没有值」,不会累积任何东西。新建列表这一步在每一次调用里执行,所以每次调用都得到一个属于自己的空列表。这个模式在需要「默认为空容器」的函数里用得很频繁,看到 L=None 与随后的 if L is None 就能认出它。
函数里别改传进来的列表
还有一类问题是同一个根源的另一面。把列表交给函数,函数内部对它的修改会留在调用处。下面这个函数想把列表里的元素加起来,顺手把第 0 个元素改成了 99:
def total(nums):
nums[0] = 99
s = 0
for x in nums:
s += x
return s
a = [1, 2, 3]
print(total(a)) # 104
print(a) # [99, 2, 3],调用处的 a 被改坏了
调用处只看得到 total(a) 这一行,看不出自己的 a 会被改。官方文档把这件事的机制写得很清楚:调用函数时,实参是按值传递的,但这个「值」是对象的引用,不是对象的内容。也就是说,函数拿到的是「那个列表」这个指路牌,顺着它改内容,改动就落在同一个列表上。
要保护调用处的数据,在函数开头复制一份,后面都对着副本改:
def total(nums):
nums = nums[:]
nums[0] = 99
s = 0
for x in nums:
s += x
return s
b = [1, 2, 3]
print(total(b)) # 104
print(b) # [1, 2, 3],b 没被改
nums[:] 是上一课学过的切片:它复制出一个新列表,复制 k 个元素的代价是 O(k)。这里 nums = nums[:] 把局部的名字 nums 重新指向那份副本,之后的 nums[0] = 99 改的是副本。顺便留一句:nums = nums[:] 只是让函数内的名字换了对象;如果写的是 nums[:] = ...,那是就地改内容,调用处照样会变。
在函数内改传进来的列表,有时正是想要的效果——把数据按类分到两个列表里,用函数完成搬运,比在调用处摊开写要清楚得多。要记住的是默认不改:函数拿到一个列表,除非它的名字与文档说明了它会改,否则改动调用处的数据都属于越界。判断方法是回到调用处读一行——只看调用行,能不能知道传进去的列表会被改?答不上来,就该在函数里复制。
函数调用也有代价
上一课给列表与字典的每个操作都标了价格,函数调用同样不免费。每次调用都要做几件事:把这次调用的形参与局部变量安置好、记住执行到哪一行、执行完再回到调用处把返回值接上。这些动作与函数体里做了多少运算无关,是每次调用一笔固定的开销。
本机(Python 3.14.7,free-threading 构建)用 timeit 实测的三个数:
| 循环体里做的事 | 每次迭代耗时 |
|---|---|
空循环(只写 pass) | 约 8.5 纳秒 |
total += i,直接写在循环里 | 约 24.6 纳秒 |
调用一个空函数(函数体只有 return None) | 约 22.9 纳秒 |
第三行是函数调用本身的价格:约 14 纳秒,与第二行和第一行之间的差(一条加法加一次赋值,约 16 纳秒)几乎相等,两者是同一个量级。把这两笔钱放在一起看,结论就出来了:函数体里做的事越多,这笔固定开销占的比例越小。 函数体只有一两行、又在一个上百万次的循环里被调用时,这笔钱能占到总耗时的一半左右;函数体是一次统计、一遍扫描这类实事时,它就是一个可以忽略的零头。这与上一课「常数因子会叠加、但相对主项会变小」是同一条判断。
另有一处与调用相关的开销现在只记一句话:递归调用——函数在自身内部调用自己——的调用次数会随问题规模急剧增加,代价不只是每次调用的固定开销,还与调用层数有关。递归是后面单独一课的主题,这一课先把普通函数的调用代价看清楚。
表里的数字是本机实测的,换一台机器、换一个 Python 版本都会变。要记的是「函数调用有一笔固定的常数开销」这个事实,以及它相对于函数体工作量的比例关系;具体数字自己用 timeit 量。
把摊平的题改成函数
理论说完了,用一道做过的题走一遍完整过程。题目是第 1 课那道款式的填空题:读入 N,求 1 到 N 之间的奇数个数。学生写出来的版本通常是这样,一个文件从上往下:
n = int(input())
if n < 0:
n = 0
count = (n + 1) // 2
print(count)
改写成函数版,按「每个函数只做一件事」分三个:读入并检查数据、算出答案、把答案输出。
def read_n():
n = int(input())
if n < 0:
n = 0
return n
def odd_count(n):
return (n + 1) // 2
def show(count):
print(count)
def solve():
n = read_n()
show(odd_count(n))
solve()
两个版本的代码各存一个文件跑,输入 10,本机实测:
$ echo 10 | python flat.py
5
$ echo 10 | python func.py
5
输出一个字符都不差。拿边界与较大规模各测一遍,结果同样一致:
| 输入 | 函数版输出 | 摊平版输出 |
|---|---|---|
0 | 0 | 0 |
1 | 1 | 1 |
10 | 5 | 5 |
1234567 | 617284 | 617284 |
改写要守的规矩就一条:输出必须与原版逐字一致。抽函数是重组代码的组织方式,不是改算法,所以改写完成之后不能「顺手」改掉哪一步的判断或哪一处边界。
改写带来的东西没法从输出上看出来,要从使用上看:小样例自测时,直接调用 odd_count(10) 比对 5 就够,不必准备输入文件让整个程序跑一遍;正式提交时,同一份 odd_count 被 solve 用一次,两条路径共用同一个实现。要改「奇数个数怎么算」,只改 odd_count 这一个地方。
这一课的动手部分落在 lab 里(任务与断言见 lab/0004-python-function-basics/,做完对照 lab/solutions/)。要做的是三件事。
- 挑一道自己从头写过、规模在二十行左右的题,先把它的摊平版存好,记录几组输入对应的输出。
- 把它改写成三到四个函数,每个函数只管一件事(读数据、算、输出),再写一个
solve把它们串起来;对照原来的几组输入,输出必须逐字一致。 - 再写一版故意用全局变量在几个函数之间传状态的,两份都跑一遍同样的输入,看输出是否还对得上、以及从函数定义那一行还能不能看出它动了哪些数据。
这一课用到的 Python
| 要做的事 | 写法 | 边界 |
|---|---|---|
| 定义函数 | def odd_count(n): | 括号与冒号都不能省,函数体要缩进 |
| 调用函数 | odd_count(10) | 必须先定义后调用,写名字不带括号不会执行 |
| 形参与实参 | 定义里的 n 是形参,调用里的 10 是实参 | 按位置对应,顺序写错不报错但结果不同 |
| 给参数一个默认值 | def count_in_range(lo, hi, step=1): | 没有默认值的形参必须排在前面;默认值只在定义时算一次 |
| 按名字传参 | count_in_range(lo=1, hi=9) | 关键字实参要放在位置实参之后,名字必须与形参一致 |
| 把结果交回调用处 | return count | 不带 return、或某个分支没走到 return,交回的是 None |
| 一次交回两个值 | return count, best | 实际是一个元组,调用处可写成 count, best = ... 拆开 |
| 函数里的变量 | 直接写名字,就是局部的 | 函数内赋过值的名字是局部的,赋值之前就读它会报 UnboundLocalError |
| 默认空容器 | def f(a, L=None): 加 if L is None: | 直接写 L=[] 会让所有调用共用同一个列表 |
| 保护调用处的列表 | 在函数里先写 nums = nums[:] | 切片复制是 O(k);nums[i] = v 会改到调用处的数据 |
这门课后面写的每一个算法都会是一个函数:深度优先搜索写成 dfs、广度优先搜索写成 bfs、二分写成 check 加二分主体。它们都要在两种场合被调用——造出来的小数据上验证思路,正式数据上求答案。这一课练的就是这件事,抽函数的手感熟了,后面每一课都是在往这个骨架里填内容。
- Python 官方文档 · 定义函数
- 《Python 编程:从入门到实践(第 2 版)》第 8 章 函数