前几天把一份《Python 测验3(函数)》翻出来重做了一遍。说实话,当时做这套题的时候我心里是有点膨胀的,觉得函数不就是def加return嘛,结果对答案的时候傻眼了:有三四道题是我以为稳对、实际全错的。后来我没有急着去看标准答案,而是把每道错题都丢进解释器里跑一遍,逐个拆解背后的运行机制。为什么单独把函数拎出来出一个测验?因为函数是几乎所有 Python 项目的“最小组织单元”,你可以不写类,但不能不写函数。函数题表面上考语法,实际考的是对命名空间、对象引用、调用栈这些底层概念的掌握程度,而这些恰恰是写业务代码时最容易想当然的地方。
这篇文章就是我重做这套函数测验的完整复盘。包含具体题目、错误输出、底层原理、正确写法,还会把考试题延伸成真实项目里的函数设计经验。适合刚刚学完 Python 基础、想检验自己函数掌握程度的读者,也适合给学员出题、备课的朋友参考。每道题我都会给出在解释器里可以直接跑的代码,建议你边看边敲。
1. 这套函数测验,到底在考什么
1.1 为什么“函数”值得单独出一次测验
很多初学者觉得函数是 Python 里最好糊弄过去的知识点:定义、传参、返回值,好像半天就能看完。但真到写项目时,函数写得好不好,直接决定了一个模块能不能测试、能不能复用、别人能不能看懂。
函数题和其他语法题最大的区别在于:它考的是“边界情况”和“运行机制”。比如默认参数会不会被共享、局部变量和全局变量怎么绑定、闭包捕获的到底是什么、return和print搞混了会有什么后果。这些坑在一两行的小 demo 里根本看不出来,一旦放进真实代码里,就是线上事故级别的 bug。
所以我复盘这套测验时给自己定了三条标准:第一,结果对不对;第二,能不能用自己的话讲清楚“为什么是这个结果”;第三,能不能根据这个知识点写出更健壮的代码。只有三条都过了,才算真正吃透一道题。
1.2 我按什么顺序重新梳理这份测验
原题的题目顺序比较跳跃,一会儿考参数、一会儿考作用域。我复盘时没有按原顺序走,而是按“运行时行为”重新归了类:
- 第一类,关于“函数定义时发生了什么”。典型考点是默认参数、函数对象属性。
- 第二类,关于“函数调用时名字怎么查找”。典型考点是作用域、闭包、全局变量。
- 第三类,关于“函数返回什么、不返回什么”。典型考点是
return、None、多返回值。 - 第四类,关于“参数怎么收集、怎么展开”。典型考点是
*args、**kwargs、解包。
按这个顺序走下来,你会发现一个很有意思的事实:函数题里的大多数错误,都不是“语法不会写”,而是“不理解 Python 在背后替你做了什么”。下面我按这个分类把错题一道一道拆开。
2. 第一类错题:可变默认参数引发的连锁反应
2.1 原题:两次调用同一个函数,结果居然“记住了”上次的数据
题目是这样的:
def add_item(item, items=[]): items.append(item) return items print(add_item(1)) print(add_item(2)) print(add_item(3, [0])) print(add_item(4))请写出输出结果。
我当时毫不犹豫地写了:
[1] [2] [0, 3] [4]理由是:每次调用add_item时,items如果没传,就创建一个新的空列表[],然后往里面添加元素。传给add_item(3, [0])那次,我重新传了一个列表,所以应该是[0, 3]。看起来逻辑没毛病,但实际运行结果让我当场愣住:
[1] [1, 2] [0, 3] [1, 2, 4]第二次调用输出[1, 2],第四次调用输出[1, 2, 4]。也就是说,第二次和第四次调用时,函数“记得”第一次调用添加的1。这个现象背后藏着一个非常核心的机制:Python 函数的默认参数值,只会在函数定义时被求值一次,而不是每次调用时都求值。
2.2 为什么默认列表会被“共享”:定义时求值机制
当解释器执行到def add_item(item, items=[]):这一行时,它会创建一个函数对象,然后把items=[]这个空列表对象保存到函数对象的某个属性里。这个属性就在add_item.__defaults__里。
之后每次调用add_item时,如果没有显式传入items,Python 就会直接从__defaults__里取同一个列表对象,而不是新建一个。你可以亲自验证一下:
print(add_item.__defaults__) # 输出: ([1, 2, 4],)这个元组里装的还是那个列表对象,而且已经被改得面目全非了。
这里最关键的理解点是:默认参数在“定义时”就固定下来了,它不是一个每次调用都会重新执行的“初始值表达式”。这和你习惯的很多语言不太一样。比如在某些语言里,默认参数每次调用都会重新赋值;但在 Python 里,默认参数是函数对象的一部分。
2.3 正确写法:None 哨兵值,而不是可变对象
标准修正方案是用None作为默认值,然后在函数体内判断并重新创建列表:
def add_item(item, items=None): if items is None: items = [] items.append(item) return items这样每次不传items时,函数体内都会新创建一个列表对象,彻底避开“共享”问题。
为什么大家都推荐写items=None而不是items=[]?因为None是不可变对象,不存在被修改的问题,在函数体内重新赋值也不会影响其他调用。而且这个写法语义清晰:看到None你就知道这里是在用“哨兵值”表示“未提供该参数”,比一个空列表要直观得多。
2.4 延伸考点:值传递还是引用传递,其实是“对象引用传递”
做完这道题之后,我顺手复盘了另一个相关知识点:Python 函数的参数到底是值传递还是引用传递?答案是:两者都不是,准确说法是“对象引用传递”。
看这个例子:
def change_first(nums): nums[0] = 99 lst = [1, 2, 3] change_first(lst) print(lst) # [99, 2, 3]列表被修改了。初学者会以为这是“引用传递”。但再看这个:
def reassign(nums): nums = [100, 200, 300] lst = [1, 2, 3] reassign(lst) print(lst) # [1, 2, 3]外部列表没有被改成新列表。如果真的是引用传递,nums = [...]应该会改变外部变量指向。但实际情况是:传入函数的是对象的引用,但这个引用本身是按值传递的。也就是说,函数内的局部变量nums和外部变量lst指向同一个对象,但它们是两个不同的名字;对nums重新赋值,只是让nums指向了新对象,外部lst没有任何影响。
我的理解方式是:把参数想象成“门牌号复印件”。你拿到的是对象在内存里的地址(门牌号),可以通过这个地址找到对象并修改它;但如果你在函数里改写“门牌号复印件”,并不会改变对方手里的原件。
这道题给我最大的收获是:以后写函数时,如果参数是可变对象,先问自己三个问题——我是不是要在函数内部修改它?外部调用者是否希望我修改它?如果不想修改,是不是应该先拷贝一份?
3. 第二类错题:作用域、闭包与那些“变了却没变”的变量
3.1 局部变量遮蔽 UnboundLocalError:为什么全局变量在函数里不能用
第二道让我印象深刻的题是这样的:
count = 0 def inc(): count += 1 return count print(inc())我当时觉得很简单:count是全局变量,函数里直接count += 1然后返回,输出1。结果一跑,直接报错:
UnboundLocalError: local variable 'count' referenced before assignment这个报错,不少写过一段时间 Python 的人都遇到过。关键在于:count += 1这个赋值语句让 Python 把count当成了局部变量。既然它是局部变量,而函数第一行还没有给count赋值就直接使用它,自然就报了“引用在赋值之前”的错误。
这个规则的底层逻辑,是 Python 的“命名空间绑定”机制:在一个函数体内,只要某个名字出现在赋值语句左侧,Python 就从那一刻起,把这个函数里所有对该名字的引用都绑定到局部变量上。这不是运行时才决定的,编译函数体的时候就已经决定了。
如果你真的想修改全局变量,需要显式声明:
count = 0 def inc(): global count count += 1 return count print(inc()) # 1global count的意思就是告诉 Python 解释器:函数内部出现的count不要再当作局部变量,请直接去全局命名空间里找它。
3.2 闭包里的一层变量:nonlocal 到底解决了什么问题
全局变量之后,又碰到了一种更隐蔽的情况。考虑这个计数器:
def make_counter(): count = 0 def counter(): count += 1 return count return counter c = make_counter() print(c())这段代码同样会报UnboundLocalError。原因和上面的global问题一模一样,只不过这次count不在全局作用域,而在外层函数make_counter的局部作用域里。
要想在内层函数中修改外层函数的局部变量,用global是不行的,因为count不是全局变量。这里需要用nonlocal:
def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter c = make_counter() print(c()) # 1 print(c()) # 2nonlocal会向上查找“最近的、既不是全局也不是局部的变量”,并允许你修改它。
接下来要问一个关键问题:闭包捕获的到底是变量的值,还是变量本身?答案是变量本身,准确说是“变量所在的单元格”。counter函数并不保存count当前的值,而是保存着一个指向count的引用,外层每次修改count,内层都能感知到。
你可以用__closure__来验证:
c = make_counter() print(c.__closure__) # (<cell at 0x...: int object at 0x...>,)这个元组里的cell对象,就是闭包保存变量的“容器”。
3.3 经典陷阱:循环里造 lambda,为什么结果全是同一个值
考察闭包时,几乎必考的另一道题是循环和lambda的组合:
funcs = [lambda: i * i for i in range(3)] for f in funcs: print(f())我当时想,i依次是 0、1、2,所以输出应该是0 1 4。实际输出却是:
4 4 4原因还是闭包捕获的是变量本身,不是循环每次迭代时的快照。循环结束后,i的最终值是2,而所有lambda函数共享同一个小循环作用域里的i,所以每次调用f()拿到的都是 2。
解决方式有两种。第一种是用默认参数绑定当前值:
funcs = [lambda i=i: i * i for i in range(3)] for f in funcs: print(f())默认参数在函数定义时求值,所以i=i把当前循环变量值“固化”到了默认参数里。第二种是用一个额外的工厂函数:
def make_square(i): return lambda: i * i funcs = [make_square(i) for i in range(3)]make_square每次调用都会创建新的作用域,i是它的局部变量,闭包捕获的是每个作用域里独立的i,互不干扰。
3.4 调试闭包的小工具:inspect 和closure
如果你在项目里遇到“闭包表现诡异”,建议先用inspect模块看看函数的闭包信息。比如:
import inspect def outer(x): def inner(): return x return inner f = outer(42) print(inspect.getclosurevars(f)) # ClosureVars(nonlocals={'x': 42}, globals={}, builtins={}, unbound=set())getclosurevars会清清楚楚告诉你这个函数依赖哪些非局部变量,以及它们的当前值。排查闭包问题时,比盲目加print高效得多。
我还习惯用一个小小的判断口诀:问自己“这个内层函数想用的是创建时的值,还是以后随时可能变的变量?”如果不希望它随后续变化而改变,就要么在创建时通过默认参数“拍照”,要么不要让变量暴露在外层作用域。
4. 第三类错题:return 与 print 的纠缠,以及没有显式返回的函数
4.1 原题:print("hello") 之后,result 到底是什么
再来一道很经典但很容易错的题:
def hello(): print("hello") result = hello() print(result)输出是什么?我当时写的是:
hello hello想得很天真:hello()打印一次 hello,返回值赋给result,再把result打印一次,所以是两行 hello。但实际输出是:
hello None原因就一句话:print是输出到终端,return才是把值交给调用者;没有return的函数,返回值永远是None。
这是初学者最容易犯的混淆。很多人一开始写函数时,喜欢在函数内部用print来“返回”结果,结果后面想拿这个结果做进一步运算时,发现拿到的是None,最后到处找 bug。
一个非常实用的判断标准:如果函数只是向终端展示信息,用print;如果函数要产出数据、供其他代码继续处理,用return。两者职责完全不同。
4.2 多返回值其实是返回一个元组
这道题还算简单,但接下来的这题更绕:
def minmax(nums): return min(nums), max(nums) result = minmax([3, 1, 4, 1, 5]) print(result) print(type(result))输出是:
(1, 5) <class 'tuple'>从语法上看,一个return后面跟着两个用逗号分隔的表达式,好像“返回了多个值”。但 Python 里并没有“返回多个值”这回事,实际是把它们打包成了一个元组对象返回。调用方接收到的,永远只有一个对象,只不过这个对象是元组。
接收时可以解包:
low, high = minmax([3, 1, 4, 1, 5]) print(low, high) # 1 5这个解包过程也是元组解包,本质上和a, b = (1, 5)没有区别。
如果返回的数据项比较多,比如三个以上,我建议不要再裸着返回元组,而是用具名元组或者数据类,这样读代码的人能一眼看懂每个字段的含义:
from collections import namedtuple Range = namedtuple("Range", ["low", "high"]) def minmax(nums): return Range(min(nums), max(nums)) r = minmax([3, 1, 4, 1, 5]) print(r.low, r.high) # 1 54.3 return 除了返回结果,还能提前退出函数
复盘到这里,我发现测验里很多题只关注“返回值是什么”,但很少人关注return的另一个作用:提前终止函数执行。
比如这段代码:
def check_score(score): if score < 0: return "invalid" if score > 100: return "invalid" if score >= 60: return "pass" return "fail"如果score < 0,函数在第一个return处就结束了,后面所有逻辑都不会执行。这种“守卫式”写法可以显著减少嵌套层级,让代码更平、更清晰。对比一种反面写法:
def check_score(score): if score >= 0: if score <= 100: if score >= 60: return "pass" else: return "fail" else: return "invalid" else: return "invalid"两层以上的if嵌套,只要再叠加一个循环或异常处理,代码基本就没法读了。所以我在实际项目里写函数时,会刻意把“异常分支”和“边界情况”前置,用多个return提前退出,把正常的主流程留在最后。
4.4 给返回值加上“说明书”:注解与 docstring
测验题不会考函数注解,但真实项目会要求你写明白函数返回什么。Python 支持在函数签名上加“类型注解”:
def parse_score(text: str) -> int | None: try: return int(text) except ValueError: return None-> int | None明确告诉调用者:这个函数返回整数,或者None。配合mypy、pyright这些静态检查工具,可以在运行之前就发现很多“以为返回 int,结果拿了 None”的隐患。
在此基础上,再加一个简短的 docstring,说明参数含义、返回内容、可能抛出的异常。不需要长篇大论,几行就够:
def parse_score(text: str) -> int | None: """把字符串转成整数分数。 参数: text: 要解析的字符串。 返回: 解析成功返回分数,失败返回 None。 """我见过太多“完全没注释”的函数,过三个月再看,签名上是一堆缩写和魔法数字,根本不敢改。写 docstring 和类型注解的成本很低,收益却很高,尤其是别人需要调用你写的函数时。
5. 第四类错题:*args、**kwargs 与解包的一场误会
5.1 当时的错题:星号到底什么时候“收集”,什么时候“展开”
有一道题我错得特别丢人:
def collect(*args, **kwargs): print(args) print(kwargs) collect(1, 2, x=3) collect(*[1, 2], **{"x": 3})我以为两次调用的结果不一样:第一次是“接收多个普通参数”,第二次是“传入列表和字典”,所以输出应该不同。但实际两次输出完全一致:
(1, 2) {'x': 3} (1, 2) {'x': 3}原因要分两层理解。
第一层,在函数定义里,*args的作用是“把调用方传入的多余位置参数收集成一个元组”;**kwargs的作用是“把多余的关键字参数收集成一个字典”。这是“收集”。
第二层,在函数调用里,*和**的作用正好反过来。*[1, 2]把列表“展开”成位置参数传入,等价于collect(1, 2);**{"x": 3}把字典“展开”成关键字参数传入,等价于collect(x=3)。这是“展开”。
“收集”和“展开”都叫解包(unpacking),但用在定义时和调用时,效果完全不同。我的记法是:在定义处,星号负责“把碎的收成整的”;在调用处,星号负责“把整的拆成碎的”。
5.2 解包操作符的三种使用场景
整理一下,*和**在 Python 3 里有三种主要用法:
在函数定义中收集参数:
def f(a, b, *rest, **options): ...在函数调用中展开参数:
data = [1, 2] f(*data) config = {"x": 10} f(**config)在列表或字典字面量中展开:
first = [1, 2] second = [3, 4] combined = [*first, *second] # [1, 2, 3, 4] d1 = {"a": 1} d2 = {"b": 2} merged = {**d1, **d2} # {'a': 1, 'b': 2}第三种用法在合并列表、合并字典的场景里非常实用,代码可读性远高于循环update的写法。
5.3 装饰器里的参数转发:为什么必须写 *args, **kwargs
测验题只考了基础语法,但*args, **kwargs在真实项目里最常出现在“装饰器”中。
比如写一个给函数计时的装饰器:
import time def timer(func): def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) print(f"耗时: {time.perf_counter() - start:.6f} 秒") return result return wrapper为什么wrapper必须用*args, **kwargs?因为装饰器不知道它装饰的函数有哪些参数,可能是add(a, b),也可能是connect(host, port, timeout=3)。用*args, **kwargs把任意参数原封不动地接过来,再用展开的方式原封不动地传给被装饰函数,这样这个装饰器才能通用于所有函数。
这就是“参数转发”的经典写法。如果把它写死,比如def wrapper(a, b):,这个装饰器就只能装饰接收两个位置参数的函数,泛化能力就丢了。
5.4 项目里的取舍:*args 不能当“万能垃圾桶”
这里要泼一盆冷水:*args和**kwargs很方便,但在业务代码里不能滥用。原因有三个。
第一,可读性变差。函数签名是def process(*args, **kwargs),调用方根本不知道应该传什么,只能去看文档或源码。第二,IDE 和类型检查工具失效。没有具体参数名,自动补全和静态检查基本帮不上忙。第三,错误延迟暴露。参数名写错、个数不对,往往要等到运行到很深的逻辑时才报错,定位成本很高。
我自己的原则是:
- 自己写的业务函数,尽量用明确的参数名和类型注解,避免直接用
*args, **kwargs。 - 装饰器、框架回调、通用分发器、日志封装这类“需要转发参数”的代码,才放心大胆地用
*args, **kwargs。 - 如果函数已经需要
*args才能接住各种情况,说明参数设计可能有问题,先去检查调用方是否太随意。
6. 调试复盘:拿到报错信息后,我是怎么一步步定位问题的
6.1 三种最常见的函数类报错长什么样
重做这套测验的过程中,我故意把每道错误答案都跑了一遍,目的就是让报错信息“长记性”。下面是函数题里最常见的三类报错:
| 报错类型 | 典型触发原因 | 典型排查方向 |
|---|---|---|
NameError: name 'xxx' is not defined | 函数名或变量名拼写错误、未导入模块 | 检查拼写、检查 import |
TypeError: f() missing 1 required positional argument | 调用时少传了必填位置参数 | 对照函数签名检查调用参数 |
UnboundLocalError: local variable 'xxx' referenced before assignment | 函数内先使用了后被赋值的同名变量 | 检查是否有赋值语句意外把它变成局部变量,考虑 global / nonlocal |
初学者遇到TypeError最容易慌,因为报错信息很长,还带一串路径。其实TypeError的错误消息开头就告诉了你原因,比如:
TypeError: add() missing 1 required positional argument: 'b'这句话已经直白地告诉你:调用add时少传了一个名为b的位置参数。直接对着函数签名去数参数个数即可。
6.2 traceback 阅读顺序:从最后往前看
有一次我调一个三层调用链时,盯着 traceback 看了半天没头绪。后来养成了一个习惯:绝不从头读,永远从最后一行开始读。
看这个例子:
def f1(): return f2() def f2(): x = 1 / 0 return x f1()报错是这样的:
Traceback (most recent call last): File "demo.py", line 11, in <module> f1() File "demo.py", line 2, in f1 return f2() File "demo.py", line 5, in f2 x = 1 / 0 ZeroDivisionError: division by zero从下往上看:最后一行是错误类型ZeroDivisionError和原因division by zero;倒数第二行告诉你发生在f2函数的第 5 行;再往上是f1的第 2 行,说明f1调用了f2;最上面是模块层,说明整个调用起点。
所以读 traceback 的顺序是:先看最后一行“死于何处”,再顺着往上“谁调用了它”,从最顶上找到“最初的调用者”。如果错误消息本身已经够直白,通常只需要看最后两行就够定位了。
6.3 插桩调试与 pdb:两种高效的定位方式
遇到逻辑错误(不报错但结果不对)时,需要更主动的手段。最直接的是插桩,也就是在关键位置加print看中间值:
def calc(a, b): print("a =", a, "b =", b) result = a * b print("result =", result) return result这个方法土,但在小函数里非常高效。唯一要注意的是:调试完记得删掉print,或者用logging代替,否则代码里到处是调试输出。
如果问题比较复杂,print来回插太麻烦,可以直接用内置调试器。在函数里加一行:
def mysterious(a, b): breakpoint() ...运行到这一行时,程序会进入交互式调试器,你可以输入p a查看变量、输入n单步执行、输入s进入函数内部、输入c继续运行。不需要安装任何第三方库,Python 3.7 之后内置了breakpoint(),非常方便。
6.4 用 doctest 把错题变成测试用例
这是我自己复盘时最受用的一招。Python 的doctest允许你把函数调用的“预期输出”直接写在 docstring 里,然后自动验证。
比如把刚才那道add_item的修正版写成:
def add_item(item, items=None): """把 item 添加到列表中;未提供列表时返回新列表。 >>> add_item(1) [1] >>> add_item(2) [2] >>> add_item(3, [0]) [0, 3] """ if items is None: items = [] items.append(item) return items if __name__ == "__main__": import doctest doctest.testmod()doctest会执行>>>后面的代码,然后和下一行的预期输出比对。如果输出不一致,会明确告诉你哪里不一样。
这个方法特别适合把测验错题改造成“可回归的测试用例”:以后每次复习,直接跑一遍所有 docstring 里的示例,哪些知识点又搞混了,立刻能暴露出来。等于把一次性的测验变成了长期有效的自检脚本。
7. 从测验题到真实项目:函数设计中的几个坏味道
7.1 参数列表越来越长:从位置参数到配置对象
测验题里的函数大多只有两三个参数,但真实项目里,函数参数膨胀的速度远超想象。我最常见到的一个版本是这样的:
def create_user(name, age, email, phone, city, country, is_admin=False, is_active=True, ...):十几个参数排在那里,调用的时候一大串,谁看谁头疼。
这种“长参数列表”本身就是一个信号:函数可能承担了太多职责,或者参数之间有关联关系,应该被组织成对象。一个更干净的做法是用数据类:
from dataclasses import dataclass @dataclass class UserProfile: name: str age: int email: str phone: str city: str country: str def create_user(profile: UserProfile, is_admin: bool = False, is_active: bool = True): ...调用时:
profile = UserProfile(name="张三", age=18, email="...", phone="...", city="...", country="...") create_user(profile)这样参数被组织成了“数据对象”,函数签名从十几个参数变成“一个对象加少量选项参数”,清晰程度直接上升一个档次。
不过也要注意,不要把一切参数都塞进对象里。我的判断标准是:如果多个参数总是同时出现、同时变化、描述同一个实体,就应该打包成对象;如果只是几个互不相关的选项,仍作为独立参数更直观。
7.2 副作用与纯函数:把可测试的边界画清楚
函数维护成本高,很多时候不是因为逻辑难,而是因为它把“计算”和“操作外部环境”混在了一起。所谓纯函数,就是满足两个条件的函数:
- 输入相同,输出必然相同。
- 不修改外部状态,不执行 I/O 操作。
纯函数有一个巨大的优势:可预测、可测试。你不需要准备复杂的运行环境,随便传什么参数都能跑,直接对比返回值即可。
反观不纯的函数,比如在函数里直接改数据库、直接写文件、直接改全局变量,测试起来就要费很大力气去 mock 外部依赖。
所以我做项目时的做法是:把业务逻辑尽量拆成纯函数,把 I/O 操作尽量放到函数边界上。比如先写一个calculate_total_price(items)的纯函数,再写一个save_order(order)专门负责持久化。前者不需要数据库也能单测,后者只需要 mock 一个文件路径就能测。
7.3 命名与类型提示:三个月后还能读懂的“自己写的代码”
测验题里的函数名都很短,比如f、g、foo,考试时可以这样写,项目里不行。
函数名本身应该是一个动词短语,能描述“这个函数做了什么”。比如:
- 不好:
def data(x): - 好:
def parse_score(text: str) -> int | None:
看到parse_score,你大概能猜出这个函数负责解析分数;看到data,你什么都不知道,只能点进源码去看。
在函数签名上,类型注解的价值前面已经提过。这里再补充一点:注解不只是给人类看的,也是给工具看的。mypy和pyright会在运行前帮你发现“把str当int用”之类的低级错误。
我自己写新函数时,会强迫自己遵守一个简单流程:先想清楚参数和返回值,再写签名和 docstring,最后写函数体。这样写出来的函数,即使隔了几个月再回来看,也能很快进入状态。
7.4 一个实际案例:坏味道函数 vs 整洁函数
最后用一段对比来展示“从测验到实战”的差别。先看一个从真实代码里抽出来的“坏味道函数”:
def process(order, discount=0, tax=0.05): if discount < 0 or discount > 1: return "invalid discount" if tax < 0 or tax > 1: return "invalid tax" subtotal = sum(item["price"] * item["qty"] for item in order["items"]) discounted = subtotal * (1 - discount) if discount else subtotal total = discounted * (1 + tax) return round(total, 2)这个函数的问题在于:计算逻辑和校验逻辑混在一起,返回值既有正常金额又有错误字符串,类型不统一,后续很难维护。
拆开后的版本:
from dataclasses import dataclass @dataclass class Order: items: list def calculate_subtotal(order: Order) -> float: return sum(item["price"] * item["qty"] for item in order.items) def apply_discount(subtotal: float, discount: float) -> float: return subtotal * (1 - discount) def apply_tax(subtotal_after_discount: float, tax: float) -> float: return subtotal_after_discount * (1 + tax) def calculate_total(order: Order, discount: float = 0, tax: float = 0.05) -> float: subtotal = calculate_subtotal(order) subtotal = apply_discount(subtotal, discount) return round(apply_tax(subtotal, tax), 2)校验逻辑单独放一层,计算逻辑拆成可以单独测试的纯函数,每个函数职责单一、命名清楚。虽然代码行数变多了,但维护成本显著降低。
最后再分享一个我自己的习惯:每次做完函数相关的小测验,我都会把错题改造成一个独立脚本,放进一个叫questions/的目录里,然后用doctest把题目里的“预期输出”写成测试用例。下次再复习的时候直接跑一遍全套测试,哪些知识点又生疏了一眼就能看出来。这个方法比反复翻答案有用得多。
函数这个东西,背语法手册没有用,真正能检验理解程度的,就是你面对一个“想不到的结果”时,愿不愿意一层一层往底层挖。测验只不过是把这些“想不到的结果”集中摆到你面前而已。挖得越深,后面的坑就越少。