0%

流畅的 Python 第 2 版(11):符合 Python 风格的对象

得益于 Python 数据模型,自定义类型的行为可以像内置类型那样自然。实现如此自然的行为,靠的不是继承,而是鸭子类型:只需按照预定行为实现对象所需的方法即可。对库或框架来说,程序员可能希望你定义的类能像 Python 内置的类一样。满足这个预期也算得上是符合 Python风格

对象表示形式

每门面向对象语言至少都有一种获取对象字符串表示形式的标准方式。Python 提供了两种方式:

  • repr():以便于开发者理解的方式返回对象的字符串表示形式。Python 控制台或调试器在显示对象时采用这种方式
  • str():以便于用户理解的方式返回对象的字符串表示形式。使用 print() 打印对象时采用这种方式

在背后支持 repr()str() 的是特殊方法 __repr____str__。除此之外,还有两个特殊方法 __bytes____format__,可为对象提供其他表示形式。

  • __bytes__ 方法与 __str__ 方法类似,bytes() 函数调用它获取对象的字节序列表示形式
  • __format__ 方法供 f 字符串内置函数 format()str.format() 使用,通过调用 obj.__format__(format_spec) 以特殊的格式化代码显示对象的字符串表示形式

再谈向量类

如下实现了一个 Vector2d 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> v1 = Vector2d(3, 4)
>>> v1
Vector2d(3.0, 4.0)
>>> x, y = v1
>>> x, y
(3.0, 4.0)
>>> v1_clone = eval(repr(v1))
>>> v1_clone
Vector2d(3.0, 4.0)
>>> v1 == v1_clone
True
>>> abs(v1)
5.0
* >>> bytes(v1)
b'd\x00\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x00\x00\x10@'
>>> str(v1)
'(3.0, 4.0)'
  • typecode 是类属性,在 Vector2d 实例和字节序列之间转换时使用
  • __init__ 方法中把 x 和 y 转换成浮点数,尽早捕获错误,以防调用 Vector2d 构造函数时传入不当参数
  • 定义 __iter__ 方法,把 Vector2d 实例变成可迭代对象,这样才能拆包
  • __repr__ 方法使用 {!r} 获取各个分量的表示形式,然后插值,构成一个字符串。因为 Vector2d 实例是可迭代对象,所以 *self 会把 x 分量和 y 分量提供给 format 方法

备选构造函数

我们已经可以把 Vector2d 实例转换成字节序列了。同理,我们也希望能从字节序列构建 Vector2d 实例。使用 array.array 的类方法 .frombytes 可以从字节序列构建 Vector2d :

1
2
3
4
5
@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 与 staticmethod

classmethod 装饰器定义操作类而不是操作实例的方法。由于 classmethod 改变了调用方法的方式,因此接收的第一个参数是类本身,而不是实例。classmethod 最常见的用途是定义备选构造函数。上面例子中就使用 cls 参数构建了一个新实例,即 cls(*memv)

相比之下,staticmethod 装饰器也会改变方法的调用方式,使其接收的第一个参数没什么特殊的。其实,静态方法就是普通的函数,只是碰巧位于类的定义体中,而不是在模块层定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Demo:
@classmethod
def klassmeth(*args):
return args

@staticmethod
def statmeth(*args):
return args

# (<class '__main__.Demo'>,)
print(Demo.klassmeth())
# (<class '__main__.Demo'>, 'test')
print(Demo.klassmeth("test"))
# ()
print(Demo.statmeth())
# ('test',)
print(Demo.statmeth("test"))
  • 不管怎样调用 Demo.klassmeth ,它的第一个参数始终是 Demo 类
  • Demo.statmeth 的行为与普通的函数一样

有些函数即使不直接处理类,也与类联系紧密,因此你会想把函数与类放在一起定义,这种情况就是 staticmethod 的用武之地。对于这种情况,在类的前面或后面定义函数,保持二者在同一个模块中也是可行的方法(所以 staticmethod 其实不是特别有用)。

格式化显示

f 字符串、内置函数 format()str.format() 方法会把各种类型的格式化方式委托给相应的 .__format__(format_spec) 方法。format_spec 参数是格式说明符。格式说明符使用的表示法叫格式规范微语言(Format Specification Mini-Language)。

1
2
3
4
>>> format(42, 'b')
'101010'
>>> format(2/3, '.1%')
'66.7%'

格式规范微语言是可扩展的,各个类可以自行决定如何解释 format_spec 参数。如果一个类没有定义 __format__,那么该方法就会从 object 继承,并返回 str(my_object)。如果传入格式说明符,则 object.__format__ 会抛出 TypeError。

1
2
3
4
5
6
7
8
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'

>>> format(v1, '.3f')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported format string passed to Vector2d.__format__

如下在 Vector2d 类中实现了自己的 __format__ 方法:

1
2
3
4
5
6
7
8
9
10
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)

可哈希的 Vector2d

按照定义,目前 Vector2d 实例不可哈希,因此不能放入集合中:

1
2
3
4
5
6
7
8
9
10
>>> hash(v1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'Vector2d'

>>> v1.__hash__
>>> print(v1.__hash__)
None
>>> print(v1.__eq__)
<bound method Vector2d.__eq__ of Vector2d(3.0, 4.0)>

如果一个类定义了 __eq__ 但没有定义 __hash__,那么它的实例将自动变为不可哈希(Python 自动设置 __hash__ = None)。为了把 Vector2d 实例变成可哈希的,必须实现 __hash__ 方法(还需要 __eq__ 方法,前面已经实现了)。此外,还要让向量实例不可变。

目前我们可以随意设置 Vector2d 实例的 _x_y 属性,这可能会导致向量的哈希值改变。为了避免这种情况,必须把 _x_y 属性设为只读属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
  • 使用两个前导下划线(尾部没有下划线或有一个下划线)​,把属性标记为私有的
  • @property 装饰器把读值方法标记为特性(property),读值方法与公开属性同名

现在,向量不会被意外修改,有了一定的安全性。接下来可以实现 __hash__ 方法了,这个方法应该返回一个 int 值,理想情况下还要考虑对象属性的哈希值,因为相等的对象应该具有相同的哈希值。

1
2
def __hash__(self):
return hash((self.x, self.y))

实现 __hash__ 方法之后,向量就变成可哈希的了。

特性的使用方法表明,可以先以最简单的方式定义类,也就是使用公开属性,因为如果以后需要对读值方法和设值方法增加控制,则可以通过特性实现。这样做对一开始通过公开属性的名称(例如 x 和 y)与对象交互的代码没有影响。
在 Python 中,到处使用读值方法和设值方法是不明智的,在Python中,可以先使用公开属性,然后等需要时再变成特性。

1
2
3
>>> my_object.set_foo(my_object.get_foo() + 1)
# 不如直接这样
>>> my_object.foo += 1

支持位置模式匹配

目前,Vector2d 实例兼容关键字类模式:

1
2
3
4
5
6
7
8
9
10
11
12
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')

但是如果使用如下位置模式则会出错:

1
2
3
4
case Vector2d(_, 0):
print(f'{v!r} is horizontal')

TypeError: Vector2d() accepts 0 positional sub-patterns (1 given)

为了让 Vector2d 支持位置模式,需要添加一个名为 __match_args__ 的类属性,按照在位置模式匹配中的使用顺序列出实例属性。

1
2
class Vector2d:
__match_args__ = ('x', 'y')

现在就可以匹配 Vector2d 对象的位置模式:

1
2
3
4
5
6
7
8
9
10
11
12
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 类型小结

额外说一下,如果你定义的类型有标量数值,那么可能还要实现 __int__ 方法和 __float__ 方法(分别被 int() 构造函数和 float() 构造函数调用)​,以便在某些情况下强制转换类型。

当应用程序真正需要这些特殊方法时才应实现它们。终端用户并不关心应用程序中的对象是否符合 Python风格。如果你的类是供其他 Python 程序员使用的库的一部分,那么你肯定猜不到程序员会对你的对象做什么,他们或许更希望你的代码符合 Python风格

Python 私有属性和 受保护 的属性

Python 不能像 Java 那样使用 private 修饰符创建私有属性,但是它有一个简单的机制,能避免子类意外覆盖 私有 属性

对于实例属性,如果以 __xxx 形式命名(两个前导下划线,尾部没有或最多有一个下划线),那么 Python 就会把属性名存入实例属性 __dict__ 中,而且会在前面加上一个下划线和类名。这个语言功能叫名称改写(name mangling)。

1
2
3
4
5
6
7
>>> class A:
... def __init__(self):
... self.__a = 10
...
>>> t = A()
>>> t.__dict__
{'_A__a': 10}

名称改写是一种安全措施,不能保证万无一失:它的目的是避免意外访问,不能防止故意做错事。只要知道改写私有属性名称的机制,任何人都能直接读取私有属性——这实际上对调试和序列化很有用

1
2
3
4
5
6
>>> t.__a
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'A' object has no attribute '__a'
>>> t._A__a
10

当然有人不喜欢这种句法,他们约定使用一个下划线前缀编写 受保护 的属性(例如self._x)​。批评使用两个下划线这种改写机制的人认为,应该使用命名约定来避免意外覆盖属性。Python 解释器不会对使用单下划线的属性名做特殊处理,不过这是很多 Python 程序员严格遵守的约定,他们不会在类的外部访问这种属性。遵守使用一个下划线标记对象的私有属性很容易,就像遵守使用全大写字母编写常量一样。Python 文档的某些角落把使用一个下划线前缀标记的属性称为 受保护 的属性。

使用 __slots__ 节省空间

默认情况下,Python 把各个实例的属性存储在一个名为 __dict__ 的字典中。之前介绍过,字典消耗的内存很多(即使有一些优化措施)。但是,如果定义一个名为 __slots__ 的类属性,以序列的形式存储属性名称,那么 Python 将使用其他模型存储实例属性:__slots__ 中的属性名称存储在一个隐藏的引用数组中,消耗的内存比字典少。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> class Pixel:
... __slots__ = ('x', 'y')
...
>>> p = Pixel()
>>> p.__dict__
Traceback (most recent call last):
File "<python-input-2>", line 1, in <module>
p.__dict__
AttributeError: 'Pixel' object has no attribute '__dict__'. Did you mean: '__dir__'?

>>> p.x = 10
>>> p.y = 20
>>> p.color = 'red'
Traceback (most recent call last):
File "<python-input-5>", line 1, in <module>
p.color = 'red'
^^^^^^^
AttributeError: 'Pixel' object has no attribute 'color' and no __dict__ for setting new attributes
  • __slots__ 必须在定义类时声明,之后再添加或修改均无效。属性名称可以存储在一个元组或列表中(推荐使用元组,因为这可以明确表明 __slots__ 无法修改)
  • 可以看到,Pixel 实例没有 __dict__ 属性,而且设置不在 __slots__ 中的属性抛出 AttributeError
1
2
3
4
5
6
7
8
9
10
11
>>> op = OpenPixel()
>>> op.__dict__
{}
>>> op.x = 8
>>> op.__dict__
{}
>>> op.x
8
>>> op.color = 'green'
>>> op.__dict__
{'color': 'green'}
  • 子类实例存在 __dict__ 属性
  • 设置存在于基类 __slots__ 中的属性,不会存入实例的 __dict__(存入实例的一个隐藏的引用数组中)
  • 设置不存在于基类 __slots__ 中的属性,会存入实例的 __dict__

这个例子则说明,子类只继承 __slots__ 的部分效果。为了确保子类的实例也没有 __dict__ 属性,必须在子类中再次声明 __slots__ 属性。

  • 如果在子类中声明 __slots__= ()(一个空元组)​,则子类的实例将没有 dict 属性,而且只接受基类的 __slots__ 属性列出的属性名称
  • 如果子类需要额外属性,则在子类的 __slots__ 属性中列出来

关于 __slots__,还有几个注意事项:

  • 实例只能拥有 __slots__ 列出的属性,除非把 __dict__ 加入 __slots__ 中(但是这样做就失去了节省内存的功效)
  • __slots__ 的类不能使用 @cached_property 装饰器,除非把 __dict__ 加入 __slots__
  • 如果不把 __weakref__ 加入 __slots__ 中,那么实例就不能作为弱引用的目标

覆盖类属性

Python 有一个很独特的功能:类属性可为实例属性提供默认值。如果为不存在的实例属性赋值,那么将创建一个新实例属性,此时同名类属性将被遮盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> class A:
... a = 10
...
>>> t = A()
>>> t.a
10
>>> t.a = 20
>>> t.a
20
>>> A.a
10

>>> A.a = 30
>>> t2 = A()
>>> t2.a
30
  • 如果想修改类属性的值,那么必须直接在类上修改,不能通过实例修改
  • 还有一种修改方法更符合 Python 风格,而且效果持久,也更有针对性。类属性是公开的,会被子类继承,于是我们经常会创建一个子类,只用于定制类的数据属性
1
2
3
4
5
6
7
8
9
>>> 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

小结

符合 Python 风格的对象应该正好符合所需,而不是堆砌语言功能。开发应用程序时,应该集中精力满足终端用户的需求,仅此而已。编写供其他程序员使用的库时,应该实现一些特殊方法,提供 Python 程序员预期的行为。

简洁胜于复杂。要构建符合 Python 风格的对象,就要观察真正的 Python 对象的行为。