可变对象与不可变对象
面试回答
常见问法
- Python 里什么是可变对象和不可变对象?
- 为什么列表能改,字符串不能改?
- 这和函数传参、默认参数、拷贝、字典键有什么关系?
list、tuple、str、dict这些类型分别属于哪一类?- 不可变对象是不是一定线程安全、一定可哈希?
回答
在 Python 里,变量名本质上是对象的引用(名字绑定)。 所谓可变对象,是指对象创建后,可以在原地修改内部状态; 所谓不可变对象,是指对象创建后,对象内部状态不能被原地修改,看起来像“修改”的操作,通常是创建新对象,再把名字重新绑定过去。
面试里最重要的不是背类型表,而是理解这几个影响:
-
函数传参是否会产生副作用 Python 传的是“对象引用”,如果函数里原地修改可变对象,调用方能看到变化;如果只是给局部变量重新赋值,外部通常看不到。
-
共享状态是否会互相污染 多个变量指向同一个可变对象时,任何一方原地修改,其他引用方都会受影响。
-
能不能作为字典键 / 集合元素 作为键,核心要求是哈希值稳定。可变对象通常不适合作为键,因为内容一变,哈希语义就可能失效。
-
默认参数、浅拷贝、深拷贝为什么经常出坑 本质都和“是否共享同一个可变对象”有关。
常见分类可以这样记:
- 可变对象:
list、dict、set、bytearray、大多数自定义类实例 - 不可变对象:
int、float、bool、str、tuple、frozenset、bytes、None
但要注意一个高频边界:
tuple自身不可变,不代表里面的元素也不可变- 一个
tuple里如果放了list,你不能改tuple的槽位,但仍然可以改里面那个list
t = ([1, 2], 3)
t[0].append(4)
print(t) # ([1, 2, 4], 3)
# tuple 本身没变位置绑定,但内部那个 list 被改了
一个适合面试的标准表述是:
Python 中变量保存的是对象引用。可变对象支持原地修改,不可变对象不支持原地修改。这个区别会直接影响函数参数是否产生副作用、对象能否作为字典键、默认参数是否踩坑,以及拷贝时是否会共享状态。
追问
1)为什么列表能原地改,字符串不能?
因为 list 设计上就是可变序列,支持在原对象上修改元素;str 设计上是不可变序列,任何“拼接、替换”本质上都会生成新字符串。
a = [1, 2, 3]
print(id(a))
a.append(4)
print(id(a)) # 通常不变
s = "abc"
print(id(s))
s = s + "d"
print(id(s)) # 变了,通常是新对象
2)Python 是值传递还是引用传递?
更准确的说法是:传对象引用,也常被称为 call by sharing / object sharing。 不是 C++ 那种引用传递,也不是把对象本体拷贝一份过去。 所以你要区分两件事:
- 原地修改对象:外部可见
- 重新绑定局部变量:外部不可见
def f(x):
x.append(4) # 原地修改
def g(x):
x = x + [4] # 重新绑定局部变量
a = [1, 2, 3]
f(a)
print(a) # [1, 2, 3, 4]
b = [1, 2, 3]
g(b)
print(b) # [1, 2, 3]
3)不可变对象一定能做字典键吗?
不一定。 更准确地说:字典键要求可哈希(hashable)且相等性语义稳定。 很多不可变对象可哈希,但不是“不可变 = 一定可哈希”。
例如:
str、int通常可哈希tuple只有在所有元素都可哈希时,自己才可哈希
d = {(1, 2): "ok"} # 可以
# 下面不行,因为 tuple 里有 list,list 不可哈希
# d = {([1, 2], 3): "bad"} # TypeError
4)不可变对象是不是更安全?
通常是的,因为它们不会被原地篡改,更适合:
- 做字典键、集合元素
- 做配置项、常量
- 在多处共享时降低副作用
- 在并发场景中减少状态污染风险
但要注意: “对象不可变”不等于“整个程序状态就安全”,因为你仍然可能重新绑定变量,或者对象内部持有可变成员。
原理展开
1. Python 对象模型:先分清“名字”和“对象”
Python 中:
- 对象:真正承载数据和行为的实体
- 变量名:只是一个标签,绑定到某个对象上
id(obj):可近似理解为对象身份标识is:比较是不是同一个对象==:比较值是否相等
a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(a is b) # True,同一对象
print(a == b) # True,值也相等
print(a is c) # False,不同对象
print(a == c) # True,值相等
所以“修改变量”有两种完全不同的事情:
方式一:修改对象本身(in-place mutation)
a = [1, 2]
b = a
a.append(3)
print(a) # [1, 2, 3]
print(b) # [1, 2, 3]
这里改的是同一个 list 对象。
方式二:让变量名重新绑定到新对象(rebinding)
a = [1, 2]
b = a
a = a + [3]
print(a) # [1, 2, 3]
print(b) # [1, 2]
这里 a = a + [3] 并不是原地修改,而是产生了新列表,再让 a 指向它。
2. 可变 / 不可变,本质是“对象状态能不能原地改”
这不是“变量能不能重新赋值”的区别,而是对象内部状态是否允许变更。
例如:
x = 10
x = 20
并不是把整数 10 改成了 20,而是让名字 x 从旧对象绑定到了新对象。
再看字符串:
s = "hello"
s.replace("h", "H")
print(s) # hello
str.replace() 返回的是新字符串,不会改原字符串。
而列表方法很多是原地修改:
lst = [3, 1, 2]
lst.sort()
print(lst) # [1, 2, 3]
这也是为什么面试里常问:
list.sort()和sorted()区别是什么?+=到底是不是原地修改?
关于 += 的追问
+= 不一定总是创建新对象,要看类型是否实现“原地加法”。
a = [1, 2]
old = id(a)
a += [3]
print(id(a) == old) # 通常 True,list 原地扩展
t = (1, 2)
old = id(t)
t += (3,)
print(id(t) == old) # False,tuple 产生新对象
所以不能机械记“+= 就一定新建对象”,要看具体类型。
3. 为什么这件事会影响函数传参?
因为函数参数本质也是名字绑定。
def modify_list(lst):
lst.append(4) # 改对象
def modify_str(s):
s += " world" # 绑定到新字符串
return s
a = [1, 2, 3]
modify_list(a)
print(a) # [1, 2, 3, 4]
s = "hello"
modify_str(s)
print(s) # hello
面试回答时最好直接点出:
Python 函数调用传入的是对象引用。 对可变对象做原地修改,会影响调用方; 对不可变对象做“修改”,通常只是局部变量重新绑定新对象,不会影响外部。
这就是为什么有的人会误以为“list 是引用传递,str 是值传递”,其实这个说法不准确。 机制是一套,表现不同是因为对象是否可变。
4. 为什么默认参数会踩坑?
因为函数默认参数在函数定义时就求值了,不是每次调用都重新创建。 如果默认值是可变对象,那么多次调用会共享同一个对象。
def append_to(x, target=[]):
target.append(x)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2]
print(append_to(3)) # [1, 2, 3]
正确写法:
def append_to(x, target=None):
if target is None:
target = []
target.append(x)
return target
为什么这里用 None?
None是单例- 不可变
- 语义清晰:表示“调用方没有传值”
这是 Python 工程实践里的经典规范。
5. 为什么可变对象通常不能做字典键?
字典底层依赖哈希表。 键必须满足两个要求:
- 可哈希
- 相等性稳定
如果一个键放进字典后还能变内容,那么:
- 它的哈希值可能变化
- 它参与相等比较的结果可能变化
这会破坏字典查找的一致性。
# list 不可哈希
# d = {[1, 2]: "value"} # TypeError
d = {
(1, 2): "ok",
"name": "python"
}
这里建议补一句更专业的话:
“能否作为字典键”的核心不是字面上的‘不可变’三个字,而是哈希语义是否稳定。Python 内置可变容器通常都不可哈希,就是为了避免这种逻辑错误。
6. 拷贝语义为什么总和它一起考?
因为可变对象会共享状态,而拷贝的本质就是: 你到底是要“复制一个新壳”,还是“里面的子对象也全部复制”?
赋值:不拷贝,只是多一个引用
a = [[1, 2], [3, 4]]
b = a
b[0].append(99)
print(a) # [[1, 2, 99], [3, 4]]
浅拷贝:复制最外层,内层对象仍共享
import copy
a = [[1, 2], [3, 4]]
b = copy.copy(a)
b[0].append(99)
print(a) # [[1, 2, 99], [3, 4]]
print(a is b) # False
print(a[0] is b[0]) # True
深拷贝:递归复制内部对象
import copy
a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)
b[0].append(99)
print(a) # [[1, 2], [3, 4]]
print(b) # [[1, 2, 99], [3, 4]]
面试里你可以总结成一句:
拷贝问题的关键不是“有没有拷贝”,而是“是否仍共享内部可变对象”。
7. tuple 真的是绝对不可变吗?
这是高频陷阱题。
准确说法是:
tuple的元素引用不可变- 但如果元素本身是可变对象,那个元素内部还是可以变
t = ([1, 2], [3, 4])
t[0].append(5)
print(t) # ([1, 2, 5], [3, 4])
所以:
tuple不一定可哈希tuple不一定“绝对安全”- 它只是“容器层面不可变”
8. 工程实践里怎么理解这件事?
适合用不可变对象的场景
- 作为字典键、集合元素
- 配置项、常量、标识符
- 多模块共享但不希望被修改的数据
- 函数式风格、缓存 key、去重 key
适合用可变对象的场景
- 需要频繁增删改的数据结构
- 构建过程中的中间状态
- 聚合统计、缓冲区、批量收集
实际开发里的建议
- 对外暴露的数据尽量减少可变共享
- 函数默认参数不要用可变对象
- 不要为了“省内存”到处共享可变容器
- 当副作用不明确时,优先返回新对象而不是偷偷改入参
- API 设计上要明确:这个函数是“原地修改”还是“返回新值”
例如:
def normalize_config(config: dict) -> dict:
# 更稳妥:复制后返回,避免污染调用方
new_config = dict(config)
new_config.setdefault("timeout", 30)
return new_config
如果要原地改,也最好在命名或文档上说清楚。
对比总结
1)可变对象 vs 不可变对象
| 对比项 | 可变对象 | 不可变对象 | 适用场景 | 易混点 |
|---|---|---|---|---|
| 定义 | 创建后可原地修改内部状态 | 创建后不能原地修改内部状态 | 前者适合频繁修改,后者适合稳定值 | “变量可重新赋值”不等于“对象可变” |
| 常见类型 | list、dict、set、大多数实例对象 | int、str、tuple、frozenset、bytes | 前者适合累积、更新;后者适合键、常量 | tuple 里放可变对象时,内部仍可能变 |
| 修改方式 | 通常可 in-place 修改 | 通常通过创建新对象实现“修改” | 需要性能与副作用控制时要区分 | += 是否原地修改取决于类型 |
| 函数传参表现 | 原地修改会影响调用方 | “修改”常表现为局部重绑定 | 设计 API 时要明确是否有副作用 | 不是“一个引用传递,一个值传递” |
| 字典键可用性 | 通常不可作为键 | 通常更适合,但仍要看是否可哈希 | 键、集合元素、缓存 key | “不可变”不等于“一定可哈希” |
2)is vs ==
| 对比项 | is | == | 正确用法 | 易错点 |
|---|---|---|---|---|
| 比较内容 | 比较是否同一对象 | 比较值是否相等 | x is None;对象身份判断 | 把 is 用来比较数字、字符串字面值 |
| 本质 | 比较对象身份 | 调用相等性语义 | 需要判断单例时用 is | 受缓存机制影响,is 偶尔“看起来能用”但不可靠 |
| 面试重点 | 身份比较 | 值比较 | None、True、False、单例对象 | x == None 风格和语义都不优 |
3)赋值 vs 浅拷贝 vs 深拷贝
| 对比项 | 赋值 | 浅拷贝 | 深拷贝 | 适用场景 | 易混点 |
|---|---|---|---|---|---|
| 是否新建最外层对象 | 否 | 是 | 是 | 按需选择共享程度 | 很多人以为浅拷贝就“完全独立” |
| 是否共享内层对象 | 是 | 是 | 否 | 嵌套可变结构要小心 | 列表套列表最容易出坑 |
| 成本 | 最低 | 中等 | 最高 | 深拷贝更安全但更贵 | 深拷贝不应滥用 |
4)list vs tuple
| 对比项 | list | tuple | 适用场景 | 易混点 |
|---|---|---|---|---|
| 可变性 | 可变 | 容器本身不可变 | list 适合动态集合,tuple 适合固定结构 | tuple 不是“里面所有东西都不能变” |
| 性能与语义 | 修改方便,语义偏“可编辑” | 语义偏“记录/固定结构” | 返回固定多值时常用 tuple | 不是所有 tuple 都可做字典键 |
| 哈希性 | 不可哈希 | 视元素而定 | 适合做稳定键 | 元素中只要有不可哈希对象,整体也不可哈希 |
易错点
- 把“变量”当成“对象本身”,说成“变量可变/不可变”
- 误以为 Python 对
list是引用传递、对str是值传递 - 误以为不可变对象一定可哈希
- 误以为
tuple一定能做字典键 - 误以为
a = a + [1]和a.append(1)完全一样 - 把
is当作值比较运算符使用 - 看到小整数、短字符串
is为真,就误以为可以用is比较普通值 - 忘记默认参数只在函数定义时求值一次
- 只会背“浅拷贝拷一层”,但说不清为什么嵌套列表还会联动
- 在 API 设计里偷偷修改传入的
dict或list,制造隐蔽副作用
记忆技巧
-
一句话记忆: 可变对象是“能在原地改内容”,不可变对象是“改了通常就是新对象”。
-
看函数副作用时,先问自己两个问题:
- 传进来的是不是可变对象?
- 函数内部是在“原地修改”,还是“重新绑定名字”?
-
看字典键能不能用时,不要只背“不可变”: 要记成:键必须可哈希,而且哈希语义稳定。
-
默认参数口诀: 默认参数别放
[]、{}、set(),优先用None。 -
is和==口诀:is看身份,==看值。 面试里最稳妥的表达是:只在和None比较时优先用is。 -
tuple的记忆点: tuple 只是“外壳固定”,不保证内部成员也固定。
面试速答版
Python 里变量保存的是对象引用,不是对象本身。可变对象比如 list、dict、set,可以在原地修改;不可变对象比如 int、str、tuple,看起来像修改时,通常是创建新对象再重新绑定变量。
这个区别很重要,因为它直接影响三件事:第一,函数传参是否会产生副作用;第二,多个变量共享同一个对象时会不会互相污染;第三,能不能作为字典键或集合元素。比如列表传进函数后如果被 append,外部能看到变化;但字符串拼接通常只是生成新字符串,不会改原对象。
另外要注意,不可变对象更适合做字典键,但核心要求其实是可哈希;像 tuple 只有在所有元素都可哈希时才能做键。默认参数不要用可变对象,也是因为它会在多次调用间共享状态。
面试加分版
这个问题我一般会从 Python 的对象模型来回答。Python 里变量名本质上只是对象的引用,也就是“名字绑定到对象”。所以可变和不可变,不是说变量能不能重新赋值,而是说对象创建之后,它的内部状态能不能被原地修改。
像 list、dict、set 这类对象支持原地修改,所以多个变量如果引用的是同一个对象,一方修改,另一方也会看到变化;而 int、str、tuple 这类不可变对象,所谓“修改”通常是创建新对象,然后把变量名重新绑定过去。这也是为什么 Python 函数传参经常容易被误解。准确地说,Python 传的是对象引用:如果函数内部对传入的可变对象做原地修改,调用方能感知到;如果只是局部重新赋值,外部通常感知不到。
这个区别在工程里非常重要。第一,它决定了函数有没有副作用。比如一个函数如果直接修改传入的 dict,很可能污染调用方状态。第二,它关系到默认参数陷阱,因为默认参数只在函数定义时求值一次,如果你把列表当默认值,多次调用会共享同一个列表。第三,它影响哈希和字典键。能不能做字典键,核心不是“长得像不像不可变对象”,而是哈希值和相等性语义是否稳定。像 list 不能做键,而 tuple 只有在元素都可哈希时才能做键。
再往深一点说,面试官还可能追问 tuple 是不是绝对不可变。这里要回答:tuple 自身不可变,指的是它的元素绑定关系不能改,但如果元素本身是可变对象,比如里面放了一个 list,那个 list 仍然可以变,所以 tuple 不是“递归意义上的完全不可变”。
实际开发里,我的习惯是:对外暴露的数据尽量减少共享可变状态;默认参数统一用 None;设计函数时明确是“原地修改”还是“返回新对象”;在做缓存 key、字典 key、集合去重时,优先使用哈希稳定的不可变结构。这样代码的可预测性会更强,也更不容易出现隐蔽 bug。