资讯专栏INFORMATION COLUMN

Python学习之路27-对象引用、可变性和垃圾回收

Batkid / 1626人阅读

摘要:函数的参数作为引用时唯一支持的参数传递模式是共享传参,它指函数的形参获得实参中各个引用的副本,即形参是实参的别名。而在上面这个例子中,类的属性实际上是形参所指向的对象所指对象,的别名。

《流畅的Python》笔记

本篇是“面向对象惯用方法”的第一篇,一共六篇。本篇主要是一些概念性的讨论,内容有:Python中的变量,对象标识,值,别名,元组的某些特性,深浅复制,引用,函数参数,垃圾回收,del命令,弱引用等,比较枯燥,但却能解决程序中不易察觉的bug。

1. 变量、标识、相等性和别名

先用一个形象的比喻来说明Python中的变量:变量是标注而不是盒子。也就是说,Python中的变量更像C++中的引用,最能说明这一点的就是多个变量指向同一个列表,但也有例外,在遇到某些内置类型,比如字符串str时,变量则变成了“盒子”:

# 代码1
>>> a = [1, 2]  
>>> b = a  # 标注,引用
>>> a.append(3)
>>> b
[1, 2, 3]
>>> c = "c"  
>>> d = c  # “盒子”
>>> c = "cc"
>>> d
"c"

补充:说到了赋值方式,Python和C++一样,也是等号右边先执行。

1.1 相等性( == )与标识( is )

用一个更学术的词来替换“标注”,那就是“别名”。在C++中,引用就是变量的别名,Python中也是,比如代码1中的变量b就是变量a的别名,但如果是以下形式,变量b则不是a的别名:

# 代码2
>>> a = [1, 2]
>>> b = [1, 2]
>>> a == b   # a和b的值相等
True
>>> a is b   # a和b分别绑定了不同的对象,虽然对象的值相等 
False

==检测对象的值是否相等,is运算符检测对象的标识(ID)是否相等,id()返回对象标识的整数表示。一般判断两对象的标识是否相等并不直接使用id(),更多的是使用is运算符。

对象ID在不同的实现中有所不同:在CPython中,id()返回对象的内存地址,但在其他Python解释器中可能是别的值。但不管怎么,对象的ID一定唯一,且在生命周期中保持不变。

通常我们关心的是值,而不是标识,所以==出现的频率比is高。但在变量和单例值之间比较时,应该使用is。目前,最常使用is检测变量绑定的值是不是None,推荐的写法是:

# 代码3
x is None  # 并非 x == None
x is not None  # 并非 x != None

is运算符比==速度快,因为它不能重载,所以Python不用寻找并调用特殊方法,而是直接比较两个对象的ID。a == b其实是语法糖,实际调用a.__eq__(b)。虽然继承自object__eq__方法也是比较对象的ID,结果和is一样,但大多数内置类型覆盖了该方法,处理过程更复杂,这就是为什么is==快。

1.2 元组的相对不可变性

元组和大多数Python集合一样,保存的是对象的引用。元组的不可变性其实是指tuple数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。如果引用的对象可变,即便元组本身不可变,元素依然可变,不变的是元素的标识

# 代码4
>>> t1 = (1, 2, [30, 40])
>>> t2 = (1, 2, [30, 40])
>>> t1 == t2
True
>>> id(t1[-1])
2019589413704
>>> t1[-1].append(99)
>>> t1
(1, 2, [30, 40, 99])
>>> id(t1[-1])  # 内容变了,标识没有变
2019589413704
>>> t1 == t2
False

这同时也说明,并不是每个元组都是可散列的

2.深浅复制

复制对象时,相等性和标识之间的区别有更深入的影响。副本与源对象相等,但ID不同。而如果对象内部还有其他对象,这就涉及到了深浅复制的问题:到底是复制内部对象呢还是共享内部对象?

2.1 默认做浅复制

对列表和其他可变序列来说,我们可以使用构造方法或[:]来创建副本。然而,这两种方法做的都是浅复制,它们只复制了最外层的容器,副本中的元素是源容器中元素的引用。如果所有元素都是不可变的,那这样做没问题,还能节省内存;但如果其中有可变元素,这么做就可能出问题:

# 代码5
l1 = [3, [11, 22], (7, 8)]
l2 = list(l1)      # <1>
l1.append(100)
l1[1].remove(22)
print("l1:", l1, "
l2:", l2)
l2[1] += [33, 44]  # <2>
l2[2] += (10, 11)  # <3>
print("l1:", l1, "
l2:", l2)

# 结果
l1: [3, [11], (7, 8), 100]  # 追加元素只影响了l1
l2: [3, [11], (7, 8)]       # 但删除l1[1]中的元素影响了两个列表
l1: [3, [11, 33, 44], (7, 8), 100]     # +=对可变对象是就地操作,影响了两个列表
l2: [3, [11, 33, 44], (7, 8, 10, 11)]  # +=对不可变对象会创建新对象,只影响了l2

以上代码有3点需要解释:

<1>:l1[1]l2[1]指向同一列表,l1[2]l2[2]指向同一元组。因为是浅复制,只是复制引用;

<2>:+=运算对可变对象来说是就地运算,不会创建新对象,所以对两个列表都有影响;

<3>:+=运算对元组这样的不可变对象来说,等同于l2[2] = l2[2] + (10, 11),此操作隐式地创建了新对象,l2[2]重新绑定到了新对象,所以只有列表l2[2]发生了改变,而l1[2]没有改变。

2.2 为任意对象做深复制和浅复制

浅复制并非是一种错误,只是一种选择。而有时我们需要的是深复制,即副本不共享内部对象的引用。copy模块提供的deepcopycopy函数能为任意对象做深复制和浅复制。

# 代码6
import copy

l1 = [3, [11, 22]]
l2 = copy.copy(l1)      # 浅复制
l3 = copy.deepcopy(l1)  # 深复制
l1[1].append(33)    # 影响了l2,但没有影响l3
print("l1:", l1, "
l2:", l2, "
l3:", l3)

# 结果
l1: [3, [11, 22, 33]] 
l2: [3, [11, 22, 33]] 
l3: [3, [11, 22]]

在做深复制时,如果对象之间有循环引用,朴素的深复制算法(换句话说就是你自己写的深复制算法)很可能会陷入无限循环,然后报错。deepcopy会记住已经复制的对象,而不会进入无限循环:

# 代码7
>>> a = [10, 20]
>>> b = [a, 30]  # 包含a的引用
>>> b
[[10, 20], 30]
>>> a.append(b)  # 相互引用
>>> a
[10, 20, [[...], 30]]
>>> a[2][0]
[10, 20, [[...], 30]]
>>> a[2][0][2][0]
[10, 20, [[...], 30]]
>>> from copy import deepcopy
>>> c = deepcopy(a) # 不会报错,能正确处理相互引用的问题
>>> c
[10, 20, [[...], 30]]

此外,深复制有时可能太深了。例如,对象可能会引用不该复制的外部资源或单例值,这时,深复制就不应该复制这些值。如果要控制copydeepcopy的行为,我们可以在对象中重写特殊方法__copy____deepcopy__,具体内容这里就不展开了,大家可以参考copy模块的官方文档。

3. 函数参数

通过别名共享对象还能解释Python中传递参数的方式,以及使用可变类型作为参数默认值引起的问题。

3.1 函数的参数作为引用时

Python唯一支持的参数传递模式是共享传参(call by sharing),它指函数的形参获得实参中各个引用的副本,即形参是实参的别名。这种方案的结果就是,函数可能会修改作为参数传入的可变对象,但无法修改这些对象的标识(不能把一个对象替换成另一个对象):

# 代码8
def f(a, b):
    a += b
    return a

x, y = 1, 2
print(f(x, y), x, y)
a, b = [1, 2], [3, 4]
print(f(a, b), a, b)
t, u = (10, 20), (30, 40)
print(f(t, u), t, u)

# 结果
3 1 2 # x, y是不可变对象,没有影响到x, y
[1, 2, 3, 4] [1, 2, 3, 4] [3, 4]   # x是可变对象,影响到了x
(10, 20, 30, 40) (10, 20) (30, 40) # x没有指向新的元组,但形参a指向了新的元组
3.2 参数默认值

不要使用可变类型作为参数的默认值!其实这个问题在之前的文章“Python学习之路7-函数”的2.3小节中有所提及。现在我们来看下面这个例子:

首先定义一个类:

# 代码9
class Bus:
    def __init__(self, passengers=[]):  # 默认值是个可变对象
        self.passengers = passengers
        
    def pick(self, name):
        self.passengers.append(name)
    
    def drop(self, name):
        self.passengers.remove(name)

下面是这个类的行为:

# 代码10
>>> bus1 = Bus(["Alice", "Bill"]) # 直到第8行Bus的表现都是正常的
>>> bus1.passengers
["Alice", "Bill"]
>>> bus1.pick("Charlie")
>>> bus1.drop("Alice")
>>> bus1.passengers
["Bill", "Charlie"]
>>> bus2 = Bus()  # 使用默认值
>>> bus2.pick("Carrie")
>>> bus2.passengers
["Carrie"]   # 到目前为止也是正常的
>>> bus3 = Bus()  # 也是用默认值
>>> bus3.passengers
["Carrie"]   # 不正常了!
>>> bus3.pick("Dave")
>>> bus2.passengers
["Carrie", "Dave"]  # bus2的值也被改变了
>>> bus2.passengers is bus3.passengers  # 这俩是同一对象的别名
True
>>> bus1.passengers # bus1依然正常
["Bill", "Charlie"]

上述行为的原因在于,参数的默认值在导入模块时计算,方法或函数的形参指向这个默认值。而在上面这个例子中,类的属性self.passengers实际上是形参passengers所指向的对象(所指对象,referent)的别名。而bus1行为正常是因为从一开始它的passengers就没有指向默认值。

这里有点像单例模式:参数的默认值是唯一的只要采用默认值,不管创建多少个Bus的实例,它们的self.passengers都是同一个空列表[]对象的别名,不会为每一个实例多带带创建一个专属的[]

运行上述代码之后,可以查看Bus.__init__对象的__defaults__属性,它存储了参数的默认值:

# 代码11
>>> Bus.__init__.__defaults__
(["Carrie", "Dave"],)
>>> Bus.__init__.__defaults__[0] is bus2.passengers  # self.passengers就是一个别名!
True

这也说明了为什么要用None作为接收可变值的参数的默认值:

# 代码12
class Bus:
    def __init__(self, passengers=None):  # 默认值是个可变对象
        if passengers is None:  # 并不推荐 if passengers == None 这种写法
            self.passengers = []
        else:
            self.passengers = list(passengers)  # 注意这里!
    -- snip --

代码12中的第7行并不是直接把形参passengers赋值给self.passengers,而是形参的副本(这里是浅复制)。如果直接赋值,即self.passengers = passengersself.passengers变成了用户传入的参数的别名),则用户传入的参数在运行过程中可能会被修改,而这并不一定是用户想要的,这便违反了"最少惊讶原则"(居然还真有这么个原则)

4. del和垃圾回收
对象绝不会自行销毁;然而,无法得到对象时,可能会被当做垃圾回收。——Python语言参考手册

del语句删除变量(即"引用"),而不是对象。del命令可能导致对象被当做垃圾回收,但这仅发生在当删除的变量保存的是对象的最后一个引用,或者无法得到对象时(如果两个对象相互引用,如代码7,当它们的引用只存在二者之间时,垃圾回收程序会判定它们都无法获取,进而把它们都销毁)。重新绑定也可能会导致对象的引用数量归零,进而对象被销毁。

在CPython中,垃圾回收使用的主要算法是引用计数。实际上,每个对象都会统计有多少个引用指向自己。当引用计数归零时,对象立即被销毁。但在其他Python解释器中则不一定是引用计数算法。

补充:有个__del__特殊方法,它不是用来销毁实例的,而是在实例被销毁前用来执行一些最后的操作,比如释放外部资源等。我们不应该在代码中调用它,Python解释器会在销毁实例时先调用它(如果定义了),然后再释放内存。它相当于C++中的析构函数。

我们可以使用weakref.finalize来演示对象被销毁时的情况:

# 代码13
>>> import weakref
>>> s1 = {1, 2, 3}
>>> s2 = s1
>>> def bye(): # 它充当一个回调函数
...     print("Gone with the wind...")
# 一定不要传入待销毁对象的绑定方法,否则会有一个指向对象的引用
>>> ender = weakref.finalize(s1, bye) # 在s1引用的对象上注册bye回调
>>> ender.alive
True
>>> del s1
>>> ender.alive
True  # 说明 del s1并没有删除对象
>>> s2 = "spam" 
Gone with the wind...  # 引用计数为零,对象被删除
>>> ender.alive
False
5. 弱引用

不知道大家看到上述代码第15行时会不会产生如下疑惑:第8行代码明明把s1引用传给了finalize函数(为了监控对象和调用回调,必须要有引用),那么对象{1, 2, 3}则应该至少有三个引用,可为什么最后它还是被销毁了呢?这就牵扯到了弱引用这个概念。

5.1 weakref.ref

弱引用不会妨碍所指对象被当做垃圾回收,即弱引用不会增加对象的引用计数。(弱引用常被用于缓存,但具体用在缓存的哪些地方目前笔者还不清楚.....)

弱引用还是可调用对象,下面的代码展示了如何使用weakref.ref实例获取所指对象。

补充在代码之前:Python控制台会自动把结果不为None的表达式的结果绑定到变量_(下划线)上。这也说明了一个问题:微观管理内存时,隐式赋值会为对象创建新引用,而这有可能会导致一些意外结果。

# 代码14
>>> import weakref
>>> a_set = {1, 2} # 对象{1, 2}的引用数+1
>>> wref = weakref.ref(a_set) # 并没有增加所指对象的引用数
>>> wref

>>> wref() # 弱引用是个可调用对象
{1, 2} # 发生了隐式赋值,变量 _ 指向了对象{1, 2},引用数+1
>>> a_set = {2, 3} # 引用数 -1
>>> wref() # 所指对象依然存在,还没有被销毁
{1, 2}
>>> wref() is None  # 此时所指对象依然存在
False # 变量 _ 指向了对象False,对象{1, 2}引用数归零,销毁
>>> wref() is None  # 验证所指对象已被销毁
True
5.2 weakref集合

weakref.ref类其实是底层接口,供高级用途使用,一般程序最好使用werakref集合和finalize函数,即最好使用WeakKeyDictionaryWeakValueDictionaryWeakSetfinalize(它们在内部使用弱引用),不推荐自己动手创建并处理weakref.ref实例,除非你的工作就是专门和这些东西打交道的。

WeakValueDictionary类实现的是一种可变映射,里面的("键值对"中的"值",而不是字典中的"值")是对象的弱引用。被引用的对象在程序中的其他地方被当做垃圾回收后,对应的键会自动从WeakValueDictionary中删除。因此,它经常用于缓存。(查看缓存中变量是否依然存在?给框架用?)

# 代码15
>>> import weakref
>>> class Cheese:
...     def __init__(self, kind):
...         self.kind = kind
...
>>> stock = weakref.WeakValueDictionary()
>>> catalog = [Cheese("Red Leicester"), Cheese("Parmesan")]
>>> for cheese in catalog:
...     stock[cheese.kind] = cheese
...
>>> sorted(stock.keys())  
["Red Leicester", "Parmesan"]   # 表现正常
>>> del catalog
>>> sorted(stock.keys())
["Parmesan"]  # 这是怎么回事?
>>> del cheese  # 这是问题所在
>>> sorted(stock.keys())
[]

临时变量引用了对象,这可能会导致该变量的存在时间比预期长。通常,这对局部变量来说不是问题,因为它们在函数返回时会被销毁。但上述代码中,for循环中的变量cheese是全局变量,除非显示删除,否则不会消失。

WeakValueDictionary对应的是WeakKeyDictionary,后者的是弱引用,它的一些可能用途如下:

它的实例可以为应用中其他部分拥有的对象附加数据,这样就无需为对象添加属性。这对属性访问受限的对象尤其有用。

WeakSet类的用途则很简单:"保存元素弱引用的集合。当某元素没有强引用时,集合会把它删除。"如果一个类需要知道它的所有实例,一种好的方案是创建一个WeakSet类型的类属性,保存实例的弱引用。

5.3 弱引用的局限

weakref集合以及一般的弱引用,能处理的对象类型有限:

基本的listdict实例不能作为弱引用的所指对象,但它们的子类则可以;

class MyList(list):
    """MyList的实例可作为弱引用的所指对象"""

set的实例可作为所指对象;

自定义类的实例可以;

inttuple的实例不能作为弱引用的所指对象,它们的子类也不行。

但这些局限基本上是CPython的实现细节,其他Python解释器的情况可能不同。

6. CPython对不可变类型走的捷径

本节内容是Python实现的细节,可以跳过

这些细节是CPython核心开发者走的捷径和优化措施,利用这些细节写的代码在其他Python解释器中可能没用,在CPython未来的版本中也可能没用。下面是具体内容:

对元组t来说,t[:]tuple(t)不创建副本,而是返回同一个对象的引用;

strbytesfrozenset实例也是如此,并且frozensetcopy方法返回的也不是副本(注意,frozenset的实例fs不能用fs[:],因为fs不是序列);

str的实例还有共享字符串字面量的行为:

>>> s1 = "ABC"
>>> s2 = "ABC"
>>> s1 is s2
True

这叫做"驻留"(interning),这是一种优化措施。CPython还会在小的整数上使用这种优化,防止重复创建常用数字,如0,-1。但CPython不会驻留所有字符串和数字,驻留的条件是实现细节,而且没有文档说明。所以千万不要依赖这个特性!(比较字符串或数字请用==,而不是is!)

7. 总结

每个Python对象都有标识、类型和值,只有对象的值可能变化。

变量保存的是引用,这对Python编程有很多实际的影响:

简单的赋值不会创建副本;

+=*=等运算符来说,如果左边的变量绑定了不可变对象,则会创建新对象,然后重新绑定;如果是可变对象,则就地修改;

对现有的变量赋予新值不会修改之前绑定的对象。这叫重新绑定:现有变量绑定了其它对象。如果变量是之前那个对象的最后一个引用,该对象会被回收;

函数的参数以别名的形式传递,这意味着,函数可能会修改通过参数传入的可变对象。这一行为无法避免,除非在函数内部创建副本,或者使用不可变对象;

不要使用可变类型作为函数的默认值!

==用于比较值,is用于比较引用。

某些情况下,可能需要保存对象的引用,但不留存对象本身,比如记录某个类的所有实例,这可以用弱引用解决。


迎大家关注我的微信公众号"代码港" & 个人网站 www.vpointer.net ~

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/41836.html

相关文章

  • Python中的对象引用变性垃圾回收

    摘要:一对象引用基础知识变量是标注而不是容器。也就是说元组中不可变的是元素的标识,但元组的值会随着引用的可变对象变化而变化。在中每个对象的引用都会有统计。弱引用不会妨碍对象被当做垃圾回收。 导语:本文章记录了本人在学习Python基础之面向对象篇的重点知识及个人心得,打算入门Python的朋友们可以来一起学习并交流。 本文重点: 1、明确变量保存的是引用这一本质;2、熟悉对象引用的基础知识;...

    ytwman 评论0 收藏0
  • python 对象引用,变性垃圾回收

    摘要:每个变量都有标识类型和值对象一旦创建它的标识绝不会变标识可以简单的理解为对象在内存中的地址别名跟是别名指向如果增加新的内容也会增加相等性为运算符比较连个对象的值对象中保存的数据标识为因为他们都指向这个列表比较对象的标识元组相对不可变性元组保 a = [1,2,3,4] b = a 每个变量都有标识,类型和值.对象一旦创建,它的标识绝不会变;标识可以简单的理解为对象在内存中的地址. ...

    Flands 评论0 收藏0
  • Python基础题目大全,测试你的水平,巩固知识(含答案)

    摘要:里,有两种方法获得一定范围内的数字返回一个列表,还有返回一个迭代器。在引用计数的基础上,还可以通过标记清除解决容器对象可能产生的循环引用的问题。列举常见的内置函数的作用,过滤函数,循环函数累积函数一行代码实现乘法表。 showImg(https://segmentfault.com/img/remote/1460000019294205); 1、为什么学习Python? 人生苦短?人间...

    huhud 评论0 收藏0
  • 流畅的python读书笔记-第八章-对象引用变性垃圾回收

    摘要:运算符比较两个对象的标识函数返回对象标识的整数表示。实际上,每个对象都会统计有多少引用指向自己。对象被销毁了,调用了回调,的值变成了。当对象的引用数量归零后,垃圾回收程序会把对象销毁。引用的目标对象称为所指对象。 对象不是个盒子 showImg(https://segmentfault.com/img/bV95mW?w=1784&h=988); class Gizmo: def...

    zgbgx 评论0 收藏0
  • python对象引用变性垃圾回收

    摘要:对象引用和可变性变量不是盒子,而是便利贴变量的赋值方式比如是将一个变量分配给一个对象比如整数。运算符比较两个对象的标识函数返回对象标识的整数表示。每个对象都会统计有多少引用指向自己。对象被销毁执行回调函数输出 对象引用和可变性 变量不是盒子,而是‘便利贴’ >>> a = [1,2,3] >>> b = a >>> a.append(5) >>> a [1, 2, 3, 5] >>> ...

    chavesgu 评论0 收藏0

发表评论

0条评论

Batkid

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<