xiaojie4620

导航

Python 对象的引用和可变性

对象引用、可变性以及回收机制

代码案例,图片来自于 FluentPython-version 2

变量就是一个标签,贴在了某个对象上;而非 变量是一个盒子,用来存放元素。

a = [1, 2, 3]  
b = a          
a.append(4)    
print(b) # [1, 2, 3, 4]

image

上图的例子中,如果我们把 b 看作一个盒子,里面存放着从 a 盒子复制来的 [1, 2, 3],那么这种行为就无法解释。因为,b 就只是一个标签和 a 一样贴在了 数组 [1, 2, 3] 这个对象上面。只要 a 和 b 所指向的那个对象发生了改变,所以无论通过 a 还是 b 访问,看到的都是改变后的对象。

Identity, Equality, and Aliases — 标识、相等性与别名

用一个例子来引入

charles = {'name': 'Charles L. Dodgson', 'born': 1832}
lewis = charles  # lewis is an **alias** for charles.
print(lewis == charles) # True
print(id(charles), id(lewis))  # 1872554149376 1872554149376
lewis['balance'] = 950 # Adding an item to lewis is the same as adding an item to charles.
print(lewis) # {'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950}

好,现在假设又有一个人 alex 他的个人资料也是 {'name': 'Charles L. Dodgson', 'born': 1832} ,那 charles 和 alex 会是一个人吗?明显不是!

image

alex = {'name': 'Charles L. Dodgson', 'born': 1832, "balance": 950}
print(alex == charles) # True
print(alex is charles) # False

值得注意的是:

  • 之所以 alex == charles 是因为 字典类型 dict 底层的 __eq__ 方法的实现。
  • 但是这两者是不同的对象,因此当我们执行 alex is charles 时,python 返回了 false,因为这两个标签代表的不是同一个对象。

总结一下:

  • Aliases :在最开始例子中 lewis 就是 charles 的别名,因为这两个变量绑定是同一个对象。
  • Equality :绑定给 alex 和 charles 的对象的 值 是相等的,因为他们拥有相同的字段,每个字段的值也是相等的。但是二者的标识并不相同。
  • Identity :对象一经创建,其标识就不会改变;你可以将它理解为对象在内存中的地址。is 运算符用于比较两个对象的标识;id() 函数会返回一个代表该标识的整数。

对象 ID 的实际含义取决于具体实现。在 CPython 中,id() 返回对象的内存地址,但在其他 Python 解释器中,它可能代表别的内容。核心要点是:ID 一定是一个唯一的整数标记,并且在对象的整个生命周期内不会发生变化。

关于 == 和 is 运算符的选择,当我们明确知道要使用 is 对比时才使用该运算符,其他情况或者在你不确定的情况下,尽量使用 == 运算符,因为这个运算符也能和 None 一起工作 —— 尽管这会有一点点慢。

The Relative Immutability of Tuples

元组和 Python 中的大多数容器(列表,字典,集合等)一样,都是 容器类型,而且它们存储的是对象的引用。如果这些引用的对象发生改变了那容器也会改变(尽管 Tuple 是不可变的容器,但是这些元素的值依然可以发生变化)。

换句话说,元组的不可变性实际指的是元组数据结构本身的物理内容(也就是它保存的引用)不可变,并不延伸到这些引用所指向的对象。

a = 7
tuples = (9, 8, a, 6, 5)
print(id(a) == id(tuples[2])) # True
print(tuples) # (9, 8, 7, 6, 5)
a = 10
print(id(a) == id(tuples[2])) # False
print(tuples)# (9, 8, 7, 6, 5)

其实这个牵扯到这个运算符的特性, =(赋值)永远只是在移动“标签”,创造新对象或切换指向,绝不修改原对象的内容。

但是如果存了一个可变的对象呢?

t1 = (1, 2, [1, 2])
t2 = (1, 2, [1, 2])

print(t1 == t2) # True
c = t2[-1]
print(id(c) == id(t2[-1])) # True
c.append(3)
print(id(c) == id(t2[-1])) # True
print(t1, t2) # (1, 2, [1, 2]) (1, 2, [1, 2, 3])

t1, t2 是元组,是不可变的,但是他们包含了一个 list ,这个 list 是可变的。一开始 t1 和 t2 的值是相等的,但是修改了以后是 两个不同的元组了(只是值不相同,因为 id 本来就不同)。

Copies Are Shallow by Default

生成一个 列表的 副本的简单方式是 l1 = list(l2) 或者 l1 = l2[:] ,虽然这样复制后是两个不同的 对象,但是这种复制方式本质上还是 浅拷贝,因为这种拷贝方式仅复制最外层容器,副本里面存放的是指向原容器中相同元素的引用。

l1 = [1, 2, 3]
l2 = list(l1)
l3 = l1[:]
print(l1 is l2) # False
print(l1 is l3) # False

上述例子展示了 确实是两个不同的对象,但是如果是嵌套的容器呢?

l1 = [3, [66, 55, 44], (7, 8, 9)]

l2 = list(l1)
l1.append(100)
l1[1].remove(55)
print(l1 is l2, l1[1] is l2[1]) # False True
print(f"l1:{l1}") # [3, [66, 44], (7, 8, 9), 100]
print(f"l2:{l2}") # [3, [66, 44], (7, 8, 9)]
print(l1[2] is l2[2]) # True

l2[1] += [33, 22]
l2[2] += (10, 11)

print(f"l1:{l1}") # [3, [66, 44, 33, 22], (7, 8, 9), 100]
print(f"l2:{l2}") # [3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]
print(l1[2] is l2[2]) # False

因为 l2 是 l1 的浅拷贝,只会复制最外层的容器,所以 l1 和 l2 是两个不同的对象,但是其内部的那个列表又是同一个对象。

image

上面的图配上代码就能很好的理解一开始的那句话了。当进行下述更改时:

l1.append(100), l1[1].remove(55)

因为 l1, l2 是不同的列表,所以 100 只会出现在 l1 中,但是如果修改 l1 中的那个列表,就会导致 l2 中的列表一起改变,因为深层的引用还是同一个列表对象。

l2[1] += [33, 22],l2[2] += (10, 11)

因为 列表是可变对象,所以进行 += 操作后调用的是 __iadd__ 方法,所以还是在原来的对象上进行操作,因此 l1 也能看到变化的结果,但是 元组则是不可变对象,因此执行 += 操作时只能通过创建一个新的对象来完成这个运算,导致最终 l2 的元组变成一个新的元组对象。

image

Deep and Shallow Copies of Arbitrary Objects

对任意对象的深拷贝和浅拷贝,深拷贝没什么好说的全部复制一份。但是对于浅拷贝:

最开始二者的内部元素都是共用的,只有执行赋值,或者修改不可变对象等需要创建新对象的操作时,此时就会断开共用的引用链条。对于可变的对象,如果是原地修改则还是共用同一个对象。

注意:

  • copy.deepcopy() 会递归复制需要复制的嵌套对象,但不保证所有对象都会创建新实例。也就是说,深拷贝递归复制对象结构,通常使副本不再与原对象共享内部的可变对象。
  • deepcopy 会记录已经复制过的对象,避免递归复制陷入无限循环,也能处理重复引用。

Function Parameters as References

Python 中唯一的参数传递方式是共享传参。共享传参指函数的每个形参都会获得实参中各个引用的副本,换句话说,函数内部的形参成了实参的别名。

def f(a, b):
    a += b
    return a

x, y = 1, 2
(f(x, y)) # return 3
print(x, y) # 1, 2

a, b = [1, 2], [3, 4]
f(a, b) # return [1, 2, 3, 4]
print(a, b) # [1, 2, 3, 4] [3, 4]

t, u = (10, 20), (30, 40)
f(t, u) # (10, 20, 30, 40)
print(t, u)# (10, 20), (30, 40)

按照之前的思路可以知道为什么。因为 number x 、 tuple t 是不可变对象,但是 list a 是可变对象,虽然在进行赋值时,a, b 是和最开始传入的参数共享同一个对象,但是除了 list 之外,其他对象进行赋值改动后,a 就变成另一个对象的标签了,所以并没对不可变对象进行实质性的改变。

Mutable Types as Parameter Defaults: Bad Idea

带默认值的可选参数是 Python 函数定义中一项很实用的特性,能够让我们的接口在迭代更新的同时保持向后兼容。不过,应当避免将可变对象用作参数的默认值。

考虑这样一份代码:

class HauntedBus:
    """A bus model haunted by ghost passengers"""
    # When the passengers argument is not passed, 
    # this parameter is bound to the default list object, which is initially empty.
    def __init__(self, passengers=[]):
        # This assignment makes self.passengers an alias for passengers, 
        # which is itself an alias for the default list, when no passengers argument is given.
        self.passengers = passengers  
        
    def pick(self, name):
        # When the methods .remove() and .append() are used with self.passengers, 
        # we are actually mutating the default list, which is an attribute of the function object.
        self.passengers.append(name)  
    def drop(self, name):
        self.passengers.remove(name)

bus1 = HauntedBus(['A', 'B'])
print(bus1.passengers) # ['A', 'B']
bus1.pick("C")
bus1.drop("A")
print(bus1.passengers) # ['B', 'C']
bus2 = HauntedBus()
print(bus2.passengers) # []
bus2.pick("D")
print(bus2.passengers) # ['D']
bus3 = HauntedBus()
print(bus3.passengers) # ['D']
bus3.pick("E")
print(bus2.passengers) # ['D', 'E']
print(bus2.passengers is bus3.passengers) # True
print(bus1.passengers) # ['B', 'C']

上述案例就说明了为什么不能用 可变对象作为默认值了。

在 Python 中,所有默认值都会在函数定义时(通常是模块加载时)完成求值,并且这些默认值会成为函数对象的属性。因此,如果某个默认值是可变对象,一旦对它做出修改,该改动就会影响后续每一次对这个函数的调用。

所以,如果我们用 可变对象 作为默认参数,那当我们不显示传 乘客参数,就会给实例自动绑定同一个空数组作为乘客列表,这就是为什么 bus2 bus3 的操作二者都能看到。

我们可以检查 HauntedBus.__init__ 对象,就能看到 幽灵学生(乘客) 会出现在它的 __defaults__ 属性中:print(HauntedBus.__init__.__defaults__) # (['D', 'E'],)

除此之外,我们还能验证 bus2.passengers 是一个绑定在 HauntedBus.__init__.__defaults__ 第一个属性上的别名。

print(HauntedBus.__init__.__defaults__[0] is bus2.passengers) # True

这里也直接说明了,为什么在 使用 @dataclass 创建数据类时不要将 可变对象 直接作为字段的默认值。

Defensive Programming with Mutable Parameters

当你编写接收可变参数的函数代码时,应当仔细考虑调用方是否期望传入的实参发生改变。

举个例子,如果你的函数接收一个字典,并且在处理过程中需要修改它,那么这个副作用是否要在函数外部可见?这实际上取决于具体场景,本质上是要让函数编写者和调用方的预期保持一致。

话不多说,来个例子。

class TwilightBus:
    """ A bus model that makes pasengers vanish """
    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.passengers 和 basketball_team 指向同一个对象
print(bus.passengers is basketball_team) # True
bus.drop("Tina")
bus.drop("Pat")
print(basketball_team) # ['Sue', 'Maya', 'Diana']

如果我们不想让 bus 这个实例修改原来的 篮球队中的成员呢?很简单,我们之前就实现过了。用该列表的副本进行初始化就行了。

def __init__(self, passengers = None):
    if passengers is None:
        self.passengers = []
    else:
        self.passengers = list(passengers)

除非某个方法明确设计为修改传入的参数对象,否则不要轻易直接将该参数对象赋值给类的实例变量,造成对象别名引用。

posted on 2026-10-26 22:13  kuokuo  阅读(2)  评论(0)    收藏  举报