跳到主要内容

可变对象和不可变对象

变量、引用和对象

要理解可变对象和不可变对象,首先需要理解 Python 中的变量、引用和对象以及这几者之间的关系。

对象

在 Python 中一切都是对象,数字、字符串、列表、函数、类,本质都是对象。

10
"hello"
[1, 2, 3]

对象通常包含:

  1. 类型(type)
  2. 值(value)
  3. 身份(id,内存地址)

变量

变量是对象的名字(标签),它本身不存储数据,只是指向某个对象。例如:

a = 10

Python 实际做了两件事:

  1. 创建整数对象 10
  2. 让变量 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。