学 Python 学到第 6 章,绝大多数教程会把"函数"放在这里,因为从这一章开始,代码的组织方式就彻底变了。前面五章学的是变量、字符串、列表、字典、条件与循环,本质上是把所有东西摊在一张桌子上;到了函数,第一次有了"把一段逻辑打包起来、取个名字、随时调用"的能力。我个人一直觉得,函数这一章是 Python 学习中的第一道真正的分水岭:跨过去的人开始写能改、能复用、能测的代码,跨不过去的人会在后面的面向对象、模块化、框架使用里一路卡壳,因为你会在别人的代码里看到*args、**kwargs、闭包、装饰器,看不懂就只能绕道走。这篇内容我打算把第 6 章彻底讲透,从def背后到底发生了什么、五种参数形态怎么用、默认参数的坑在哪,一路写到作用域、闭包、装饰器和一套能直接抄走的小工具函数库。适合刚学完循环控制、准备进阶的同学,也适合写了半年脚本却总觉得代码越写越乱、想回头补地基的人。我会尽量把每一步的"为什么"讲清楚,而不只是给答案。
1. 函数这一章到底在解决什么问题
1.1 从"能跑通"到"能维护"的真实分界线
我刚带人的时候有个习惯,让对方写一个"读入一串数字,算出平均值和最大值"的小脚本。没学函数的人通常这么写:读输入、切分、转类型、算平均、算最值、打印,六七十行全堆在一个缩进层级里。程序能跑,结果也对,但接着我让他"再读一批数据,同样算一遍",他就开始复制粘贴了;我再说"平均值的算法改成忽略异常值",他得回去改两处,改漏一处就出 bug。
这就是函数存在的第一层理由:消除重复,并且让"改一处就全改到"成为可能。但更深的一层理由,是它给一段逻辑起了名字。cal_average(data)这七个字符,比你写十行total = 0; for x in data: total += x; ...的信息量大得多——读代码的人不需要关心你怎么算,只需要知道这里在算平均值。命名本身就是一种抽象,而抽象是控制复杂度的唯一手段。
所以我建议你在学这一章时,别把它当成"又一个语法点"。语法只是表象,真正的收获是两种能力:把问题拆成小块的能力(一个函数只干一件事),以及为小块设计接口的能力(传什么进去、拿什么出来)。这两种能力跟语言无关,换到任何一门语言都通用。
1.2 参数设计和返回值设计,才是真正要练的手感
新手写函数最常见的两个毛病,我见得非常多。第一个是参数列表失控,写着写着变成def process(a, b, c, d, e, f, g),调用的时候你自己都得数位置,这种函数维护起来是灾难。第二个是"万能函数",一个函数里既读文件、又清洗数据、还打印报表,最后还顺手发了邮件,两百行,谁也不敢碰。
我的经验是给参数列表立几条规矩:超过四个参数就考虑用字典或数据类打包;布尔型的参数尽量别用位置传,因为f(True, False)这种调用没人看得懂,必须写成f(verbose=True, dry_run=False);凡是调用方大概率会改的值,做成参数;凡是函数内部固定不变的值,做成模块级常量或默认值,别塞进参数里。
返回值同理。我的原则是"一个函数只有一种成功返回的形态":要么总是返回数据,要么总是返回True/False,要么直接抛异常。最怕的是那种"成功返回结果,失败返回None"的写法,调用方拿到None不知道是出错还是确实没数据,最后到处if result is not None。宁可让错误大声一点,抛个异常出来,也比默默返回None强。
1.3 这一章的推荐学习顺序和准备动作
按我的经验,这一章的内容有明确的依赖关系,顺序错了会很痛苦。合理的推进节奏是:先搞清楚def执行时做了什么、函数对象是什么(这一步很多人跳过,后面学装饰器就崩),然后吃透五种参数形态和默认参数的求值时机,再进入返回值与作用域,最后才是闭包、高阶函数、装饰器。递归可以放最后,它更像一个思维练习而不是日常工具。
准备动作很简单:一个能跑 Python 3.8 以上版本的解释器(因为要用到位置限定参数/),一个能交互式运行的环境(REPL 或笔记本都行),以及一个专门用来做实验的demo.py。别小看这个实验文件,函数这一章有大量"反直觉"的行为,光看是记不住的,必须自己敲一遍、改一改、看报错。我在下面每个知识点后面都会给出可以直接粘进去跑的最小例子,你照着改参数看结果,比读十遍文字管用。
2. def 到底做了什么:容易被跳过的底层细节
2.1 函数是对象,def 是一次赋值
很多人以为def是"声明一个函数",更准确的理解是:def会创建一个函数对象,然后把这个对象绑定到你给的名字上。这句话听起来像咬文嚼字,但它解释了后面几乎所有"奇怪"的现象。
你在交互环境里敲这么几行,就能直观看到:
def greet(name): return f"hi {name}" print(type(greet)) # <class 'function'> print(greet.__name__) # greet alias = greet # 没有调用,只是又绑了一个名字 print(alias("A同学")) # hi A同学 print(greet is alias) # True,同一个对象alias = greet没有括号,所以不是调用,是让两个名字指向同一个函数对象。这解释了一件事:Python 里函数可以作为参数传、可以作为返回值返回、可以放进列表和字典里——因为它本来就只是个普通对象,跟整数、字符串地位一样。所谓"高阶函数""装饰器",本质上都是建立在这条性质上的用法,不是什么魔法。
还有一点值得注意:def是一条可执行语句,它在你执行到这一行时才生效。所以函数必须先定义后调用,写在if分支里的def只有那个分支被执行到才存在。这一点在写条件导入、插件式注册的时候会用到。
2.2 调用时发生的三件事:绑定参数、开新命名空间、返回值
一次函数调用的完整过程,我习惯拆成三步讲:
第一步,建立实参与形参的绑定。Python 会按规则把调用时给的参数对上号,对不上就报TypeError。这一步没有类型检查(除非你写了注解并且用工具检查),传什么类型都收,出错留到函数体里才爆——这也是为什么函数内部的类型错误,报错行号常常离真正的问题很远。
第二步,创建一个新的局部命名空间。函数体内新建的变量都活在这个空间里,函数返回时这个空间被丢弃(除了被闭包抓住的情况,后面讲)。这解释了一个经典困惑:为什么我在函数里改了一个变量,外面没变?因为那是两个不同的名字空间。
第三步,执行函数体直到return或末尾。没写return或者只写return,返回值都是None。这一点务必记牢,新手最常见的 bug 之一就是"函数里算出了结果但忘了 return",然后在外面拿到None一头雾水。
def calc(nums): total = sum(nums) # 算完了 avg = total / len(nums) # 也算完了 result = calc([1, 2, 3]) print(result) # None —— 忘了写 return2.3 一个必须动手做的实验:看函数对象的属性
想真正理解后面要讲的默认参数陷阱,最好先看一眼函数对象身上挂了什么:
def f(a, b=10, *args, **kwargs): print(kwargs) print(f.__defaults__) # (10,) 默认值就存在这里 print(f.__code__.co_varnames) # ('a', 'b', 'args', 'kwargs') print(f.__dict__) # {} 函数自己的属性字典,装饰器常往这里写东西__defaults__这个东西是关键。默认值是在def语句执行的那一刻求值一次,然后把结果存进__defaults__,之后每次调用都复用这个已经算好的对象,而不是每次重新算。记住这句话,下一个小节的问题就通了。
3. 五种参数形态与默认参数陷阱
3.1 位置参数、默认参数、可变参数、关键字参数的全景对比
Python 的参数机制比其他语言灵活得多,灵活就意味着规则多。我把它整理成一张表,你对着看会清晰很多:
| 形态 | 写法 | 调用方式 | 典型用途 | 注意事项 |
|---|---|---|---|---|
| 位置参数 | def f(a, b) | f(1, 2) | 最常用,参数少时首选 | 顺序错了不报错但结果错,是隐蔽 bug 源头 |
| 默认参数 | def f(a, b=10) | f(1)或f(1, 20) | 提供合理缺省值 | 默认值必须是不可变对象 |
| 可变位置参数 | def f(*args) | f(1, 2, 3) | 参数个数不确定,做包装器 | args是元组,不是列表 |
| 关键字参数 | def f(a, b) | f(b=2, a=1) | 参数多、可读性优先 | 关键字必须写在位置参数之后 |
| 可变关键字参数 | def f(**kwargs) | f(x=1, y=2) | 透传配置、做通用接口 | kwargs是字典,键必须是合法字符串 |
| 仅关键字参数 | def f(a, *, b) | f(1, b=2) | 强制调用方写明参数名 | Python 3 才支持,可读性利器 |
| 仅位置参数 | def f(a, b, /) | f(1, 2) | 参数名不想暴露给调用方 | 3.8 以上才有 |
几个细节值得单独拎出来说。*args拿到的是元组,**kwargs拿到的是字典,这两个名字是约定俗成(args、kwargs本身叫什么无所谓,重要的是*和**)。*单独出现时表示它后面的参数必须用关键字传,这个特性我在工程里用得非常多,因为它能把f(True, False)这种"读不懂的调用"直接变成语法错误,逼着调用方写清楚。
def export(data, *, fmt="csv", compress=False): ... export(rows, fmt="json") # 可以 export(rows, "json") # 报错,位置传不进去export(rows, "json")会直接抛TypeError,这不是麻烦,这是保护。三个月后回来看这行代码,你不需要翻函数定义就知道"json"是什么含义。
3.2 默认参数的可变对象陷阱,以及它为什么这么坑
这是函数这一章最经典、面试出现频率最高、实际项目里也最容易中招的问题。先看代码:
def add_item(item, bucket=[]): bucket.append(item) return bucket print(add_item("a")) # ['a'] print(add_item("b")) # ['a', 'b'] ← 不是 ['b'] print(add_item("c")) # ['a', 'b', 'c']为什么会累积?因为那个[]在def执行时就被创建了一次,存进了add_item.__defaults__,后面三次调用共用这一个列表对象。append是原地修改,所以改动被保留了。
正确的写法是用None做哨兵,在函数体里新建:
def add_item(item, bucket=None): if bucket is None: bucket = [] bucket.append(item) return bucket这里有个很多人忽略的细节:判断必须用is None,不能用if not bucket。因为调用方完全可能传一个空列表进来表示"我要往这个空列表里加",用not bucket会把它当成没传,然后新建一个,导致调用方手里的列表没被修改——这种 bug 比上面那个还难查,因为现象是"有时候生效有时候不生效"。
同样的坑还存在于字典、集合、以及你自己写的可变对象。所以我在团队里立了一条死规矩:默认值只允许写不可变字面量(数字、字符串、None、True/False、元组),一旦看到[]、{}、set()出现在参数默认值里,代码评审直接打回。这条规则看着粗暴,但省下来的调试时间非常可观。
3.3 一个反直觉的补充:默认值表达式是"定义时"求值
上面那个坑还能延伸出一个更隐蔽的版本:
import time def log(msg, ts=time.time()): print(ts, msg) time.sleep(1) log("第一条") # 打印的是函数定义那一刻的时间ts不会每次调用刷新,因为time.time()在def执行时就求值了。想要每次调用取当前时间,得写成ts=None然后在函数体里ts = ts or time.time()(注意这里 0 是假值,但时间戳不会是 0,所以这种写法在这里勉强安全;更严谨的写法还是if ts is None)。
我见过一个真实案例:某开发者在函数默认值里写了配置文件路径的解析os.path.expanduser("~/.config/app.json"),在容器里跑得好好的,本地一跑就找不到文件,因为那个路径在模块导入时就被固化成了某个用户的家目录。这类问题的共同解法只有一个——凡是需要"每次调用都重新计算"的东西,一律不要写在默认值里。
3.4 参数顺序的硬规则和常见报错对照
参数书写顺序是有严格规定的:位置参数 → 默认参数 →*args→ 仅关键字参数 →**kwargs。违反顺序在定义时就报SyntaxError,比如def f(a=1, b),报错是non-default argument follows default argument。
调用时的规则同样严格:关键字参数必须写在位置参数后面,f(b=2, 1)会报positional argument follows keyword argument。另外同一个参数不能既按位置又按关键字传两次,f(1, a=2)会报got multiple values for argument 'a'。
我把调用阶段最常见的几类TypeError整理成下面这张表,建议存下来对着排查:
| 报错信息片段 | 直接原因 | 修复方向 |
|---|---|---|
missing 1 required positional argument | 少传了必填参数 | 检查调用处的参数个数 |
takes 2 positional arguments but 3 were given | 多传了位置参数 | 看是否把方法的self漏了或重复传 |
got an unexpected keyword argument | 关键字名拼错,或该参数没有这个名 | 核对函数签名拼写 |
got multiple values for argument | 同一参数传了两次 | 位置与关键字只保留一种 |
unexpected keyword argument 'self' | 类方法定义时漏写self形参 | 补上self |
顺便提一个新手极高频的场景:调用类的实例方法时多传或少传了self,报错信息会显得莫名其妙。理解"实例方法在调用时自动把实例绑定到第一个参数"这件事,这类报错就不难读懂了。
4. 作用域、闭包与函数式工具
4.1 LEGB 规则:名字查找的固定顺序
函数内部出现一个变量名,Python 按 Local → Enclosing → Global → Built-in 的顺序找。局部找不到就去外层函数找,再找不到去模块全局找,最后去内置命名空间找,都没有就NameError。这个顺序不是可选的,是语言规范。
真正让新手栽跟头的是另一条规则:只要你在函数体内对某个名字做了赋值,这个名字在编译期就被判定为局部变量,即使赋值语句在引用之后。于是出现这种报错:
count = 0 def bump(): count = count + 1 # UnboundLocalError bump()count = ...让count成了局部变量,于是右边的count去局部空间找,还没赋值,报错。想改全局变量必须显式声明global count。但我的建议是:尽量不要在函数里改全局变量,把状态传进去、把结果返回出来,代码的可测性会好一个量级。global用得越多,代码越像一锅粥。
nonlocal是给闭包准备的,用来修改外层函数里的变量。下面这个计数器例子就很典型:
def make_counter(): n = 0 def inc(): nonlocal n n += 1 return n return inc c = make_counter() print(c(), c(), c()) # 1 2 3如果没有nonlocal n,n += 1同样会触发UnboundLocalError,原因和上面完全一样。
4.2 闭包的本质,以及循环里的延迟绑定陷阱
闭包不是什么高深概念,就一句话:内层函数引用了外层函数的局部变量,并且内层函数被返回到了外层之外,那些变量就不会被销毁。make_counter返回的inc一直记着那个n,这是 Python 用"单元格对象"(cell)实现的效果。
闭包最容易踩的坑,是循环里创建函数:
funcs = [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2],不是 [0, 1, 2]原因还是"延迟绑定":lambda 里引用的是变量i本身,不是它当时的取值。等循环结束后i变成 2,所有函数看到的都是最后一个值。修复办法是用默认参数把当前值"固定"下来:
funcs = [lambda i=i: i for i in range(3)] print([f() for f in funcs]) # [0, 1, 2]这里正好用上了前面讲的"默认值在定义时求值"——同一个特性,在 3.2 里是坑,在这里是解药。理解原理之后,特性本身没有好坏,只看你怎么用。
4.3 lambda、map/filter 与列表推导式的取舍
lambda就是匿名函数,只能写单个表达式。它最合适的场景是给sorted、max、min这类函数传一个临时的小 key:
records = [{"name": "A同学", "score": 88}, {"name": "B同学", "score": 95}] top = max(records, key=lambda r: r["score"]) print(top["name"]) # B同学但如果 lambda 里出现了条件表达式、多层嵌套,我的建议是立刻改成def。因为匿名函数没法写文档字符串、没法打断点、报错时堆栈里只显示<lambda>,调试成本比省下来的那几行高得多。
至于map/filter,在 Python 3 里它们返回迭代器,配合推导式比较,大部分场景推导式更直观:
nums = [1, 2, 3, 4, 5] print(list(map(lambda x: x * 2, nums))) # [2, 4, 6, 8, 10] print([x * 2 for x in nums]) # 同上,更易读 print(list(filter(lambda x: x % 2, nums))) # [1, 3, 5] print([x for x in nums if x % 2]) # 同上我个人的判断标准很简单:要做的事能用一句话表达式描述,用推导式;需要复用、需要命名、需要测试,写成def。map真正不可替代的地方是它接受任意可迭代对象且惰性求值,处理超大文件流的时候有用,但这个属于进阶话题了。
4.4 从闭包到装饰器:写一个能用的计时器
装饰器是这一章的"毕业设计",但它的原理其实就是闭包加一层语法糖:@deco等价于func = deco(func)。理解了这个等价关系,装饰器一点都不神秘。
我推荐所有人的第一个装饰器都写计时器,因为它是真实有用的:
import functools import time def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: cost = time.perf_counter() - start print(f"{func.__name__} 耗时 {cost:.4f}s") return wrapper @timer def slow_sum(n): return sum(range(n)) slow_sum(10**6)这里有三个细节值得说。第一,*args, **kwargs让wrapper能接住任意签名的函数,这是可变参数最典型的实战用途。第二,functools.wraps必须加,它会把原函数的__name__、__doc__复制过来,不加的话你在日志里看到的名字全是wrapper,排查问题时非常要命。第三,计时逻辑放finally里,这样即使被装饰的函数抛异常,耗时照样会打印出来——这个小心思在实际调试中经常救命。
5. 动手实操:写一个能直接用的小工具库
5.1 需求拆解:为什么选这个例子
光讲语法容易忘,我习惯用一个真正会用到的场景把整章串起来:写一个data_utils.py,提供三个函数——parse_numbers把输入字符串转成数字列表(带容错)、stats算基础统计量、retry一个重试装饰器。这个例子覆盖了参数校验、默认参数、可变参数、返回值设计、类型注解、装饰器,基本把这一章的重点一网打尽。
先定接口,再写实现,这是我在实际项目里强制自己遵守的顺序。接口想清楚了,实现怎么写都不会太离谱;接口没想清楚就动手,写到一半大概率推翻重来。
5.2 第一个函数:parse_numbers 的参数与容错设计
需求是这样的:输入可能是一串用逗号或空格分隔的数字,也可能里面混着脏数据。我希望接口是parse_numbers(text, sep=None, skip_invalid=True)。
sep=None表示自动识别分隔符,这是很常见的"默认值 + 哨兵"套路。skip_invalid=True默认跳过非法项,但如果调用方要严格校验,传False就该报错。这种"默认宽松、可选严格"的设计比反过来要好,因为大多数人大多数时候只想快速拿到结果。
from typing import List, Optional def parse_numbers(text: str, sep: Optional[str] = None, skip_invalid: bool = True) -> List[float]: """把文本解析成数字列表。 >>> parse_numbers("1,2,3") [1.0, 2.0, 3.0] >>> parse_numbers("1 x 2", sep=" ", skip_invalid=True) [1.0, 2.0] """ if not isinstance(text, str): raise TypeError(f"text 必须是字符串,收到 {type(text).__name__}") parts = text.split(sep) if sep else text.replace(",", " ").split() result = [] for raw in parts: item = raw.strip() if not item: continue try: result.append(float(item)) except ValueError: if skip_invalid: continue raise ValueError(f"无法解析的数字: {item!r}") from None return result几个设计考量说一下。返回float而不是int,是因为文本可能是小数,统一类型能省掉调用方一堆判断。from None是为了让错误堆栈干净,不被底层的ValueError噪音淹没——这个技巧在小函数里作用不大,但在多层调用的解析逻辑里能明显提升可读性。参数字典sep用了Optional[str]注解,明确告诉使用者"可以传 None",比不写注解或者写成str = None规范得多(后者在类型检查工具里会报警)。
5.3 第二个函数:stats 的返回值设计与边界处理
统计函数最容易被忽略的是边界情况:空列表怎么办?只有一个元素时标准差怎么算?我的做法是空输入直接抛异常,因为"没有数据的统计量"在业务上没有意义,返回 0 或者None都是在给调用方埋雷。
def stats(nums: List[float]) -> dict: if not nums: raise ValueError("nums 不能为空") n = len(nums) mean = sum(nums) / n variance = sum((x - mean) ** 2 for x in nums) / n return { "count": n, "min": min(nums), "max": max(nums), "mean": mean, "std": variance ** 0.5, }这里我选择返回字典而不是元组。元组的问题是result[3]这种访问方式三个月后你自己都不记得代表什么;字典虽然多打几个字符,但result["mean"]自解释。当然字典的代价是没有类型提示(除非用TypedDict),参数一多容易拼错键。规模再大一点我会换成数据类,但那已经超出第 6 章的范围了。
标准差这里用的是总体方差公式(除以n),如果你做的是抽样统计应该除以n - 1。这个差异在样本量大时无所谓,样本量小于 30 时结果会明显不同,实际做数据分析时务必先确认口径,别拿到数字就用。
5.4 第三个函数:retry 装饰器的完整实现
重试装饰器是装饰器最实用的落地形态之一,写起来也有几个必须注意的点。
import functools import time def retry(times: int = 3, delay: float = 0.5, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): last_err = None for attempt in range(1, times + 1): try: return func(*args, **kwargs) except exceptions as e: last_err = e if attempt == times: break time.sleep(delay * attempt) raise last_err return wrapper return decorator @retry(times=3, delay=0.2, exceptions=(ValueError,)) def flaky(): ...这是一个"带参数的装饰器",结构上比上一个小节的计时器多了一层函数嵌套:最外层收装饰器自己的参数,中间层收被装饰的函数,最内层是真正的执行逻辑。初学者容易在这里绕晕,我的记忆办法是"几层括号就几层函数"——@retry(times=3)先执行retry(times=3)得到一个装饰器,再用它去装饰函数。
另外那个delay * attempt是退避策略,第几次失败就等几倍时间。固定间隔重试在遇到拥塞时容易雪上加霜,递增退避是更稳妥的选择。exceptions参数默认捕获Exception看着很宽,但实际使用时一定要收窄到具体异常类型,否则连参数写错这种低级错误都会被反复重试三遍,白白浪费时间。
6. 常见问题与排查实录
6.1 报错速查表与定位思路
我把这些年被问得最多的函数相关问题整理成一张表,遇到问题先对号入座,比盲目搜索快得多:
| 现象 | 高概率原因 | 排查动作 |
|---|---|---|
函数返回None | 忘了return,或return写在循环里导致提前退出 | 看函数体末尾和循环结构 |
| 列表参数越调越长 | 默认值是可变对象 | 检查__defaults__,改None哨兵 |
UnboundLocalError | 函数内赋值让名字变成局部变量 | 补global/nonlocal,或改写传参 |
| 修改参数后外部数据也变了 | 传的是可变对象引用,函数内原地修改 | 函数内先copy()或改成返回新对象 |
RecursionError | 递归没写终止条件,或深度超限 | 加 base case,或改迭代 |
装饰后函数名变成wrapper | 没加functools.wraps | 补上装饰器 |
| 循环里创建的 lambda 结果全一样 | 延迟绑定,闭包捕获的是变量不是值 | 用默认参数固定当前值 |
6.2 "传值还是传引用"这个老问题,一次讲清
几乎每个人学到这里都会问:Python 到底是值传递还是引用传递?准确的答案是对象引用传递(有人叫"传对象引用")。展开说就是:函数收到的是实参对象的引用副本,形参和实参指向同一个对象。
于是行为分成两种。对不可变对象(数字、字符串、元组)的任何"修改"其实都是新建对象并重新绑定,外面看不到变化:
def bump(x): x = x + 1 return x n = 10 bump(n) print(n) # 10,没变对可变对象(列表、字典、集合)的原地修改会影响到外部:
def add_bad(items): items.append(999) data = [1, 2] add_bad(data) print(data) # [1, 2, 999],被改了知道这个之后,工程上的做法就明确了:如果函数需要修改容器内容,要么明确在文档里写清"会原地修改",要么在函数内先复制一份。我倾向于后者,因为副作用越少,函数越好测:
def add_safe(items): result = list(items) result.append(999) return result代价是多一次内存分配。数据量小的时候完全不用在意,数据量大的时候(比如几百万行的列表),复制成本就不可忽视了,这时候才考虑原地修改 + 明确约定。这是典型的"先正确后优化"。
6.3 递归:什么时候该用,什么时候必须停手
递归在函数这一章的练习里出现频率极高,阶乘、斐波那契、目录遍历全是它。我的实话是:日常业务代码里递归的使用场景比你想象的少得多,主要出现在树形结构处理(目录、菜单、组织架构)和分治算法里。
写递归只有两条铁律:一是必须有终止条件,二是每次递归规模必须真的缩小。这两条听着简单,实际写起来最容易在"规模缩小"上翻车。比如这个经典错误:
def factorial(n): if n == 0: return 1 return n * factorial(n) # 参数没变,无限递归至于斐波那契,直接照定义写递归在n=35左右就会明显卡顿,因为同一个子问题被重复计算了指数级次数。改成迭代或者加缓存(functools.lru_cache)都能解决:
from functools import lru_cache @lru_cache(maxsize=None) def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)顺便说,lru_cache本身就是个装饰器,正好和 4.4 的内容呼应。但要注意它有副作用:被装饰的函数参数必须可哈希,而且缓存会一直占内存,处理大参数范围时要设maxsize。
还有一个必踩的坑:Python 默认递归深度限制是 1000 左右,超过就RecursionError。可以用sys.setrecursionlimit()调大,但别指望靠这个解决问题——调太大可能导致解释器栈溢出直接崩溃,这种崩溃连异常都抓不到。真遇到深递归,老老实实改成用栈模拟的迭代写法。
6.4 几个我踩过的小坑,写在这里省你时间
第一个:默认参数里别写函数调用。前面说过time.time()那个例子,更隐蔽的是def f(items=sorted(DEFAULT_LIST))这种,看着很合理,实际在模块导入时就固化了一份,后续改DEFAULT_LIST完全无效。
第二个:return后面跟了逗号会变成元组。return a, b返回的是元组,这是特性;但return result,这种手滑多打了个逗号,返回的是单元素元组,调用方拿到(result,)一脸懵。
第三个:空函数必须写pass。def todo():后面什么都不写会IndentationError,用pass或者...(省略号)占位都行。我个人习惯用...,因为一眼能看出"这里以后要填"。
第四个:递归函数调试时别只看最后一行报错。RecursionError的堆栈会打印几百层,真正有用的信息在第一次递归调用的入参上,往上翻可能要看很久。我的做法是在函数开头临时打一行日志,把关键参数打出来,跑一次就定位了。
第五个:函数名不要和内置函数重名。把函数命名为len、sum、list这类名字,在自己模块里可能看着没事,一旦被import *到别的模块,内置函数就被覆盖了,报错信息会非常诡异。同样的道理,input、print、id、type都是高危名字。
7. 把函数用出工程味:三条我坚持的习惯
7.1 单一职责和函数长度,用一个数字当红线
"一个函数只做一件事"这话太虚,我给它一个可操作的量化标准:超过 30 行就该考虑拆,超过 50 行必须拆(除非是密集的配置表或数据映射)。另一个更好用的判断是"函数里出现了几个and"——如果一个 if 条件里串了三个以上逻辑判断,通常说明这里有多个职责混在一起了。
拆分的时候有个技巧:先找注释。你写的每一条注释,往往就是一个潜在的函数边界。# 清洗数据、# 计算指标、# 生成报告,这三行注释就是三个函数的名字。反过来,如果你发现一个函数写完根本不需要注释,那通常说明它已经足够清晰了。
7.2 类型注解不执行,但它值那个打字时间
Python 的类型注解在运行时完全不生效,写错了也不报错,所以很多人懒得写。但我在实际项目里的体会是:注解最大的价值不是给机器看,是给三个月后的自己看,其次是给 IDE 看——有了注解,编辑器才能给出准确的补全和跳转,用起来效率差一个档次。
我的实践是:公共函数(会被别人调用的)必须写完整注解,内部临时的小函数可以不写。配合mypy这类静态检查工具跑一遍,能在不改一行运行代码的情况下揪出一批拼写错误和类型错配,性价比很高。
7.3 文档字符串和 doctest,写一次收益好几年
函数下面那个三引号不是可有可无的装饰品。我给自己定的规矩是:每个公共函数的文档字符串至少包含一句话说明、参数含义、返回值说明;如果函数逻辑简单,顺便写两行doctest示例,因为它可以被真正执行验证。
def clamp(value: float, low: float, high: float) -> float: """把 value 限制在 [low, high] 区间内。 >>> clamp(5, 0, 10) 5 >>> clamp(-3, 0, 10) 0 >>> clamp(99, 0, 10) 10 """ return max(low, min(value, high))跑python -m doctest demo.py -v就能验证这些示例。它的好处是示例永远不会过期——一旦你的实现改了导致示例不成立,测试立刻红。这比写在注释里的"示例代码"可靠一万倍,那些注释里的代码几个月后基本全是错的。
我在实际项目里带新人的时候,会要求他们把这一章的三个函数(parse_numbers、stats、retry)都手写一遍,不许复制。写完再回头看一眼自己一个月前写的函数,如果看不懂了,说明当时少写了文档或者参数设计有问题——这个自我检验的方法,比任何教程都有效。函数这一章真正的收获不是记住几种参数写法,而是养成"先想接口、再写实现、顺手留文档"的肌肉记忆,这个习惯会跟着你走很远。