🐍Python 语言基础

可变对象与不可变对象

面试回答

常见问法

  • Python 里什么是可变对象和不可变对象?
  • 为什么列表能改,字符串不能改?
  • 这和函数传参、默认参数、拷贝、字典键有什么关系?
  • listtuplestrdict 这些类型分别属于哪一类?
  • 不可变对象是不是一定线程安全、一定可哈希?

回答

在 Python 里,变量名本质上是对象的引用(名字绑定)。 所谓可变对象,是指对象创建后,可以在原地修改内部状态; 所谓不可变对象,是指对象创建后,对象内部状态不能被原地修改,看起来像“修改”的操作,通常是创建新对象,再把名字重新绑定过去

面试里最重要的不是背类型表,而是理解这几个影响:

  1. 函数传参是否会产生副作用 Python 传的是“对象引用”,如果函数里原地修改可变对象,调用方能看到变化;如果只是给局部变量重新赋值,外部通常看不到。

  2. 共享状态是否会互相污染 多个变量指向同一个可变对象时,任何一方原地修改,其他引用方都会受影响。

  3. 能不能作为字典键 / 集合元素 作为键,核心要求是哈希值稳定。可变对象通常不适合作为键,因为内容一变,哈希语义就可能失效。

  4. 默认参数、浅拷贝、深拷贝为什么经常出坑 本质都和“是否共享同一个可变对象”有关。

常见分类可以这样记:

  • 可变对象listdictsetbytearray、大多数自定义类实例
  • 不可变对象intfloatboolstrtuplefrozensetbytesNone

但要注意一个高频边界:

  • 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)且相等性语义稳定。 很多不可变对象可哈希,但不是“不可变 = 一定可哈希”。

例如:

  • strint 通常可哈希
  • 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. 为什么可变对象通常不能做字典键?

字典底层依赖哈希表。 键必须满足两个要求:

  1. 可哈希
  2. 相等性稳定

如果一个键放进字典后还能变内容,那么:

  • 它的哈希值可能变化
  • 它参与相等比较的结果可能变化

这会破坏字典查找的一致性。

# 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 不可变对象

对比项可变对象不可变对象适用场景易混点
定义创建后可原地修改内部状态创建后不能原地修改内部状态前者适合频繁修改,后者适合稳定值“变量可重新赋值”不等于“对象可变”
常见类型listdictset、大多数实例对象intstrtuplefrozensetbytes前者适合累积、更新;后者适合键、常量tuple 里放可变对象时,内部仍可能变
修改方式通常可 in-place 修改通常通过创建新对象实现“修改”需要性能与副作用控制时要区分+= 是否原地修改取决于类型
函数传参表现原地修改会影响调用方“修改”常表现为局部重绑定设计 API 时要明确是否有副作用不是“一个引用传递,一个值传递”
字典键可用性通常不可作为键通常更适合,但仍要看是否可哈希键、集合元素、缓存 key“不可变”不等于“一定可哈希”

2)is vs ==

对比项is==正确用法易错点
比较内容比较是否同一对象比较值是否相等x is None;对象身份判断is 用来比较数字、字符串字面值
本质比较对象身份调用相等性语义需要判断单例时用 is受缓存机制影响,is 偶尔“看起来能用”但不可靠
面试重点身份比较值比较NoneTrueFalse、单例对象x == None 风格和语义都不优

3)赋值 vs 浅拷贝 vs 深拷贝

对比项赋值浅拷贝深拷贝适用场景易混点
是否新建最外层对象按需选择共享程度很多人以为浅拷贝就“完全独立”
是否共享内层对象嵌套可变结构要小心列表套列表最容易出坑
成本最低中等最高深拷贝更安全但更贵深拷贝不应滥用

4)list vs tuple

对比项listtuple适用场景易混点
可变性可变容器本身不可变list 适合动态集合,tuple 适合固定结构tuple 不是“里面所有东西都不能变”
性能与语义修改方便,语义偏“可编辑”语义偏“记录/固定结构”返回固定多值时常用 tuple不是所有 tuple 都可做字典键
哈希性不可哈希视元素而定适合做稳定键元素中只要有不可哈希对象,整体也不可哈希

易错点

  • 把“变量”当成“对象本身”,说成“变量可变/不可变”
  • 误以为 Python 对 list 是引用传递、对 str 是值传递
  • 误以为不可变对象一定可哈希
  • 误以为 tuple 一定能做字典键
  • 误以为 a = a + [1]a.append(1) 完全一样
  • is 当作值比较运算符使用
  • 看到小整数、短字符串 is 为真,就误以为可以用 is 比较普通值
  • 忘记默认参数只在函数定义时求值一次
  • 只会背“浅拷贝拷一层”,但说不清为什么嵌套列表还会联动
  • 在 API 设计里偷偷修改传入的 dictlist,制造隐蔽副作用

记忆技巧

  • 一句话记忆可变对象是“能在原地改内容”,不可变对象是“改了通常就是新对象”。

  • 看函数副作用时,先问自己两个问题

    1. 传进来的是不是可变对象?
    2. 函数内部是在“原地修改”,还是“重新绑定名字”?
  • 看字典键能不能用时,不要只背“不可变”: 要记成:键必须可哈希,而且哈希语义稳定。

  • 默认参数口诀默认参数别放 []{}set(),优先用 None

  • is== 口诀is 看身份,== 看值。 面试里最稳妥的表达是:只在和 None 比较时优先用 is

  • tuple 的记忆点tuple 只是“外壳固定”,不保证内部成员也固定。


面试速答版

Python 里变量保存的是对象引用,不是对象本身。可变对象比如 listdictset,可以在原地修改;不可变对象比如 intstrtuple,看起来像修改时,通常是创建新对象再重新绑定变量。

这个区别很重要,因为它直接影响三件事:第一,函数传参是否会产生副作用;第二,多个变量共享同一个对象时会不会互相污染;第三,能不能作为字典键或集合元素。比如列表传进函数后如果被 append,外部能看到变化;但字符串拼接通常只是生成新字符串,不会改原对象。

另外要注意,不可变对象更适合做字典键,但核心要求其实是可哈希;像 tuple 只有在所有元素都可哈希时才能做键。默认参数不要用可变对象,也是因为它会在多次调用间共享状态。


面试加分版

这个问题我一般会从 Python 的对象模型来回答。Python 里变量名本质上只是对象的引用,也就是“名字绑定到对象”。所以可变和不可变,不是说变量能不能重新赋值,而是说对象创建之后,它的内部状态能不能被原地修改

listdictset 这类对象支持原地修改,所以多个变量如果引用的是同一个对象,一方修改,另一方也会看到变化;而 intstrtuple 这类不可变对象,所谓“修改”通常是创建新对象,然后把变量名重新绑定过去。这也是为什么 Python 函数传参经常容易被误解。准确地说,Python 传的是对象引用:如果函数内部对传入的可变对象做原地修改,调用方能感知到;如果只是局部重新赋值,外部通常感知不到。

这个区别在工程里非常重要。第一,它决定了函数有没有副作用。比如一个函数如果直接修改传入的 dict,很可能污染调用方状态。第二,它关系到默认参数陷阱,因为默认参数只在函数定义时求值一次,如果你把列表当默认值,多次调用会共享同一个列表。第三,它影响哈希和字典键。能不能做字典键,核心不是“长得像不像不可变对象”,而是哈希值和相等性语义是否稳定。像 list 不能做键,而 tuple 只有在元素都可哈希时才能做键。

再往深一点说,面试官还可能追问 tuple 是不是绝对不可变。这里要回答:tuple 自身不可变,指的是它的元素绑定关系不能改,但如果元素本身是可变对象,比如里面放了一个 list,那个 list 仍然可以变,所以 tuple 不是“递归意义上的完全不可变”。

实际开发里,我的习惯是:对外暴露的数据尽量减少共享可变状态;默认参数统一用 None;设计函数时明确是“原地修改”还是“返回新对象”;在做缓存 key、字典 key、集合去重时,优先使用哈希稳定的不可变结构。这样代码的可预测性会更强,也更不容易出现隐蔽 bug。