免费编程软件「pythonpycharm」链接https://pan.quark.cn/s/48a86be2fdc0一个让老程序员都翻过车的Bug三年前我在写一个电商系统的订单处理模块。有个函数需要给商品添加标签如果调用时没给标签列表就自动新建一个空列表来装def add_tag(item, tags[]): tags.append(item) return tags看着没毛病吧测试一下print(add_tag(电子产品)) # [电子产品] print(add_tag(包邮)) # [电子产品, 包邮] 等等什么 print(add_tag(今日特价)) # [电子产品, 包邮, 今日特价]我当时盯着屏幕看了十秒钟脑子里只有一个念头为什么第二次调用的结果里还带着第一次的数据我期望的是每次不传tags时都得到一个全新的空列表。但实际结果是所有调用都共用同一个列表。这还不是最离谱的。如果我把代码改一下def add_tag(item, tags[]): tags.append(item) return tags def main(): user_tags add_tag(电子产品) user_tags.append(高性价比) admin_tags add_tag(包邮) print(admin_tags) # [电子产品, 高性价比, 包邮] main()看到这个输出我真的想砸键盘。这个Bug花了两个小时才定位到。而原因说来很简单Python的默认参数在函数定义时就被创建了之后每次调用都用的同一个对象。今天我把这个坑彻底讲清楚包括它为什么会这样、会带来哪些隐藏问题以及怎么正确地绕开它。第一步复现这个“灵异”现象先写一个最简单的例子def surprise(x, data[]): data.append(x) return data print(surprise(1)) # [1] print(surprise(2)) # [1, 2] print(surprise(3)) # [1, 2, 3]每次调用data都在累计之前传进去的值。再看一个用字典的例子def collect(key, value, cache{}): cache[key] value return cache print(collect(a, 1)) # {a: 1} print(collect(b, 2)) # {a: 1, b: 2}同样的问题。但如果你用不可变对象做默认参数就没事def greet(name, suffix先生): return name suffix print(greet(张)) # 张先生 print(greet(李)) # 李先生 —— 正常每次都是新的先生为什么字符串没问题列表就有问题因为默认参数的值只计算一次在函数定义时。suffix先生里的先生是字符串不可变你没法修改它所以每次用都一样也没机会出问题data[]里的[]是列表可变每次调用函数时如果你修改了这个列表修改会保留到下一次调用问题的本质是默认参数不是“每次调用时创建新对象”而是“在函数定义时创建一次对象然后每次调用都复用这个对象”。第二步用id()看穿真相id()函数可以返回对象的内存地址。我们来验证一下def show_id(x[]): print(id(x)) return x print(id([])) # 随便创建一个空列表看一个地址 show_id() # 地址A show_id() # 地址A和上一次相同 show_id() # 地址A还是同一个三次调用默认参数x指向的是同一个列表对象。对比一下def good(xNone): if x is None: x [] # 每次调用到这里才创建新列表 print(id(x)) good() # 地址B good() # 地址C不同的地址看到了吗x []这行代码在函数体里每次调用都会执行所以每次都创建一个全新的列表。而默认参数x[]只执行一次——Python读代码时遇到def这一行就创建好了[]这个对象存在某个地方每次调用都拿过来用。一句话默认参数在def时创建函数体里的代码在调用时执行。第三步为什么Python要这样设计你可能想问这不是坑吗Python为什么不设计成“每次调用重新创建默认参数”这个问题有技术上的原因也有设计上的考量。原因1性能函数定义只执行一次。如果每次调用都重新创建默认参数即使是一个空列表会带来不必要的开销。Python很注重简单场景下的性能。原因2一致性Python的函数参数绑定机制是统一的。默认参数值在函数定义时被计算并保存这个规则很简单一致。如果改成“每次调用重新计算”会增加语言的复杂性。原因3这是一个特性不是bugGuido van RossumPython之父认为这个行为是有用的。在某些场景下你需要函数调用之间保持状态这个特性可以方便地实现——比如一个带缓存的函数可以把缓存字典作为默认参数。def expensive_query(query, cache{}): if query in cache: return cache[query] result do_real_query(query) # 耗时操作 cache[query] result return result这个代码利用了默认参数的持久性来缓存结果下次用同样的query就能直接返回。当然这个技巧比较高级普通开发者最好还是用更明确的方式比如类或者闭包。所以准确地说这不是设计缺陷而是一个容易被误用的特性。第四步真实的Bug案例比你想象的更隐蔽案例1API请求参数累积def fetch_data(url, params{}): params[timestamp] time.time() response requests.get(url, paramsparams) return response第一次调用params是个空字典加上timestamp后发给API。 第二次调用params还是那个字典里面已经有timestamp了你又加了一个timestamp变成了两个timestamp参数。API可能就懵了。案例2数据验证函数def validate(data, errors[]): if name not in data: errors.append(缺少name字段) if age not in data: errors.append(缺少age字段) return errors errors1 validate({name: 张三}) errors2 validate({age: 18}) print(errors1) # [缺少age字段] print(errors2) # [缺少age字段, 缺少name字段] —— 混了第二个调用的错误列表里包含了第一次调用产生的错误。因为用的是同一个列表。案例3类实例化的坑class Order: def __init__(self, items[]): self.items items order1 Order() order1.items.append(手机) order2 Order() print(order2.items) # [手机] —— 不是空列表这个坑尤其隐蔽。你想让每个订单都有自己独立的商品列表结果所有订单都共享了同一个列表。修复class Order: def __init__(self, itemsNone): if items is None: items [] self.items items第五步标准解法None占位符正确的写法非常固定def good_function(paramNone): if param is None: param [] # 现在可以放心修改param了 param.append(something) return param但要注意一个细节如果调用方可能主动传入None作为合法值怎么办def process(dataNone): if data is None: data [] # 如果调用方传入None也会被替换成空列表如果你需要区分“没传参数”和“传了None”可以这样做_sentinel object() # 创建一个唯一的标识 def process(data_sentinel): if data is _sentinel: data [] # 只有没传参数时才创建新列表 # 如果调用方传了Nonedata就是None不会被替换这种写法很少用到绝大多数场景用None占位符就够了。为什么用None因为None是Python中表示“没有值”的标准方式。它不可变安全而且语义清晰。看到paramNone有经验的Python开发者立刻知道这是为了避免可变默认参数的陷阱。什么类型需要这样做所有可变类型列表[]字典{}集合set()自定义类的实例如果会被修改任何可能在函数内部被修改的对象不可变类型字符串、整数、浮点数、元组、布尔值不需要这个技巧因为函数内部修改不了它们。第六步类型注解怎么写才规范现代Python有类型注解和None默认值配合时有个常见的困惑def process(items: list None) - list: # 这样写对吗 if items is None: items [] items.append(x) return itemsPylance/Pyright可能会报警None类型和list类型不匹配。正确写法from typing import Optional, List def process(items: Optional[List] None) - List: if items is None: items [] items.append(x) return itemsPython 3.10可以用更简洁的写法def process(items: list | None None) - list: if items is None: items [] items.append(x) return items用| None明确表示这个参数可以是None。第七步哪些函数内部可以安全地修改默认参数只有一种情况是安全的你明确知道自己在做什么并且是故意利用这个特性。比如前面提到的缓存函数def memoize(func): cache {} def wrapper(n): if n not in cache: cache[n] func(n) return cache[n] return wrapper # 或者更直接的 def fibonacci(n, memo{}): if n in memo: return memo[n] if n 1: return n memo[n] fibonacci(n-1, memo) fibonacci(n-2, memo) return memo[n]这种情况下你确实希望memo字典在调用之间保持状态。但即使这样更好的写法仍然是def fibonacci(n, memoNone): if memo is None: memo {} if n in memo: return memo[n] # ... 其余代码相同这样更清晰也避免了副作用带来的困惑。经验法则除非有充分的理由并且写清楚注释否则永远用None占位符。第八步检查工具帮你自动发现问题现代Python工具可以自动检测这个坑Pylint会警告W0102: dangerous-default-valueFlake8通过插件可以检测Pyright / mypy静态类型检查也能发现不一致如果你用VSCode Pylance默认就有相关提示。很多公司代码规范里明确规定禁止使用可变对象作为默认参数。一个完整的对比总结# 错误写法 —— 会累积状态 def bad_append(item, target[]): target.append(item) return target # 正确写法 —— 每次都重新创建 def good_append(item, targetNone): if target is None: target [] target.append(item) return target # 测试 print(bad_append(1)) # [1] print(bad_append(2)) # [1, 2] ← 出问题了 print(good_append(1)) # [1] print(good_append(2)) # [2] ← 正常场景正确做法错误做法默认值用空列表def f(xNone): if x is None: x []def f(x[]):默认值用空字典def f(xNone): if x is None: x {}def f(x{}):默认值用空集合def f(xNone): if x is None: x set()def f(xset()):默认值用字符串def f(x)安全不需要特殊处理—默认值用整数def f(x0)安全—最后总结这个坑的核心就一句话默认参数的值在函数定义时创建一次之后每次调用都复用。如果默认参数是可变对象列表、字典、集合你在函数内部修改它修改会保留到下一次调用这会导致非常隐蔽的bug尤其是当你以为“每次调用都是新的”的时候黄金法则永远不要用可变对象作为函数的默认参数。正确做法def func(paramNone): if param is None: param [] # 或者 {} / set() # 现在可以放心修改param了现在我写任何函数只要参数有默认值都会下意识地问自己这个默认值可变吗如果是列表、字典、集合立刻改成None模式。这个习惯救了我无数次。希望也能救你。