当前位置: 首页 > article >正文

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]
    

    分析与重点:

    1. a = [1, 2, 3] 时,创建了一个列表对象,并将变量 a 标签附加到该对象。
    2. b = a 时,并未复制列表内容,而是将 b 作为新标签,指向与 a 相同的对象。
    3. a.append(4) 修改列表时,变量 b 也指向同一对象,因此能看到更新。
    4. 如果按照“盒子存储拷贝数据”的误解,则无法解释这个现象。

    正确理解:

    • 界定“引用”概念:变量是名称,背后指向同一内存中的对象。
    • 类比:ab 是贴在同一张列表上的两个便签。

    补充示例:

    >>> 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 中,赋值是 “绑定变量名到对象” 的过程:

    1. 右侧计算或创建对象。
    2. 左侧变量名绑定到该对象。
  • 案例 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

分析与重点:

  1. Gizmo 实例创建过程显示了“右侧优先”:
  • 当执行 y = Gizmo() * 10 时,Gizmo() 会先生成一个实例,打印其 id
  • 然后 * 10 操作失败抛出异常,导致 y 未绑定到任何对象。
  1. 结果观察:
>>> 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. 总结:牢记几点关键原则

  1. 变量是便签,不是盒子。

    • 思考:变量是粘附在对象上的标签,而不是内容的存放容器。
  2. 赋值是绑定,不是复制。

    • 赋值语句只是改变变量标签对应的对象,而不是生成新对象(除非另有操作)。
  3. 右侧优先,先创建对象。

    • 赋值时总是先计算右侧,再绑定左侧。
  4. 注意可变对象和引用行为。

    • 修改可变对象时,所有引用它的变量都会反映更改。

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 操作符表明 lewischarles 引用的是同一个对象。
  • 更新 lewis 的内容同时影响 charles,因为它们是同一个对象的引用。

示例 2:不同对象但值相等

# alex 是 charles 的一个复制,但独立存在
alex = {'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950}

# 比较身份
print(alex is charles)  # False,两者是完全不同的对象
print(alex == charles)  # True,两者值相等

在这里插入图片描述

说明

  • is 比较对象身份,而 == 比较对象的值。
  • 虽然 alexcharles 拥有相同的值(因为字典的 __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

一、浅拷贝:默认行为

定义

  • 浅拷贝只拷贝容器的最外层对象,内部的嵌套对象和原始容器共享引用。

浅拷贝的创建方式:

  1. 使用类型构造器(如 list()):
    l1 = [3, [55, 44], (7, 8, 9)]
    l2 = list(l1)  # 用 list() 进行拷贝
    
  2. 使用切片 [:]
    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)]

关键点总结:

  1. 共享引用的影响

    • l1[1]l2[1] 同时指向内层列表 [66, 55, 44]。因此,对内层列表的修改会互相影响。
    • l2[1] += [33, 22] 等价于 l2[1].extend([33, 22]),直接修改了列表,使 l1[1] 同样变化。
  2. 不可变对象的行为

    • l2[2] += (10, 11) 创建了一个新的元组 (7, 8, 9, 10, 11) 并重新绑定到 l2[2]l1[2] 保持原始元组 (7, 8, 9),两者不再共享。

在这里插入图片描述
在这里插入图片描述


二、深拷贝:避免共享引用

定义

  • 深拷贝会递归地复制所有层级的对象,使得副本与原始对象完全独立。

深拷贝的创建方式:

  1. 使用 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']
关键点总结:
  1. 浅拷贝

    • bus2bus1 的浅拷贝,二者共享 passengers 列表的引用。
    • 修改 bus1passengers 会影响 bus2
  2. 深拷贝

    • bus3bus1 的深拷贝,二者拥有独立的 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 能检测并正确处理循环引用。
  • [...[...]] 表明该层嵌套对象引用的是同一个循环结构。

四、浅拷贝与深拷贝的适用场景

使用浅拷贝的场景:

  1. 数据结构主要包含不可变类型(如元组、字符串、数字等)。
  2. 数据只在顶层复制即可,无需递归复制内部对象。

使用深拷贝的场景:

  1. 数据结构包含嵌套的可变对象(如嵌套列表、字典等)。
  2. 希望互相独立,避免共享引用导致的意外修改。

五、易错点与注意事项

  1. 可变与不可变对象的区别

    • 浅拷贝的对象内部如果是不可变类型(如元组),即使嵌套较深也不会出问题。
    • 如果是可变类型,修改深层对象会影响到原对象。
  2. 意外的共享引用

    • 使用浅拷贝时,要小心数据对象的嵌套和共享。
    • 如果修改深层对象,请明确确保是否需要使用深拷贝。
  3. 循环引用

    • 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 没有被修改)

解读

  • 对不可变对象(如 xt)的操作只是生成新对象,不会影响原始参数。
  • 对可变对象(如 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 默认参数计算的关键点

  1. 默认参数只在函数定义时计算一次,并保存在函数对象内部。
    • 这意味着即使多次调用该函数,默认参数的值也不会重新生成。
  2. 如果默认参数是一个可变对象(如列表、字典),这种行为的结果就是:所有函数调用都共享同一个默认参数的值。

图解代码——默认参数被共享

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]  (意外结果)

内存结构图解

  1. 函数定义阶段:
    • 函数被定义时,item_list 默认绑定到一个空列表对象,列表的内存地址在 __defaults__ 中保存:
    add_item_to_list.__defaults__ --> [<空列表的地址>]
    

  1. 函数运行阶段(case 1):

    • 调用 add_item_to_list(1)item_list 使用默认值。
    • 执行 append(1) 后,内存中的列表变为 [1]

    内存情况如下:

    add_item_to_list.__defaults__ --> [1]
    

  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]

改进后内存结构图解

  1. 函数定义阶段:

    • 默认值 item_list=None 是一个不可变对象,因此没有共享的隐患:
    add_item_to_list.__defaults__ --> (None,)
    
  2. 函数运行阶段(case 1):

    • 调用 add_item_to_list(1)item_list=None,新建列表 []
    • 执行 append(1) 后,列表变为 [1]

    内存情况:

    case1_list --> [1]
    add_item_to_list.__defaults__ --> (None,)
    

  1. 函数运行阶段(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']  (共享默认列表导致问题)

内存模型和问题图解

  1. __init__ 方法定义时:

    • 默认值 passengers=[] 被绑定到函数上:
    HauntedBus.__init__.__defaults__ --> [<空列表>]
    
  2. bus2bus3 都使用默认列表:

    • 当调用 HauntedBus() 时,passengers 参数被赋值为共享的默认列表。
    • bus2.passengersbus3.passengers 都指向这个共享的列表对象。

    内存情况如下:

    bus2.passengers --> [<共享列表>]
    bus3.passengers --> [<共享列表>]
    
  3. bus2.passengersbus3.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']

改进后内存模型图

  1. __init__ 方法定义时:

    SafeBus.__init__.__defaults__ --> (None,)
    
  2. 每个实例初始化时都会创建独立的列表对象:

    bus4.passengers --> [Eve]
    bus5.passengers --> [Frank]
    
  3. 修改 bus4.passengers.append('Eve') 时,列表 [Eve]bus4 独有的,互不干扰。


总结

  1. 默认参数值是一个在函数定义时绑定的值,如果是可变对象,那么它在所有调用之间是共享的。
  2. 这种行为的根本原因在于 Python 性能优化,避免每次调用都重新计算默认参数。
  3. 解决方案:用不可变对象 None 初始化默认参数,函数内部手动创建新的可变对象(如列表)。
  4. 在面向对象编程中也适用这一原则,避免初始化时使用共享的可变对象。

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']

关键总结:

  1. Call by Sharing 的本质

    • 可变对象会被修改(如列表、字典),不可变对象永远不会被原地修改。
    • 若不希望函数修改参数,可主动复制参数。
  2. 可变默认参数的陷阱

    • 永远不要使用可变对象作为默认参数。
    • 使用 None 和逻辑初始化代替。
  3. 防御性编程原则

    • 不要直接操作传入对象,应根据需要拷贝参数。
    • 优先考虑用户的预期行为,避免不必要的副作用。

最佳实践:拷贝传入对象需要消耗资源,但这通常比调试隐蔽的 Bug 更容易接受。除非明确需要,否则尽量避免修改外部传入的数据。

5、del and Garbage Collection

核心概念

  1. del 是一个语句,不是函数

    • 使用形式:del x,而 del(x) 只是语法允许的特例(x(x) 通常等价)。
    • 它删除的是变量的引用,而不是直接删除对象。
  2. del 删除引用,不是直接删除对象

    • 在 Python 中,对象会有一个引用计数,记录它被多少个变量所引用。当一个对象的引用计数变为 0 时,垃圾回收器会销毁它。
    • del 的作用是减少对象的引用。当 del 导致某对象的所有引用被删除,它就可能被垃圾回收。
    • 反之,如果还有其他变量引用这个对象,即使使用 del,该对象仍然存在。
  3. Python 的垃圾回收机制

    • 默认实现(CPython)中,使用引用计数算法:
      • 每个对象都有一个引用计数器,当引用计数减为 0 时,对象立即销毁。
    • 还有其他机制(如循环检测),可以回收即使引用计数大于 0 的“孤立的循环”对象。
    • 注意:不同的 Python 实现(如 PyPy)有可能不依赖引用计数,这意味着 del 不会立即触发垃圾回收。
  4. 弱引用

    • 弱引用(weakref)不会增加对象的引用计数,因此即使存在弱引用,目标对象也能被垃圾回收。
    • 这一特性常用于缓存等场景。

重点示例

示例 1:del 删除引用,非删除对象

a = [1, 2]
b = a  # b 引用了与 a 相同的对象
del a  # 删除 a 引用
print(b)  # 输出 [1, 2],因为 b 仍然引用该对象
b = [3]  # 给 b 赋值新对象,[1, 2] 对象不再被引用,可能被垃圾回收

解析

  1. 起始对象 [1, 2] 被变量 ab 引用。
  2. del a 只是删除了 a[1, 2] 的引用,b 仍然指向它,因此 [1, 2] 仍然存在。
  3. 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,对象被销毁

解析

  1. weakref.finalize 注册回调函数 bye,当对象被垃圾回收时执行。
  2. del s1 仅删除 s1 引用,s2 仍然保持对目标对象的引用,因此对象未被销毁。
  3. s2 = 'spam' 解除最后一个引用,目标对象 {1, 2, 3} 被销毁,触发回调函数。回调函数被调用后,ender.alive 变为 False

难点与易错点

  1. 误解 del 的作用

    • 易错点:认为 del 的作用是直接删除对象。
    • 正确理解:del 是删除变量的引用,只有当对象没有其他引用时,它才可能被垃圾回收。

    补充示例:多变量绑定

    x = [10, 20]
    y = x
    del x
    print(y)  # 正常输出 [10, 20]
    

    即使删除了 x,对象仍然存活,因为 y 保有引用。

  2. 错误实现 __del__ 方法

    • 易错点:不当实现 __del__ 方法,导致程序易出错或资源未能正确释放。
    • 注意:除非必要,否则尽量避免显式编写 __del__ 方法。现代 Python 通常使用上下文管理器(with 语句)更安全地管理资源。

重要提醒

  1. 循环引用

    • 如果两个或多个对象在互相引用,即使外部引用有所减少,它们可能无法在简单的引用计数下被销毁。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 形成了循环引用,仍然会被垃圾回收器处理
    
  2. 关于其他实现

    • 例如,PyPy 的垃圾回收器不依赖引用计数,而是使用追踪(tracing)算法,因此 __del__ 方法行为可能与 CPython 不同。

总结

  • del 的主要作用是删除变量对对象的引用,而不是直接销毁对象。
  • 对象的销毁由垃圾回收器决定,通常在对象的引用计数降为零时发生。
  • 弱引用允许对象被垃圾回收,同时对其存储“弱的”访问路径,是一些特定场景下的利器。
  • 理解引用和垃圾回收机制有助于编写更内存高效的代码,避免内存泄漏和意外行为。

(这里弱引用就不展开讲述,因为在我工程开发中很少碰到)

6、Tricks Python Plays with Immutables

一、什么是不可变对象?

在Python中,不可变对象是一类内容一旦创建就不能被修改的对象,包括:

  • tuple(元组)
  • str(字符串)
  • frozenset
  • intfloat(数字类型)
核心特点:
  • 不可变性:内容固定,无法通过改变内部值来修改对象。
  • 一些操作会“看似”创建新对象,但实际上可能返回的是对同一对象的引用。

二、操作不可变对象时的“奇妙现象”

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不可变,其内容永远不变,因此没必要真正创建新的对象。这样设计可以提升性能,节省内存


三、核心建议与总结

  1. 永远用==比较内容,避免用is比较字符串或数字的值

    • is用于检查对象是否是同一个引用。
    • ==用于检查对象内容是否相等。
  2. 理解Python对不可变对象的优化设计

    • 切片[:]、构造tuple()不会创建新对象
    • 字符串和小整数共享机制(interning)提升效率
  3. 不要依赖解释器的实现细节

    • 如:字符串和整数的共享行为是CPython优化的一部分,但其他Python实现或未来版本可能不会这样。
  4. frozenset.copy()只返回同一个对象的引用,这对用户没有任何实际影响。


多理解一个维度:为何“优化”是合理的?

Python通过这些技巧对不可变对象进行共享或优化,并不会对代码功能产生影响,目的只是为了在不改变行为的前提下节省内存和提升性能

例如:

# 对用户来说,行为一致
>>> t1 = (1, 2, 3)
>>> t2 = t1[:]
>>> print(t1 == t2)        # True,内容相同
>>> print(t1 is t2)        # True,同一对象

用户不需要知道背后的优化细节,只要保证最终结果符合预期即可。


四、总结表:易混淆操作和行为

类型操作是否创建新对象备注
tuple[:]引用同一对象
tupletuple(obj)引用同一对象
list[:]生成新对象,内容独立
str字符串驻留机制否(可能共享)同内容字符串可能共享
int小整数优化否(可能共享)-5到256之间的整数可能共享
frozenset.copy()返回原对象引用

7、Chapter Summary

每个Python对象都有一个身份、一个类型和一个值。只有对象的值会随时间变化。实际上,如果两个变量引用的是具有相等值的不可变对象(a == bTrue ),它们是引用了副本还是作为别名引用同一个对象通常并不重要,因为不可变对象的值不会改变,但有一个例外。这个例外就是像元组这样的不可变集合:如果一个不可变集合包含对可变项的引用,那么当可变项的值发生变化时,该不可变集合的值实际上也会改变。在实际应用中,这种情况并不常见。在不可变集合中,永远不会改变的是其中对象的身份。frozenset(不可变集合)类不会出现这个问题,因为它只能包含可哈希的元素,而根据定义,可哈希对象的值永远不能改变。

在Python编程中,变量持有引用这一事实会产生许多实际影响:

  • 简单赋值不会创建副本。
  • 对于+=*=这样的增强赋值操作,如果左手边的变量绑定到一个不可变对象,会创建新对象;但如果是可变对象,则会就地修改。
  • 给现有变量赋新值不会改变之前绑定的对象。这被称为重新绑定:变量现在绑定到了不同的对象。如果该变量是对先前对象的最后一个引用,那么先前对象将被垃圾回收。
    【章节总结 223(此处“223”可能是页码,按原文保留)】
  • 函数参数是作为别名传递的,这意味着函数可以修改接收到的任何可变对象参数。除了进行局部复制或使用不可变对象(例如传递元组而不是列表)之外,无法阻止这种情况。
  • 将可变对象用作函数参数的默认值是危险的,因为如果参数在原地被修改,默认值也会改变,进而影响到未来所有依赖该默认值的调用。

在CPython中,当对象的引用计数达到零时,对象就会被丢弃。如果对象形成了循环引用且没有外部引用,也可能会被丢弃。

在某些情况下,持有一个不会使对象保持存活的对象引用可能会很有用。例如,一个类想要跟踪其所有当前实例。这可以通过弱引用来实现,弱引用是一种底层机制,更实用的集合类WeakValueDictionaryWeakKeyDictionaryWeakSet,以及weakref模块中的finalize函数都基于此机制。更多相关内容,请访问fluentpython.com上的 “弱引用” 一文。


http://www.kler.cn/a/573394.html

相关文章:

  • ARM 架构下 cache 一致性问题整理
  • CAD2025电脑置要求
  • MySQL篇:基础知识总结与基于长期主义的内容更新
  • 使用Docker搭建Oracle Database 23ai Free并扩展MAX_STRING_SIZE的完整指南
  • (二 十)趣学设计模式 之 迭代器模式!
  • UDP透传程序
  • 【踩坑随笔】`npm list axios echarts`查看npm依赖包报错
  • Redis maven项目 jedis 客户端操作(一)
  • 在https的网站里访问http的静态资源
  • AI数字人源码开发---SaaS化源码部署+PC+小程序一体化
  • 微信小程序投票系统的构建与实现
  • 200W数据去重入库的几种方法及优缺点
  • 云原生 DB 技术将取代K8S为基础云数据库服务-- 2025年云数据库专栏(一)
  • formdata 传list
  • c++ 接口/多态
  • 拉货搬家小程序开发中保障用户隐私和数据安全的方法
  • IntelliJ IDEA集成MarsCode AI
  • CentOS 8.2 更新源
  • 谷歌浏览器插件开发避免跨域以及流式数据输出
  • Oracle常用分析诊断工具(9)——AWR