流畅的 Python 第二版(GPT 重译)(六)(1)https://developer.aliyun.com/article/1484606
要使Vector2d
与位置模式配合使用,我们需要添加一个名为__match_args__
的类属性,按照它们将用于位置模式匹配的顺序列出实例属性:
class Vector2d: __match_args__ = ('x', 'y') # etc...
现在,当编写用于匹配Vector2d
主题的模式时,我们可以节省一些按键,如您在示例 11-10 中所见。
示例 11-10。Vector2d
主题的位置模式——需要 Python 3.10
def positional_pattern_demo(v: Vector2d) -> None: match v: case Vector2d(0, 0): print(f'{v!r} is null') case Vector2d(0): print(f'{v!r} is vertical') case Vector2d(_, 0): print(f'{v!r} is horizontal') case Vector2d(x, y) if x==y: print(f'{v!r} is diagonal') case _: print(f'{v!r} is awesome')
__match_args__
类属性不需要包括所有公共实例属性。特别是,如果类__init__
具有分配给实例属性的必需和可选参数,可能合理地在__match_args__
中命名必需参数,但不包括可选参数。
让我们退后一步,回顾一下我们到目前为止在Vector2d
中编码的内容。
Vector2d 的完整列表,版本 3
我们已经在Vector2d
上工作了一段时间,只展示了一些片段,因此示例 11-11 是vector2d_v3.py的综合完整列表,包括我在开发时使用的 doctests。
示例 11-11。vector2d_v3.py:完整的版本
""" A two-dimensional vector class >>> v1 = Vector2d(3, 4) >>> print(v1.x, v1.y) 3.0 4.0 >>> x, y = v1 >>> x, y (3.0, 4.0) >>> v1 Vector2d(3.0, 4.0) >>> v1_clone = eval(repr(v1)) >>> v1 == v1_clone True >>> print(v1) (3.0, 4.0) >>> octets = bytes(v1) >>> octets b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@' >>> abs(v1) 5.0 >>> bool(v1), bool(Vector2d(0, 0)) (True, False) Test of ``.frombytes()`` class method: >>> v1_clone = Vector2d.frombytes(bytes(v1)) >>> v1_clone Vector2d(3.0, 4.0) >>> v1 == v1_clone True Tests of ``format()`` with Cartesian coordinates: >>> format(v1) '(3.0, 4.0)' >>> format(v1, '.2f') '(3.00, 4.00)' >>> format(v1, '.3e') '(3.000e+00, 4.000e+00)' Tests of the ``angle`` method:: >>> Vector2d(0, 0).angle() 0.0 >>> Vector2d(1, 0).angle() 0.0 >>> epsilon = 10**-8 >>> abs(Vector2d(0, 1).angle() - math.pi/2) < epsilon True >>> abs(Vector2d(1, 1).angle() - math.pi/4) < epsilon True Tests of ``format()`` with polar coordinates: >>> format(Vector2d(1, 1), 'p') # doctest:+ELLIPSIS '<1.414213..., 0.785398...>' >>> format(Vector2d(1, 1), '.3ep') '<1.414e+00, 7.854e-01>' >>> format(Vector2d(1, 1), '0.5fp') '<1.41421, 0.78540>' Tests of `x` and `y` read-only properties: >>> v1.x, v1.y (3.0, 4.0) >>> v1.x = 123 Traceback (most recent call last): ... AttributeError: can't set attribute 'x' Tests of hashing: >>> v1 = Vector2d(3, 4) >>> v2 = Vector2d(3.1, 4.2) >>> len({v1, v2}) 2 """ from array import array import math class Vector2d: __match_args__ = ('x', 'y') typecode = 'd' def __init__(self, x, y): self.__x = float(x) self.__y = float(y) @property def x(self): return self.__x @property def y(self): return self.__y def __iter__(self): return (i for i in (self.x, self.y)) def __repr__(self): class_name = type(self).__name__ return '{}({!r}, {!r})'.format(class_name, *self) def __str__(self): return str(tuple(self)) def __bytes__(self): return (bytes([ord(self.typecode)]) + bytes(array(self.typecode, self))) def __eq__(self, other): return tuple(self) == tuple(other) def __hash__(self): return hash((self.x, self.y)) def __abs__(self): return math.hypot(self.x, self.y) def __bool__(self): return bool(abs(self)) def angle(self): return math.atan2(self.y, self.x) def __format__(self, fmt_spec=''): if fmt_spec.endswith('p'): fmt_spec = fmt_spec[:-1] coords = (abs(self), self.angle()) outer_fmt = '<{}, {}>' else: coords = self outer_fmt = '({}, {})' components = (format(c, fmt_spec) for c in coords) return outer_fmt.format(*components) @classmethod def frombytes(cls, octets): typecode = chr(octets[0]) memv = memoryview(octets[1:]).cast(typecode) return cls(*memv)
总结一下,在本节和前几节中,我们看到了一些您可能希望实现以拥有完整对象的基本特殊方法。
注意
只有在您的应用程序需要时才实现这些特殊方法。最终用户不在乎构成应用程序的对象是否“Pythonic”。
另一方面,如果您的类是其他 Python 程序员使用的库的一部分,您实际上无法猜测他们将如何处理您的对象,他们可能期望我们正在描述的更多“Pythonic”行为。
如示例 11-11 中所编码的,Vector2d
是一个关于对象表示相关特殊方法的教学示例,而不是每个用户定义类的模板。
在下一节中,我们将暂时离开Vector2d
,讨论 Python 中私有属性机制的设计和缺点——self.__x
中的双下划线前缀。
Python 中的私有和“受保护”的属性
在 Python 中,没有像 Java 中的private
修饰符那样创建私有变量的方法。在 Python 中,我们有一个简单的机制来防止在子类中意外覆盖“私有”属性。
考虑这种情况:有人编写了一个名为Dog
的类,其中内部使用了一个mood
实例属性,但没有暴露它。你需要将Dog
作为Beagle
的子类。如果你在不知道名称冲突的情况下创建自己的mood
实例属性,那么你将覆盖从Dog
继承的方法中使用的mood
属性。这将是一个令人头疼的调试问题。
为了防止这种情况发生,如果你将一个实例属性命名为__mood
(两个前导下划线和零个或最多一个尾随下划线),Python 会将该名称存储在实例__dict__
中,前缀是一个前导下划线和类名,因此在Dog
类中,__mood
变成了_Dog__mood
,而在Beagle
中变成了_Beagle__mood
。这种语言特性被称为名称修饰。
示例 11-12 展示了来自示例 11-7 中Vector2d
类的结果。
示例 11-12. 私有属性名称通过前缀_
和类名“修饰”
>>> v1 = Vector2d(3, 4) >>> v1.__dict__ {'_Vector2d__y': 4.0, '_Vector2d__x': 3.0} >>> v1._Vector2d__x 3.0
名称修饰是关于安全性,而不是安全性:它旨在防止意外访问,而不是恶意窥探。图 11-1 展示了另一个安全设备。
知道私有名称是如何被修饰的人可以直接读取私有属性,就像示例 11-12 的最后一行所示的那样——这对调试和序列化实际上是有用的。他们还可以通过编写v1._Vector2d__x = 7
来直接为Vector2d
的私有组件赋值。但如果你在生产代码中这样做,如果出现问题,就不能抱怨了。
名称修饰功能并不受所有 Python 爱好者的喜爱,以及写作为self.__x
的名称的倾斜外观也不受欢迎。一些人更喜欢避免这种语法,只使用一个下划线前缀通过约定“保护”属性(例如,self._x
)。对于自动双下划线修饰的批评者,他们建议通过命名约定来解决意外属性覆盖的问题。Ian Bicking——pip、virtualenv 等项目的创建者写道:
永远不要使用两个前导下划线。这是非常私有的。如果担心名称冲突,可以使用显式的名称修饰(例如,
_MyThing_blahblah
)。这与双下划线基本相同,只是双下划线会隐藏,而显式名称修饰则是透明的。⁷
图 11-1. 开关上的盖子是一个安全设备,而不是安全设备:它防止事故,而不是破坏。
单个下划线前缀在属性名称中对 Python 解释器没有特殊含义,但在 Python 程序员中是一个非常强烈的约定,你不应该从类外部访问这样的属性。⁸。尊重一个将其属性标记为单个下划线的对象的隐私是很容易的,就像尊重将ALL_CAPS
中的变量视为常量的约定一样容易。
在 Python 文档的某些角落中,带有单个下划线前缀的属性被称为“受保护的”⁹。通过约定以self._x
的形式“保护”属性的做法很普遍,但将其称为“受保护的”属性并不那么常见。有些人甚至将其称为“私有”属性。
总之:Vector2d
的组件是“私有的”,我们的Vector2d
实例是“不可变的”——带有引号——因为没有办法使它们真正私有和不可变。¹⁰
现在我们回到我们的Vector2d
类。在下一节中,我们将介绍一个特殊的属性(不是方法),它会影响对象的内部存储,对内存使用可能有巨大影响,但对其公共接口影响很小:__slots__
。
使用__slots__
节省内存
默认情况下,Python 将每个实例的属性存储在名为__dict__
的dict
中。正如我们在“dict 工作原理的实际后果”中看到的,dict
具有显着的内存开销——即使使用了该部分提到的优化。但是,如果你定义一个名为__slots__
的类属性,其中包含一系列属性名称,Python 将使用替代的存储模型来存储实例属性:__slots__
中命名的属性存储在一个隐藏的引用数组中,使用的内存比dict
少。让我们通过简单的示例来看看它是如何工作的,从示例 11-13 开始。
示例 11-13。Pixel
类使用__slots__
>>> class Pixel: ... __slots__ = ('x', 'y') # ① ... >>> p = Pixel() # ② >>> p.__dict__ # ③ Traceback (most recent call last): ... AttributeError: 'Pixel' object has no attribute '__dict__' >>> p.x = 10 # ④ >>> p.y = 20 >>> p.color = 'red' # ⑤ Traceback (most recent call last): ... AttributeError: 'Pixel' object has no attribute 'color'
①
在创建类时必须存在__slots__
;稍后添加或更改它没有效果。属性名称可以是tuple
或list
,但我更喜欢tuple
,以明确表明没有改变的必要。
②
创建一个Pixel
的实例,因为我们看到__slots__
对实例的影响。
③
第一个效果:Pixel
的实例没有__dict__
。
④
正常设置p.x
和p.y
属性。
⑤
第二个效果:尝试设置一个未在__slots__
中列出的属性会引发AttributeError
。
到目前为止,一切顺利。现在让我们在示例 11-14 中创建Pixel
的一个子类,看看__slots__
的反直觉之处。
示例 11-14。OpenPixel
是Pixel
的子类
>>> class OpenPixel(Pixel): # ① ... pass ... >>> op = OpenPixel() >>> op.__dict__ # ② {} >>> op.x = 8 # ③ >>> op.__dict__ # ④ {} >>> op.x # ⑤ 8 >>> op.color = 'green' # ⑥ >>> op.__dict__ # ⑦ {'color': 'green'}
①
OpenPixel
没有声明自己的属性。
②
惊喜:OpenPixel
的实例有一个__dict__
。
③
如果你设置属性x
(在基类Pixel
的__slots__
中命名)…
④
…它不存储在实例__dict__
中…
⑤
…但它存储在实例的隐藏引用数组中。
⑥
如果你设置一个未在__slots__
中命名的属性…
⑦
…它存储在实例__dict__
中。
示例 11-14 显示了__slots__
的效果只被子类部分继承。为了确保子类的实例没有__dict__
,你必须在子类中再次声明__slots__
。
如果你声明__slots__ = ()
(一个空元组),那么子类的实例将没有__dict__
,并且只接受基类__slots__
中命名的属性。
如果你希望子类具有额外的属性,请在__slots__
中命名它们,就像示例 11-15 中所示的那样。
示例 11-15。ColorPixel
,Pixel
的另一个子类
>>> class ColorPixel(Pixel): ... __slots__ = ('color',) # ① >>> cp = ColorPixel() >>> cp.__dict__ # ② Traceback (most recent call last): ... AttributeError: 'ColorPixel' object has no attribute '__dict__' >>> cp.x = 2 >>> cp.color = 'blue' # ③ >>> cp.flavor = 'banana' Traceback (most recent call last): ... AttributeError: 'ColorPixel' object has no attribute 'flavor'
①
本质上,超类的__slots__
被添加到当前类的__slots__
中。不要忘记单项元组必须有一个尾随逗号。
②
ColorPixel
实例没有__dict__
。
③
你可以设置此类和超类的__slots__
中声明的属性,但不能设置其他属性。
“既能节省内存又能使用它”是可能的:如果将'__dict__'
名称添加到__slots__
列表中,那么你的实例将保留__slots__
中命名的属性在每个实例的引用数组中,但也将支持动态创建的属性,这些属性将存储在通常的__dict__
中。如果你想要使用@cached_property
装饰器(在“第 5 步:使用 functools 缓存属性”中介绍),这是必要的。
当然,在__slots__
中有'__dict__'
可能完全打败它的目的,这取决于每个实例中静态和动态属性的数量以及它们的使用方式。粗心的优化比过早的优化更糟糕:你增加了复杂性,但可能得不到任何好处。
另一个你可能想要保留的特殊每实例属性是__weakref__
,这对于对象支持弱引用是必要的(在“del 和垃圾回收”中简要提到)。该属性默认存在于用户定义类的实例中。但是,如果类定义了__slots__
,并且你需要实例成为弱引用的目标,则需要在__slots__
中包含'__weakref__'
。
现在让我们看看将__slots__
添加到Vector2d
的效果。
简单的槽节省度量
示例 11-16 展示了在Vector2d
中实现__slots__
。
示例 11-16. vector2d_v3_slots.py:__slots__
属性是Vector2d
的唯一添加
class Vector2d: __match_args__ = ('x', 'y') # ① __slots__ = ('__x', '__y') # ② typecode = 'd' # methods are the same as previous version
①
__match_args__
列出了用于位置模式匹配的公共属性名称。
②
相比之下,__slots__
列出了实例属性的名称,这些属性在这种情况下是私有属性。
为了测量内存节省,我编写了mem_test.py脚本。它接受一个带有Vector2d
类变体的模块名称作为命令行参数,并使用列表推导式构建一个包含 10,000,000 个Vector2d
实例的list
。在示例 11-17 中显示的第一次运行中,我使用vector2d_v3.Vector2d
(来自示例 11-7);在第二次运行中,我使用具有__slots__
的版本,来自示例 11-16。
示例 11-17. mem_test.py 创建了 10 百万个Vector2d
实例,使用了命名模块中定义的类
$ time python3 mem_test.py vector2d_v3 Selected Vector2d type: vector2d_v3.Vector2d Creating 10,000,000 Vector2d instances Initial RAM usage: 6,983,680 Final RAM usage: 1,666,535,424 real 0m11.990s user 0m10.861s sys 0m0.978s $ time python3 mem_test.py vector2d_v3_slots Selected Vector2d type: vector2d_v3_slots.Vector2d Creating 10,000,000 Vector2d instances Initial RAM usage: 6,995,968 Final RAM usage: 577,839,104 real 0m8.381s user 0m8.006s sys 0m0.352s
如示例 11-17 所示,当每个 10 百万个Vector2d
实例中使用__dict__
时,脚本的 RAM 占用量增长到了 1.55 GiB,但当Vector2d
具有__slots__
属性时,降低到了 551 MiB。__slots__
版本也更快。这个测试中的mem_test.py脚本基本上处理加载模块、检查内存使用情况和格式化结果。你可以在fluentpython/example-code-2e存储库中找到它的源代码。
提示
如果你处理数百万个具有数值数据的对象,你应该真的使用 NumPy 数组(参见“NumPy”),它们不仅内存高效,而且具有高度优化的数值处理函数,其中许多函数一次操作整个数组。我设计Vector2d
类只是为了在讨论特殊方法时提供背景,因为我尽量避免在可以的情况下使用模糊的foo
和bar
示例。
总结__slots__
的问题
如果正确使用,__slots__
类属性可能会提供显著的内存节省,但有一些注意事项:
- 你必须记得在每个子类中重新声明
__slots__
,以防止它们的实例具有__dict__
。 - 实例只能拥有
__slots__
中列出的属性,除非在__slots__
中包含'__dict__'
(但这样做可能会抵消内存节省)。 - 使用
__slots__
的类不能使用@cached_property
装饰器,除非在__slots__
中明确命名'__dict__'
。 - 实例不能成为弱引用的目标,除非在
__slots__
中添加'__weakref__'
。
本章的最后一个主题涉及在实例和子类中覆盖类属性。
覆盖类属性
Python 的一个显著特点是类属性可以用作实例属性的默认值。在Vector2d
中有typecode
类属性。它在__bytes__
方法中使用了两次,但我们设计上将其读取为self.typecode
。因为Vector2d
实例是在没有自己的typecode
属性的情况下创建的,所以self.typecode
将默认获取Vector2d.typecode
类属性。
但是,如果写入一个不存在的实例属性,就会创建一个新的实例属性,例如,一个typecode
实例属性,而同名的类属性则保持不变。但是,从那时起,每当处理该实例的代码读取self.typecode
时,实例typecode
将被检索,有效地遮蔽了同名的类属性。这打开了使用不同typecode
自定义单个实例的可能性。
默认的Vector2d.typecode
是'd'
,意味着每个向量分量在导出为bytes
时将被表示为 8 字节的双精度浮点数。如果在导出之前将Vector2d
实例的typecode
设置为'f'
,则每个分量将以 4 字节的单精度浮点数导出。示例 11-18 演示了这一点。
注意
我们正在讨论添加自定义实例属性,因此示例 11-18 使用了没有__slots__
的Vector2d
实现,如示例 11-11 中所列。
示例 11-18。通过设置以前从类继承的typecode
属性来自定义实例
>>> from vector2d_v3 import Vector2d >>> v1 = Vector2d(1.1, 2.2) >>> dumpd = bytes(v1) >>> dumpd b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@' >>> len(dumpd) # ① 17 >>> v1.typecode = 'f' # ② >>> dumpf = bytes(v1) >>> dumpf b'f\xcd\xcc\x8c?\xcd\xcc\x0c@' >>> len(dumpf) # ③ 9 >>> Vector2d.typecode # ④ 'd'
①](#co_a_pythonic_object_CO13-1)
默认的bytes
表示长度为 17 字节。
②
在v1
实例中将typecode
设置为'f'
。
③
现在bytes
转储的长度为 9 字节。
④
Vector2d.typecode
保持不变;只有v1
实例使用typecode
为'f'
。
现在应该清楚为什么Vector2d
的bytes
导出以typecode
为前缀:我们想要支持不同的导出格式。
如果要更改类属性,必须直接在类上设置,而不是通过实例。你可以通过以下方式更改所有实例(没有自己的typecode
)的默认typecode
:
>>> Vector2d.typecode = 'f'
然而,在 Python 中有一种惯用的方法可以实现更持久的效果,并且更明确地说明更改。因为类属性是公共的,它们会被子类继承,所以习惯上是通过子类来定制类数据属性。Django 类基视图广泛使用这种技术。示例 11-19 展示了如何实现。
示例 11-19。ShortVector2d
是Vector2d
的子类,只覆盖了默认的typecode
>>> from vector2d_v3 import Vector2d >>> class ShortVector2d(Vector2d): # ① ... typecode = 'f' ... >>> sv = ShortVector2d(1/11, 1/27) # ② >>> sv ShortVector2d(0.09090909090909091, 0.037037037037037035) # ③ >>> len(bytes(sv)) # ④ 9
①
创建ShortVector2d
作为Vector2d
的子类,只是为了覆盖typecode
类属性。
②
为演示构建ShortVector2d
实例sv
。
③
检查sv
的repr
。
④
检查导出字节的长度为 9,而不是之前的 17。
这个例子还解释了为什么我没有在Vector2d.__repr__
中硬编码class_name
,而是从type(self).__name__
获取它,就像这样:
# inside class Vector2d: def __repr__(self): class_name = type(self).__name__ return '{}({!r}, {!r})'.format(class_name, *self)
如果我在class_name
中硬编码,Vector2d
的子类如ShortVector2d
将不得不覆盖__repr__
以更改class_name
。通过从实例的type
中读取名称,我使__repr__
更安全地继承。
我们结束了构建一个简单类的覆盖,利用数据模型与 Python 的其他部分协作:提供不同的对象表示,提供自定义格式代码,公开只读属性,并支持 hash()
以与集合和映射集成。
流畅的 Python 第二版(GPT 重译)(六)(3)https://developer.aliyun.com/article/1484609