0004 · 把一段代码变成函数

把一段代码变成函数

本节目标:能把重复代码抽成带参数的函数,说清位置参数、默认参数、关键字参数与返回值,并知道函数内外各自能看见哪些名字。

前面三课把「算法要跑多久」这件事说清楚了:数基本操作、写 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

三条调用规则,记下来能省很多调试时间:

结果怎么交出来

函数把结果交回调用处,用 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 = ... 一次拆成两个变量——这种写法叫解包赋值,元组里有几个元素,左边就写几个名字。

两个容易踩的空档:

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

输出一个字符都不差。拿边界与较大规模各测一遍,结果同样一致:

输入函数版输出摊平版输出
000
111
1055
1234567617284617284

改写要守的规矩就一条:输出必须与原版逐字一致。抽函数是重组代码的组织方式,不是改算法,所以改写完成之后不能「顺手」改掉哪一步的判断或哪一处边界。

改写带来的东西没法从输出上看出来,要从使用上看:小样例自测时,直接调用 odd_count(10) 比对 5 就够,不必准备输入文件让整个程序跑一遍;正式提交时,同一份 odd_count 被 solve 用一次,两条路径共用同一个实现。要改「奇数个数怎么算」,只改 odd_count 这一个地方。

练习 把一道做过的题改成三四个函数

这一课的动手部分落在 lab 里(任务与断言见 lab/0004-python-function-basics/,做完对照 lab/solutions/)。要做的是三件事。

  1. 挑一道自己从头写过、规模在二十行左右的题,先把它的摊平版存好,记录几组输入对应的输出。
  2. 把它改写成三到四个函数,每个函数只管一件事(读数据、算、输出),再写一个 solve 把它们串起来;对照原来的几组输入,输出必须逐字一致。
  3. 再写一版故意用全局变量在几个函数之间传状态的,两份都跑一遍同样的输入,看输出是否还对得上、以及从函数定义那一行还能不能看出它动了哪些数据。

这一课用到的 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 加二分主体。它们都要在两种场合被调用——造出来的小数据上验证思路,正式数据上求答案。这一课练的就是这件事,抽函数的手感熟了,后面每一课都是在往这个骨架里填内容。

没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。