文章目录
- 一、故事从变量赋值说起
- 二、不可变类型 (Immutable Types)
- 三、可变类型 (Mutable Types)
- 四、一个常见的陷阱:当元组遇到列表
- 五、为什么这个区别如此重要?
- 1. 函数参数的传递
- 2. 字典的键 (Dictionary Keys)
- 3. 函数的默认参数陷阱
- 六、进阶话题与扩展
- 1. 浅拷贝 vs. 深拷贝:`copy` 与 `deepcopy`
- 2. `+=` 运算符:可变与不可变对象的差异
- 3. CPython 的对象缓存机制
- 4. 并发中的可变对象
- 5. 冻结(只读)数据结构
- 6. 性能小贴士
- 总结
在 Python 的学习和实践中,有一个核心概念是绕不开的,那就是"可变"(Mutable)与"不可变"(Immutable)类型。刚开始,你可能觉得这只是个定义问题,但随着你写出更复杂的程序,你会发现,能否深刻理解这两者的区别,直接决定了你的代码是否健壮、高效,以及是否会踩到一些意想不到的"坑"。
这篇文章将带你由浅入深,彻底搞懂这个关键概念。
一、故事从变量赋值说起
在 Python 中,我们常说"变量是贴在对象上的标签"。理解这句话是后续一切的基础。
当你写下 x = 100
时,Python 做了两件事:
- 在内存中创建了一个代表数字
100
的对象。 - 创建了一个名为
x
的变量(标签),然后把它"贴"到100
这个对象上。
那么,当我们"修改"变量时,会发生什么呢?这就要看对象的类型了。
二、不可变类型 (Immutable Types)
顾名思义,不可变类型的对象,其值在创建后就不能被改变。任何对它的"修改"操作,实际上都会创建一个全新的对象。
常见的不可变类型包括:
- 数字:
int
,float
,bool
- 字符串:
str
- 元组:
tuple
- 冻结集合:
frozenset
让我们用代码和内存地址 id()
来眼见为实。
示例 1: 字符串 str
my_string = "hello"
print(f"初始字符串: '{my_string}', 内存地址: {id(my_string)}")# 尝试"修改"字符串
my_string = my_string + " world"print(f"修改后字符串: '{my_string}', 内存地址: {id(my_string)}")
输出:
初始字符串: 'hello', 内存地址: 4389754112
修改后字符串: 'hello world', 内存地址: 4389754224
看到了吗?内存地址变了!Python 并没有修改原来的 "hello"
对象,而是创建了一个全新的 "hello world"
对象,然后把 my_string
这个标签从旧对象身上撕下来,贴到了新对象上。
三、可变类型 (Mutable Types)
与不可变类型相反,可变类型的对象,其值可以在创建后被原地修改,而不需要创建新对象。
常见的可变类型包括:
- 列表:
list
- 字典:
dict
- 集合:
set
- 字节数组:
bytearray
示例 2: 列表 list
my_list = [1, 2, 3]
print(f"初始列表: {my_list}, 内存地址: {id(my_list)}")# 尝试修改列表
my_list.append(4)print(f"修改后列表: {my_list}, 内存地址: {id(my_list)}")
输出:
初始列表: [1, 2, 3], 内存地址: 4510696320
修改后列表: [1, 2, 3, 4], 内存地址: 4510696320
内存地址完全没变!append
操作是在原始列表对象上直接进行的修改。my_list
这个标签自始至终都贴在同一个对象上。
四、一个常见的陷阱:当元组遇到列表
元组 tuple
是不可变的,对吧?这意味着我们不能增加或删除它的元素。但如果元组里包含了可变类型的对象(比如列表),情况就变得有趣了。
# 元组本身是不可变的
my_tuple = (1, 2, ['a', 'b'])print(f"初始元组: {my_tuple}, 内存地址: {id(my_tuple)}")# 尝试修改元组中的列表
my_tuple[2].append('c')print(f"修改后元组: {my_tuple}, 内存地址: {id(my_tuple)}")# 尝试直接修改元组元素(这会报错)
# my_tuple[0] = 99 # TypeError: 'tuple' object does not support item assignment
输出:
初始元组: (1, 2, ['a', 'b']), 内存地址: 4474840192
修改后元组: (1, 2, ['a', 'b', 'c']), 内存地址: 4474840192
元组的内存地址没变,但它里面的列表内容却实实在在地改变了。
结论:不可变性指的是对象本身的结构固定。对于元组来说,是它所包含的元素的"引用"不可变。它引用的那个列表还是那个列表(内存地址没变),但列表自身的内容是可以被修改的。
五、为什么这个区别如此重要?
理解可变与不可变,在实际编程中至关重要,尤其体现在以下几个方面:
1. 函数参数的传递
在 Python 中,函数参数传递的是对象的引用。
- 如果传递的是不可变对象,你在函数内部无法修改原始调用者的变量。
- 如果传递的是可变对象,你在函数内部的修改会直接影响到原始对象。
def process_data(immutable_str, mutable_list):immutable_str = "changed"mutable_list.append(99)print(f"函数内部: str='{immutable_str}', list={mutable_list}")s = "original"
l = [1, 2]process_data(s, l)print(f"函数外部: str='{s}', list={l}")
输出:
函数内部: str='changed', list=[1, 2, 99]
函数外部: str='original', list=[1, 2, 99]
看到结果了吗?字符串 s
没变,但列表 l
被永久地改变了。
2. 字典的键 (Dictionary Keys)
字典的键必须是不可变类型。
这是因为字典的查找效率极高,其内部依赖于对键进行哈希运算(hash()
)。哈希值要求在对象的生命周期内保持不变。
- 不可变对象的值固定,哈希值也固定。
- 可变对象的值可以变,如果允许它当键,它的哈希值也可能变,整个字典的结构就会崩溃。
my_dict = {}
my_dict["key"] = "value" # 字符串可以当键
my_dict[123] = "value" # 整数可以当键
my_dict[(1, 2)] = "value" # 元组可以当键# 尝试用列表当键
try:my_dict[[1, 2]] = "value"
except TypeError as e:print(e) # 输出: unhashable type: 'list'
3. 函数的默认参数陷阱
这是一个经典的面试题,也是新手最容易犯的错误:永远不要使用可变类型作为函数的默认参数。
def add_item(item, item_list=[]):item_list.append(item)return item_list# 第一次调用
print(add_item(1)) # 输出: [1]# 第二次调用
print(add_item(2)) # 输出: [1, 2] (你可能期望的是 [2])# 第三次调用
print(add_item(3)) # 输出: [1, 2, 3]
原因:函数的默认参数 item_list=[]
只在函数定义时被创建一次。后续所有不提供 item_list
参数的调用,都共享着同一个列表对象。
正确做法:
def add_item_fixed(item, item_list=None):if item_list is None:item_list = [] # 在函数体内创建新列表item_list.append(item)return item_list
六、进阶话题与扩展
1. 浅拷贝 vs. 深拷贝:copy
与 deepcopy
Python 标准库 copy
模块提供两种复制策略:
- 浅拷贝 (
copy.copy
):仅复制最外层容器,新容器内部仍引用原有子对象。 - 深拷贝 (
copy.deepcopy
):递归复制整棵对象图,确保任何层级的修改互不影响。
import copya = [1, [2, 3]]
b = copy.copy(a) # 浅拷贝
c = copy.deepcopy(a) # 深拷贝a[1].append(4)
print(b) # [1, [2, 3, 4]] —— 受影响
print(c) # [1, [2, 3]] —— 不受影响
2. +=
运算符:可变与不可变对象的差异
对于不可变对象(如 str
, tuple
),x += y
会创建 新对象;而对 list
等可变对象,+=
会就地修改。
s = "abc"
print(id(s))s += "d"
print(id(s)) # 地址变化,说明创建了新对象lst = [1, 2]
print(id(lst))lst += [3]
print(id(lst)) # 地址不变,说明原地修改
3. CPython 的对象缓存机制
为了性能,CPython 会缓存 小整数 (-5~256)
与部分短字符串。因此下面代码在 CPython 中可能打印 True
,并不代表语言层面对这些对象做了特殊对待,而是实现细节:
a = 100
b = 100
print(a is b) # True (CPython)
其它解释器(PyPy、Jython 等)不一定有相同表现,因此不要依赖该特性来做逻辑判断。
4. 并发中的可变对象
在多线程 / 协程场景下,共享可变对象必须采用同步原语,否则会产生竞态条件或数据损坏;不可变对象天然只读,可安全共享。
from threading import Lockcounter = 0
lock = Lock()def inc():global counterwith lock:counter += 1
5. 冻结(只读)数据结构
frozenset
:不可变集合,可作为字典键;types.MappingProxyType
:为字典提供只读视图;- 三方库
immutables.Map
:高性能、结构共享的持久化不可变映射。
6. 性能小贴士
- 频繁拼接字符串时,先收集到
list
再使用''.join(chunks)
,可避免创建大量中间对象; - 对可变对象使用就地修改操作(如
list.append
, 切片赋值)通常更节省内存、提升性能。
总结
特性 | 不可变类型 (Immutable) | 可变类型 (Mutable) |
---|---|---|
定义 | 创建后值不能被改变 | 创建后值可以被原地修改 |
示例 | int , str , tuple , frozenset | list , dict , set |
修改行为 | 创建新对象,变量指向新对象 | 在原对象上修改,变量指向不变 |
字典键 | 可以作为字典的键 | 不可以作为字典的键 |
函数传参 | 函数内修改不影响外部原变量 | 函数内修改会影响外部原变量 |
掌握 Python 的可变与不可变类型,是写出清晰、可预测且无 Bug 代码的基石。希望这篇文章能让你对这个概念有更深入的理解。