第三部分:类和协议
第十一章:一个 Python 风格的对象
使库或框架成为 Pythonic 是为了让 Python 程序员尽可能轻松和自然地学会如何执行任务。
Python 和 JavaScript 框架的创造者 Martijn Faassen。¹
由于 Python 数据模型,您定义的类型可以像内置类型一样自然地行为。而且这可以在不继承的情况下实现,符合鸭子类型的精神:你只需实现对象所需的方法,使其行为符合预期。
在之前的章节中,我们研究了许多内置对象的行为。现在我们将构建行为像真正的 Python 对象一样的用户定义类。你的应用程序类可能不需要并且不应该实现本章示例中那么多特殊方法。但是如果你正在编写一个库或框架,那么将使用你的类的程序员可能希望它们的行为像 Python 提供的类一样。满足这种期望是成为“Pythonic”的一种方式。
本章从第一章结束的地方开始,展示了如何实现在许多不同类型的 Python 对象中经常看到的几个特殊方法。
在本章中,我们将看到如何:
- 支持将对象转换为其他类型的内置函数(例如
repr()
、bytes()
、complex()
等) - 实现一个作为类方法的替代构造函数
- 扩展 f-strings、
format()
内置函数和str.format()
方法使用的格式迷你语言 - 提供对属性的只读访问
- 使对象可哈希以在集合中使用和作为
dict
键 - 使用
__slots__
节省内存
当我们开发Vector2d
时,我们将做所有这些工作,这是一个简单的二维欧几里德向量类型。这段代码将是第十二章中 N 维向量类的基础。
示例的演变将暂停讨论两个概念性主题:
- 如何以及何时使用
@classmethod
和@staticmethod
装饰器 - Python 中的私有和受保护属性:用法、约定和限制
本章的新内容
我在本章的第二段中添加了一个新的引语和一些文字,以解释“Pythonic”的概念——这在第一版中只在最后讨论过。
“格式化显示”已更新以提及在 Python 3.6 中引入的 f-strings。这是一个小改变,因为 f-strings 支持与format()
内置和str.format()
方法相同的格式迷你语言,因此以前实现的__format__
方法可以与 f-strings 一起使用。
本章的其余部分几乎没有变化——自 Python 3.0 以来,特殊方法大部分相同,核心思想出现在 Python 2.2 中。
让我们开始使用对象表示方法。
对象表示
每种面向对象的语言至少有一种标准方法可以从任何对象获取字符串表示。Python 有两种:
repr()
返回一个表示开发者想要看到的对象的字符串。当 Python 控制台或调试器显示一个对象时,你会得到这个。
str()
返回一个表示用户想要看到的对象的字符串。当你print()
一个对象时,你会得到这个。
特殊方法__repr__
和__str__
支持repr()
和str()
,正如我们在第一章中看到的。
有两个额外的特殊方法支持对象的替代表示:__bytes__
和__format__
。__bytes__
方法类似于__str__
:它被bytes()
调用以获取对象表示为字节序列。关于__format__
,它被 f-strings、内置函数format()
和str.format()
方法使用。它们调用obj.__format__(format_spec)
以获取使用特殊格式代码的对象的字符串显示。我们将在下一个示例中介绍__bytes__
,然后介绍__format__
。
警告
如果您从 Python 2 转换而来,请记住,在 Python 3 中,__repr__
,__str__
和 __format__
必须始终返回 Unicode 字符串(类型 str
)。 只有 __bytes__
应该返回字节序列(类型 bytes
)。
向量类 Redux
为了演示生成对象表示所使用的许多方法,我们将使用类似于我们在第一章中看到的 Vector2d
类。 我们将在本节和未来的章节中继续完善它。 示例 11-1 说明了我们从 Vector2d
实例中期望的基本行为。
示例 11-1。 Vector2d
实例有几种表示形式
>>> 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)
①
Vector2d
的组件可以直接作为属性访问(无需 getter 方法调用)。
②
Vector2d
可以解包为一组变量的元组。
③
Vector2d
的 repr
模拟了构造实例的源代码。
④
在这里使用 eval
显示 Vector2d
的 repr
是其构造函数调用的忠实表示。²
⑤
Vector2d
支持与 ==
的比较;这对于测试很有用。
⑥
print
调用 str
,对于 Vector2d
会产生一个有序对显示。
⑦
bytes
使用 __bytes__
方法生成二进制表示。
⑧
abs
使用 __abs__
方法返回 Vector2d
的大小。
⑨
bool
使用 __bool__
方法,对于零大小的 Vector2d
返回 False
,否则返回 True
。
Vector2d
来自示例 11-1,在 vector2d_v0.py 中实现(示例 11-2)。 该代码基于示例 1-2,除了 +
和 *
操作的方法,我们稍后会看到在第十六章中。 我们将添加 ==
方法,因为它对于测试很有用。 到目前为止,Vector2d
使用了几个特殊方法来提供 Pythonista 在设计良好的对象中期望的操作。
示例 11-2。 vector2d_v0.py:到目前为止,所有方法都是特殊方法
from array import array import math class Vector2d: typecode = 'd' # ① def __init__(self, x, y): self.x = float(x) # ② self.y = float(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 __abs__(self): return math.hypot(self.x, self.y) # ⑨ def __bool__(self): return bool(abs(self)) # ⑩
①
typecode
是我们在将 Vector2d
实例转换为/从 bytes
时将使用的类属性。
②
在 __init__
中将 x
和 y
转换为 float
可以及早捕获错误,这在 Vector2d
被使用不合适的参数调用时很有帮助。
③
__iter__
使 Vector2d
可迭代;这就是解包工作的原因(例如,x, y = my_vector
)。 我们简单地通过使用生成器表达式逐个产生组件来实现它。³
④
__repr__
通过使用 {!r}
插值组件来构建字符串;因为 Vector2d
是可迭代的,*self
将 x
和 y
组件提供给 format
。
⑤
从可迭代的 Vector2d
中,很容易构建一个用于显示有序对的 tuple
。
⑥
要生成 bytes
,我们将类型码转换为 bytes
并连接…
⑦
…通过迭代实例构建的 array
转换为的 bytes
。
⑧
要快速比较所有组件,将操作数构建为元组。 这适用于 Vector2d
的实例,但存在问题。 请参阅以下警告。
⑨
大小是由x
和y
分量形成的直角三角形的斜边的长度。
⑩
__bool__
使用abs(self)
来计算大小,然后将其转换为bool
,因此0.0
变为False
,非零为True
。
警告
示例 11-2 中的__eq__
方法适用于Vector2d
操作数,但当将Vector2d
实例与持有相同数值的其他可迭代对象进行比较时也返回True
(例如,Vector(3, 4) == [3, 4]
)。这可能被视为一个特性或一个错误。进一步讨论需要等到第十六章,当我们讨论运算符重载时。
我们有一个相当完整的基本方法集,但我们仍然需要一种方法来从bytes()
生成的二进制表示中重建Vector2d
。
另一种构造方法
由于我们可以将Vector2d
导出为字节,自然我们需要一个从二进制序列导入Vector2d
的方法。在标准库中寻找灵感时,我们发现array.array
有一个名为.frombytes
的类方法,非常适合我们的目的——我们在“数组”中看到了它。我们采用其名称,并在vector2d_v1.py中的Vector2d
类方法中使用其功能(示例 11-3)。
示例 11-3. vector2d_v1.py 的一部分:此片段仅显示了frombytes
类方法,添加到 vector2d_v0.py 中的Vector2d
定义中(示例 11-2)
@classmethod # ① def frombytes(cls, octets): # ② typecode = chr(octets[0]) # ③ memv = memoryview(octets[1:]).cast(typecode) # ④ return cls(*memv) # ⑤
①
classmethod
装饰器修改了一个方法,使其可以直接在类上调用。
②
没有self
参数;相反,类本身作为第一个参数传递—按照惯例命名为cls
。
③
从第一个字节读取typecode
。
④
从octets
二进制序列创建一个memoryview
,并使用typecode
进行转换。⁴
⑤
将从转换结果中得到的memoryview
解包为构造函数所需的一对参数。
我刚刚使用了classmethod
装饰器,它非常特定于 Python,所以让我们谈谈它。
类方法与静态方法
Python 教程中没有提到classmethod
装饰器,也没有提到staticmethod
。任何在 Java 中学习面向对象编程的人可能会想知道为什么 Python 有这两个装饰器而不是其中的一个。
让我们从classmethod
开始。示例 11-3 展示了它的用法:定义一个在类上而不是在实例上操作的方法。classmethod
改变了方法的调用方式,因此它接收类本身作为第一个参数,而不是一个实例。它最常见的用途是用于替代构造函数,就像示例 11-3 中的frombytes
一样。请注意frombytes
的最后一行实际上通过调用cls
参数来使用cls
参数以构建一个新实例:cls(*memv)
。
相反,staticmethod
装饰器改变了一个方法,使其不接收特殊的第一个参数。实质上,静态方法就像一个普通函数,只是它存在于类体中,而不是在模块级别定义。示例 11-4 对比了classmethod
和staticmethod
的操作。
示例 11-4. 比较classmethod
和staticmethod
的行为
>>> class Demo: ... @classmethod ... def klassmeth(*args): ... return args # ① ... @staticmethod ... def statmeth(*args): ... return args # ② ... >>> Demo.klassmeth() # ③ (<class '__main__.Demo'>,) >>> Demo.klassmeth('spam') (<class '__main__.Demo'>, 'spam') >>> Demo.statmeth() # ④ () >>> Demo.statmeth('spam') ('spam',)
①
klassmeth
只返回所有位置参数。
②
statmeth
也是如此。
③
无论如何调用,Demo.klassmeth
都将Demo
类作为第一个参数接收。
④
Demo.statmeth
的行为就像一个普通的旧函数。
注意
classmethod
装饰器显然很有用,但在我的经验中,staticmethod
的好用例子非常少见。也许这个函数即使从不涉及类也与之密切相关,所以你可能希望将其放在代码附近。即使如此,在同一模块中在类的前面或后面定义函数大多数情况下已经足够接近了。⁵
现在我们已经看到了classmethod
的用途(以及staticmethod
并不是很有用),让我们回到对象表示的问题,并看看如何支持格式化输出。
格式化显示
f-strings、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') # ① '0.2075' >>> '1 BRL = {rate:0.2f} USD'.format(rate=brl) # ② '1 BRL = 0.21 USD' >>> f'1 USD = {1 / brl:0.2f} BRL' # ③ '1 USD = 4.82 BRL'
①
格式说明符是'0.4f'
。
②
格式说明符是'0.2f'
。替换字段中的rate
部分不是格式说明符的一部分。它确定哪个关键字参数进入该替换字段。
③
再次,说明符是'0.2f'
。1 / brl
表达式不是其中的一部分。
第二个和第三个标注指出了一个重要的观点:例如'{0.mass:5.3e}'
这样的格式字符串实际上使用了两种不同的表示法。冒号左边的'0.mass'
是替换字段语法的field_name
部分,它可以是 f-string 中的任意表达式。冒号后面的'5.3e'
是格式说明符。格式说明符中使用的表示法称为格式规范迷你语言。
提示
如果 f-strings、format()
和str.format()
对你来说是新的,课堂经验告诉我最好先学习format()
内置函数,它只使用格式规范迷你语言。在你掌握了这个要领之后,阅读“格式化字符串字面值”和“格式化字符串语法”来了解在 f-strings 和str.format()
方法中使用的{:}
替换字段符号,包括!s
、!r
和!a
转换标志。f-strings 并不使str.format()
过时:大多数情况下 f-strings 解决了问题,但有时最好在其他地方指定格式化字符串,而不是在将要呈现的地方。
一些内置类型在格式规范迷你语言中有自己的表示代码。例如——在几个其他代码中——int
类型支持分别用于输出基数 2 和基数 16 的b
和x
,而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__
我们将通过实现自己的格式迷你语言来解决这个问题。第一步是假设用户提供的格式说明符是用于格式化向量的每个float
组件。这是我们想要的结果:
>>> 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) # ① return '({}, {})'.format(*components) # ②
①
使用内置的format
应用fmt_spec
到每个向量组件,构建格式化字符串的可迭代对象。
②
将格式化字符串插入公式'(x, y)'
中。
现在让我们向我们的迷你语言添加自定义格式代码:如果格式说明符以'p'
结尾,我们将以极坐标形式显示向量:,其中
r
是幅度,θ(theta)是弧度角。格式说明符的其余部分(在'p'
之前的任何内容)将像以前一样使用。
提示
在选择自定义格式代码的字母时,我避免与其他类型使用的代码重叠。在格式规范迷你语言中,我们看到整数使用代码'bcdoxXn'
,浮点数使用'eEfFgGn%'
,字符串使用's'
。因此,我选择了'p'
来表示极坐标。因为每个类都独立解释这些代码,所以在新类型的自定义格式中重用代码字母不是错误,但可能会让用户感到困惑。
要生成极坐标,我们已经有了用于幅度的__abs__
方法,我们将使用math.atan2()
函数编写一个简单的angle
方法来获取角度。这是代码:
# 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'): # ① 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) # ⑧
①
格式以'p'
结尾:使用极坐标。
②
从fmt_spec
中删除'p'
后缀。
③
构建极坐标的tuple
:(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
实例是不可散列的,因此我们无法将它们放入set
中:
>>> 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) # ① 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)) # ⑥ # remaining methods: same as previous Vector2d
①
使用正好两个前导下划线(零个或一个尾随下划线)使属性私有化。⁶
②
@property
装饰器标记属性的 getter 方法。
③
getter 方法的名称与其公共属性相对应:x
。
④
只需返回self.__x
。
⑤
重复相同的公式用于y
属性。
⑥
每个仅读取x
、y
分量的方法都可以保持原样,通过self.x
和self.y
读取公共属性而不是私有属性,因此此列表省略了类的其余代码。
注意
Vector.x
和Vector.y
是只读属性的示例。读/写属性将在第二十二章中介绍,我们将深入探讨@property
。
现在,我们的向量相对安全免受意外变异,我们可以实现__hash__
方法。它应返回一个int
,理想情况下应考虑在__eq__
方法中也使用的对象属性的哈希值,因为比较相等的对象应具有相同的哈希值。__hash__
特殊方法的文档建议计算一个包含组件的元组的哈希值,这就是我们在示例 11-8 中所做的。
示例 11-8。vector2d_v3.py:hash的实现
# inside class Vector2d: def __hash__(self): return hash((self.x, self.y))
通过添加__hash__
方法,我们现在有了可散列的向量:
>>> v1 = Vector2d(3, 4) >>> v2 = Vector2d(3.1, 4.2) >>> hash(v1), hash(v2) (1079245023883434373, 1994163070182233067) >>> {v1, v2} {Vector2d(3.1, 4.2), Vector2d(3.0, 4.0)}
提示
实现属性或以其他方式保护实例属性以创建可散列类型并不是绝对必要的。正确实现__hash__
和__eq__
就足够了。但是,可散列对象的值永远不应更改,因此这提供了一个很好的借口来谈论只读属性。
如果您正在创建具有合理标量数值的类型,还可以实现__int__
和__float__
方法,这些方法由int()
和float()
构造函数调用,在某些情况下用于类型强制转换。还有一个__complex__
方法来支持complex()
内置构造函数。也许Vector2d
应该提供__complex__
,但我会把这留给你作为一个练习。
支持位置模式匹配
到目前为止,Vector2d
实例与关键字类模式兼容——在“关键字类模式”中介绍。
在示例 11-9 中,所有这些关键字模式都按预期工作。
示例 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)
流畅的 Python 第二版(GPT 重译)(六)(2)https://developer.aliyun.com/article/1484607