02- 数据类型与内存模型
本节目标:把”变量是盒子”的错误心智换成”标签 + 对象”,理解 Python 可变/不可变的灵魂分界,掌握赋值的本质,识别三大经典坑(参数传递、可变默认参数、二维列表),并区分
==/is和 浅拷贝/深拷贝。
一、打破”变量是盒子”——标签 + 对象 心智
反直觉演示
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4] ← 没碰 a,a 也变了
print(b) # [1, 2, 3, 4]按”盒子”心智推不通。真相必须重建。
真模型:对象在内存里,变量是贴在对象上的”标签”
- 内存里有”对象”:列表
[1, 2, 3]是一个对象,有自己的内存位置 - 变量名是标签(便利贴),贴在某个对象上
a = [1, 2, 3]= 创建一个列表对象,贴上a标签b = a= 把b标签也贴到同一个对象上——没复制b.append(4)= 改了对象本身,a/b 都看到
执行 b = a 后:
[a]──┐
↓
┌──────────────┐
│ [1, 2, 3] │ ← 同一对象,两张标签
└──────────────┘
↑
[b]──┘
核心口诀(必背)
赋值不是复制对象,是把另一个名字(标签)绑到同一对象上。
二、对象三件套:类型 / 值 / 身份
Python 里每个对象都有:
| 属性 | 含义 | 怎么看 |
|---|---|---|
| 类型(type) | 哪种东西(int / list / str / dict…) | type(x) |
| 值(value) | 具体内容 | print(x) |
| 身份(identity / id) | 内存地址(唯一身份证号) | id(x) |
id() 验证”是不是同一对象”
a = [1, 2, 3]
b = a
c = [1, 2, 3] # 另一个新列表,内容相同
print(id(a) == id(b)) # True ← a 和 b 同对象
print(id(a) == id(c)) # False ← c 是另一对象类比:人和身份证
- 对象 = 一个人
- id = 身份证号
- 变量名 = 这个人的称呼(同一人可有多种叫法)
三、可变(mutable) vs 不可变(immutable)——灵魂分界
| 阵营 | 典型类型 | 关键特点 |
|---|---|---|
| 不可变 | int, float, bool, str, tuple, frozenset, None | 对象一旦创建,内容永远不能改 |
| 可变 | list, dict, set | 对象内容可以原地修改 |
”不可变”的真正含义
对象本身一旦创建,内部内容不能改。“修改”会创建一个新对象。
a = 5
b = a
print(id(a) == id(b)) # True 两标签同对象
b = b + 1 # 实际:算出新对象 6,把 b 重新贴上去
print(a) # 5 ← a 没变(还贴在 5 上)
print(b) # 6
print(id(a) == id(b)) # False ← b 已经在另一对象上画面:
开始:
a, b ──→ [5]
执行 b = b + 1:
a ──→ [5] ← a 留在原对象
b ──→ [6] ← b 被重新贴到新对象
“可变”的真正含义
可变对象允许原地改(id 不变):
a = [1, 2, 3]
b = a
b.append(4) # 原地改对象本身
print(a) # [1, 2, 3, 4] ← a 也变了
print(id(a) == id(b)) # True ← 还是同一对象“原地改” vs “重新赋值”
| 操作 | 可变对象 | 不可变对象 |
|---|---|---|
.append() / .update() / x[0]=... 等方法/索引修改 | 原地改(id 不变) | 不允许 |
x = ...、x = x + ... 等重新赋值 | 换贴新对象(id 变) | 换贴新对象(id 变) |
⚠️ 微妙点:b += [4] 和 b = b + [4] 不一样
# A
a = [1, 2, 3]
b = a
b += [4] # 等价于 b.extend([4]),原地改
print(a) # [1, 2, 3, 4] ← a 也变
# B
a = [1, 2, 3]
b = a
b = b + [4] # 创建新列表,b 换贴
print(a) # [1, 2, 3] ← a 不变→ 这个坑只在可变对象上出现。
四、赋值的本质:换贴标签,从不复制
唯一规则
所有赋值
名字 = 表达式都只做一件事:让左边那个名字指向右边那个对象。没有任何复制。
不管你写的是:
a = 10
a = [1, 2, 3]
a = b
a = some_func()都是”让 a 指向右边那个对象”。
“变量似乎变了”只有两种来源
| 现象 | 真实发生 |
|---|---|
名字换贴到另一对象(a = a + 1) | id 变;其它标签不受影响 |
对象本身被原地改(a.append(...)) | id 不变;所有指向该对象的标签都看到变化 |
哪些操作是”原地改”
- list:
append/extend/insert/remove/pop/sort/reverse/a[i]=.../a += ... - dict:
update/pop/d[k]=... - set:
add/update/remove/pop
其它(+、*、sorted(a)、list(a)、字符串 replace…)都是返回新对象,不动原对象。
五、三大经典坑(雷区)
坑一:函数参数”看起来像引用传递”
Python 参数传递机制 = 按对象引用传递:你传进去的是”对象的标签”,函数内的参数名和外部变量名指向同一对象。
# 原地改影响外部
def modify(lst):
lst.append("hi")
a = [1, 2]
modify(a)
print(a) # [1, 2, 'hi'] ← 影响
# 重新赋值不影响外部
def replace(lst):
lst = ["new"] # 只是给局部名换贴标签
b = [1, 2]
replace(b)
print(b) # [1, 2] ← 不影响函数里
xxx.append()、xxx[i]=...等原地操作影响外面;xxx = 新东西只影响函数内部,外面看不到。
写函数两条原则:
- 想返回新结果 → 不原地改输入,用
x + [1]、sorted(x)、x.copy()这种生成新对象的操作 - 想修改输入 → 文档里明说
坑二:可变默认参数 🔥
def append_user(name, users=[]): # 默认 users=[]
users.append(name)
return users
print(append_user("张三")) # ['张三']
print(append_user("李四")) # ['张三', '李四'] ← 残留!
print(append_user("王五")) # ['张三', '李四', '王五']原因: 默认参数对象只在 def 执行时创建一次,所有调用共享同一个。
def 执行时:
函数 append_user 挂着默认参数 users = OBJ_A(空列表)
每次没传 users 的调用都用同一个 OBJ_A,所以"上一次"留下的内容会残留。
正确写法(必背):
def append_user(name, users=None): # 默认 None
if users is None:
users = [] # 函数体内现建
users.append(name)
return users永远不要把可变对象(list / dict / set)作为函数默认参数。要”默认空集合”用
None,函数体内现建。
坑三:二维列表初始化(地质数据处理常踩)
grid = [[0] * 4] * 3 # ← 看起来很优雅,实际是地雷
grid[0][1] = 99
print(grid)
# [[0, 99, 0, 0], [0, 99, 0, 0], [0, 99, 0, 0]]
# 三行全被改!原因: [[0]*4] * 3 创建 3 个标签指向同一个内部列表。
正确写法:
grid = [[0] * 4 for _ in range(3)] # 列表推导式,每行独立
grid[0][1] = 99
# [[0, 99, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] ✅六、== 与 is
| 运算符 | 比较什么 |
|---|---|
== | 值是否相等 |
is | 身份是否相同(等价于 id(a) == id(b)) |
a = [1, 2, 3]
b = [1, 2, 3]
c = a
a == b # True 内容一样
a is b # False 不同对象
a == c # True
a is c # True 同对象用哪个
| 场景 | 用什么 |
|---|---|
| 一般值比较 | == |
| 判断是不是 None | is None ⭐ (PEP 8 推荐) |
| 判断是不是 True/False | 一般直接 if x: |
| 判断是不是同一对象 | is |
⚠️ 小整数池陷阱
CPython 把 -5 到 256 的整数缓存起来,导致:
a = 100
b = 100
a is b # True 巧合(都用缓存对象)
a = 1000
b = 1000
a is b # False ← 1000 不在缓存,不同对象铁律:比较值用
==,比较身份用is。除None/True/False这三个单例外,其它一律用==。
七、浅拷贝 vs 深拷贝
浅拷贝(Shallow Copy)
复制最外层容器,内部嵌套的可变对象仍共享。
import copy
a = [1, 2, [3, 4]]
b = a.copy() # 等价于 copy.copy(a) / list(a) / a[:]
print(a is b) # False 外层是新对象
print(a[2] is b[2]) # True 内部 [3,4] 还是共享
b[2].append(99)
print(a) # [1, 2, [3, 4, 99]] ← a 内部也被改!深拷贝(Deep Copy)
递归复制每一层,整棵对象树都是新的。
import copy
a = [1, 2, [3, 4]]
b = copy.deepcopy(a)
print(a[2] is b[2]) # False 内部也是新对象
b[2].append(99)
print(a) # [1, 2, [3, 4]] ← a 完全不受影响选择
| 情况 | 用什么 |
|---|---|
| 容器只装基本类型(int/str) | a.copy() 即可 |
| 容器嵌套了可变对象(list/dict) | copy.deepcopy(a) |
| 性能敏感且结构简单 | 浅拷贝(deepcopy 慢得多) |
list 浅拷贝的几种写法
import copy
a = [1, 2, 3]
b1 = a.copy()
b2 = list(a)
b3 = a[:]
b4 = copy.copy(a)
# 深拷贝只有一种
b5 = copy.deepcopy(a)八、本节核心打包(5 句话)
- Python 里变量是”标签”,不是”盒子”——所有变量名都贴在对象上。
- 每个对象有类型 + 值 + 身份(id)三件套;
id(x)看身份。 - 不可变(int/str/tuple…)= 内容不能改,“修改”实际是换贴新对象;可变(list/dict/set)= 能原地改对象本身。
=永远是”换贴标签”,从不复制;.append()等才是”原地改对象”——这是函数参数、默认参数、二维 list 三大坑的根源。- 比值用
==,比身份用is;只有None/True/False适合is,整数用is会被小整数池坑。
九、关键术语速查表
| 术语 | 一句话定义 |
|---|---|
| 对象(Object) | 内存里的一个实体,有类型/值/身份 |
| 标签 / 变量名 | 指向对象的引用,不是容器 |
| id(x) | 对象的内存地址(身份证号) |
| type(x) | 对象的类型 |
| 不可变(immutable) | 对象内容不能改(int/str/tuple/frozenset/None) |
| 可变(mutable) | 对象内容可原地修改(list/dict/set) |
| 原地操作 | 在对象本身修改,id 不变,影响所有引用 |
| 重新赋值 | 让名字指向新对象,id 变,只影响该名字 |
| 按对象引用传递 | Python 的参数传递机制,传的是对象引用 |
| 小整数池 | CPython 缓存 -5~256,导致 is 比小整数有误导 |
| 浅拷贝 | 只复制最外层,内部嵌套共享 |
| 深拷贝 | 递归复制每一层,完全独立 |
十、常见陷阱速查表
| 写法 | 问题 | 修复 |
|---|---|---|
def f(x=[]): | 默认列表跨调用共享 | def f(x=None): if x is None: x=[] |
grid = [[0]*4] * 3 | 三行指向同一内列表 | [[0]*4 for _ in range(3)] |
if x == None: | 不推荐 | if x is None: |
if a is 1000: | 小整数池外行为不可靠 | if a == 1000: |
b = a; b.sort() 期望不改 a | sort 是原地排序 | b = sorted(a) |
copy.copy(嵌套结构) 期望完全隔离 | 浅拷贝不够 | copy.deepcopy(...) |