这篇文章主要介绍 Python 的面对对象惯用法,首先介绍对象与对象名称的区别,解释对象标识、值和别名等概念,以及垃圾回收、del 命令、如何通过弱引用记住对象,而无需对象本身存在。最后会介绍符合 Python 风格的对象。
对象引用、可变性和垃圾回收
变量不是盒子
Python 变量类似于 Java 中的引用式变量,最好把他们理解为附加在对象上的标签。
1 | 1, 2, 3] a = [ |
如果把变量看做便利贴,就能很好地说明变量 a 和变量 b 引用用一个列表,而不是那个列表的副本:
对于应用式变量而言,说把变量分配给对象更合理,因为对象在赋值前之前已经创建。对于赋值语句而言,对象在右边创建或获取,在此之后左边的变量才会绑定到对象上,就如同为对象贴上标签:
1 | class SimpleObject: |
标识、相等性和别名
因为变量只不过是标注,所以可以为对象贴上多个标注。贴的多个标注就是别名:
1 | 'name':'lily', 'born':2000} lily = { |
- lily 和 lily_alias 绑定的是同一个对象,而 lily 和 another_lily 虽然内容相同,但是是两个不同的对象
- 通过
a is b
的方式,可以判断变量所引用对象的标识是否相同 ==
运算符比较的是两个对象的值(对象中保存的数据),而 is 比较对象的标识- 在判断变量和单例值(例如 None)比较时,应该使用 is
每个对象都有标识、类型和值。对象一旦创建,它的标识绝对不会变,可以把标识理解为对象在内存中的地址,is 运算符比较两个对象的标识,id() 函数返回对象标识的整数表示。
元祖和多数 Python 集合(列表、字典、集合等等)一样,保存的是对象的引用。如果引用的元素是可变的,即便元祖本身不可变,元素依然可变。元祖的不可变性其实指的是 tuple 数据结构里的物理内容(即保存的引用),与引用的对象无关。元祖的值会随着引用的可变对象的变化而变化,元祖不可变的是元素的标识:
1 | 1, 2, [30, 40]) t1 = ( |
默认做浅复制
复制列表(或多数内置的可变集合)最简单的方式是使用内置的类型构造方法:
1 | 3, [55, 44], (7, 8, 9)] l1 = [ |
- 副本和源列表值相同,但是二者引用不同的对象
- 对于列表和其他可变序列来说,还能使用
l2 = l1[:]
创建副本
构造方法或 [:] 做的是浅复制,即复制了最外层容器,副本中元素是源容器中元素的引用。如果所有元素都是不可变的,那么这样做没有什么问题,还能节省内存。但是如果有可变的元素,那么可能会导致意想不到的问题。
1 | 100) l1.append( |
- 列表复制后,l1 l2 是两个不同的对象,但是二者引用了同一个列表和同一个元祖
- 往 l1 中添加元素,对 l2 没有影响,因为二者是不同的列表对象
- 往 l1[1] 中删除元素,对 l2 有影响,因为二者引用了同一个列表
- 同样,使用就地运算符修改 l2[1],对 l1 也有影响,因为二者引用的是同一个列表
- 但是,使用就地运算符修改 l2[2],由于其是元祖,是不可变类型,就地运算符将产生一个新的元祖来保存结果,因此 l2[2] 引用了这个新的元祖,而 l1[1] 仍然引用之前的元祖
浅复制容易操作,但是得到的结果可能并不是你想要的。有时我们需要的是深复制(即副本不共享内部对象的引用)。copy 模块提供的 deepcopy 和 copy 函数能为任意对象做深复制和浅复制。
如下展示了浅拷贝(copy)和深拷贝(deepcopy)的区别:
1 | #!/usr/bin/env python3 |
1 | import copy |
- 可以看到 bus1 和 bus2 共享同一个列表,因为 bus2 是 bus1 的浅拷贝
- 而 bus3 和 bus1 引用的是不同的列表对象,因为 bus3 是 bus1 的深拷贝
深拷贝不是简单的事,如果对象有循环引用,那么朴素的算法会进入无限循环,deepcopy 函数会记住已经复制的对象,因此能够优雅地处理循环引用:
1 | 10, 20] a = [ |
此外深复制有时可能太深了。我们可以实现特殊方法 __copy__
和 __deepcopy__
,控制 copy 和 deepcopy 的行为。
函数的参数作为引用时
Python 唯一支持的参数传递模式是共享传参(call by sharing),共享传参指各个形式参数获得实参中引用关系的副本,即形参与实参引用的是同一个对象。这样函数可以通过参数修改传入的可变对象,但是无法修改实参让其引用另一个对象。
1 | def f(a, b): |
参数可以有默认值,这样可以在 API 进化的同时保持向后兼容,但是我们应该避免使用可变对象作为参数的默认值,如下展示了一个例子:
1 | class TestList: |
- TestList 的构造函数,list 参数默认为空列表
- 创建两个 TestList 对象,都使用默认参数进行初始化,对 l1 中的列表添加元素,l2 也受到影响
- 创建两个 TestList 对象,这次显式传入空列表,对 l3 中的列表添加元素,对 l4 没有影响
- 最后创建一个 TestList 对象,仍然使用默认参数进行初始化,l5 初始化完成后,list 元素并不为空
- 最后可以看到 l1、l2、l5 的 list 是同一个对象,但是和 l3、l4 的 list 不是同一个对象
出现这个问题的根源是,默认值是在定义函数时计算的(通常在加载模块时),因此默认值变成了函数对象的属性,因此如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响:
1 | dir(TestList.__init__) |
默认参数是可变对象时,可以使用 None 作为接收可变值参数的默认值,如下所示:
1 | class MyList(): |
该程序虽然解决了默认参数是可变对象带来的奇怪调用问题,但是如果构造时传入了列表,那么 MyList 中 list 和该实参参数引用的是同一个列表,对 MyList 的修改,将影响原有的 list 对象:
1 | 1, 2, 3] l_arg = [ |
在设计接口时,如果定义的函数接收可变参数,也应该谨慎考虑调用方是否期望修改传入的参数。例如函数如果接受一个字典,而且的过程中要修改它,那么要考虑这个修改的副作用要不要体现到函数外部。具体情况具体分析,需要函数的编写者和调用者达成共识。接口的设计应该符合 最小惊讶原则
。
为了解决该问题,可以在 MyList 中创建一个实参的副本,这样处理方式还更灵活,因为实参可以是任何可迭代对象了:
1 | class MyList(): |
del 和垃圾回收
对象绝对不会自行销毁,但是无法得到对象时,可能会被当做垃圾回收。del 语句用于删除名称,而不是删除对象。del 命令可能导致对象当做垃圾回收,但是仅当删除的变量保存的是对象的最后一个引用,或者无法得到对象时。重新绑定也可能导致对象的引用计数归零,导致对象被销毁。
在 CPython 中垃圾回收使用的主要算法是引用计数,每个对象都会统计有多少引用指向自己,当引用计数归零时,对象立即被销毁:CPython 会在对象上调用 __del__
方法(如果定义了,该方法给实例最后的机会,释放外部资源),然后释放分配给对象的内存。Python 的其他实现有更复杂的垃圾回收程序,而且不依赖于引用计数,对象的引用计数为 0 时,可能不会立即调用 del 方法。
如下演示了对象声明结束时的情形:
1 | import weakref |
- 这里注销时的回调函数一定不能绑定要销毁的对象,否则永远会有一个指向该对象的引用,该对象也就永远不会被销毁。这里 fininalize 持有的是 s1 的弱引用,不会增加对象的引用计数
- del 不删除对象,只是删除该对象上的一个引用。del s1 后 s2 仍然指向元祖对象,所以该对象不会被销毁
- s2 重新赋值后,元祖对象无法获取,对象被销毁了
弱引用
因为有引用,对象才会在内存中存在。当对象的引用计数归零后,垃圾回收程序会把对象销毁。弱引用不会增加对象的引用数量,引用的目标对象被称为所指对象。因此弱引用不会妨碍所指对象被当做垃圾回收。弱引用在缓存应用中很有用,因为不想仅因为着缓存引用而始终保存着缓存对象。在 Python 控制台里,会自动把变量 _
绑定到结果不为 None 的表达式上。
使用 weakref.ref 实例创建某个实例的弱引用。弱引用是可调用的对象,返回的是被引用的对象,如果所指对象不存在了,返回 None:
1 | import weakref |
wref()
返回被引用的对象,由于当前在控制台会话中,所以_
变量也指向它- 之后 a_set 指向另一个集合,由于
_
现在也指向最近的表达式上,因此{0, 1}
这个集合没有强引用了,因此wref()
返回 None
weakref 类其实是低层接口,供高级用途使用。多数程序最好使用 weakref 集合和 finalize,即应该使用 WeakKeyDictionary、WeakValueDictionary、WeakSet 和 finalize(在内部使用弱引用),不要自己动手创建并处理 weakref.ref 的实例。
WeakValueDictionary 类实现的是一种可变映射,里面的值是对象的弱引用。被引用的对象在程序中的其他地方被当做垃圾回收后,对应的键会自动从 WeakValueDictionary 中删除。因此 WeakValueDictionary 常用于缓存。
1 | class Cheese: |
1 | import cheese |
- stock 保存了 catalog 中 Cheese 实例的弱引用
- 当 catalog 被删除后,理论上 stock 中的 cheese 应该都消失,但是最后还存在一个
Cheese(D)
。这是因为 for 循环中的变量 c 其实是全局变量(在 Python 控制台中),循环结束后,它还是指向Cheese(D)
,所以引用计数不为 0 ,不被销毁
与 WeakValueDictionary 对应的是 WeakKeyDictionary,后者的键是弱引用。WeakSet 是保存元素弱引用的集合类,元素没有强引用使时,集合会把它删除。如果一个类需要知道所有实例,可以创建一个 WeakSet 类型的类属性,保存实例的引用。如果使用常规 set,实例永远不会被垃圾回收,因为类中对实例的强引用,而类存在的时间与 Python 进程一样长,除非显式删除类。
不是每个 Python 对象都可以作为弱引用的目标,这些局限基本上是 CPython 的实现细节,其他 Python 解释器中情况可能不一样。list 和 dict 实例不能作为弱引用的目标,但是它们的子类可以解决这个问题。set 实例可以作为引用目标。用户定义的类型也没有问题。int 和 tuple 的实例不能作为弱引用的目标,甚至它们的子类也不行。
对于元祖 t 来说,你可能会复现,t[:] 和 tuple(t) 都不会创建副本,它们返回的都是同一个元祖的引用:
1 | id(t) |
str、bytes 和 frozenset 实例也有这种行为。甚至对于 str、即使新建一个同样内容的字符串,它们也都是指向同一个对象:
1 | 'test' a = |
共享字符串字面量是一种优化措施,称为驻留,CPython 还会在小的整数上使用这个优化措施,防止重复创建热门的数字。注意 CPython 不会驻留所有字符串和整数,驻留的条件是实现细节,没有文档说明。所以千万不要依赖字符串或整数的驻留。
Python 对不可变类型施加的优化能节省内存,提升解释器的速度。而且不会给你带来麻烦,因为它们都是不可变类型。如果两个变量指向的不可变对象具有相同的值,实际上它们指代的是副本还是同一个对象的别名基本没有什么关系。
符合 Python 风格的对象
得益于 Python 数据模型,自定义类型的行为可以像内置类型那样自然。实现如此自然的行为,靠的不是继承,而是鸭子类型,即只需要按照预定行为实现对象所需方法即可。
对象表示形式
Python 提供了两种方式用于获取一种对象的字符串表示形式:
- repr():以便于开发者理解的方式返回对象的字符串表示形式
- str():以便于用于理解的方式返回对象的字符串表示形式
我们需要自己实现 __repr__
和 __str__
特殊方法,为 repr()
和 str()
提供支持。另外还有两个特殊的方法:
- __bytes__:bytes() 函数调用它获取对象的字节序列表示
- __formats__:会被内置的 format() 函数和 str.format() 方法调用,使用特殊的格式代码显示对象的字符串表示形式
重新实现向量类
1 | #!/usr/bin/env python3 |
- typecode 是类属性
- 在
__init__
方法中把 x、y 转型为浮点数,这样能尽早捕获错误,以防止传入不当参数 - 实现了
__iter__
方法,把 Vector2d 对象变成可迭代对象,这样能实现拆包。这里直接通过生成器表达式不断产生分量 __repr__
方法首先输出类名,然后使用{!r}
输出各个分量的表示形式。因为 Vector2d 是一个可迭代对象,所以 *self 会把 x 和 y 分量提供给 format 函数__bytes__
方法首先输出 typecode 的字节形式,然后将该对象转化为数组,再输出数组的字节序列
如下展示了 Vector2d 的使用方法:
1 | from vector2d_v0 import Vector2d |
- 这里使用 eval 函数来调用 repr() 函数的返回结果,得到了正确的实例。这说明 repr() 函数的返回结果是对构造方法的准确描述。
- print 函数调用 str() 函数,进而调用实例的
__str__
方法。而控制台输出一个实例,是调用该实例的__repr__
方法 - bytes 函数会调用实例的
__bytes__
方法 - abs 函数会调用实例的
__abs__
方法 - bool 函数会调用实例的
__bool__
方法
为了能够从字节序列转换成 Vector2d 的实例,接下来在定义一个 frombytes 类方法:
1 |
|
- 这里用 classmethod 装饰器表示类方法,它的第一个参数是 cls 类名参数
- 从第一个字节中读取 typecode,通过 memoryview 的 cast 将其转换为
typecode
类型的序列 - 通过
*memv
进行拆包,得到构造方法所需要的参数,构造对象
1 | bytes(v1)) v3 = vector2d_v0.Vector2d.frombytes( |
classmethod 和 staticmethod
classmethod 定义了操作类,而不是操作实例的方法,类方法的第一个参数是类本身(按照约定,通常命名为 cls),而不是实例。classmethod 最常见的用途是定义备选构函数。
staticmethod 装饰器也会改变方法的调用方法,但是第一个参数不是特殊的值。静态方法就是普通的函数,只是碰巧在类的定义体中,而不是在模块层定义。
1 | class Demo: |
- 无论怎么调用,classmethod 的第一个参数始终是类型
- 而静态方法的行为与普通函数相似
classmethod 装饰器非常有用,但是 staticmethod 则不存在不得不用的情况,有时函数虽然不处理类,但是函数的功能与类紧密相关,可以把它作为类内的静态方法,但是也可以在同一模块中的类前面或者后面定义该函数。
格式化显示
内置的 format() 函数和 str.format() 方法把各个类型的格式化方式委托给相应的 .__format__(format_spec)
方法。format_spec 是格式说明符,它是:
- format(my_obj, format_spec) 的第二个参数
- str.format() 方法的格式化字符串,{} 里替换字段中冒号后面的部分
1 | 1 / 3 f = |
类似于 {0.mass:5.3e}
这样的格式字符串包含两部分:
- 冒号左边的在替换字段语法中是字段名,它可以通过关键字或位置参数的方式指定要输出的字段
- 冒号右边的是格式说明符,格式说明符使用的表示方法称为
格式规范微语言
格式规范微语言是可以扩展的,各个类可以自行决定如何解释 format_spec 参数。如果类没有定义 __format__
方法,从 object
继承的方法会返回 str(my_object)
:
1 | 3, 4) v1 = vector2d_v0.Vector2d( |
此时传入格式说明符将会报错,我们需要实现自己的微语言来解决这个问题,方法如下:
1 | def __format__(self, fmt_spec=''): |
- 通过将 fmt_spec 应用到向量的各个分量上,构成一个生成器
- 最后将将该生成器应用到格式化字符串中,输出每一个分量格式化的字符串
1 | 3, 4) v1 = vector2d_v0.Vector2d( |
我们也可以为类型添加自定义的格式代码,例如如下代码增加了格式代码 p
用来显示向量的极坐标:
1 | def angle(self): |
1 | import vector2d_v0 |
可散列的 Vector2d
目前实现的 Vector2d 还不是可散列的,因此不能放入集合中:
1 | hash(v1) |
之前已经实现了 __eq__
方法,为了把 Vector2d 实例变为可散列的,还必须使用 __hash__
方法,另外向量必须为不可变的,为此需要把 x 和 y 设置为只读属性,为此重新实现其 __init__
方法,并添加两个读值方法:
1 | def __init__(self, x, y): |
- 使用两个前导下划线(尾部没有下划线,或者有一个下划线)把属性标记为私有的
- @property 装饰器把读值方法标记为特性,读值方法与公开属性同名。需要读取 x 和 y 分量的方法可以保持不变,仍然通过 self.x 和 self.y 来读取公开属性,而不必读取私有属性
通过将对象的属性设置为只读属性,这些对象才是不可变的。接下来才能实现 __hash__
方法。
1 | def __hash__(self): |
现在改类型就是可散列类型:
1 | 3, 4) v1 = vector2d_v0.Vector2d( |
如果定义的类型有标量数值,可能还需要实现 __int__
和 __float__
方法(分别被 int() 和 float() 构造函数调用),以便在某些情况下用于强制类型转换。
要想得到功能完善的对象,这些方法可能是必备的。当然如果你的应用用不到,就没有必要全部实现这些方法。
Python 的私有属性和受保护的属性
Python 不能像 Java 那样使用 private 修饰符创建私有属性,但是 Python 有个简单的机制,能够避免子类意外覆盖私有属性。如果以 __x
(两个前导下划线,尾部没有下划线或者最多有一个下划线)命名实例属性,Python 会把属性名存入实例的 dict 属性中,而且会在前面加上一个下划线和类名。这个语言特性叫做改写(name mangling):
名称改写是一种安全措施,不能保证万不一失,它的目的是避免意外访问,不能防止故意做错事。只要知道改写私有属性名的机制,任何人都能直接读取私有属性。
1 | v1.__dict__ |
不是所有程序员都喜欢名称改写功能,也不是所有人都喜欢 self.__x
这种不对称的名称。他们约定使用一个下划线前缀编写 受保护的
属性(例如 self._x)。Python 解释器不会对使用单个下划线的属性名称做特殊处理,这只是 Python 程序员严格遵守的约定,它们不会在类外部访问这种属性。Python 文档中有时会把使用一个下划线前缀标记的属性称为 受保护的属性
。
使用 slots 类属性节省空间
默认情况下,Python 在各个实例中的 __dict__
属性中存储实例属性。为了使用底层的散列表提升访问速度,字典会消耗大量内存。如果要处理包含数百万个属性的实例,通过 __slots__
类属性,能节省大量内存,方法是让解释器在元祖中存储实例属性,而不用字典。需要注意,继承自超类的 __slots__
属性没有效果,Python 只会使用各个类中定义的 __slots__
属性。
定义 __slots__
的方式,创建一个类属性,使用 __slots__
这个名称,并且把它的值设为一个字符串构成的可迭代对象,其中各个元素表示各个实例属性。在类中定义 __slots__
属性的目的是告诉解释器:这个类中的所有实例属性都在这儿了。这样 Python 会在各个实例中使用类似元祖的结构存储实例属性,从而避免使用消耗内存的 __dict__
。
在类中定义 __slots__
属性之后,实例不能再有 __slots__
中所列名称之外的其他属性了。这只是一个副作用,不是 __slots__
存在的真正原因,所以不要使用 __slots__
属性禁止类的用户新增实例属性。
如果把 __dict__
这个名称添加到 __slots__
中,实例会在元祖中保存各个实例的属性,此外还支持动态创建属性,这些属性存储在常规的 __dict__
中。还有一个实例属性需要注意:__weakref__
属性。为了让对象支持弱引用,必须有这个属性,用户定义的类中默认就有 __weakref__
属性,可是如果类中定义了 __slots__
属性,而且想把实例作为弱引用的目标,那么要把 __weakref__
添加到 __slots__
中。
覆盖类属性
Python 中有个很独特的特性,类属性可以为实例属性提供默认值,在上面的例子中,我们通过 self.typecode
的方式获取类属性 typecode,实例对象本身没有这个属性。但是如果为不存在的实例属性赋值,会新建实例属性,并为新建的实例属性赋值,同名的类属性不受影响。之后实例读取的 self.typecode
也是实例属性 typecode
,即把同名的类属性给覆盖了。
如果想修改类属性的值,必须直接在类上修改,不能通过实例修改,例如如果想修改所有实例的 typecode 属性的默认值,可以通过如下方式实现:
1 | Vector2d.typecode = 'f' |
有种修改方法更符合 Python 风格,而且效果持久,也更有针对性,类属性是公开的,因此会被子类继承,于是经常会创建一个子类,只用于定制类的数据属性。
不管怎么样,符合 Python 风格的对象应该正好符合所需,而不是堆砌语言特性。