Python学习之路28-符合Python风格的对象

《流畅的Python》笔记。

本篇是“面向对象惯用方法”的第二篇。前一篇讲的是内置对象的结构和行为,本篇则是自定义对象。本篇继续“Python学习之路20”,实现更多的特殊方法以让自定义类的行为跟真正的Python对象一样。

1. 前言

本篇要讨论的内容如下,重点放在了对象的各种输出形式上:

  • 实现用于生成对象其他表示形式的内置函数(如repr()bytes()等);
  • 使用一个类方法实现备选构造方法;
  • 扩展内置的format()函数和str.format()方法使用的格式微语言;
  • 实现只读属性;
  • 实现对象的可散列;
  • 利用__slots__节省内存;
  • 如何以及何时使用@classmethod@staticmethd装饰器;
  • Python的私有属性和受保护属性的用法、约定和局限。

本篇将通过实现一个简单的二维欧几里得向量类型,来涵盖上述内容。

不过在开始之前,我们需要补充几个概念:

  • repr():以便于开发者理解的方式返回对象的字符串表示形式,它调用对象的__repr__特殊方法;
  • str():以便于用户理解的方式返回对象的字符串表示形式,它调用对象的__str__特殊方法;
  • bytes():获取对象的字节序列表示形式,它调用对象的__bytes__特殊方法;
  • format()str.format()格式化输出对象的字符串表示形式,调用对象的__format__特殊方法。

2. 自定义向量类Vector2d

我们希望这个类具备如下行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 代码1
>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y) # Vector2d实例的分量可直接通过实例属性访问,无需调用读值方法
3.0 4.0
>>> x, y = v1 # 实例可拆包成变量元组
>>> x, y
(3.0, 4.0)
>>> v1 # 我们希望__repr__返回的结果类似于构造实例的源码
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1)) # 只是为了说明repr()返回的结果能用来生成实例
>>> v1 == v1_clone # Vector2d需支持 == 运算符
True
>>> print(v1) # 我们希望__str__方法以如下形式返回实例的字符串表示
(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的初始版本如下:

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
33
34
35
# 代码2
from array import array
import math

class Vector2d:
# 类属性,在Vector2d实例和字节序列之间转换时使用
typecode = "d" # 转换成C语言中的double类型

def __init__(self, x, y):
self.x = float(x) # 构造是就转换成浮点数,尽早在构造阶段就捕获错误
self.y = float(y)

def __iter__(self): # 将Vector2d实例变为可迭代对象
return (i for i in (self.x, self.y)) # 这是生成器表达式!

def __repr__(self):
class_name = type(self).__name__ # 获取类名,没有采用硬编码
# 由于Vector2d实例是可迭代对象,所以*self会把x和y提供给format函数
return "{}({!r}, {!r})".format(class_name, *self)

def __str__(self):
return str(tuple(self)) # 由可迭代对象构造元组

def __bytes__(self):
# ord()返回字符的Unicode码位;array中的数组的元素是double类型
return (bytes([ord(self.typecode)]) + bytes(array(self.typecode, self)))

def __eq__(self, other): # 这样实现有缺陷,Vector(3, 4) == [3, 4]也会返回True
return tuple(self) == tuple(other) # 但这个缺陷会在后面章节修复

def __abs__(self): # 计算平方和的非负数根
return math.hypot(self.x, self.y)

def __bool__(self): # 用到了上面的__abs__来计算模,如果模为0,则是False,否则为True
return bool(abs(self))

3. 备选构造方法

初版Vector2d可将它的实例转换成字节序列,但却不能从字节序列构造Vector2d实例,下面添加一个方法实现此功能:

1
2
3
4
5
6
7
8
9
# 代码3
class Vector2d:
-- snip --
@classmethod
def frombytes(cls, octets): # 不用传入self参数,但要通过cls传入类本身
typecode = chr(octets[0]) # 从第一个字节中读取typecode,chr()将Unicode码位转换成字符
# 使用传入的octets字节序列构建一个memoryview,然后根据typecode转换成所需要的数据类型
memv = memoryview(octets[1:]).cast(typecode)
return cls(*memv) # 拆包转换后的memoryview,然后构造一个Vector2d实例,并返回

4. classmethod与staticmethod

代码3中用到了@classmethod装饰器,与它相伴的还有@staticmethod装饰器。

从上述代码可以看出,classmethod定义的是传入而不是传入实例的方法,即传入的第一个参数必须是,而不是实例classmethod改变了调用方法的方式,但是,在实际调用这个方法时,我们不需要手动传入cls这个参数,Python会自动传入。(按照传统,第一个参数一般命名为cls,当然你也可以另起名)

staticmethod也会改变方法的调用方式,但第一个参数不是特殊值,既不是cls,也不是self,就是用户传入的普通参数。以下是它们的用法对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 代码4
>>> class Demo:
... @classmethod
... def klassmeth(*args):
... return args # 返回传入的全部参数
... @staticmethod
... def statmeth(*args):
... return args # 返回传入的全部参数
...
>>> Demo.klassmeth()
(<class 'Demo'>,) # 不管如何调用Demo.klassmeth,它的第一个参数始终是Demo类自己
>>> Demo.klassmeth("spam")
(<class 'Demo'>, 'spam')
>>> Demo.statmeth()
() # Demo.statmeth的行为与普通函数类似
>>> Demo.statmeth("spam")
('spam',)

classmethod很有用,但staticmethod一般都能找到很方便的替代方案,所以staticmethod并不是必须的。

5. 格式化显示

内置的format()函数和str.format()方法把各个类型的格式化方式委托给相应的.__format__(format_spec)方法。format_spec是格式说明符,它是:

  • format(my_obj, format_spec)的第二个参数;

  • 也是str.format()方法的格式字符串,{}里替换字段中冒号后面的部分,例如:

    1
    2
    3
    # 代码5
    >>> brl = 1 / 2.43
    >>> "1 BRL = {rate:0.2f} USD".format(rate=brl) # 此时 format_spec为'0.2f'

    其中,冒号后面的0.2f是格式说明符,冒号前面的rate是字段名称,与格式说明符无关。格式说明符使用的表示法叫格式规范微语言(Format Specification Mini-Language)。格式规范微语言为一些内置类型提供了专门的表示代码,比如b表示二进制的int类型;同时它还是可扩展的,各个类可以自行决定如何解释format_spec参数,比如时间的转换格式%H:%M:%S,就可用于datetime类型,但用于int类型则可能报错。

如果类没有定义__format__方法,则会返回__str__的结果,比如我们定义的Vector2d类型就没有定义__format__方法,但依然可以调用format()函数:

1
2
3
4
# 代码6
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'

但现在的Vector2d在格式化显示上还有缺陷,不能向format()传入格式说明符:

1
2
3
4
>>> format(v1, ".3f")
Traceback (most recent call last):
-- snip --
TypeError: non-empty format string passed to object.__format__

现在我们来为它定义__format__方法。添加自定义的格式代码,如果格式说明符以'p'结尾,则以极坐标的形式输出向量,即<r, θ>'p'之前的部分做正常处理;如果没有'p',则按笛卡尔坐标形式输出。为此,我们还需要一个计算弧度的方法angle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 代码7
class Vector2d:
-- snip --

def angle(self):
return math.atan2(self.y, self.x) # 弧度

def __format__(self, format_spec=""):
if format_spec.endswith("p"):
format_spec = format_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = "<{}, {}>"
else:
coords = self
outer_fmt = "({}, {})"
components = (format(c, format_spec) for c in coords)
return outer_fmt.format(*components)

以下是实际示例:

1
2
3
4
5
# 代码8
>>> format(Vector2d(1, 1), "0.5fp")
'<1.41421, 0.78540>'
>>> format(Vector2d(1, 1), "0.5f")
'(1.00000, 1.00000)'

6. 可散列的Vector2d

关于可散列的概念可以参考之前的文章《Python学习之路22》

目前的Vector2d是不可散列的,为此我们需要实现__hash__特殊方法,而在此之前,我们还要让向量不可变,即self.xself.y的值不能被修改。之所以要让向量不可变,是因为我们在计算向量的哈希值时需要用到self.xself.y的哈希值,如果这两个值可变,那向量的哈希值就能随时变化,这将不是一个可散列的对象。

补充

  • 在文章《Python学习之路22》中说道,用户自定义的对象默认是可散列的,它的散列值等于id()的返回值。但是此处的Vector2d却是不可散列的,这是为什么?其实,如果我们要让自定义类变为可散列的,正确的做法是同时实现__hash____eq__这两个特殊方法。当这两个方法都没有重写时,自定义类的哈希值就是id()的返回值,此时自定义类可散列;当我们只重写了__hash__方法时,自定义类也是可散列的,哈希值就是__hash__的返回值;但是,如果只重写了__eq__方法,而没有重写__hash__方法,此时自定义类便不可散列。
  • 这里再次给出可散列对象必须满足的三个条件:
    • 支持hash()函数,并且通过__hash__方法所得到的哈希值是不变的;
    • 支持通过__eq__方法来检测相等性;
    • a == b为真,则hash(a) == hash(b)也必须为真。

根据官方文档,最好使用异或运算^混合各分量的哈希值,下面是Vector2d的改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 代码9
class Vector2d:
-- snip --

def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)

@property # 把方法变为属性调用,相当于getter方法
def x(self):
return self.__x

@property
def y(self):
return self.__y

def __hash__(self):
return hash(self.x) ^ hash(self.y)

-- snip --

文章至此说的都是一些特殊方法,如果想到得到功能完善的对象,这些方法可能是必备的,但如果你的应用用不到这些东西,则完全没有必要去实现这些方法,客户并不关心你的对象是否符合Python风格。

Vector2d暂时告一段落,现在来说一说其它比较杂的内容。

7. Python的私有属性和”受保护的”属性

Python不像C++、Java那样可以用private关键字来创建私有属性,但在Python中,可以以双下划线开头来命名属性以实现”私有”属性,但是这种属性会发生名称改写(name mangling):Python会在这样的属性前面加上一个下划线和类名,然后再存入实例的__dict__属性中,以最新的Vector2d为例:

1
2
3
4
# 代码10
>>> v1 = Vector2d(1, 2)
>>> v1.__dict__
{'_Vector2d__x': 1.0, '_Vector2d__y': 2.0}

当属性以双下划线开头时,其实是告诉别的程序员,不要直接访问这个属性,它是私有的。名称改写的目的是避免意外访问,而不能防止故意访问。只要你知道规则,这些属性一样可以访问。

还有以单下划线开头的属性,这种属性在Python的官方文档的某个角落里被称为了”受保护的”属性,但Python不会对这种属性做特殊处理,这只是一种约定俗成的规矩,告诉别的程序员不要试图从外部访问这些属性。这种命名方式很常见,但其实很少有人把这种属性叫做”受保护的”属性。

还是那句话,Python中所有的属性都是公有的,Python没有不能访问的属性!这些规则并不能阻止你有意访问这些属性,一切都看你遵不遵守上面这些”不成文”的规则了。

8. 覆盖类属性

这里首先需要区分两个概念,类属性实例属性

  • 类属性属于整个类,该类的所有实例都能访问这个属性,可以动态绑定类属性,动态绑定的类属性所有实例也都可以访问,即类属性的作用域是整个类。可以按Vector2d中定义typecode的方式来定义类属性,即直接在class中定义属性,而不是在__init__中;
  • 实例属性只属于某个实例对象,实例也能动态绑定属性。实例属性只能这个实例自己访问,即实例属性的作用域是类对象作用域。实例属性需要和self绑定,self指向的是实例,而不是类。

Python有个很独特的特性:类属性可用于为实例属性提供默认值

Vector2d中有个typecode类属性,注意到,我们在__bytes__方法中通过self.typecode两次用到了它,这里明明是通过self调用实例属性,可Vector2d的实例并没有这个属性。self.typecode其实获取的是Vector2d.typecode类属性的值,而至于怎么从实例属性跳到类属性的,以后有机会单独用一篇文章来讲。

补充:证明实例没有typecode属性

1
2
3
4
# 代码11
>>> v = Vector2d(1, 2)
>>> v.__dict__
{'_Vector2d__x': 1.0, '_Vector2d__y': 2.0} # 实例中并没有typecode属性

如果为不存在的实例属性赋值,则会新建该实例属性。假如我们为typecode实例属性赋值,同名类属性不会受到影响,但会被实例属性给覆盖掉(类似于之前在函数闭包中讲的局部变量和全局变量的区别)。借助这一特性,可以为各个实例的typecode属性定制不同的值,比如在生成字节序列时,将实例转换成4字节的单精度浮点数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 代码12
>>> 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@' # 明白为什么要在字节序列前加上typecode的值了吗?为了支持不同格式。
>>> len(dumpf)
9
>>> Vector2d.typecode
'd'

如果想要修改类属性的值,必须直接在类上修改,不能通过实例修改。如果想修改所有实例的typecode属性的默认值,可以这么做:

1
2
# 代码13
Vector2d.typecode = "f"

然而有种方式更符合Python风格,而且效果持久,也更有针对性。通过继承的方式修改类属性,生成专门的子类。Django基于类的视图就大量使用了这个技术:

1
2
3
4
5
6
7
8
9
# 代码14
>>> class ShortVector2d(Vector2d):
... typecode = "f" # 只修改这一处
...
>>> sv = ShortVector2d(1/11, 1/27)
>>> sv
ShortVector2d(0.09090909090909091, 0.037037037037037035) # 没有硬编码class_name的原因
>>> len(bytes(sv))
9

9. __slots__类属性

默认情况下,Python在各个实例的__dict__属性中以映射类型存储实例属性。正如《Python学习之路22》中所述,为了使用底层的散列表提升访问速度,字典会消耗大量内存。如果要处理数百万个属性不多的实例,其实可以通过__slots__类属性来节省大量内存。做法是让解释器用类似元组的结构存储实例属性,而不是字典。

具体用法是,在类中创建这个__slots__类属性,并把它的值设为一个可迭代对象,其中的元素是其余实例属性的字符串表示。比如我们将之前定义的Vector2d改为__slots__版本:

1
2
3
4
5
6
# 代码15
class Vector2d:
__slots__ = ("__x", "__y")

typecode = "d" # 其余保持不变
-- snip --

试验表明,创建一千万个之前版本的Vector2d实例,内存用量高达1.5GB,而__slots__版本的Vector2d的内存用量不到700MB,并且速度也比之前的版本快。

__slots__也有一些需要注意的点:

  • 使用__slots__之后,实例不能再有__slots__中所列名称之外的属性,即,不能动态添加属性;如果要使其能动态添加属性,必须在其中加入'__dict__',但这么做又违背了初衷;
  • 每个子类都要定义__slots__属性,解释器会忽略掉父类的__slots__属性;
  • 自定义类中默认有__weakref__属性,但如果定义了__slots__属性,而且还要自定义类支持弱引用,则需要把'__weakref__'加入到__slots__中。

总之,不要滥用__slots__属性,也不要用它来限制用户动态添加属性(除非有意为之)。__slots__在处理列表数据时最有用,例如模式固定的数据库记录,以及特大型数据集。然而,当遇到这类数据时,更推荐使用Numpy和Pandas等第三方库。

10. 总结

本篇首先按照一定的要求,定义了一个Vector2d类,重点是如果实现这个类的不同输出形式;随后,能从字节序列”反编译”成我们需要的类,我们实现了一个备选构造方法,顺带介绍了@classmethod@staticmethod装饰器;接着,我们通过重写__format_方法,实现了自定义格式化输出数据;然后,通过使用@property装饰器,定义”私有”属性以及重写__hash__方法等操作实现了这个类的可散列化。至此,关于Vector2d的内容基本结束。最后,我们介绍了两种常见类型的属性(“私有”,“保护”),覆盖类属性以及如何通过__slots__节省内存等问题。

本文实现了这么多特殊方法只是为展示如何编写标准Python对象的API,如果你的应用用不到这些内容,大可不必为了满足Python风格而给自己增加负担。毕竟,简洁胜于复杂

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~