Python的默认参数把我坑惨了,原来可变对象和不可变对象的区别这么大

简介: 本文揭秘Python经典陷阱:可变默认参数(如`history=[]`)在函数定义时仅创建一次,导致多次调用共享同一对象,引发数据污染。通过日志系统Bug实例,详解可变/不可变对象差异、默认参数求值时机,并给出标准解法——用`None`作哨兵,在函数内初始化。避坑口诀:“默认参数用不可变,可变对象内部造”。

一个让我排查了三个小时的Bug

去年写了一个用户行为追踪模块,需要对每个访问用户记录一系列操作日志:

def log_user_action(action, history=[]):
   history.append(action)
   print(f"当前操作: {action}")
   print(f"操作历史: {history}")
   return history

看起来平平无奇对吧?一个函数记录用户操作,默认参数history=[]让每次调用都从空列表开始。

测试的时候单独跑,一切正常:

log_user_action("登录")  
# 当前操作: 登录
# 操作历史: ['登录']

上线之后,奇怪的事情发生了。运维同事跑过来跟我说:“日志系统出问题了,用户A的日志里全是B用户的操作。”

我赶紧去查代码,模拟了两个用户交替调用:

# 用户A
log_user_action("A登录")
log_user_action("A浏览商品")

# 用户B  
log_user_action("B登录")

猜猜输出是什么?

当前操作: A登录
操作历史: ['A登录']

当前操作: A浏览商品
操作历史: ['A登录', 'A浏览商品']

当前操作: B登录
操作历史: ['A登录', 'A浏览商品', 'B登录']    # B的列表里有A的数据!

用户B的操作历史里,赫然出现了用户A的操作记录。两个用户的数据串在一起,完全乱套了。

我盯着代码看了半天,history=[]明明每次都传了个空列表,怎么会累积呢?

那天下午,我把Python的可变和不可变对象、默认参数的求值时机彻底研究了一遍。今天把这些坑讲清楚,希望你别重蹈我的覆辙。

代理 IP 使用小技巧 让你的数据抓取效率翻倍 (48).png

第一步:先搞清楚可变和不可变对象

Python里的对象分两种:

不可变对象: 一旦创建,值就不能改。数字、字符串、元组、布尔值、None都属于这一类。

a = 1
b = a
a = 2          # 这不是改1,是让a指向2
print(b)       # 还是1 —— b指向的对象没变

当你以为你在“改”一个不可变对象时,实际上是创建了一个新对象,然后让变量指向新对象。

s = "hello"
print(id(s))   # 假设是 140234567890
s = s + " world"
print(id(s))   # 新的id —— 创建了新字符串

可变对象: 创建之后可以原地修改内容。列表、字典、集合、自定义类的实例都属于这一类。

lst = [1, 2, 3]
print(id(lst))   # 假设是 140234567890
lst.append(4)
print(id(lst))   # 还是 140234567890 —— 对象没变,内容变了

可变对象就像一块白板,你可以在上面擦擦写写,白板还是那块白板。不可变对象就像刻在石头上的字,想改只能换块石头。

第二步:Python默认参数的一个冷门特性

搞清楚可变和不可变之后,现在说关键问题:

Python函数的默认参数,在函数定义时就被求值了,而不是在函数调用时。

什么意思?

def add(x, y=[]):
   y.append(x)
   return y

当Python解释器读到def add(x, y=[]):这一行的时候,它就执行了[],创建了一个空列表对象,然后把这个对象“焊死”在函数对象上。之后的每一次调用,用的都是这同一个列表对象。

每次调用add(1),都是往那个同一个列表里追加元素。这就是为什么用户B的日志里会出现用户A的数据——他们用的是同一个history列表。

验证一下:

def test_default(arg=[]):
   arg.append(1)
   print(id(arg))   # 每次打印的id都一样

test_default()  # 打印某个id
test_default()  # 打印同样的id
test_default()  # 还是同样的id

三调用的arg指向的是同一个列表对象。但如果是不可变对象作为默认参数:

def test_default(arg=1):
   arg = arg + 1
   print(id(arg))   # 每次打印的id都不同

test_default()  # 某个id
test_default()  # 不同的id
test_default()  # 又是不同的id

每次对不可变对象进行“修改”,实际上是创建了新对象,原来的默认参数对象纹丝不动。

第三步:为什么会有这种设计?

你可能会问:为啥Python要这么设计?这不是坑人吗?

其实这个设计背后的逻辑是:Python的函数是对象,默认参数是函数对象的属性

def foo(x, y=10):
   pass

print(foo.__defaults__)   # (10,)

默认参数在函数定义时被求值,作为元组保存在__defaults__属性里。这样每次调用函数的时候,直接从这个属性里读默认值,不需要重新求值——提高了执行效率。

这个设计对不可变对象来说完全没问题。10就是10,谁来了它都不变。

但问题是Python不限制默认参数的类型。如果你用了可变对象(列表、字典),这个对象也是定义时创建一次,然后被所有调用共享。这就出事了。

这算不算设计缺陷?Python之父Guido van Rossum在2012年的邮件里承认:“这个问题让每个人都踩过坑。如果时光倒流,我会让默认参数在函数调用时求值。”但作为一门已经跑了几十年的语言,这种兼容性问题改不了,只能靠开发者自己注意。

第四步:正确的写法

既然知道了问题,怎么改?

None做哨兵值,在函数内部创建新对象:

# 错误写法
def log_user_action(action, history=[]):
   history.append(action)
   return history

# 正确写法
def log_user_action(action, history=None):
   if history is None:
       history = []
   history.append(action)
   return history

现在每次调用,如果没传history,都会创建一个全新的空列表。用户之间不再互相污染:

log_user_action("A登录")     # ['A登录']
log_user_action("A浏览")     # ['A登录', 'A浏览']  
log_user_action("B登录")     # ['B登录']  —— 干净的新列表

None是不可变对象,作为默认参数绝对安全。用None做哨兵,在函数内部判断并创建可变对象,这是Python社区公认的标准写法。

同样的套路适用于所有可变对象:

# 字典
def process_data(data, config=None):
   if config is None:
       config = {}
   # ...

# 集合
def track_tags(tags, tag_set=None):
   if tag_set is None:
       tag_set = set()
   # ...

# 自定义对象
def create_user(name, profile=None):
   if profile is None:
       profile = UserProfile()
   # ...

第五步:还有一些更隐蔽的坑

坑一:类属性里的可变对象

class User:
   permissions = []  # 类属性,所有实例共享

alice = User()
bob = User()
alice.permissions.append("admin")
print(bob.permissions)  # ['admin'] —— Bob也被加了权限!

正确写法是在__init__里创建实例属性:

class User:
   def __init__(self):
       self.permissions = []  # 每个实例自己的一份

坑二:函数默认参数依赖另一个可变默认参数

# 极其危险的写法
def build_url(base="", params={}):
   # ...

def make_request(url=build_url(""), headers={}):
   # ...

默认参数之间如果有依赖关系,一个变了另一个也跟着变,调试的时候能把人逼疯。

坑三:缓存陷阱

有人想用默认参数做缓存:

def expensive_calc(key, cache={}):
   if key in cache:
       return cache[key]
   result = really_slow_calculation(key)
   cache[key] = result
   return result

这代码看起来聪明,实际上有问题——cache是全局共享的,不会被清理,内存会一直涨。而且测试之间会互相污染。正确的做法是用functools.lru_cache

第六步:一个常见的面试题

这个坑太经典了,面试官特别喜欢考:

def func(a, b=[]):
   b.append(a)
   return b

print(func(1))
print(func(2))
print(func(3, []))  # 注意这里传了参数
print(func(4))

输出是什么?

  • func(1)[1]
  • func(2)[1, 2] —— 同一个列表
  • func(3, [])[3] —— 传了新列表,不受默认参数影响
  • func(4)[1, 2, 4] —— 又回到了那个累积的列表

这道题考的就是你对默认参数求值时机和可变对象共享的理解。能答上来,说明你已经避开了这个坑。

总结

一句话记住:默认参数在定义时只创建一次,可变对象会被所有调用共享;不可变对象安全,可变对象危险。

用我那个日志系统的例子来总结:

  • **用history=[]**:像是给所有用户发了一本共同的日记本,A写的内容B也能看到
  • **用history=None**:像是每个用户发了一本全新的空白日记本,各写各的

现在我写函数但凡默认参数是可变的(列表、字典、集合),一定用None做哨兵,在函数内部创建。这个习惯救了我无数次,希望也能救你。

有一个简单的记忆方法:默认参数用不可变,可变对象内部造。每次写完函数看一眼默认参数,如果是[]{},立马改成None。想清楚再动手,少排查几小时Bug。

目录
相关文章
|
23天前
|
数据管理 Windows
DeepSeek 对话导出 Word/PDF 实践:备份、打印与结构化转换
DeepSeek导出需按用途分链路:官方入口用于账号级历史备份;浏览器打印适合定稿PDF阅读;Markdown+Pandoc或专用工具(如DS随心转)生成可编辑Word,兼顾表格、公式与多轮对话批量处理。目标决定路径,而非追求“万能导出”。(239字)
381 0
|
2月前
|
人工智能 JSON 机器人
邮件自动化办公Agent:自动分类、起草回复、跟进待办的全链路案例
本文揭秘邮件自动化实战:如何将销售总监每日73封杂乱邮件,压缩为仅需20分钟处理的7封关键信件。涵盖自动分类(规则+LLM双层判断)、智能回稿(模板匹配+变量填充)、待办提取(语义识别+任务同步)三大核心模块,并分享踩坑经验与渐进式落地策略——让邮件回归价值本身,而非消耗注意力的“工作前戏”。
460 0
|
24天前
|
人工智能 JSON 安全
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
阿里云AI安全产品联动防御Fastjson攻击
2687 13
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
|
24天前
|
人工智能 数据可视化 数据挖掘
阿里云百炼 Harness 工具全解:联网 / 代码解释 / 识图,Qwen 内置增强组件使用指南
Harness是阿里云百炼Token Plan为Qwen3.8-Max-Preview等旗舰模型内置的增强工具集,支持联网搜索、代码解释器、网页抓取、文搜图、图搜图等多模态能力,开箱即用、按调用计费,助力AI编程与智能体高效执行复杂任务。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
|
24天前
|
缓存 JSON API
DeepSeek-V4全解析:Flash/Pro双版本计费、性能差异与API实战调用教程
当前行业内超长上下文大模型的使用门槛长期居高不下,要么推理速度迟缓难以支撑实时业务,要么调用成本高昂无法大规模落地,DeepSeek-V4系列采用双版本差异化策略,同步推出deepseek-v4-flash与deepseek-v4-pro两款MoE架构模型,分别瞄准轻量化高频业务、复杂深度推理两大核心赛道,全系标配100万Token超长上下文窗口,将百万级长文本处理能力下放至普通开发者与中小企业,彻底打破长上下文模型的使用壁垒。
884 4
|
23天前
|
人工智能 安全 Java
大模型Agent落地的工程现实:一个Java老兵的观察与架构实践
本文为Java后端工程师撰写的AI工程化实战指南,聚焦大模型从“演示”走向“生产”的关键跃迁。涵盖LLM本质认知、RAG局限与Agentic RAG升级、Agent架构设计模式、MCP协议集成、Spring AI落地要点、推理模型成本权衡及安全可观测性等十大核心议题,凝练一线踩坑经验,强调工程能力在AI落地中的决定性作用。
242 1
|
23天前
|
存储 人工智能 程序员
揭秘 GitHub 最火的开源 Skills 仓库,夯爆了!30 秒带你用上,让 AI 效率起飞
GitHub 大神开源的 18 万 Star 的 Skills 仓库揭秘!手把手教你安装使用,带你看懂这套 AI 编程标准操作流程,包括 grill-me 需求拷问、TDD 测试驱动开发、Bug 诊断、AI 辅助学习、大项目规划、代码架构改进等核心技能,把经典软件工程方法论变成 AI 能执行的指令。
495 0
|
23天前
|
人工智能 自然语言处理 安全
别让你的AI当"差不多先生"——Hermes 0.18+0.19双版本连更,把智能体从"会干活"推到了"能托付"
Hermes 0.18–0.19 版本聚焦“可信AI”,六大升级重塑人机协作:/goal 自我验证+持久化兜底确保任务真完成;/learn 技能蒸馏让AI持续积累经验;MoA多模型协商提升决策质量;冷启动提速80%、渲染优化14倍;Smart Approvals智能审批兼顾安全与效率;/journey记忆时间线+上下文压缩保障长期项目连贯性。真正实现“交办即安心”。
213 1
|
23天前
|
人工智能 自然语言处理 数据挖掘
AI大模型工具深度运用实践:如何搭建自己的AI助手_AI Agent工作流构建与智能体来了案例解析
本文详解AI Agent从理论到实践:对比普通AI工具,揭示智能体“理解目标→拆解任务→调用工具→执行闭环”的核心机制;系统梳理LLM、任务规划、工具调用与知识库四大能力;提供零代码搭建AI助手三步法(定目标、建知识库、设工作流),助普通人快速打造专属智能助手。(239字)
236 1
|
1月前
|
缓存 JSON 网络协议
高QPS场景API接口全链路性能调优:从内核参数到业务代码的三大核心方向
本文系统解析Python高并发API全链路性能优化:从Linux内核参数(文件描述符、TCP栈、内存调度)调优,到中间件(Gunicorn+Uvicorn进程模型、数据库/Redis连接池、多级缓存),再到业务层(异步化、批量IO、数据结构与序列化优化),提供可落地的生产级方案。(239字)
232 1