广告

Python 可变对象与不可变对象的区别到底有哪些?从原理到实战的全面对比

可变对象与不可变对象的基本概念

定义与核心差异

在 Python 的对象模型中,变量名只是对对象的引用,数据的实际存储在对象本身和其所在的内存块中。因此,理解可变与不可变对象的区别,首先要明确对象的值是否能够就地修改。不可变对象一旦创建,其“值”就不能被直接修改;可变对象的内容可以直接在原有对象上进行变更而不产生新的对象。

常见的不可变对象包括整数、浮点数、字符串、元组和布尔值等;常见的可变对象包括列表、字典、集合,以及自定义对象中那些允许就地修改字段的部分。不可变对象通常具备哈希性,适合作为字典键或集合成员,而可变对象在修改后可能改变哈希值,导致不适合用作键。

在赋值与引用方面,Python 的变量名只是标签绑定到对象上,并非直接把对象值复制到新的命名标签中。因此对可变对象的修改往往会在所有引用该对象的变量中体现,而对不可变对象的“修改”通常意味着分配一个新的对象并让变量指向它。

# 示例:可变对象的引用行为
a = [1, 2, 3]
b = a
a.append(4)
print(b)  # [1, 2, 3, 4]
print(id(a), id(b))  # 两个变量引用同一个对象

原理解析:为什么会有可变与不可变的对象模型

底层实现与内存模型

在 CPython 的实现里,每个对象都拥有一个类型指针、引用计数以及数据本体等元数据。不可变对象通常通过共享、缓存和常量池等手段实现值的不可变性,例如整数的小整数缓存和字符串的驻留机制。这些优化降低了对象创建成本,同时提升了哈希与比较的速度。

相比之下,可变对象的内容在同一个对象的内存块中可以就地修改,这意味着对其字段或元素的变更不会产生新的对象,但会带来引用计数的更新和可能的内存再分配。这也解释了为何不可变对象在哈希场景中更稳定,而可变对象在作为字典键时可能导致错误

下面的代码演示了不可变对象与可变对象在“同值不同对象标识”方面的差异,以及小整数缓存对 id() 的影响:

x = 256
y = 256
print(id(x) == id(y))  # 可能为 True,取决于实现中的整数缓存
a = "hello"
b = "hello"
print(id(a) == id(b))  # CPython 可能为 True,字符串会被内部优化为共享对象
t = (1, 2, 3)
t2 = t
t = t + (4,)
print(t)   # (1, 2, 3, 4)
print(t2)  # (1, 2, 3)

Python 常见的可变对象与不可变对象实例

可变对象示例

可变对象的内容可以就地修改,不会产生新的对象标识,因此对其进行就地变更时,所有引用该对象的变量都会看到更新后的内容。

常见的可变对象包括列表、字典、集合,以及自定义对象中可变字段的实现。理解这一点有助于避免意外的副作用,尤其是在函数参数传递和缓存场景中。

lst = [1, 2, 3]
lst2 = lst
lst.append(4)
print(lst)  # [1, 2, 3, 4]
print(lst2) # [1, 2, 3, 4]
print(id(lst), id(lst2))  # 同一个对象的标识

不可变对象示例

不可变对象在创建后不可就地修改值,若要“改变值”,通常需要创建一个新的对象并让变量指向它。

典型不可变对象包括整型、浮点型、字符串、元组和冻结集合等。它们的不可变性使其在哈希和并发场景下更为安全。

t = (1, 2, 3)
t2 = t
t = t + (4,)
print(t)   # (1, 2, 3, 4)
print(t2)  # (1, 2, 3)

哈希性与字典/集合:为何不可变对象更适合作为键

可哈希性原理

字典和集合要求成员具备稳定的哈希值,因此不可变对象通常是可哈希的,而可变对象的内容可变性会导致哈希值不再稳定,进而破坏数据结构的一致性。

在 CPython 中,字符串、整型等不可变对象往往有缓存与内部优化,提升了哈希相关操作的性能。可变对象如列表、字典本身通常不可哈希,因此不能直接作为字典键或集合元素。

以下代码展示了一个简单的哈希键使用示例,以及一个不可哈希对象作为字典键导致的错误:

d = {}
d[(1, 2, 3)] = "tuple key"
# 下行会报错,因为 list 是不可哈希的,不能作为字典键
# d[[1, 2, 3]] = "list key"
print(d[(1, 2, 3)])

实战场景:如何选型和避免坑

变量赋值与函数参数传递

在实际编码中,变量赋值只是标签绑定到对象,函数参数传递通常是对象引用传递,这意味着对可变对象的修改可能在函数外部可见。

为了避免意外的副作用,可以遵循一些简单做法:对可变对象进行就地修改前,明确传递的是引用还是需要创建副本,并在需要不可变行为时使用不可变对象或进行显式拷贝。

def extend_list(value, lst=None):
    if lst is None:
        lst = []
    lst.append(value)
    return lst

print(extend_list(1))  # [1]
print(extend_list(2))  # [2],每次调用都是独立的新列表

def extend_list_bad(value, lst=[]):  # 不推荐的写法,使用可变默认参数
    lst.append(value)
    return lst

print(extend_list_bad(1))  # [1]
print(extend_list_bad(2))  # [1, 2], reused 的同一个列表对象

性能与内存角度:对比对应用的影响

内存占用与缓存策略

不可变对象的缓存与共享能带来内存复用,减少重复分配,适用于重复值的场景;而可变对象在频繁修改时需要多次分配,可能产生碎片化与垃圾回收压力。从长时序来看,合理使用不可变对象有助于减少内存开销和提高访问速度,但在高变动的数据处理中,过度复制则可能成为性能瓶颈。

下面的示例演示通过元组替代频繁变化的结构来优化内存与性能:

# 将不断变化的数据用不可变结构表示
data = (1, 2, 3)
# 每次变更都需要创建新对象
data = data + (4,)
print(data)

常见误区与要点整理

误解1:不可变对象等同于不可变性

不可变对象的“不可变性”是指其值在创建后不能就地修改,但变量名仍然可以指向不同的对象,这与对象本身的不可变性是两个层面的概念。

例如,整数、字符串等不可变对象创建后不会被就地修改,但你仍然可以通过重新绑定让变量指向一个新的对象。

p = [1, 2, 3]
# 这不是不可变对象的操作,而是对可变对象的就地修改
p[0] = 9
print(p)  # [9, 2, 3]
print(id(p))  # 指向同一个对象

误解2:所有修改都需要新对象

并非如此:可变对象允许就地修改;仅当你需要避免副作用、实现“不可变语义”或进行并发安全设计时,才需要引入不可变对象或进行对象重建。

p = [1, 2, 3]
old_id = id(p)
p[0] = 9
print(p)         # [9, 2, 3]
print(id(p) == old_id)  # True,仍在同一个对象上修改
广告

后端开发标签