
最近帮朋友批改一份面试用的Python笔试题发现一道考察函数默认参数的题目错误率高得吓人。题目本身只有十几行问的是连续三次调用同一个函数后返回的列表分别是什么。结果收上来的答案五花八门有说会报错的有说三次都是同一个结果有说每次都会重新创建空列表的。于是我想把这道题连同背后牵扯出来的知识点完整拆一遍做成一篇既可以当面试复习资料、也能当日常避坑指南的内容。这道题不考冷门API不考底层C源码考的是Python里一个非常基础但又非常反直觉的机制默认参数到底什么时候被求值。真正吃透它你对函数对象的理解、对可变与不可变对象的认知甚至对类属性共享机制的认识都会比大多数写了两三年Python的人更深一层。1. 一道输出题三个想当然的答案1.1 原题与常见错解先看题目本身def append_item(item, container[]): container.append(item) return container print(append_item(1)) print(append_item(2)) print(append_item(3))问三次打印的输出分别是什么很多候选人第一眼会这么推理第一次调用container[]append(1)后返回[1]。第二次调用时默认参数又被重新赋成了新的空列表所以append(2)后返回[2]。第三次类似返回[3]。这个推理链条非常自然但很遗憾是错的。正确答案是[1] [1, 2] [1, 2, 3]换句话说第二次调用时container并不是一个全新的空列表而是被第一次调用修改过的那个[1]第三次调用时它又带着[1, 2]继续追加。默认参数好像被所有调用共享了。还有一部分人会掉进另一个坑以为第二次调用用[1]继续传参但第三次调用如果传入一个新列表[0]又会是什么结果我们把题改一下def append_item(item, container[]): container.append(item) return container print(append_item(1)) print(append_item(2, [10])) print(append_item(3))正确输出是[1] [10, 2] [1, 3]第二次调用显式传入[10]这个列表是调用时新构造的所以只包含[10, 2]。第三次调用没有传参又回到了那个被记住的默认列表上此时它已经因为第一次调用变成了[1]追加3之后是[1, 3]。1.2 运行结果为什么让不少人意外这个结果真正反直觉的地方在于大部分从其他语言转过来的程序员会天然地把函数定义里的参数默认值理解成每次调用时重新执行一遍的初始化语句。比如在C里你写void f(int x 0)每次调用不传参时x都会是0。在JavaScript里你写function f(x 0)每次调用不传参时参数也会被重新求值。这种每次调用重新初始化的心智模型太深入人心了。Python不一样。Python的def本身就是一条可执行语句函数对象在代码加载到def那一行的瞬间就被创建了默认参数的值在那一刻就被计算出来然后作为属性长期挂在函数对象上。第二次、第三次调用的时候Python根本不会回去重新计算那个默认值它只是把已经存在的那个对象再取出来用。这就像办了一张健身卡你去健身房刷的是同一张卡而不是每次去都重新办一张。1.3 为什么面试官偏爱这道题这道题能成为一道经典笔试题不是偶然它的考察密度太高了考你对def语句执行时机的理解不背语言手册很难答对考你对可变对象与不可变对象差异的敏感度如果默认值换成数字或字符串这个问题就完全不存在考你是否见过真实项目里的类似事故写过业务代码的人多少都踩过这个坑还顺带考了你愿不愿意做实验验证很多候选人上来就凭空推理而不是在脑子里执行一遍id()看对象身份所以别小看这十几行代码。你的答案背后暴露的不只是这道题会不会更是你对Python对象模型整体理解到了哪个程度。2. 默认参数为什么没被重置函数定义时的一次性绑定2.1 def 是执行语句不是纯声明很多初学者会把def类比成Java、C#里的方法声明认为函数从程序入口开始才真正存在。这是一个长期存在的误解。Python在模块被导入时会逐行执行模块里的顶层代码。执行到def append_item(...)这一行时会发生这么几件事创建一个新的函数对象给这个函数对象起一个名字绑定到当前作用域的变量名append_item上计算函数签名里的各个默认参数表达式把计算结果保存起来第三步里的计算默认参数表达式就是所有问题的根源。container[]中的[]是一个列表字面量它会在def执行的那一刻真实地创建一个列表对象。这个对象不会在每次调用时被重新创建而是被永久地绑定在函数对象的属性上直到函数被垃圾回收。我用一个最直观的类比把函数对象想象成一个储物柜定义函数时你把一个默认列表放了进去之后每次调用不传这个参数Python都从储物柜里拿出同一个列表给你用。你在函数体里对这个列表做的任何修改都是在修改柜子里的那个原件。2.2 默认值存放在defaults里为了证实上面说的永久绑定可以直接查看函数对象的__defaults__属性它保存了所有位置参数的默认值def append_item(item, container[]): container.append(item) return container print(append_item.__defaults__) # 输出: ([],) append_item(1) print(append_item.__defaults__) # 输出: ([1],)看到没有调用函数之后__defaults__里的列表内容变了。这直接证明函数体里操作的那个container就是默认值元组里保存的那个列表对象本身。它不是副本不是临时变量就是同一个东西。同理如果你用关键字参数的形式传参函数也能正常工作print(append_item(1, container[]))但注意这里你显式传入了新列表函数体修改的就不是默认列表了。这也是为什么面试题里第二次调用显式传[10]时不会污染默认列表。知道__defaults__的存在还有一个隐藏好处你可以在调试时直接查看函数默认值到底是什么、有没有被意外修改。不少棘手的线上问题就是靠这个属性定位到某个函数的默认参数在不知情的情况下被上一轮调用污染了。2.3 不可变默认参数为什么安全与可变性到底什么关系很多人会问那我平时写的def f(x, a0)、def f(x, s)为什么从来没出过问题因为问题的根源不在默认参数而在你修改了它。数字、字符串、元组、None这些不可变对象函数体里没法就地修改它们的内容最多是把变量名重新指向一个新对象默认值元组里存的原对象不受影响。所以它们天然安全。而列表、字典、集合这类可变对象append、extend、add、索引赋值这些操作都是就地修改会直接动到默认值元组里保存的那个对象。更重要的是这种伤害是有累积性的。上一次调用遗留的数据会完整地流到下一次调用。用一句话总结不要在函数定义处直接使用可变对象作为默认值。这条规则不是为了限制你而是因为Python把默认值的初始化时机放在了定义阶段而不是调用阶段从设计上就决定了可变对象放这里会有共享风险。3. 面试追问链从默认参数到类属性、partial与装饰器3.1 类属性Student选课的例子面试官问完默认参数十有八九会顺势追问一句你知道类属性也有类似的共享问题吗这其实是在考察你能否把对象绑定时机这个底层逻辑迁移到别的场景。看这个经典的类定义class Student: courses [] def __init__(self, name): self.name name def enroll(self, course): self.courses.append(course) alice Student(Alice) bob Student(Bob) alice.enroll(Math) bob.enroll(English) print(alice.courses) # [Math, English] print(bob.courses) # [Math, English]两个学生实例明明分别注册了不同课程最终却共享了同一份课程列表。原因和默认参数几乎一样courses []在类定义体执行时创建了一次之后所有Student实例访问self.courses时都顺着一级一级的查找链找到了这个类属性拿到的是同一个列表对象。解决办法也很相似如果你希望每个实例各自持有一个列表就应该在__init__里用实例属性初始化def __init__(self, name): self.name name self.courses []这也解释了为什么在笔试题里self.courses []和courses []出现在不同位置会有完全不同的语义。类体的代码只执行一次__init__里的代码每次创建实例时都会执行一次。3.2 functools.partial 里的提前绑定面试官如果继续深挖可能会聊到functools.partial。它本质上也是一种参数绑定机制但它的绑定时机和默认参数不同partial在创建时就锁死了一部分实参之后调用时根本不会再重新求值。from functools import partial def power(base, exponent): return base ** exponent square partial(power, exponent2) cube partial(power, exponent3) print(square(4)) # 16 print(cube(4)) # 64这里exponent2在调用partial时就被绑定到了square内部后续调用square(4)等价于power(4, exponent2)。这个机制用起来非常顺手但它也提醒我们Python里参数默认值和参数绑定并不只在def语句里出现任何创建可调用对象时做参数预绑定的操作都要理解绑定发生在创建阶段。真正危险的是把partial和可变默认参数组合起来用比如把可变集合绑进去作为累积器。这个玩法在面试里偶尔会作为压轴题出现你得能识别出它其实就是默认参数共享问题的变体。3.3 装饰器里藏着的可变默认参数装饰器是面试追问里另一个高发区。很多候选人知道装饰器怎么写但没意识到装饰器内部也可能中招def record_call(func, records[]): def wrapper(*args, **kwargs): records.append(func.__name__) print(records) return func(*args, **kwargs) return wrapper record_call def hello(): pass record_call def world(): pass hello() # [hello] world() # [hello, world]这里records[]作为record_call的默认参数在装饰器函数定义时被创建了一次。两个函数hello和world都用同一个装饰器装饰器的默认参数列表被两者共享于是world被调用时能看到hello留下的记录。这种写法造成的bug很隐蔽如果你脑子里想的是每次用record_call装饰新函数时都会得到一个新的records列表那现实会给你狠狠上一课。要修复也很简单把records[]改成recordsNone在函数内部判断def record_call(func, recordsNone): if records is None: records [] def wrapper(*args, **kwargs): records.append(func.__name__) return func(*args, **kwargs) return wrapper3.4 dataclasses 的 default_factory 是怎么解决历史问题的聊到这儿面试官如果愿意再延伸一层可能会问Python官方有没有从语言层面提供干净的可变默认值写法。答案是有的而且藏在dataclasses里。from dataclasses import dataclass, field from typing import List dataclass class Task: name: str steps: List[str] field(default_factorylist) t1 Task(build) t2 Task(test) t1.steps.append(compile) print(t1.steps) # [compile] print(t2.steps) # []field(default_factorylist)的意思是每次创建Task实例时调用一次list()工厂函数生成一个全新的列表。这正好解决了我们前面纠结的默认值绑定时机问题——default_factory把初始化时机从类定义阶段推迟到了实例创建阶段。这也是为什么Python社区建议如果你需要可变默认值优先考虑用dataclasses.field、default_factory或者干脆用不可变哨兵值而不是在原生的def默认参数里直接放[]。官方设计已经给了你正确姿势就不用再纠结原生写法的坑了。4. 用内置工具把原理拆给面试官看4.1 id() 验证对象身份面试的时候最加分的不是背出结论而是当场演示验证过程。id()是第一个应该用的工具它返回对象在内存中的唯一身份标识。如果两次调用拿到的id相同说明它们操作的是同一个对象。def append_item(item, container[]): container.append(item) print(id(container)) return container append_item(1) # 输出: 140229312578944 append_item(2) # 输出: 140229312578944两次调用的id完全一致这就从对象身份层面证实了默认参数容器是同一个对象第二次调用带着第一次修改的痕迹继续为你服务。如果换成传入新列表append_item(3, []) # 输出: 140229312822656另一个新的id你一看id变了就知道这次操作的是一个全新列表不再污染默认列表。4.2 查看defaults元组__defaults__不仅能在调试时看默认值在面试现场也可以作为实锤抛出来。当你调用完函数后再去打印能看到默认列表内容被函数体内修改了def demo(x, data[]): data.append(x) return data print(demo.__defaults__) # ([],) demo(1) demo(2) print(demo.__defaults__) # ([1, 2],)这是非常直观的证据链调用前默认列表是空的调用后默认列表里已经有了1和2。说明函数体修改的就是默认值本身而不是一份拷贝。我建议初学者都用这个命令去观察自己写的每一个带默认参数的函数看多了心里对函数对象保存默认值这个模型就会非常笃定以后再也不会被这类题迷惑。4.3 inspect 模块的签名分析如果面试官希望看到更工程化的分析手段inspect模块可以派上用场。它能获取函数的完整签名信息包括参数默认值、参数类型、是否可调用等import inspect def append_item(item, container[]): pass sig inspect.signature(append_item) print(sig.parameters[container].default) # 输出: []你可以通过inspect拿到默认对象再对它调用id()、type()甚至直接修改变量验证共享性。这种用反射手段分析函数对象的方式在真实调试中也非常有用尤其是你面对一个不知道是库函数还是自己代码的函数时。不过要提醒一句inspect拿到的默认值同样指向__defaults__里的同一个对象所以它和直接看__defaults__没有本质区别只是API更规范、更可读。4.4 面试现场怎么讲最加分如果面试官让你解释这道笔试题我不建议直接抛结论因为默认参数可变所以会共享。更好的回答结构是这样先点出核心结论默认参数表达式在def语句执行时只会被求值一次而不是每次调用时重新求值再讲机制def是执行语句它创建函数对象并把默认值存到__defaults__元组里然后给证据用id()或直接看__defaults__证明连续两次调用拿到的是同一个列表对象最后落到规范写法如果确实需要可变默认值用None哨兵配合内部判空或者用dataclasses.field(default_factory...)这个回答既不空洞又有层次感。面试官想听到的正是这种原理-验证-落地的完整链路而不是死记硬背的别用可变默认参数六个字。5. 这道题在真实项目中的翻车现场与推荐写法5.1 一个告警去重列表的真实事故理论知识说完了讲一个我在实际项目里看到过的例子大家会更直观地明白这类问题为什么值得认真对待。某个监控告警服务里有一个函数负责记录已经处理过的告警IDdef process_alert(alert_id, seen[]): if alert_id not in seen: seen.append(alert_id) # 模拟处理告警 print(fprocessing {alert_id}) else: print(fskipping {alert_id}) return seen单独调用几次一切正常process_alert(A001) # processing A001 process_alert(A002) # processing A002 process_alert(A001) # skipping A001因为seen列表在函数定义时创建之后一直挂在默认值元组里所以多次调用之间它一直都记得之前的告警ID。看起来还挺好用的甚至像是一个免费的缓存。但问题出在哪里出在这个服务是长期运行的seen列表会无限增长占用内存越来越大而且所有调用方共用一份记录。如果未来有人对这个函数做单元测试测试之间也会互相污染测试1往seen里塞了一个ID测试2断言自己处理的列表是空的时候就会失败而且测试之间执行顺序不同失败情况还不一样极其难排查。更隐蔽的是如果这个函数的返回值被某个调用方保存并继续修改handler process_alert(A003) # 业务代码继续对 handler 做 append 操作 handler.append(evil)此时你修改的仍然是默认参数里的那个列表下一个调用process_alert(A004)会看到异常数据。两个看起来毫无关联的代码路径因为这个共享默认参数产生了强耦合。5.2 None 哨兵的常规写法与争论点这类事故的正确修法面试官希望听到的通常是None哨兵写法def process_alert(alert_id, seenNone): if seen is None: seen [] if alert_id not in seen: seen.append(alert_id) # 处理逻辑 return seen这个方案的思路是默认值用不可变的None每次调用不传参时函数体内部重新创建一个全新的列表保证各调用方互不影响。同时None作为哨兵值能明确区分调用方还是想用一个已有的列表和调用方希望创建一个新列表两种情况。有人会问既然默认参数不能用[]那能不能用()空元组替代答案是如果你的函数只需要读取这个序列不需要修改那用元组确实安全因为元组不可变。但如果你需要往里面加数据最终还是要创建一个新的可变对象那就绕回了None加内部判空。也有人纠结if seen is None是不是多了一次判断性能有影响这种开销微乎其微完全不是需要优先考虑的问题。正确性和可维护性远比这点性能重要。如果你真的在写一个热路径且频繁调用那也应该考虑其他设计比如让调用方主动传入列表而不是依赖默认参数。5.3 评审时如何快速扫出这类隐患在代码评审环节我愿意把这些年积累的扫描经验分享出来能帮大家快速定位同类问题。第一肉眼扫def函数定义行看到形如param[]、param{}、paramset()的写法直接打上待确认标签。这一步最快但也是最依赖专注力的一步。第二用IDE辅助。PyCharm通常会给可变默认参数画黄色波浪线鼠标放上去会提示Default argument value is mutable。VS Code搭配Pylance、Pyright这类静态检查工具也会给出类似警告。养成不忽略黄色波浪线的习惯能避免很多麻烦。第三在团队规范或CI脚本里加一条静态检查规则。Python生态里不少静态分析工具都内置了对可变默认参数的检查直接开启即可。这类规则精确率高、误报少值得作为强制检查项。第四定期搜索代码库里的模式def xxx(.*[]或 {}。用正则搜一遍能快速找出历史代码里的存量隐患。5.4 多线程与并发共享默认值的另一层风险如果项目里有并发场景共享默认参数还会引入一个额外风险多个线程同时修改同一个默认列表会导致数据竞态和不可预期的结果。考虑这样一个函数def log_event(event, history[]): history.append(event) return history两个线程同时调用log_event都在对同一个列表执行append。虽然CPython的list.append在底层由GIL保护单个操作本身看起来原子但你无法保证两个线程的append顺序符合业务预期也无法保证后续对history的读取不会拿到一个处于中间状态的数据。更麻烦的是一旦有人对这个共享列表执行了非原子操作比如先判断再写入竞态问题就会彻底暴露。所以在线程池、异步回调这类场景里尤其不能依赖默认参数做数据暂存。正确的做法是显式传入独立容器或者用线程安全的数据结构又或者把状态保存在明确管理生命周期的对象里。我在实际使用中发现这类问题最难受的点在于单机单线程测试完全正常代码一上生产、一开并发数据就开始错乱而且难以稳定复现。排查到最后才发现罪魁祸首竟然是函数定义里一个不起眼的[]。所以每次写新函数我都会习惯性地检查一遍参数默认值是不是不可变对象这个习惯救过我很多次也推荐大家培养起来。