对于一个库或框架来说,Pythonic 是让 Python 程序员尽可能轻松自然地掌握如何执行任务
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? --Martijn Faassen, creator of Python and JavaScript frameworks
多亏了 Python 数据模型,您的用户定义类型可以像内置类型一样自然地运行。这可以在没有继承的情况下完成,靠的是鸭子类型的精神:您只需实现对象按预期运行所需的方法。
在前面的章节中,我们研究了许多内置对象的行为。我们现在将构建用户定义的类,使它们的行为就像真正的 Python 对象。您的应用程序类可能不需要也不应该实现本章示例中那么多的特殊方法。但是,如果您正在编写库或框架,那么使用您的类的程序员可能希望它们的行为类似于 Python 提供的类。实现这种期望是“Pythonic”的一种方式。
本章从第 1 章结束的地方开始,展示了如何实现在许多不同类型的 Python 对象中常见的几种特殊方法。
在本章中,我们将看到如何:
- 支持将对象转换为其他类型的内置函数(例如,repr()、bytes()、complex() 等)。
- 用类方法实现替代构造函数。
- 扩展 f-strings 使用的格式微语言、内置的 format() 和 str.format() 方法。
- 提供对属性的只读访问。
- 使对象可散列以用于集合和用作字典键。
- 使用 __slots__ 节省内存。
我们将在开发一个简单的二维欧几里得矢量类型 Vector2d 时完成所有这些工作。这段代码将是第 12 章中 N 维向量类的基础。
示例的演变将暂停以讨论两个概念性主题:
- 如何以及何时使用 @classmethod 和 @staticmethod 装饰器。
- Python 中的私有和受保护属性:用法、约定和限制。
本章的新内容
我在本章的第二段添加了一个新的题词和几句话来强调“Pythonic”的概念——这只是在第一版的最后讨论过。
“Formatted Displays”已更新以提及 Python 3.6 中引入的 f -string。这是一个很小的变化,因为 f-strings 支持与 format() 内置格式和 str.format() 方法相同的格式化微语言,所以任何以前实现的 __format__ 方法都可以与 f-strings 一起使用。
本章的其余部分几乎没有变化——特殊方法自 Python 3.0 以来基本相同,核心思想出现在 Python 2.2 中。
让我们从使用对象表示方法开始。
对象表现形式
每一种面向对象的语言都至少有一种从任何对象获取字符串表示的标准方法。 Python提供了两个方式:
repr()
????????返回一个表示开发人员希望看到的对象的字符串。当 Python 控制台或调试器显示一个对象时,你会得到它。
str()
????????返回一个表示用户希望看到的对象的字符串。这就是你在 print() 一个对象时得到的。
正如我们在第 1 章中看到的,特殊方法 __repr__ 和 __str__ 支持 repr() 和 str()。
还有两个额外的特殊方法来支持对象的替代表示:__bytes__ 和 __format__。__bytes__ 方法类似于 __str__:它由 bytes() 调用以获取表示为字节序列的对象。关于 __format__,它被 f-strings、内置函数 format() 和 str.format() 方法使用。他们调用 obj.__format__(format_spec) 来获取使用特殊格式代码的对象的字符串显示。我们将在下一个示例中介绍 __bytes__,然后介绍 __format__。
再谈向量类
为了演示用于生成对象表示的许多方法,我们将使用一个类似于我们在第 1 章中看到的 Vector2d 类。我们将在本节和以后的部分中以此为基础。示例 11-1 说明了我们期望 Vector2d 实例的基本行为。
例 11-1。 Vector2d 实例有几种表示形式
>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y) 1
3.0 4.0
>>> x, y = v1 2
>>> x, y
(3.0, 4.0)
>>> v1 3
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1)) 4
>>> v1 == v1_clone 5
True
>>> print(v1) 6
(3.0, 4.0)
>>> octets = bytes(v1) 7
>>> octets
b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
>>> abs(v1) 8
5.0
>>> bool(v1), bool(Vector2d(0, 0)) 9
(True, False)
- Vector2d 的组件可以作为属性直接访问(没有 getter 方法调用)。
- Vector2d 可以解包为变量元组。
- Vector2d 的 repr 模拟用于构造实例的源代码。
- 在这里使用 eval 表明 Vector2d 的 repr 是其构造函数调用的忠实表示。
- Vector2d 支持与 == 比较;这对测试很有用
- print 调用 str,它为 Vector2d 生成有序对显示。
-
bytes 使用 __bytes__ 方法生成二进制表示。 -
abs 使用 __abs__ 方法返回 Vector2d 的模。 -
bool 使用 __bool__ 方法为模为0的 Vector2d 返回 False,否则返回 True。
示例 11-1 中的 Vector2d 在 vector2d_v0.py(示例 11-2)中实现。代码基于示例 1-2,除了 + 和 * 操作的方法,我们将在后面的第 16 章中看到。我们将添加 == 的方法,因为它对测试很有用。此时,Vector2d 使用几种特殊方法来提供 Pythonista 在设计良好的对象中期望的操作。
例 11-2。 vector2d_v0.py:到目前为止的方法都是特殊的方法
class Vector2d:
typecode = 'd' 1
def __init__(self, x, y):
self.x = float(x) 2
self.y = float(y)
def __iter__(self):
return (i for i in (self.x, self.y)) 3
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self) 4
def __str__(self):
return str(tuple(self)) 5
def __bytes__(self):
return (bytes([ord(self.typecode)]) + 6
bytes(array(self.typecode, self))) 7
def __eq__(self, other):
return tuple(self) == tuple(other) 8
def __abs__(self):
return math.hypot(self.x, self.y) 9
def __bool__(self):
return bool(abs(self)) 10
- typecode 是我们在 Vector2d 实例与字节之间转换时将使用的类属性。
- 在 __init__ 中将 x 和 y 转换为浮点数可以及早捕获错误,这在使用不合适的参数调用 Vector2d 时很有帮助。
- __iter__ 使 Vector2d 可迭代;这就是解包工作的原因(例如,x, y = my_vector)。我们只是通过使用生成器表达式来一个接一个地生成组件来实现它。
- __repr__ 通过使用 {!r} 对组件进行插值来构建字符串以获取它们的 repr;因为 Vector2d 是可迭代的,*self 将 x 和 y 组件提供给格式化。
- 从可迭代的 Vector2d 中,很容易构建一个元组以显示为有序对。
- 为了生成字节,我们将类型代码转换为字节并连接……
- ...从通过迭代实例构建的array转换而来的bytes。
- 要快速比较所有组件,请从操作数中构建元组。这适用于作为 Vector2d 实例的操作数,但存在问题。请参阅以下警告。
- 模是由 x 和 y 分量形成的直角三角形的斜边的长度。
- __bool__ 使用 abs(self) 计算模,然后将其转换为 bool,因此 0.0 变为 False,非零为 True。
Warning:
示例 11-2 中的 __eq__ 方法适用于 Vector2d 操作数,但在将 Vector2d 实例与其他持有相同数值的可迭代对象(例如 Vector(3, 4) == [3, 4])进行比较时,也会返回 True。这可能被视为功能或错误。进一步的讨论需要等到第 16 章,当我们讨论运算符重载时。
我们有一套相当完整的基本方法,但我们仍然需要一种方法来从 bytes() 生成的二进制表示重建 Vector2d。
替代构造函数
由于我们可以将 Vector2d 导出为字节序列,自然我们需要一种从二进制序列中导入 Vector2d 的方法。查看标准库以获取灵感,我们发现 array.array 有一个名为 .frombytes 的类方法符合我们的目的——我们在?“Arrays”中看到了它。我们在 vector2d_v1.py 中的 Vector2d 的类方法中采用它的名称并使用它的功能(示例 11-3)。
例 11-3。 vector2d_v1.py 的一部分:此代码段仅显示了 frombytes 类方法,已添加到 vector2d_v0.py 中的 Vector2d 定义(示例 11-2)
@classmethod 1
def frombytes(cls, octets): 2
typecode = chr(octets[0]) 3
memv = memoryview(octets[1:]).cast(typecode) 4
return cls(*memv) 5
- classmethod 装饰器修饰了该方法,以便可以直接在类上调用它。
- 不传入self参数;相反,类本身作为第一个参数传递——通常命名为 cls。
- 从第一个字节读取typecode。
- 从octets二进制字节序列创建一个memoryview,并使用typecode对其进行转换。
- 将转换产生的memoryview解包到构造函数所需的一对参数中。
我刚刚使用了?classmethod 装饰器,它是?Python所特有的,所以让我们谈谈它。
classmethod 和 staticmethod
Python 教程中没有提到 classmethod 装饰器,也没有提到staticmethod。任何在 Java 中学习过面向对象的人都可能想知道为什么 Python 有这两种装饰器,而不是只有其中一种。
让我们从classmethod开始。示例 11-3 展示了它的用途:定义一个对类而不是实例进行操作的方法。classmethod 改变了方法的调用方式,因此它接收类本身作为第一个参数,而不是一个实例。它最常见的用途是用于替代构造函数,例如示例 11-3 中的 frombytes。请注意 frombytes 的最后一行实际上是如何通过调用 cls 参数来构建新实例来使用它的:cls(*memv)。
相比之下,staticmethod 装饰器更改方法,使其不接收特殊的第一个参数。本质上,静态方法就像一个普通函数,它恰好存在于类体中,而不是在模块级别定义。示例 11-4 对比了 classmethod 和 staticmethod 的操作。
例 11-4。比较classmethod和staticmethod的行为
>>> class Demo:
... @classmethod
... def klassmeth(*args):
... return args 1
... @staticmethod
... def statmeth(*args):
... return args 2
...
>>> Demo.klassmeth() 3
(<class '__main__.Demo'>,)
>>> Demo.klassmeth('spam')
(<class '__main__.Demo'>, 'spam')
>>> Demo.statmeth() 4
()
>>> Demo.statmeth('spam')
('spam',)
- klassmeth 只返回所有位置参数。
- statmeth 也是如此。
- 无论您如何调用它,Demo.klassmeth 都会接收 Demo 类作为第一个参数。
- Demo.statmeth 的行为就像一个普通的函数。
Note:
classmethod 装饰器显然很有用,但我从未见staticmethod的引人注目的用例。如果要定义一个不与类交互的函数,只需在模块中定义即可。也许函数是密切相关的,即使它从不接触类,所以你可能想把它放在代码附近。即便如此,在同一模块中的类之前或之后定义函数对于所有实际目的来说已经足够接近了。
然我们已经看到了 classmethod 的好处(而 staticmethod 不是很有用),让我们回到对象表示的问题,看看如何支持格式化输出。
格式化显示
f -string、format() 内置函数和 str.format() 方法通过调用它们的 .__format__(format_spec) 方法将实际格式委托给每种类型。format_spec 是格式说明符,它是:
- format(my_obj, format_spec) 中的第二个参数,或
- 在 f-string 或 fmt.str.format() 中的 fmt 中用 {} 分隔的替换字段中冒号后出现的任何内容
例如:
>>> brl = 1 / 4.82 # BRL to USD currency conversion rate
>>> brl
0.20746887966804978
>>> format(brl, '0.4f') 1
'0.2075'
>>> '1 BRL = {rate:0.2f} USD'.format(rate=brl) 2
'1 BRL = 0.21 USD'
>>> f'1 USD = {1 / brl:0.2f} BRL' 3
'1 USD = 4.82 BRL'
- 格式说明符是“0.4f”。
- 格式说明符是“0.2f”。替换字段中的rate部分不是格式说明符的一部分。它确定 .format() 的哪个关键字参数进入该替换字段。
- 同样,格式说明符是“0.2f”。 1 / brl 表达式不是它的一部分。
第二个和第三个标注说明了一个重要点:格式字符串(例如“{0.mass:5.3e}”)实际上使用了两个单独的符号。冒号左边的 '0.mass' 是替换字段语法的 field_name 部分,它可以是 f-string中的任意表达式。冒号后的“5.3e”是格式说明符。格式说明符中使用的符号称为Format Specification Mini-Language。
TIP: 如果你不熟悉 f-strings、format() 和 str.format(),课堂经验告诉我最好先学习 format() 内置函数,它只使用Format Specification Mini-Language.了解其要点后,请阅读Formatted string literals和Format String Syntax语法以了解 {:} 替换字段表示法,它用于 f-strings 和 str.format() 方法(包括 !s、!r 和 !转换标志)。f-string不会使 str.format() 过时:大多数时候 f-string可以解决问题,但有时最好在其他地方指定格式化字符串,而不是将其呈现的位置。
标准格式说明符:
format_spec ::= [[fill]align][sign][#][0][width][grouping_option][.precision][type]
fill ::= <any character>
align ::= "<" | ">" | "=" | "^"
sign ::= "+" | "-" | " "
width ::= digit+
grouping_option ::= "_" | ","
precision ::= digit+
type ::= "b" | "c" | "d" | "e" | "E" | "f" | "F" | "g" | "G" | "n" | "o" | "s" | "x" | "X" | "%"
一些内置类型在格式规范微语言中有自己的表示代码。例如,在其他几个代码中,int 类型分别支持 b 和 x 用于基数 2 和基数 16 输出,而 float 实现 f 用于定点显示和 % 用于百分比显示:
>>> format(42, 'b')
'101010'
>>> format(2 / 3, '.1%')
'66.7%'
格式规范微语言是可扩展的,因为每个类都可以根据自己的喜好解释 format_spec 参数。例如, datetime 模块中的类在 strftime() 函数及其 __format__ 方法中使用相同的格式代码。以下是使用 format() 内置方法和 str.format() 方法的几个示例:
>>> from datetime import datetime
>>> now = datetime.now()
>>> format(now, '%H:%M:%S')
'18:49:05'
>>> "It's now {:%I:%M %p}".format(now)
"It's now 06:49 PM"
如果类没有 __format__,则从 object 继承的方法返回 str(my_object)。因为 Vector2d 有一个 __str__,所以可以这样做:
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
但是,如果您传递格式说明符, object.__format__ 会引发 TypeError:
>>> format(v1, '.3f')
Traceback (most recent call last):
...
TypeError: non-empty format string passed to object.__format__
我们将通过实现我们自己的格式微语言来解决这个问题。第一步是假设用户提供的格式说明符用于格式化向量的每个浮点分量。这是我们想要的结果:
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
>>> format(v1, '.2f')
'(3.00, 4.00)'
>>> format(v1, '.3e')
'(3.000e+00, 4.000e+00)'
示例 11-5 实现了 __format__ 以生成刚刚输出的显示。
例 11-5。 Vector2d.format 方法,第一版
# inside the Vector2d class
def __format__(self, fmt_spec=''):
components = (format(c, fmt_spec) for c in self) 1
return '({}, {})'.format(*components) 2
- 使用内置的format方法将 fmt_spec 应用于每个向量组件,构建可迭代的格式化字符串。
- 将格式化的字符串插入公式'(x, y)'。
现在让我们为我们的迷你语言添加一个自定义格式代码:如果格式说明符以“p”结尾,我们将在极坐标中显示向量:<r, θ>,其中 r 是幅度,θ (theta) 是以弧度为单位的角度。格式说明符的其余部分('p' 之前的任何内容)将像以前一样使用。
TIP
在为自定义格式代码选择字母时,我避免与其他类型使用的代码重叠。在格式规范微语言中,我们看到整数使用代码“bcdoxXn”,浮点数使用“eEfFgGn%”,字符串使用“s”。所以我选择了“p”作为极坐标。因为每个类使用自己的方式解释这些代码,所以以自定义格式为新类型重用代码字母不是错误,但可能会使用户感到困惑。
为了生成极坐标,我们已经有 __abs__ 方法用于计算模,我们将使用 math.atan2() 函数编写一个简单的角度方法来获取角度。这是代码:
# inside the Vector2d class
def angle(self):
return math.atan2(self.y, self.x)
有了这个,我们可以增强我们的 __format__ 以生成极坐标。请参见示例 11-6。
例 11-6。 Vector2d.format 方法,第二版,现在使用极坐标
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'): 1
fmt_spec = fmt_spec[:-1] 2
coords = (abs(self), self.angle()) 3
outer_fmt = '<{}, {}>' 4
else:
coords = self 5
outer_fmt = '({}, {})' 6
components = (format(c, fmt_spec) for c in coords) 7
return outer_fmt.format(*components) 8
- 格式以“p”结尾:使用极坐标。
- ?fmt_spec 中删除“p”后缀。
- 构建极坐标元组:(magnitude,angle)。
- 使用尖括号配置外部格式。
- 否则,对直角坐标使用 self 的 x, y 分量。
- 使用括号配置外部格式。
- 使用组件作为格式化字符串生成可迭代对象。
- 将格式化的字符串插入外部格式。
在示例 11-6 中,我们得到类似于以下的结果:
>>> format(Vector2d(1, 1), 'p')
'<1.4142135623730951, 0.7853981633974483>'
>>> format(Vector2d(1, 1), '.3ep')
'<1.414e+00, 7.854e-01>'
>>> format(Vector2d(1, 1), '0.5fp')
'<1.41421, 0.78540>'
正如本节所示,扩展格式规范微语言以支持用户定义的类型并不难。
现在让我们转向一个不只是外观的主题:我们将使 Vector2d 可散列,因此我们可以构建向量集合,或将它们用作 dict 键。
可散列的Vector2D
正如定义的那样,到目前为止我们的 Vector2d 实例是不可散列的,所以我们不能把它们放在一个集合中:
>>> v1 = Vector2d(3, 4)
>>> hash(v1)
Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
>>> set([v1])
Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
为了使 Vector2d 可散列,我们必须实现 __hash__(__eq__ 也是必需的,我们已经有了它)。我们还需要使向量实例不可变,正如我们在“什么是可哈希的”中所见。
现在,任何人都可以执行 v1.x = 7 并且代码中没有任何内容表明禁止更改 Vector2d。这是我们想要的行为:
>>> v1.x, v1.y
(3.0, 4.0)
>>> v1.x = 7
Traceback (most recent call last):
...
AttributeError: can't set attribute
我们将通过将示例 11-7 中的 x 和 y 组件设为只读属性来实现这一点。
例 11-7。 vector2d_v3.py:此处仅显示使 Vector2d 不可变所需的更改;请参阅示例 11-11 中的完整列表
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x) 1
self.__y = float(y)
@property 2
def x(self): 3
return self.__x 4
@property 5
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y)) 6
# remaining methods: same as previous Vector2d
- 恰好使用两个前导下划线(带有零个或一个尾随下划线)来使属性私有
- @property 装饰器标记属性的 getter 方法。
- getter 方法以其公开的公共属性命名:x。
- 只需返回 self.__x。
- 对 y 属性重复相同的公式。
- 每个只读取 x, y 组件的方法都可以保持原样,通过 self.x 和 self.y 而不是私有属性读取公共属性,因此该清单省略了该类的其余代码。
Note:Vector.x 和 Vector.y 是只读属性的示例。读/写属性将在第 23 章中介绍,我们将深入研究 @property。
既然我们的向量相当安全,不会发生意外的变化,我们可以实现 __hash__ 方法。它应该返回一个 int 并且理想情况下考虑也在 __eq__ 方法中使用的对象属性的散列值,因为比较相等的对象应该具有相同的散列值。__hash__ 特殊方法文档建议使用按位异或运算符 (^) 来混合各分量的散列值,这就是我们所做的。Vector2d.__hash__ 方法的代码非常简单,如例 11-8 所示。
例 11-8。 vector2d_v3.py:hash的实现
# inside class Vector2d:
def __hash__(self):
return hash(self.x) ^ hash(self.y)
添加 __hash__ 方法后,我们现在有了可散列的向量:
>>> v1 = Vector2d(3, 4)
>>> v2 = Vector2d(3.1, 4.2)
>>> hash(v1), hash(v2)
(7, 384307168202284039)
>>> set([v1, v2])
{Vector2d(3.1, 4.2), Vector2d(3.0, 4.0)}
TIP:实现属性或以其他方式保护实例属性来创建可散列类型并不是绝对必要的。正确实现 __hash__ 和 __eq__ 就足够了。但是一个可散列对象的值永远不应该改变,所以这提供了一个讨论只读属性的绝佳机会。
如果您正在创建一个具有合理标量数值的类型,您还可以实现由 int() 和 float() 构造函数调用的 __int__ 和 __float__ 方法——它们在某些上下文中用于类型强制转换。还有一个 __complex__ 方法来支持 complex() 内置构造函数。也许 Vector2d 应该提供 __complex__,??但我将把它留给你作为练习。
支持位置模式
到目前为止,Vector2d 实例与关键字类模式兼容——在“关键字类模式”中有介绍。
例如,所有这些关键字模式都按预期工作:
例 11-9。 Vector2d 主题的关键字模式—需要 Python 3.10。
def keyword_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(x=0, y=0):
print(f'{v!r} is null')
case Vector2d(x=0):
print(f'{v!r} is vertical')
case Vector2d(y=0):
print(f'{v!r} is horizontal')
case Vector2d(x=x, y=y) if x==y:
print(f'{v!r} is diagonal')
case _:
print(f'{v!r} is awesome')
但是,如果您尝试使用这样的位置模式:
case Vector2d(_, 0):
print(f'{v!r} is horizontal')
你会得到: ?
TypeError: Vector2d() accepts 0 positional sub-patterns (1 given)
为了使 Vector2d 能够适用于位置模式,我们需要添加一个名为 __match_args__ 的类属性,按照它们将用于位置模式匹配的顺序列出实例属性:
class Vector2d:
__match_args__ = ('x', 'y')
# etc...
现在我们可以在编写模式以匹配 Vector2d 主题时节省一些代码:
例 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 上工作了一段时间,只显示了片段,因此示例 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)
>>> hash(v1), hash(v2)
(7, 384307168202284039)
>>> 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) ^ hash(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)
回顾一下,在本节和前几节中,我们看到了一些基本的特殊方法,您可能想要实现这些方法以获得完整的对象。
Note: 如果您的应用程序需要它们,您应该只实现这些特殊方法。最终用户并不关心组成应用程序的对象是否是“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
Name mangling 是一种安全措施,而不能保证万无一失:它旨在防止意外访问而不是恶意窥探。图 11-1 展示了另一种安全装置。
任何知道私有名称如何被修改的人都可以直接读取私有属性,如例 11-12 的最后一行所示——这对于调试和序列化实际上很有用。他们还可以通过编写 v1._Vector2d__x = 7 直接为 Vector2d 的私有组件赋值。 但是如果你在生产代码中这样做,如果出现问题,那么可别抱怨。?并非所有 Pythonistas 都喜欢名称改写功能,写为 self.__x 的名称的倾斜外观也不是很美观。有些人更喜欢避免这种语法,并按照约定仅使用一个下划线前缀来“保护”属性(例如,self._x)。对自动双下划线修改的批评者认为,应该通过命名约定来解决对意外属性破坏的担忧。 Ian Bicking——pip、virtualenv 和其他项目的创造者——写道:
永远不要使用两个前导下划线。这是令人讨厌的自私行为。如果名称冲突是一个问题,请改用显式名称修改(例如,_MyThing_blahblah)。这与双下划线本质上是一样的,只是它在双下划线方式企图遮挡的地方是透明的。
单个下划线前缀在属性名称中使用时对 Python 解释器没有特殊意义,但 Python 程序员之间的一个非常强烈的约定是您不应从类外部访问此类属性。尊重使用单个 _ 标记其属性的对象的隐私很容易,就像尊重 ALL_CAPS(全部大写) 中的变量应被视为常量的约定一样容易。
带有单个 _ 前缀的属性在 Python 文档的某些角落被称为“受保护的”属性。按照惯例以 self._x 形式“保护”属性的做法很普遍,但称“受保护”属性并不常见。有些人甚至称其为“私有”属性。
总结:Vector2d 组件是“私有的”,而我们的 Vector2d 实例是“不可变的”——用引号引起来——因为没有办法让它们真正私有和不可变。
现在我们将回到 Vector2d 类。在下一节中,我们将介绍一个影响对象内部存储的特殊属性(不是方法),它可能对内存的使用产生巨大影响,但对其公共接口影响很小:__slots__。
使用 __slots__ 节省内存
默认情况下,Python 将每个实例的属性存储在名为 __dict__ 的字典中。正如我们在“Practical Consequences of How dict Works”中看到的,字典具有显着的内存开销——即使在该部分提到的优化。但是,如果您定义了一个名为 __slots__ 的类属性来保存属性名称序列,Python 将使用另一种存储模型来存储实例属性:在 __slots__ 中命名的属性存储在一个隐藏的数组或引用中,它使用的内存比字典少。让我们通过简单的例子来看看它是如何工作的。
例 11-13。 Pixel 类使用`slots。
>>> class Pixel:
... __slots__ = ('x', 'y') 1
...
>>> p = Pixel() 2
>>> p.__dict__ 3
Traceback (most recent call last):
...
AttributeError: 'Pixel' object has no attribute '__dict__'
>>> p.x = 10 4
>>> p.y = 20
>>> p.color = 'red' 5
Traceback (most recent call last):
...
AttributeError: 'Pixel' object has no attribute 'color'
- __slots__ 在创建类时必须存在;稍后添加或更改它无效。属性名称可能在元组或列表中,但我更喜欢元组,以明确更改它没有意义。
- 创建一个 Pixel 实例,因为我们看到了 __slots__ 对实例的影响。
- 第一个效果:Pixel 实例没有 __dict__。
- 正常设置 p.x 和 p.y 属性。
- 第二个效果:尝试设置未在 __slots__ 中列出的属性会引发 AttributeError。
到现在为止还挺好。现在让我们创建一个 Pixel 的子类来看看 __slots__ 违反直觉的一面:
例 11-14。 OpenPixel 是 Pixel 的子类。
>>> class OpenPixel(Pixel): 1
... pass
...
>>> op = OpenPixel()
>>> op.__dict__ 2
{}
>>> op.x = 8 3
>>> op.__dict__ 4
{}
>>> op.x 5
8
>>> op.color = 'green' 6
>>> op.__dict__ 7
{'color': 'green'}
- OpenPixel 没有声明自己的属性。
- 令人惊讶的是:OpenPixel 的实例有一个 __dict__。
- 如果设置属性 x(在基类 Pixel 的 __slots__ 中命名)...
- …它没有存储在实例 __dict__ 中…
- ...但它存储在实例中的隐藏引用数组中。
- 如果您设置了未在 __slots__ 中命名的属性...
- ...它存储在实例 __dict__ 中。
示例 11-14 显示 __slots__ 的效果仅由子类部分继承。要确保子类的实例没有 __dict__,您必须在子类中再次声明 __slots__。
如果你声明 __slots__ = () (一个空元组),那么子类的实例将没有 __dict__ 并且只接受在基类的 __slots__ 中命名的属性。
如果您希望子类具有其他属性,请在 __slots__ 中命名它们:
例 11-15。 ColorPixel,Pixel 的另一个子类。
>>> class ColorPixel(Pixel):
... __slots__ = ('color',) 1
>>> cp = ColorPixel()
>>> cp.__dict__ 2
Traceback (most recent call last):
...
AttributeError: 'ColorPixel' object has no attribute '__dict__'
>>> cp.x = 2
>>> cp.color = 'blue' 3
>>> 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 装饰器,这是必要的(在“Step 5: Caching Properties with functools”中介绍)。
当然,在 __slots__ 中使用 '__dict__' 可能会完全违背其目的,这取决于每个实例中静态和动态属性的数量以及它们的使用方式。
您可能希望保留的另一个特殊的实例属性是 __weakref__,这是对象支持弱引用所必需的(在“del 和垃圾收集”中简要提及)。该属性默认存在于用户定义类的实例中。但是,如果类定义了 __slots__,并且您需要将实例作为弱引用的目标,那么您需要在 __slots__ 中命名的属性中包含“__weakref__”。
现在让我们看看在 Vector2d 中添加 __slots__ 的效果。
例 11-16。 vector2d_v3_slots.py:slots 属性是 Vector2d 的唯一补充
class Vector2d:
__match_args__ = ('x', 'y') 1
__slots__ = ('__x', '__y') 2
typecode = 'd'
# methods are the same as previous version
- __match_args__ 列出位置模式匹配的公共属性名称。
- 相反, __slots__ 列出了实例属性的名称,在这种情况下是私有属性。
为了衡量内存节省,我编写了 mem_test.py 脚本。它以带有 Vector2d 类变体的模块的名称作为命令行参数,并使用列表理解来构建一个包含 10,000,000 个 Vector2d 实例的列表。在示例 11-17 所示的第一次运行中,我使用了 vector2d_v3.Vector2d(来自示例 11-7);在第二次运行中,我使用了示例 11-16 中带有 __slots__ 的版本。
例 11-17。 mem_test.py 使用命名模块中定义的类创建了 1000 万个 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
(.py310b4) TW-LR-MBP:11-pythonic-obj luciano$ 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 所示,当实例 __dict__ 用于 1000 万个 Vector2d 实例中的每个实例时,脚本的 RAM 占用量增加到 1.55 GiB,但当 Vector2d 具有 __slots__ 属性时,该占用量减少到 551 MiB。__slots__ 版本也更快。该测试中的 mem_test.py 脚本主要处理加载模块、检查内存使用情况和格式化结果。您可以在 fluentpython/example-code-2e 仓库中找到其源代码。?
TIP:如果您正在处理数百万个带有数字数据的对象,您真的应该使用 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 时,都会检索实例类型代码,从而有效地隐藏同名的类属性。这开启了使用不同类型代码自定义单个实例的可能性。
默认的 Vector2d.typecode 是 'd',这意味着在导出为字节时,每个向量分量都将表示为 8 字节的双精度浮点数。如果我们在导出之前将 Vector2d 实例的类型代码设置为“f”,则每个组件都将导出为 4 字节单精度浮点数。示例 11-18 展示了这点。
Note:我们正在讨论添加自定义实例属性,因此示例 11-18 使用 Vector2d 实现,而没有 __slots__,如示例 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) 1
17
>>> v1.typecode = 'f' 2
>>> dumpf = bytes(v1)
>>> dumpf
b'f\xcd\xcc\x8c?\xcd\xcc\x0c@'
>>> len(dumpf) 3
9
>>> Vector2d.typecode 4
'd'
- 默认字节表示为 17 字节长。
- 在 v1 实例中将 typecode 设置为 'f'。
- 现在字节转储是 9 个字节长。
- Vector2d.typecode 不变;只有 v1 实例使用typecode “f”。
现在应该清楚为什么 Vector2d 的字节导出以typecode为前缀:我们希望支持不同的导出格式。
如果要更改类属性,则必须直接在类上设置它,而不是通过实例。您可以通过执行以下操作更改所有实例(没有自己的typecode)的默认typecode:
>>> Vector2d.typecode = 'f'
但是,有一种惯用的 Python 方法可以实现更持久的效果,并且更明确地说明更改。因为类属性是公共的,它们被子类继承,所以通常的做法是为了自定义类数据属性而进行子类化。Django 基于类的视图广泛使用了这种技术。示例 11-19 展示了方法。
例 11-19。 ShortVector2d 是 Vector2d 的子类,它只覆盖默认typecode
>>> from vector2d_v3 import Vector2d
>>> class ShortVector2d(Vector2d): 1
... typecode = 'f'
...
>>> sv = ShortVector2d(1/11, 1/27) 2
>>> sv
ShortVector2d(0.09090909090909091, 0.037037037037037035) 3
>>> len(bytes(sv)) 4
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 进行了硬编码,那么像 ShortVector2d 这样的 Vector2d 子类将不得不覆盖 __repr__ 来更改 class_name。通过从实例类型中读取__name__,我让 __repr__ 更安全地继承。
到此结束我们对构建一个简单类的介绍,该类利用数据模型与 Python 的其余部分配合良好-提供不同的对象表示,提供自定义格式化代码,公开只读属性,并支持 hash() 与集合和映射集成。
|