CHAPTER 6 Object References, Mutability, and Recycling
1、Variables Are Not Boxes
1. 变量的本质与常见误解
- 传统观点: 许多人将变量理解为“盒子”,认为变量可以直接存储对象的内容。
- 更贴切的类比: 在 Python 中,变量不是“储物盒”,而是“便签”(sticky notes)。这些便签有名称,可以贴在对象上。
- 一个对象可以有多个变量标签指向它。
- 这更好地反映了变量背后的“引用”本质,尤其是在处理可变对象时(例如列表或字典)。
- 错误示例: 用 “盒子” 理解 Example 6-1 的行为会导致困惑,无法解释列表中元素变化如何通过不同变量反映。
2. 案例剖析
-
案例 6-1(变量引用同一列表对象):
>>> a = [1, 2, 3] >>> b = a >>> a.append(4) >>> b [1, 2, 3, 4]
分析与重点:
a = [1, 2, 3]
时,创建了一个列表对象,并将变量a
标签附加到该对象。b = a
时,并未复制列表内容,而是将b
作为新标签,指向与a
相同的对象。- 当
a.append(4)
修改列表时,变量b
也指向同一对象,因此能看到更新。 - 如果按照“盒子存储拷贝数据”的误解,则无法解释这个现象。
正确理解:
- 界定“引用”概念:变量是名称,背后指向同一内存中的对象。
- 类比:
a
和b
是贴在同一张列表上的两个便签。
补充示例:
>>> a = [1, 2, 3] >>> b = a >>> a = [4, 5, 6] >>> b [1, 2, 3]
- 在第 3 行,
a
被绑定到一个新列表[4, 5, 6]
,标签脱离了先前的列表。 b
仍然指向原始的[1, 2, 3]
。
3. Python 中“赋值”的实际含义
-
赋值的过程:
在 Python 中,赋值是 “绑定变量名到对象” 的过程:- 右侧计算或创建对象。
- 将左侧变量名绑定到该对象。
-
案例 6-2(右侧先创建对象):
class Gizmo:
def __init__(self):
print(f'Gizmo id: {id(self)}')
x = Gizmo()
print(x)
y = Gizmo() * 10
print(y)
# Traceback (most recent call last):
# File "/Users/test.py", line 7, in <module>
# y = Gizmo() * 10
# TypeError: unsupported operand type(s) for *: 'Gizmo' and 'int'
# Gizmo id: 4367449776
# <__main__.Gizmo object at 0x10451feb0>
# Gizmo id: 4367449056
分析与重点:
- Gizmo 实例创建过程显示了“右侧优先”:
- 当执行
y = Gizmo() * 10
时,Gizmo()
会先生成一个实例,打印其id
。 - 然后
* 10
操作失败抛出异常,导致y
未绑定到任何对象。
- 结果观察:
>>> dir()
['Gizmo', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'x']
- 在
dir()
中,y
不存在,因为赋值未完成。
补充说明: 这证明了“赋值总是从右到左”的规则。
4. 避免误解:变量与对象的关系
- 初学者常见误区:
- 误解: 变量就是对象本身。
- 事实: 对象存在于内存中,变量只是对该对象的一个引用。
- 重要提示:
- 赋值语句会将变量名与对象关联,而不是“复制”对象。
- 可变对象(如列表、字典)被多个变量引用时,修改原对象会同时影响其所有引用。
5. 补充练习与易错点
-
练习 1:变量名与对象绑定
>>> a = [1, 2] >>> b = a >>> a = a + [3] # 创建了新的对象 >>> b [1, 2]
- 解析: 运算
a + [3]
创建了一个新列表,a
被绑定到新对象,b
仍指向旧列表。
- 解析: 运算
-
练习 2:深浅拷贝
import copy >>> a = [1, 2, [3]] >>> b = copy.copy(a) # 浅拷贝 >>> a[2].append(4) >>> b [1, 2, [3, 4]] >>> c = copy.deepcopy(a) # 深拷贝 >>> a[2].append(5) >>> c [1, 2, [3, 4]]
- 解析:
- 浅拷贝复制了外层对象,但子对象仍然共享引用。
- 深拷贝复制了整个对象,包括其子对象的独立副本。
- 解析:
6. 总结:牢记几点关键原则
-
变量是便签,不是盒子。
- 思考:变量是粘附在对象上的标签,而不是内容的存放容器。
-
赋值是绑定,不是复制。
- 赋值语句只是改变变量标签对应的对象,而不是生成新对象(除非另有操作)。
-
右侧优先,先创建对象。
- 赋值时总是先计算右侧,再绑定左侧。
-
注意可变对象和引用行为。
- 修改可变对象时,所有引用它的变量都会反映更改。
2、Identity, Equality, and Aliases
概念核心
Python 中存在三个核心概念:对象身份 (Identity)、值相等 (Equality) 和 别名 (Alias)。理解这三者的区别和作用,有助于避免常见编程错误。本文通过实例深入解析这些概念。
1. 对象身份 (Identity) 与 is
操作符
对象的身份指的是某个对象在内存中的唯一标识。Python 中可以通过以下方式检查对象的身份:
is
操作符:用来判断两个对象是否是同一对象(具有相同身份)。id()
函数:返回对象的标识符,通常是其内存地址的整数表示。
示例 1:别名(同一对象,身份相同)
# 变量 charles 和 lewis 指向的是同一个对象
charles = {'name': 'Charles L. Dodgson', 'born': 1832}
lewis = charles
print(lewis is charles) # True,两个变量是同一对象的别名
print(id(charles), id(lewis)) # 相同的ID值
lewis['balance'] = 950
print(charles) # {'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950}
说明:
is
操作符表明lewis
和charles
引用的是同一个对象。- 更新
lewis
的内容同时影响charles
,因为它们是同一个对象的引用。
示例 2:不同对象但值相等
# alex 是 charles 的一个复制,但独立存在
alex = {'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950}
# 比较身份
print(alex is charles) # False,两者是完全不同的对象
print(alex == charles) # True,两者值相等
说明:
is
比较对象身份,而==
比较对象的值。- 虽然
alex
与charles
拥有相同的值(因为字典的__eq__
方法比较值),但它们不是同一对象。
2. 值相等 (==
) 与 eq
方法
在 Python 中,比较对象值使用 ==
操作符,本质上调用的是对象的 __eq__
方法。
- 值相等:检查两个对象的数据是否相同,而不关心它们的身份。
注意点:
- 对于自定义类,可以重载
__eq__
方法定义“值相等”的规则。 - 内建类型(如字典、列表)已定义适当的值相等比较规则。
结论:
- 如果仅关心数据内容是否相同,优先使用
==
。 - 如果需要比较两个变量是否引用同一对象,使用
is
。
3. 单例对象的身份比较
有时为了检查变量是否绑定到某个特定对象(如 None
),is
比较是首选操作。
示例 3:测试 None
x = None
print(x is None) # True,推荐:检查是否绑定到 None
print(x == None) # 虽然结果为 True,但不推荐
总结:
- 单例对象(如
None
和某些哨兵对象)应优先使用is
来检查是否相同。
示例 4:哨兵对象
哨兵对象是一种特殊的单例,用于区分特殊情况。
END_OF_DATA = object() # 创建一个全局唯一哨兵对象
def process_data(node):
if node is END_OF_DATA:
print("Reached the end of data")
4. 元组的不可变性与“相对”不变性
元组常被描述为不可变容器,但其不可变性体现在元组本身的结构,而非包含的对象的状态。
示例 5:元组包含可变对象
t1 = (1, 2, [30, 40])
t2 = (1, 2, [30, 40])
print(t1 == t2) # True,初始值相等
print(t1 is t2) # False,互为不同对象
t1[-1].append(99) # 修改了 t1 内部引用的列表
print(t1) # (1, 2, [30, 40, 99])
print(t1 == t2) # False,t1 和 t2 的值已不同
解析:
- 元组本身的结构不变(不可改变包含的引用),但引用的对象(如列表)可以被修改。
- 使用元组时需谨慎,若不希望引用的对象被更改,应确保其中的元素是不可变的。
提示:
若需要完全不变的数据容器,可考虑使用 frozenset
或其他方式。
5. 常见误区与注意事项
(1) “身份相等” 与 “值相等”的错误使用
在处理对象比较时,以下是常见的误解:
# 錯誤示例
if obj == None: # 不推荐
...
# 正确方式
if obj is None: # 推荐
...
(2) 深浅拷贝的困惑
当复制复杂对象时:
- 浅拷贝:复制对象但不复制子对象,它们仍共享相同的引用。
- 深拷贝:递归复制对象及其所有子对象,保证完全独立。
import copy
original = [1, 2, [3, 4]]
shallow_copy = copy.copy(original)
deep_copy = copy.deepcopy(original)
6. 总结常用规则
操作符或概念 | 用法 | 关键点 |
---|---|---|
is | 判断两个变量是否引用同一对象 | 比较对象身份,速度快 |
== | 判断两个变量的值是否相等 | 调用 __eq__ ,关注值内容 |
id() | 获取对象的身份标识 | 通常为内存地址,但可能随实现而不同 |
单例比较 | 对 None 及哨兵对象检查身份使用 is | 比较是否引用相同的全局唯一对象 |
元组的不可变性 | 元组自身不可变,但内部可变对象的值可能被改变 | 注意元组中包含可变对象的潜在风险 |
3、Copies Are Shallow by Default
一、浅拷贝:默认行为
定义:
- 浅拷贝只拷贝容器的最外层对象,内部的嵌套对象和原始容器共享引用。
浅拷贝的创建方式:
- 使用类型构造器(如
list()
):l1 = [3, [55, 44], (7, 8, 9)] l2 = list(l1) # 用 list() 进行拷贝
- 使用切片
[:]
:l2 = l1[:] # 切片操作创建浅拷贝
示例讲解:
l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1) # 创建浅拷贝
l1.append(100)
l1[1].remove(55)
print("l1:", l1)
print("l2:", l2)
l2[1] += [33, 22]
l2[2] += (10, 11)
print("l1:", l1)
print("l2:", l2)
输出:
l1: [3, [66, 44], (7, 8, 9), 100]
l2: [3, [66, 44], (7, 8, 9)]
l1: [3, [66, 44, 33, 22], (7, 8, 9), 100]
l2: [3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]
关键点总结:
-
共享引用的影响:
l1[1]
和l2[1]
同时指向内层列表[66, 55, 44]
。因此,对内层列表的修改会互相影响。l2[1] += [33, 22]
等价于l2[1].extend([33, 22])
,直接修改了列表,使l1[1]
同样变化。
-
不可变对象的行为:
l2[2] += (10, 11)
创建了一个新的元组(7, 8, 9, 10, 11)
并重新绑定到l2[2]
。l1[2]
保持原始元组(7, 8, 9)
,两者不再共享。
二、深拷贝:避免共享引用
定义:
- 深拷贝会递归地复制所有层级的对象,使得副本与原始对象完全独立。
深拷贝的创建方式:
- 使用
copy
模块中的deepcopy
方法:import copy deep_copy = copy.deepcopy(original_object)
示例讲解:
import copy
class Bus:
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers)
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
bus1 = Bus(['Alice', 'Bill', 'Claire', 'David'])
bus2 = copy.copy(bus1) # 浅拷贝
bus3 = copy.deepcopy(bus1) # 深拷贝
bus1.drop('Bill')
print("bus1 passengers:", bus1.passengers)
print("bus2 passengers:", bus2.passengers)
print("bus3 passengers:", bus3.passengers)
输出:
bus1 passengers: ['Alice', 'Claire', 'David']
bus2 passengers: ['Alice', 'Claire', 'David']
bus3 passengers: ['Alice', 'Bill', 'Claire', 'David']
关键点总结:
-
浅拷贝:
bus2
是bus1
的浅拷贝,二者共享passengers
列表的引用。- 修改
bus1
的passengers
会影响bus2
。
-
深拷贝:
bus3
是bus1
的深拷贝,二者拥有独立的passengers
列表。- 修改
bus1.passengers
不影响bus3.passengers
。
三、复杂对象中的循环引用
在复杂数据结构中(如包含循环引用的对象图),深拷贝也可以正确处理。
示例:
from copy import deepcopy
a = [10, 20]
b = [a, 30]
a.append(b) # a 中嵌套了 b,形成循环引用
c = deepcopy(a) # 深拷贝
print("a:", a)
print("c:", c)
输出:
a: [10, 20, [[...], 30]]
c: [10, 20, [[...], 30]]
说明:
deepcopy
能检测并正确处理循环引用。[...[...]]
表明该层嵌套对象引用的是同一个循环结构。
四、浅拷贝与深拷贝的适用场景
使用浅拷贝的场景:
- 数据结构主要包含不可变类型(如元组、字符串、数字等)。
- 数据只在顶层复制即可,无需递归复制内部对象。
使用深拷贝的场景:
- 数据结构包含嵌套的可变对象(如嵌套列表、字典等)。
- 希望互相独立,避免共享引用导致的意外修改。
五、易错点与注意事项
-
可变与不可变对象的区别:
- 浅拷贝的对象内部如果是不可变类型(如元组),即使嵌套较深也不会出问题。
- 如果是可变类型,修改深层对象会影响到原对象。
-
意外的共享引用:
- 使用浅拷贝时,要小心数据对象的嵌套和共享。
- 如果修改深层对象,请明确确保是否需要使用深拷贝。
-
循环引用:
deepcopy
可以正确处理循环引用,手动操作需要非常谨慎。
4、Function Parameters as References
1. Python 的参数传递方式:Call by Sharing
- Python 使用 call by sharing(共享调用)的方式来传递参数。
- 含义:函数的参数列表接收的是每个实际参数引用的副本,而非独立的值。因此,函数参数成为实际参数的别名。
- 影响:
- 如果参数是可变对象(如列表、字典等),函数可以改变对象内容。
- 如果参数是不可变对象(如整数、字符串、元组等),函数无法改变对象的标识(即不能替换原对象本身)。
示例:
def f(a, b):
a += b
return a
# 不可变对象:整数
x = 1
y = 2
result1 = f(x, y)
print(result1) # 输出: 3
print(x, y) # 输出: 1, 2(x 和 y 没有被改变)
# 可变对象:列表
a = [1, 2]
b = [3, 4]
result2 = f(a, b)
print(result2) # 输出: [1, 2, 3, 4]
print(a, b) # 输出: [1, 2, 3, 4], [3, 4](a 被修改,而 b 没有)
# 不可变对象:元组
t = (10, 20)
u = (30, 40)
result3 = f(t, u)
print(result3) # 输出: (10, 20, 30, 40)
print(t, u) # 输出: (10, 20), (30, 40)(t 和 u 没有被修改)
解读:
- 对不可变对象(如
x
、t
)的操作只是生成新对象,不会影响原始参数。 - 对可变对象(如
a
,即列表)的修改会反映到原始参数,因为参数是引用传递的。
补充示例:
想要避免修改可变对象,可以在函数内部创建对象副本:
def safe_modify_list(lst):
temp = lst[:] # 浅拷贝
temp.append(100)
return temp
original = [1, 2, 3]
modified = safe_modify_list(original)
print(original) # 输出: [1, 2, 3](未被修改)
print(modified) # 输出: [1, 2, 3, 100](返回新列表)
2. 可变对象作为默认参数:陷阱与问题
- 问题描述:
- 使用可变对象(如列表或字典)作为函数的默认参数,会导致某些意想不到的行为。
- 默认参数只在函数定义时计算一次,它是共享的,也即所有调用者的默认值会指向同一个可变对象。
- 例子可见于
HauntedBus
类:
class HauntedBus:
"""一个被“幽灵乘客”困扰的公交类"""
def __init__(self, passengers=[]): # 错误做法
self.passengers = passengers
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
# 使用例子:
bus1 = HauntedBus(['Alice', 'Bill'])
bus1.pick('Charlie')
bus1.drop('Alice')
print(bus1.passengers) # 输出: ['Bill', 'Charlie']
bus2 = HauntedBus()
bus2.pick('Carrie')
print(bus2.passengers) # 输出: ['Carrie']
bus3 = HauntedBus()
bus3.pick('Dave')
print(bus3.passengers) # 输出: ['Carrie', 'Dave'](问题点)
print(bus2.passengers) # 输出: ['Carrie', 'Dave'](共享问题)
问题分析:
- 当
passengers
参数为空时,多个HauntedBus
实例会共享同一个默认passengers
列表。 - 因此,操作一个实例(如
bus3
)的乘客列表时,会意外影响其他实例(如bus2
)。
解决方案:避免使用可变默认参数,改为使用 None
并手动初始化。
class SafeBus:
"""解决幽灵问题的安全公交类"""
def __init__(self, passengers=None):
if passengers is None:
self.passengers = [] # 创建独立的空列表
else:
self.passengers = list(passengers) # 使用副本初始化
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
# 使用例子:
bus4 = SafeBus()
bus4.pick('Eve')
bus5 = SafeBus()
bus5.pick('Frank')
print(bus4.passengers) # 输出: ['Eve']
print(bus5.passengers) # 输出: ['Frank']
总结:
- 避免在函数或方法中直接使用可变类型作为默认参数。
- 通常使用以下模式代替:
def func(arg=None): if arg is None: arg = [] # 或者更安全地初始化: arg = list(arg)
更详细的讲解“避免使用可变默认参数,改为使用 None 并手动初始化。”
在 Python 中,使用可变对象作为默认参数时,代码运行可能会出现意想不到的行为。这主要源于 Python 默认参数的实现方式。通过这份讲解,我会用代码、图解和示例从根本上分析问题,并给出清晰的解决方案。
一、为什么默认参数共享可变对象?
默认参数是一个在函数定义时绑定的值,在运行期间如果没有显式提供该参数,函数会直接使用这个默认值。对于不可变对象(如字符串、数字、元组),这种行为不会有问题。但对于可变对象(如列表或字典),可能会引发问题。
Python 默认参数计算的关键点
- 默认参数只在函数定义时计算一次,并保存在函数对象内部。
- 这意味着即使多次调用该函数,默认参数的值也不会重新生成。
- 如果默认参数是一个可变对象(如列表、字典),这种行为的结果就是:所有函数调用都共享同一个默认参数的值。
图解代码——默认参数被共享
def add_item_to_list(item, item_list=[]): # item_list 默认值是一个空列表
item_list.append(item)
return item_list
# 调用
list1 = add_item_to_list(1) # case 1
list2 = add_item_to_list(2) # case 2
print(list1) # 输出:[1, 2]
print(list2) # 输出:[1, 2] (意外结果)
内存结构图解
- 函数定义阶段:
- 函数被定义时,
item_list
默认绑定到一个空列表对象,列表的内存地址在__defaults__
中保存:
add_item_to_list.__defaults__ --> [<空列表的地址>]
- 函数被定义时,
-
函数运行阶段(case 1):
- 调用
add_item_to_list(1)
,item_list
使用默认值。 - 执行
append(1)
后,内存中的列表变为[1]
。
内存情况如下:
add_item_to_list.__defaults__ --> [1]
- 调用
-
函数运行阶段(case 2):
- 再次调用
add_item_to_list(2)
,item_list
仍使用默认的共享列表。 - 执行
append(2)
后,内存中的列表被修改为[1, 2]
。
内存情况如下:
add_item_to_list.__defaults__ --> [1, 2]
- 再次调用
核心结论
由于默认参数的值(item_list
)是可变对象,而且它在内存中只计算一次并被所有调用共享,因此修改它会影响所有调用。
二、如何避免共享的可变默认参数问题?
为了解决上述问题,我们需要保证函数每次调用时都能够有独立的可变对象。通常的做法是:
解决方案:使用 None
标记,再手动初始化
我们通常将默认值设为 None
(不可变的占位符),在函数体中再检查 None
的情况并初始化一个新的可变对象。
改进后的代码:
def add_item_to_list(item, item_list=None): # 使用 None 作为标记
if item_list is None: # 如果未传递参数,创建一个新列表
item_list = []
item_list.append(item)
return item_list
# 调用
list1 = add_item_to_list(1) # case 1
list2 = add_item_to_list(2) # case 2
print(list1) # 输出:[1]
print(list2) # 输出:[2]
改进后内存结构图解
-
函数定义阶段:
- 默认值
item_list=None
是一个不可变对象,因此没有共享的隐患:
add_item_to_list.__defaults__ --> (None,)
- 默认值
-
函数运行阶段(case 1):
- 调用
add_item_to_list(1)
,item_list=None
,新建列表[]
。 - 执行
append(1)
后,列表变为[1]
。
内存情况:
case1_list --> [1] add_item_to_list.__defaults__ --> (None,)
- 调用
-
函数运行阶段(case 2):
- 再次调用
add_item_to_list(2)
,item_list=None
,新建另一个独立列表[]
。 - 执行
append(2)
后,列表变为[2]
。
内存情况:
case1_list --> [1] case2_list --> [2] add_item_to_list.__defaults__ --> (None,)
- 再次调用
三、应用到类中的问题:HauntedBus
示例
问题代码回顾
class HauntedBus:
def __init__(self, passengers=[]): # 错误写法
self.passengers = passengers
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
bus1 = HauntedBus(['Alice', 'Bill'])
bus2 = HauntedBus()
bus3 = HauntedBus()
bus2.pick('Carrie')
bus3.pick('Dave')
print(bus2.passengers) # ['Carrie', 'Dave'] (共享默认列表导致问题)
print(bus3.passengers) # ['Carrie', 'Dave'] (共享默认列表导致问题)
内存模型和问题图解
-
__init__
方法定义时:- 默认值
passengers=[]
被绑定到函数上:
HauntedBus.__init__.__defaults__ --> [<空列表>]
- 默认值
-
bus2
和bus3
都使用默认列表:- 当调用
HauntedBus()
时,passengers
参数被赋值为共享的默认列表。 bus2.passengers
和bus3.passengers
都指向这个共享的列表对象。
内存情况如下:
bus2.passengers --> [<共享列表>] bus3.passengers --> [<共享列表>]
- 当调用
-
对
bus2.passengers
或bus3.passengers
的修改(如append
),会影响实际存储的同一个共享列表,从而导致混乱。
解决方案:使用 None
并手动初始化
修改后的代码避免了共享默认可变对象的问题:
class SafeBus:
def __init__(self, passengers=None): # 使用 None 作为默认值
if passengers is None: # 如果未传入,创建新列表
self.passengers = []
else:
self.passengers = list(passengers) # 创建传入列表的副本
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
# 示例
bus4 = SafeBus() # 新建独立列表
bus5 = SafeBus() # 新建另一个独立列表
bus4.pick('Eve') # bus4 的乘客修改不会影响 bus5
bus5.pick('Frank')
print(bus4.passengers) # ['Eve']
print(bus5.passengers) # ['Frank']
改进后内存模型图
-
__init__
方法定义时:SafeBus.__init__.__defaults__ --> (None,)
-
每个实例初始化时都会创建独立的列表对象:
bus4.passengers --> [Eve] bus5.passengers --> [Frank]
-
修改
bus4.passengers.append('Eve')
时,列表[Eve]
是bus4
独有的,互不干扰。
总结
- 默认参数值是一个在函数定义时绑定的值,如果是可变对象,那么它在所有调用之间是共享的。
- 这种行为的根本原因在于 Python 性能优化,避免每次调用都重新计算默认参数。
- 解决方案:用不可变对象
None
初始化默认参数,函数内部手动创建新的可变对象(如列表)。 - 在面向对象编程中也适用这一原则,避免初始化时使用共享的可变对象。
3. 参数副作用与防御性编程
- 问题描述:函数是否应修改传入的可变参数?这应取决于契约约定与实际需求。
- 如果函数没有明确意图修改传入参数,应避免直接操作参数,采用副本操作会更安全。
示例 1: 错误的例子 – TwilightBus
class TwilightBus:
"""会影响原始数据的'神秘公交'"""
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = passengers # 指向相同对象
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
# 使用:
basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']
bus = TwilightBus(basketball_team)
bus.drop('Tina')
bus.drop('Pat')
print(basketball_team) # 输出: ['Sue', 'Maya', 'Diana'](原始队伍被修改)
分析:TwilightBus
修改了传入的 basketball_team
列表,违反了最小惊讶原则(Principle of Least Astonishment)。这一设计会混淆用户对参数传递方式的预期。
示例 2: 修复方式
我们可以通过对参数进行拷贝,创建独立的数据结构,避免与外部数据相互影响:
class SafeTwilightBus:
"""与原始数据隔离的安全公交"""
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers) # 拷贝参数
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
# 使用:
basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']
bus = SafeTwilightBus(basketball_team)
bus.drop('Tina')
bus.drop('Pat')
print(basketball_team) # 输出: ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']
关键总结:
-
Call by Sharing 的本质:
- 可变对象会被修改(如列表、字典),不可变对象永远不会被原地修改。
- 若不希望函数修改参数,可主动复制参数。
-
可变默认参数的陷阱:
- 永远不要使用可变对象作为默认参数。
- 使用
None
和逻辑初始化代替。
-
防御性编程原则:
- 不要直接操作传入对象,应根据需要拷贝参数。
- 优先考虑用户的预期行为,避免不必要的副作用。
最佳实践:拷贝传入对象需要消耗资源,但这通常比调试隐蔽的 Bug 更容易接受。除非明确需要,否则尽量避免修改外部传入的数据。
5、del and Garbage Collection
核心概念
-
del
是一个语句,不是函数- 使用形式:
del x
,而del(x)
只是语法允许的特例(x
与(x)
通常等价)。 - 它删除的是变量的引用,而不是直接删除对象。
- 使用形式:
-
del
删除引用,不是直接删除对象- 在 Python 中,对象会有一个引用计数,记录它被多少个变量所引用。当一个对象的引用计数变为 0 时,垃圾回收器会销毁它。
del
的作用是减少对象的引用。当del
导致某对象的所有引用被删除,它就可能被垃圾回收。- 反之,如果还有其他变量引用这个对象,即使使用
del
,该对象仍然存在。
-
Python 的垃圾回收机制
- 默认实现(CPython)中,使用引用计数算法:
- 每个对象都有一个引用计数器,当引用计数减为 0 时,对象立即销毁。
- 还有其他机制(如循环检测),可以回收即使引用计数大于 0 的“孤立的循环”对象。
- 注意:不同的 Python 实现(如 PyPy)有可能不依赖引用计数,这意味着
del
不会立即触发垃圾回收。
- 默认实现(CPython)中,使用引用计数算法:
-
弱引用
- 弱引用(
weakref
)不会增加对象的引用计数,因此即使存在弱引用,目标对象也能被垃圾回收。 - 这一特性常用于缓存等场景。
- 弱引用(
重点示例
示例 1:del
删除引用,非删除对象
a = [1, 2]
b = a # b 引用了与 a 相同的对象
del a # 删除 a 引用
print(b) # 输出 [1, 2],因为 b 仍然引用该对象
b = [3] # 给 b 赋值新对象,[1, 2] 对象不再被引用,可能被垃圾回收
解析
- 起始对象
[1, 2]
被变量a
和b
引用。 del a
只是删除了a
对[1, 2]
的引用,b
仍然指向它,因此[1, 2]
仍然存在。- 把
b
绑定到新的对象[3]
后,原来的[1, 2]
不再有引用,可被垃圾回收。
示例 2:weakref.finalize
监听对象销毁
import weakref
s1 = {1, 2, 3}
s2 = s1 # 引用同一个对象
def bye():
print('...like tears in the rain.')
ender = weakref.finalize(s1, bye) # 注册回调函数
print(ender.alive) # True,表示对象仍然存活
del s1 # 删除 s1 引用,s2 仍然引用对象
print(ender.alive) # True,对象仍然存活
s2 = 'spam' # 最后一处引用解除
# 输出:...like tears in the rain.
print(ender.alive) # False,对象被销毁
解析
- 用
weakref.finalize
注册回调函数bye
,当对象被垃圾回收时执行。 del s1
仅删除s1
引用,s2
仍然保持对目标对象的引用,因此对象未被销毁。s2 = 'spam'
解除最后一个引用,目标对象{1, 2, 3}
被销毁,触发回调函数。回调函数被调用后,ender.alive
变为False
。
难点与易错点
-
误解
del
的作用- 易错点:认为
del
的作用是直接删除对象。 - 正确理解:
del
是删除变量的引用,只有当对象没有其他引用时,它才可能被垃圾回收。
补充示例:多变量绑定
x = [10, 20] y = x del x print(y) # 正常输出 [10, 20]
即使删除了
x
,对象仍然存活,因为y
保有引用。 - 易错点:认为
-
错误实现
__del__
方法- 易错点:不当实现
__del__
方法,导致程序易出错或资源未能正确释放。 - 注意:除非必要,否则尽量避免显式编写
__del__
方法。现代 Python 通常使用上下文管理器(with
语句)更安全地管理资源。
- 易错点:不当实现
重要提醒
-
循环引用
- 如果两个或多个对象在互相引用,即使外部引用有所减少,它们可能无法在简单的引用计数下被销毁。Python 的垃圾回收器能处理这种情况。
补充示例
class Node: def __init__(self, value): self.value = value self.next = None a = Node(1) b = Node(2) a.next = b b.next = a del a del b # a 和 b 形成了循环引用,仍然会被垃圾回收器处理
-
关于其他实现
- 例如,PyPy 的垃圾回收器不依赖引用计数,而是使用追踪(tracing)算法,因此
__del__
方法行为可能与 CPython 不同。
- 例如,PyPy 的垃圾回收器不依赖引用计数,而是使用追踪(tracing)算法,因此
总结
del
的主要作用是删除变量对对象的引用,而不是直接销毁对象。- 对象的销毁由垃圾回收器决定,通常在对象的引用计数降为零时发生。
- 弱引用允许对象被垃圾回收,同时对其存储“弱的”访问路径,是一些特定场景下的利器。
- 理解引用和垃圾回收机制有助于编写更内存高效的代码,避免内存泄漏和意外行为。
(这里弱引用就不展开讲述,因为在我工程开发中很少碰到)
6、Tricks Python Plays with Immutables
一、什么是不可变对象?
在Python中,不可变对象是一类内容一旦创建就不能被修改的对象,包括:
tuple
(元组)str
(字符串)frozenset
int
、float
(数字类型)
核心特点:
- 不可变性:内容固定,无法通过改变内部值来修改对象。
- 一些操作会“看似”创建新对象,但实际上可能返回的是对同一对象的引用。
二、操作不可变对象时的“奇妙现象”
1. 元组的切片和构造
- 切片操作:
t[:]
- 重新构造:
tuple(t)
对于一个元组tuple
,切片[:]
或使用构造函数tuple()
时,Python并不会创建新的元组对象,而是返回同一个对象的引用。
示例 6-17:元组引用
>>> t1 = (1, 2, 3) # 创建一个元组
>>> t2 = tuple(t1) # 用 tuple() 创建新的元组
>>> t2 is t1 # 检查是否是同一个对象
True
>>> t3 = t1[:] # 对元组进行切片
>>> t3 is t1 # 检查是否是同一个对象
True
为什么会这样?
Python认为如果元组内容相同,且元组不可变,则没有必要创建一个新的对象,直接返回引用原对象可以节省内存。
🔄 注意与易错点:
如果是不可变对象,[:]
切片返回的是相同对象;但是对于可变对象(例如列表),切片[:]
会创建一个内容相同但独立的新对象。
例如:
# 可变对象的例子
>>> lst1 = [1, 2, 3]
>>> lst2 = lst1[:]
>>> lst2 is lst1 # 切片会创建新对象
False
2. String(字符串)共享机制:Interning
Python对不可变的字符串使用了一种优化技术,称为字符串驻留机制(Interning)。这种技术会将相同内容的字符串存储在一个内存中共享。
示例 6-18:字符串共享
>>> s1 = 'ABC' # 创建一个字符串
>>> s2 = 'ABC' # 再创建一个相同内容的字符串
>>> s2 is s1 # 检查是否是同一对象
True
注意:
- 这种字符串共享主要用于提升性能。
- 并非所有字符串都参与共享,具体行为是解释器(CPython)实现的细节,没有文档说明。
- 建议:永远不要依赖
is
检查字符串是否相等,应该使用==
,如:s1 == s2
。
🔄 引申点:整数的共享机制
类似字符串,Python也会对 小整数(通常是-5到256之间的数字) 进行共享。例如:
>>> a = 100
>>> b = 100
>>> a is b # 小整数共享
True
>>> x = 1000
>>> y = 1000
>>> x is y # 大整数不一定共享
False
易错提醒:
虽然is
比较适用于判断两个变量是否引用同一对象,但对于字符串和数字,请优先用==
来判断内容是否相等!
3. FrozenSet的“假复制”
对于frozenset(不可变集合)而言,调用.copy()
方法看似会创建一个新对象,但实际上Python会“撒个谎”,让你以为这是一个新的对象,实际返回的是对同一对象的引用。
示例:
fs1 = frozenset([1, 2, 3])
fs2 = fs1.copy() # 看似复制,但返回原对象
print(fs2 is fs1)
# 普通set
s1 = {1, 2, 3}
s2 = s1.copy()
print(s2 is s1)
# True
# False
为什么这样设计?
因为frozenset不可变,其内容永远不变,因此没必要真正创建新的对象。这样设计可以提升性能,节省内存。
三、核心建议与总结
-
永远用
==
比较内容,避免用is
比较字符串或数字的值。is
用于检查对象是否是同一个引用。==
用于检查对象内容是否相等。
-
理解Python对不可变对象的优化设计。
- 切片
[:]
、构造tuple()
不会创建新对象。 - 字符串和小整数共享机制(interning)提升效率。
- 切片
-
不要依赖解释器的实现细节。
- 如:字符串和整数的共享行为是CPython优化的一部分,但其他Python实现或未来版本可能不会这样。
-
frozenset.copy()只返回同一个对象的引用,这对用户没有任何实际影响。
多理解一个维度:为何“优化”是合理的?
Python通过这些技巧对不可变对象进行共享或优化,并不会对代码功能产生影响,目的只是为了在不改变行为的前提下节省内存和提升性能。
例如:
# 对用户来说,行为一致
>>> t1 = (1, 2, 3)
>>> t2 = t1[:]
>>> print(t1 == t2) # True,内容相同
>>> print(t1 is t2) # True,同一对象
用户不需要知道背后的优化细节,只要保证最终结果符合预期即可。
四、总结表:易混淆操作和行为
类型 | 操作 | 是否创建新对象 | 备注 |
---|---|---|---|
tuple | [:] | 否 | 引用同一对象 |
tuple | tuple(obj) | 否 | 引用同一对象 |
list | [:] | 是 | 生成新对象,内容独立 |
str | 字符串驻留机制 | 否(可能共享) | 同内容字符串可能共享 |
int | 小整数优化 | 否(可能共享) | -5到256之间的整数可能共享 |
frozenset | .copy() | 否 | 返回原对象引用 |
7、Chapter Summary
每个Python对象都有一个身份、一个类型和一个值。只有对象的值会随时间变化。实际上,如果两个变量引用的是具有相等值的不可变对象(a == b
为True
),它们是引用了副本还是作为别名引用同一个对象通常并不重要,因为不可变对象的值不会改变,但有一个例外。这个例外就是像元组这样的不可变集合:如果一个不可变集合包含对可变项的引用,那么当可变项的值发生变化时,该不可变集合的值实际上也会改变。在实际应用中,这种情况并不常见。在不可变集合中,永远不会改变的是其中对象的身份。frozenset
(不可变集合)类不会出现这个问题,因为它只能包含可哈希的元素,而根据定义,可哈希对象的值永远不能改变。
在Python编程中,变量持有引用这一事实会产生许多实际影响:
- 简单赋值不会创建副本。
- 对于
+=
或*=
这样的增强赋值操作,如果左手边的变量绑定到一个不可变对象,会创建新对象;但如果是可变对象,则会就地修改。 - 给现有变量赋新值不会改变之前绑定的对象。这被称为重新绑定:变量现在绑定到了不同的对象。如果该变量是对先前对象的最后一个引用,那么先前对象将被垃圾回收。
【章节总结 223(此处“223”可能是页码,按原文保留)】 - 函数参数是作为别名传递的,这意味着函数可以修改接收到的任何可变对象参数。除了进行局部复制或使用不可变对象(例如传递元组而不是列表)之外,无法阻止这种情况。
- 将可变对象用作函数参数的默认值是危险的,因为如果参数在原地被修改,默认值也会改变,进而影响到未来所有依赖该默认值的调用。
在CPython中,当对象的引用计数达到零时,对象就会被丢弃。如果对象形成了循环引用且没有外部引用,也可能会被丢弃。
在某些情况下,持有一个不会使对象保持存活的对象引用可能会很有用。例如,一个类想要跟踪其所有当前实例。这可以通过弱引用来实现,弱引用是一种底层机制,更实用的集合类WeakValueDictionary
、WeakKeyDictionary
、WeakSet
,以及weakref
模块中的finalize
函数都基于此机制。更多相关内容,请访问fluentpython.com上的 “弱引用” 一文。