可变对象和不可变对象
变量、引用和对象
要理解可变对象和不可变对象,首先需要理解 Python 中的变量、引用和对象以及这几者之间的关系。
对象
在 Python 中一切都是对象,数字、字符串、列表、函数、类,本质都是对象。
10
"hello"
[1, 2, 3]
对象通常包含:
- 类型(type)
- 值(value)
- 身份(id,内存地址)
变量
变量是对象的名字(标签),它本身不存储数据,只是指向某个对象。例如:
a = 10
Python 实际做了两件事:
- 创建整数对象 10
- 让变量 a 指向这个对象
引用
引用是变量指向对象的一种关系,例如:
a = 10
b = a
内存关系:
a ──┐
│
▼
10
▲
│
b ──┘
此时 a 和 b 都指向同一个对象 10
变量、引用和对象之间的关系
核心原则:Python 中变量不存储值,变量只是“对象的引用(名字)”
可以从 三层结构 来理解:
变量名 → 引用 → 对象
举例:
a → 指向 → 10
在 Python 万物皆对象的设计中,值其实是对象的实例,给变量赋值时,变量只是指向了一个对象,而不是直接存储值。
赋值和修改操作的本质
- 赋值:让变量(标签)从指向一个对象,改为指向另一个对象。如果是第一次赋值,则是建立引用关系;对一个已存在的变量进行重新赋值时,变量会指向一个新的对象,从而改变其引用关系。
- 修改:直接改变变量指向的那个对象本身的内容
根据定义我们不难发现,赋值操作会改变变量的引用(指向新对象),而修改操作则可能改变对象的内容,但变量的引用可能不变。
同样是修改变量,为什么有时是换标签(改变引用),有时却是改内容(修改对象)?这就要看被操作的对象本身是否可变。根据对象在创建之后能否被修改,我们可以将其分为两大类:
不可变对象:对象创建后,值不能被修改。例如:整数、浮点数、字符串、元组等。修改变量实质上是 Python 变量重新赋值,其实是改变引用,而不是修改对象的值。可变对象:对象创建后,值可以被修改。例如:列表、字典、集合等。 修改变量实质上是修改对象的值,变量引用不变。
判断一个对象是否可变,也可以看对对象内容的修改操作是否改变其身份(id)。如果任何试图修改内容的操作都会产生一个新对象(id 变化),则该对象是不可变的;如果可以在不改变 id 的情况下修改内容,则是可变的。
"修改"不可变对象
不可变对象的值不能被修改,"修改"实质上是创建了一个新的对象,并将变量指向这个新对象。例如:
a = 10
print(id(a)) # 输出 a 的内存地址
a = 20
print(id(a)) # 输出 a 的新内存地址,说明 a 指向了一个新的对象
发生的事情其实是:
步骤1
a ──► 10
步骤2
创建新对象 20
a ──► 20
原来的 10 仍然存在(如果没有其他引用,会被垃圾回收),而 a 已经指向了新的对象 20
修改可变对象
可变对象的值可以被修改,"修改"是原地修改对象的内容,变量指向的内存地址不变。例如:
lst = [1, 2, 3]
print(id(lst)) # 输出 lst 的内存地址
lst.append(4)
print(id(lst)) # 输出 lst 的内存地址不变,说明 lst 的内容被修改了,但 lst 仍然指向同一个对象
理解了单个变量如何指向对象后,我们再来看多个变量指向同一个对象时会发生什么
引用共享
隐患
Python 中变量本质是对象的引用,变量本身不直接存储值,而是指向一个对象。当多个变量指向同一个对象时,就会形成引用共享。对于不可变对象,这通常没什么影响;但一旦涉及可变对象,就可能带来意想不到的问题:
a = [1, 2, 3]
b = a # b 和 a 指向同一个列表对象
b.append(4) # 修改了列表对象的内容
print(a) # 输出 [1, 2, 3, 4],a 也受影响
print(b) # 输出 [1, 2, 3, 4]
可以看到,通过 b 修改列表后,a 的内容也跟着变了。这是因为 a 和 b 指向的是同一个对象,无论通过哪个变量修改,都会反映在所有引用上。
这种隐式的引用共享在实际开发中容易埋下隐患,可能在不知不觉中通过一个变量修改了对象,却导致程序中其他地方的代码出现意料之外的行为。尤其是在大型项目或复杂的数据传递过程中,这类问题往往很难追踪和调试。
解决方式
要避免这类问题,可以通过显式复制对象方式解决:
a = [1, 2, 3]
b = a.copy() # 或者 b = a[:]、b = list(a)
b.append(4)
print(a) # 仍然输出 [1, 2, 3]
print(b) # 输出 [1, 2, 3, 4]
需要注意的是,对于包含可变对象的复合结构(如列表的列表),简单的浅复制可能不够,这时就需要深复制(copy.deepcopy())来完全切断引用关系。深浅拷贝文章见深拷贝和浅拷贝,这里不再赘述。
函数传参
在上一节我们了解了引用共享。这种引用共享的现象不仅存在于变量之间,在函数调用中表现得更为频繁和重要。
实际编程中,我们经常需要把变量作为参数传递给函数。Python在传递参数时,采用的正是同样的引用共享机制。这意味着,函数内部的参数变量和外部的实参变量,同样会指向同一个对象。
正因如此,引用共享的规则在函数内外是一致的:如果传入的是可变对象(如列表、字典),函数内部对它的修改会直接反映到函数外部;如果传入的是不可变对象(如数字、字符串),则任何“修改”都会创建新对象,不会影响外部变量。
def update_dict(d):
d['new_key'] = 'new_value' # 添加新键值对
print("函数内部:", d)
my_dict = {'a': 1, 'b': 2}
update_dict(my_dict)
print("函数外部:", my_dict) # 输出 {'a': 1, 'b': 2, 'new_key': 'new_value'},my_dict 被修改了
def try_modify_string(s):
s = s + " world" # 尝试修改字符串(实际上创建了新字符串)
print("函数内部:", s)
my_string = "hello"
try_modify_string(my_string)
print("函数外部:", my_string) # 输出 "hello",my_string 没有被修改
因此,如果同样需要保留函数内部对参数的修改不影响外部变量,可以在函数内部对参数进行复制(浅复制或深复制,视情况而定),以切断引用关系:
def safe_update_dict(d):
d_copy = d.copy() # 创建字典的浅复制
d_copy['new_key'] = 'new_value' # 修改复制后的字典
print("函数内部:", d_copy)
my_dict = {'a': 1, 'b': 2}
safe_update_dict(my_dict)
print("函数外部:", my_dict) # 输出 {'a': 1, 'b': 2},my_dict 没有被修改
拓展:== 和 is 有什么区别?
==:比较两个对象的值是否相等is:比较两个对象是否是同一个对象(id 是否相同)
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True ← 值相等
print(a is b) # False ← 不是同一个对象
总结
理解 Python 中的可变对象与不可变对象,关键在于把握变量、引用和对象三者的关系:
- 对象:Python 中一切都是对象,每个对象都有类型、值和身份(内存地址)
- 变量:本质是对象的标签(引用),本身不存储数据
- 引用:变量指向对象的关系
可变性决定行为差异
| 特性 | 不可变对象 | 可变对象 |
|---|---|---|
| 典型代表 | 整数、浮点数、字符串、元组 | 列表、字典、集合 |
| 修改操作 | 创建新对象,变量指向新对象 | 原地修改对象内容 |
| id 是否变化 | 是 | 否 |
| 多个变量引用 | 安全(不会相互影响) | 有隐患(可能相互影响) |
当多个变量指向同一个可变对象时,任何一个变量对对象的修改都会影响到所有引用。这种隐式关联可能导致难以追踪的 bug。