在 Python 中,“可变”描述的不是变量能否重新赋值,而是对象创建后,其内部状态能否发生变化。这个区别会直接影响函数参数、容器操作、字典键、对象共享以及自定义类的设计。理解对象身份、值和变量绑定之间的关系,比单纯背诵类型列表更重要。
变量保存的是对象引用
列表、字典和集合通常是可变对象;整数、字符串、元组通常被视为不可变对象。不过,变量本身没有“可变”或“不可变”的属性,它只是绑定到某个对象。
下面的程序可以直接运行:
numbers = [1, 2]
alias = numbers
print(numbers is alias) # True
numbers.append(3)
print(alias) # [1, 2, 3]
name = "Python"
old_name = name
name += " 3"
print(name) # Python 3
print(old_name) # Python
print(name is old_name) # False
append() 修改了原列表,因此 numbers 和 alias 都能看到变化。字符串不能原地修改,name += " 3" 实际上创建了新字符串,再让 name 指向它;old_name 仍然指向原对象。
判断行为时,可以问两个问题:
- 这次操作是在修改原对象,还是创建新对象?
- 是否还有其他变量指向同一个对象?
id() 和 is 可以帮助观察对象身份,但业务代码通常应使用 == 比较值。除 None 等单例外,不要用 is 代替值比较。
元组不可变,不代表里面的一切都不可变
元组不能替换、增加或删除元素,但它可以引用可变对象。因此,“元组不可变”准确地说,是元组保存的引用序列不能改变。
record = ("alice", ["python"])
record[1].append("sql")
print(record) # ('alice', ['python', 'sql'])
try:
record[0] = "bob"
except TypeError as exc:
print(type(exc).__name__, exc)
列表内容可以变化,但 record[0] 不能被重新赋值。这一点也会影响元组能否作为字典键:只有其所有成员都可哈希时,元组才可哈希。
valid = {(2025, 3): "release"}
print(valid[(2025, 3)])
try:
invalid = {("alice", ["python"]): "profile"}
except TypeError as exc:
print(type(exc).__name__, exc)
字典依靠键的哈希值定位数据。如果一个键在存入后发生变化,它的哈希和相等性语义可能失效。因此,列表和字典不能直接作为字典键,而只包含可哈希成员的元组通常可以。
函数参数中的共享修改
Python 调用函数时,会把对象引用绑定到形参。函数能否影响调用方看到的内容,取决于它是修改对象,还是只重新绑定局部变量。
def add_item(items, item):
items.append(item)
def add_item_copy(items, item):
return [*items, item]
def reset(items):
items = []
return items
original = ["a"]
add_item(original, "b")
print(original) # ['a', 'b']
updated = add_item_copy(original, "c")
print(original) # ['a', 'b']
print(updated) # ['a', 'b', 'c']
reset(original)
print(original) # ['a', 'b']
add_item() 修改共享列表,调用方会观察到变化。add_item_copy() 返回新列表,更适合需要可预测数据流的代码。reset() 只让局部变量指向新列表,并没有清空调用方的对象。
这也是不应使用可变默认参数的重要原因:默认值通常在函数定义时创建,并被后续调用重复使用。
def collect_bad(item, bucket=[]):
bucket.append(item)
return bucket
def collect(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(collect_bad("a")) # ['a']
print(collect_bad("b")) # ['a', 'b']
print(collect("a")) # ['a']
print(collect("b")) # ['b']
自定义类如何表达可变性
普通类的属性通常可以修改,因此实例默认表现为可变对象。需要值对象时,可以使用冻结的数据类明确表达“创建后不应修改”的约束。
from dataclasses import dataclass
@dataclass
class Cart:
items: list[str]
def add(self, item: str) -> None:
self.items.append(item)
@dataclass(frozen=True)
class Coordinate:
x: int
y: int
cart = Cart([])
cart.add("keyboard")
print(cart) # Cart(items=['keyboard'])
point = Coordinate(10, 20)
print(point)
try:
point.x = 99
except Exception as exc:
print(type(exc).__name__, exc)
frozen=True 会阻止属性重新赋值,但它不是递归冻结。如果冻结数据类包含列表,列表自身仍然可以被修改。要构造更可靠的不可变值对象,应优先使用整数、字符串、元组以及其他不可变成员。
此外,不要轻易让可变实例充当字典键。自定义 __eq__() 和 __hash__() 时尤其要保证:对象参与相等性判断的状态,在作为键期间不能改变。
选择时看语义,而不只是性能
可以按照以下原则检查代码:
- 数据需要原地累积、更新或删除时,使用列表、字典、集合或明确设计的可变类。
- 数据代表坐标、配置快照、复合键等固定值时,考虑元组或冻结数据类。
- 函数会修改传入对象时,通过命名、文档和返回值明确说明副作用。
- 需要隔离修改时,创建副本;同时注意浅复制不会复制内部嵌套对象。
- 字典键和集合成员必须可哈希,并且其哈希相关状态应保持稳定。
- 避免可变默认参数,使用
None并在函数内部创建新容器。
可变对象适合表达持续变化的状态,不可变对象适合表达稳定的值。真正需要掌握的并不是哪一类“更好”,而是一次操作究竟修改了哪个对象,以及这次修改会被哪些引用观察到。