02-Python可变对象与不可变对象(上)-赋值陷阱与函数传参的暗坑

文章目录

  • [Python 可变对象与不可变对象(上):赋值陷阱、函数传参与默认参数的暗坑](#Python 可变对象与不可变对象(上):赋值陷阱、函数传参与默认参数的暗坑)
    • 导入语
    • [1 ~> 同一个 `b = a`,结果为什么不一样](#1 ~> 同一个 b = a,结果为什么不一样)
      • [1.1 现象](#1.1 现象)
      • [1.2 原因分析](#1.2 原因分析)
      • [1.3 判断规则](#1.3 判断规则)
      • [1.4 验证](#1.4 验证)
    • [2 ~> Python 中各类型的可变性速查](#2 ~> Python 中各类型的可变性速查)
      • [2.1 完整分类表](#2.1 完整分类表)
      • [2.2 一个特别容易错的:tuple 是"真不可变"吗](#2.2 一个特别容易错的:tuple 是"真不可变"吗)
    • [3 ~> 函数传参的可变性陷阱](#3 ~> 函数传参的可变性陷阱)
      • [3.1 现象](#3.1 现象)
      • [3.2 原因分析](#3.2 原因分析)
      • [3.3 解决方案](#3.3 解决方案)
    • [4 ~> 默认参数只初始化一次------我敢说 80% 的 Python 同学都踩过](#4 ~> 默认参数只初始化一次——我敢说 80% 的 Python 同学都踩过)
      • [4.1 现象](#4.1 现象)
      • [4.2 原因分析](#4.2 原因分析)
      • [4.3 解决方案](#4.3 解决方案)
    • [5 ~> 可变对象做类属性------又一个坑](#5 ~> 可变对象做类属性——又一个坑)
      • [5.1 现象](#5.1 现象)
      • [5.2 原因分析](#5.2 原因分析)
      • [5.3 解决方案](#5.3 解决方案)
    • [思考 && 总结](#思考 && 总结)
    • 结尾

Python 可变对象与不可变对象(上):赋值陷阱、函数传参与默认参数的暗坑

最新推荐文章于 2026-06-15 12:00:00 发布 | 阅读 1.2k 阅读 | 分类:Python基础入门

📖 文章简介: 上篇聚焦可变对象与不可变对象的赋值陷阱和函数传参问题。很多人"知道" int 不可变、list 可变,但到了函数传参、默认参数这种实战场景就踩坑。本文用内存模型图拆解 b=a 后修改 a 为什么有时 b 跟着变有时不变,函数传参到底传的是什么,以及默认参数只初始化一次这个诡异特性。每个结论配 id() 验证代码,文末附真实踩坑案例------一个线上 Bug 因为默认参数是空列表导致的状态污染。


🎬 个人主页: 源码骑士

专栏传送门: 《Android开发基础》《python基础课程》

⭐️热衷从源码视角拆解技术底层原理,将复杂架构讲得通俗易懂


🎬 源码骑士的简介:

5年Android Framework系统开发经验,曾主导多项系统级性能优化专项

技术栈覆盖Android系统全链路(Binder/Handler/AMS/WMS/启动流程)及Java后端全家桶(Spring + MyBatis + Redis + Oracle)

累计产出原创技术文章100+篇,文章以源码拆解为特色,被读者评价为"看一篇胜过啃一周文档"


导入语

上篇文章我们讲明白了"变量不是盒子,是标签"。但知道这个之后,第二个问题立刻来了------为什么 b = a 之后,改 a 有时候 b 跟着变(list),有时候 b 不跟着变(int/str)?

这个问题比看起来更深。它不是"Python怪癖",而是 Python 对象模型的核心设计。我见过一个线上订单系统,因为开发同学没搞清这个区别,在函数里传了一个 list 进去,改了里面的元素,结果外面全局变量也跟着被污染了------查了大半天才反应过来。

这篇文章分上下两篇。上篇集中讲赋值陷阱、函数传参和默认参数。下篇讲深浅拷贝和内存图。每一个结论都配了你能自己跑的验证代码。


1 ~> 同一个 b = a,结果为什么不一样

1.1 现象

python 复制代码
# 情况1:list
a = [1, 2, 3]
b = a
a.append(4)
print(b)  # 输出:[1, 2, 3, 4]  ← b 变了!

# 情况2:int
a = 100
b = a
a = 200
print(b)  # 输出:100  ← b 没变!

同样写 b = a,结果一个变了,一个没变。初学者看到这里基本是崩溃的。

1.2 原因分析

关键不在"赋值"本身,而在于赋值之后你怎么操作 a

  • a.append(4) → 这是"原地修改"------把标签 a 指向的对象本身改了。b 也指向同一个对象,所以 b 能看到变化。
  • a = 200 → 这是"重新赋值"------把标签 a 从旧对象上撕下来,贴到新对象 200 上。b 还贴在旧对象 100 上,所以 b 不变。
bash 复制代码
"a.append(4)" 改的是对象的内容 ──→ a 和 b 看到的房子被装修过了
"a = 200"     改的是标签的位置 ──→ a 搬家了,b 还住在老房子里

1.3 判断规则

操作 是原地修改吗 对象变了吗 b 受影响吗
list.append() ✅ 是 原地变 b 跟着变
list.pop() ✅ 是 原地变 b 跟着变
list[0] = x ✅ 是 原地变 b 跟着变
int + 1 ❌ 不是 新建对象 b 不变
str + "x" ❌ 不是 新建对象 b 不变
a = 新值 ❌ 不是 新建对象 b 不变

1.4 验证

python 复制代码
# 用 id() 看清对象是否变了
a = [1, 2, 3]
b = a
print(f"初始 id(a) = {id(a)}")    # 同一个地址
print(f"初始 id(b) = {id(b)}")   
a.append(4)
print(f"append后 id(a) = {id(a)}")  # 地址没变!说明是原地修改

a = [1, 2, 3]
b = a
print(f"\n初始 id(a) = {id(a)}")
a = [1, 2, 3, 4]
print(f"重新赋值后 id(a) = {id(a)}")  # 地址变了!说明是新对象
print(f"重新赋值后 id(b) = {id(b)}")  # b 还是旧地址

金句: 可变对象的行为问题,根源在于你没分清"改对象"和"换对象"。改对象所有标签都能看见,换对象只有你自己的标签搬家。


2 ~> Python 中各类型的可变性速查

2.1 完整分类表

不可变类型 可变类型
intfloatbool list(列表)
str(字符串) dict(字典)
tuple(元组) set(集合)
frozenset(冻结集合) bytearray(字节数组)
bytes(字节串) 自定义类(默认可变)

2.2 一个特别容易错的:tuple 是"真不可变"吗

python 复制代码
t = (1, 2, [3, 4])   # 元组装了一个列表
t[2].append(5)        # 列表可变,这行能跑
print(t)              # 输出:(1, 2, [3, 4, 5])

元组本身的结构确实不可变(你不能 t[0] = 10),但如果元组里装了可变对象,那个可变对象不受 tuple 约束 。你可以改它里面的内容。所以 tuple 的不可变是浅不可变------容器层面不变,内容物层面管不着。


3 ~> 函数传参的可变性陷阱

3.1 现象

这是我 2022 年线上遇到的一个真实 Bug 的精简版:

python 复制代码
def add_user_to_group(user, group_list):
    group_list.append(user)
    return group_list

# 默认配置
DEFAULT_GROUP = ["admin"]

# 把张三加进去
current_group = add_user_to_group("张三", DEFAULT_GROUP)
print(current_group)  # 输出:['admin', '张三']
print(DEFAULT_GROUP)  # 输出:['admin', '张三']  ← ???默认配置被污染了

3.2 原因分析

group_list.append(user) 是原地修改。函数内部改的是传进来的 list 对象本身------而这个对象就是外面的 DEFAULT_GROUP。你以为函数在操作一份拷贝,实际上它直接在原件上盖章。

这在 Java 里是什么行为?Java 里如果传一个 ArrayList,函数里 add 也会影响外部------是一样的道理。但 Java 的 String 是 final class,int 是基本类型直接拷贝值,所以 Java 程序员天然对"传引用"敏感。Python 没有基本类型的设计,一切靠行为判断------这是转过来的同学最容易踩的坑。

3.3 解决方案

python 复制代码
# 方案一:传入拷贝(安全,推荐)
def add_user_to_group(user, group_list):
    group_list = group_list.copy()  # 先拷贝再操作
    group_list.append(user)
    return group_list

# 方案二:函数内部不改动参数(函数式风格)
def add_user_to_group(user, group_list):
    return group_list + [user]  # + 对列表来说返回新对象

经验: 函数里操作传进来的 list/dict,如果你不期望影响外部,进来第一件事就是 copy()。这个习惯能帮你省掉大把 debug 时间。


4 ~> 默认参数只初始化一次------我敢说 80% 的 Python 同学都踩过

4.1 现象

python 复制代码
def add_item(item, items=[]):     # 默认参数是个空列表
    items.append(item)
    return items

print(add_item("a"))  # 输出:['a']
print(add_item("b"))  # 输出:['a', 'b']  ← ???空列表呢?
print(add_item("c"))  # 输出:['a', 'b', 'c']

每次调用 add_item 你都期望 items=[] 能给你一个新的空列表,但它却"记住了"上次的结果。啥情况?

4.2 原因分析

Python 的默认参数是在函数定义时计算一次,而不是每次调用时重新计算。 那个 []def 语句执行时就创建好了,之后每次调用复用同一个列表对象。

bash 复制代码
def add_item(item, items=[]):
#                   ↑ 这里的 [] 只执行一次
#                   在函数定义时就创建,此后每次调用都用它

第1次调用:items = 同一个 list 对象 → append("a") → 这个 list = ["a"]
第2次调用:items = 同一个 list 对象 → append("b") → 这个 list = ["a", "b"]
第3次调用:items = 同一个 list 对象 → append("c") → 这个 list = ["a", "b", "c"]

这不是 Bug------这是 Python 语言设计者 Guido 有意为之。官方文档明确说这是"feature not a bug"。

4.3 解决方案

python 复制代码
# ✅ 正确写法:默认值用 None,函数内部判断后创建新对象
def add_item(item, items=None):
    if items is None:
        items = []   # 每次调用时才创建新列表
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['b']  ← 符合预期!
print(add_item("c"))  # ['c']

5 ~> 可变对象做类属性------又一个坑

5.1 现象

python 复制代码
class Classroom:
    students = []   # 类属性,所有实例共享

    def add_student(self, name):
        self.students.append(name)

class1 = Classroom()
class2 = Classroom()
class1.add_student("张三")
print(class2.students)  # 输出:['张三']  ← ???class2 怎么也有张三

5.2 原因分析

students = [] 是类属性,在类定义时就创建好了,所有实例共享同一个列表对象self.students.append(name)self 上找不到 students 时,会顺着继承链找到类属性------然后原地修改了类属性。所以 class1 加学生,class2 也会看到。

这本质上和默认参数的陷阱逻辑一模一样------可变对象挂在"会被多次用到"的位置上。

5.3 解决方案

python 复制代码
class Classroom:
    def __init__(self):
        self.students = []   # 实例属性,每个对象自己的

思考 && 总结

上篇的核心内容------四个关键词:原地修改 / 重新赋值 / 传对象引用 / 默认参数初始化时机。 浓缩成三句话:

  1. 可变对象支持原地修改,不可变对象不支持。 list.append() 改内容不换地址,str + "x" 换地址不换内容。分清"改对象"和"换对象",80% 的困惑就没了。
  2. 函数传参传的是对象引用。 形参和实参指向同一个对象。你在函数里原地修改,外面跟着变。要保护外部数据,进来先 copy()
  3. 默认参数在函数定义时只计算一次。 默认值不要用 []{},用 None 替代。

下篇我们深入到深浅拷贝------光知道 copy() 还不够,copydeepcopy 的差异才是我当年 debug 到凌晨三点的元凶。


结尾

各位小伙伴,本文的内容到这里就全部结束了,源码骑士在这里再次感谢您的阅读!

源码骑士 --- Python 全栈 & 系统架构

👀 关注:跟博主一起从源码视角深耕底层原理,见证每一次成长

❤️ 点赞:让优质内容被更多人看见,让知识传递更有力量

收藏:把核心知识点存好,在需要时随时查、随时用

💬 评论:分享你的经验或疑问,评论区一起交流避坑

🔄 一键四连:不要忘记给博主"一键四连"哦!今日源码拆解达成!

🗡️ 寄语:技术之路难免有困惑,但同行的人会让前进更有方向

结语:可变与不可变是 Python 对象模型的第一课,也是线上 Bug 的高发区。下篇讲深浅拷贝,敬请期待。不要忘记给博主"一键四连"哦!

相关推荐
疯狂学习GIS1 小时前
基于Python earthaccess库批量下载全球MODIS GPP(MOD17A2HGF)数据
python·脚本·批量下载·遥感影像·nasa·earthdata·自动处理
至乐活着1 小时前
用DeepSeek打造你自己的智能问答系统:从零到一的完整指南
python·deepseek·ai应用开发·智能问答系统·api教程
AI创界者1 小时前
【解压即用】Scail-2 视频动作迁移一键整合包:8G显存通吃50系,长视频/多人/精准目标替换全攻略
人工智能·python·aigc·音视频
gaohe26AIliuzeyu1 小时前
Java内部类
java·开发语言
AI科技星1 小时前
数术工坊・八卷全书(番外・实战升华副卷)【终极典藏定稿|完整无删减】
c语言·开发语言·网络·量子计算·agi
丘山望岳1 小时前
剑起霜华——平衡二叉树(AVL树 )精讲
开发语言·数据结构·c++
yyuuuzz1 小时前
云服务器软件部署的几个常见问题
运维·服务器·开发语言·网络·云计算·php·apache
z落落1 小时前
Timer与DateTimePicker:控件使用全解析
开发语言·c#
Boom_Shu2 小时前
浅拷贝与深拷贝
开发语言·c++·算法