得益于 Python 数据模型,自定义类型行为可以像内置类型那样自然。实现如此自然的行为,靠的不是继承,而是鸭子类型,我们只需要按照预定行为实现对象所需方法即可
这一章我们定义自己的类,而且让类的行为跟真正的 Python 对象一样,这一章延续第一章,说明如何实现在很多 Python 类型中常见的特殊方法。
本章包含以下话题:
__slots__
节省内存我们将开发一个二维欧几里得向量模型,这个过程中覆盖上面所有话题。这个过程中我们会讨论两个概念
每门面向对象的语言至少都有一种获取对象的字符串表示形式的标准方式。Python 提供了两种方式
repr(): 便于开发者理解的方式返回对象的字符串表示形式
str(): 便于用户理解的方式返回对象的字符串表示形式
为了给对象提供其他的表现形式,还会用到两个特殊的方法, __bytes__
和 __format__
。__bytes__
方法与 __str__
方法类似:bytes() 函数调用它获取对象的字节序列表示形式。而 __format__
方法会被内置的 format() 和 str.format() 调用。使用特殊的格式代码显示对象的字符串表示形式。
注意:Python3 中 __repr__
, __str__
, __format__
方法都必须返回 Unicode 字符串(str)类型。只有 __bytes__
方法应该返回字节序列(bytes 类型)
为了说明用于生成对象表示形式的众多方法,我们将使用一个 Vector2d 类,与第一章的类似。这几节会不断完善这个类,我们期望这个类行为如下所示:
In [4]:
v1 = Vector2d(3, 4)
print(v1.x, v1.y) # 可以直接通过属性访问
In [5]:
x, y = v1 # 可以拆包成元祖
x, y
Out[5]:
In [6]:
v1
Out[6]:
In [7]:
v1_clone = eval(repr(v1)) # repr 函数调用 Vector2d 实例,结果类似于构建实例的源码
v1 == v1_clone # 支持 == 比较
Out[7]:
In [8]:
print(v1) # 会调用 str 函数,对 Vector2d 来说,输出的是一个有序对
In [9]:
octets = bytes(v1) # 调用 __bytes__ 方法,生成实例的二进制表示形式
octets
Out[9]:
In [10]:
abs(v1) # 会调用 __abs__ 方法,返回 Vector2d 实例的模
Out[10]:
In [11]:
bool(v1), bool(Vector2d(0, 0)) # 会调用 __bool__ 方法,判断 Vector2d 的实例的向量长度
Out[11]:
In [12]:
from array import array
import math
class Vector2d:
typecode = 'd' # 类属性,Vector2d 实例和字节序之间转换使用
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)) # 将 Vector2d 变成可迭代对象,这样才可以拆包
def __repr__(self):
class_name = type(self).__name__
# {!r} 获取各个分量的表示形式,然后插值,构成一个字符串。因为 Vector2d 是可迭代对象,所以用 *self 会把 x 和 y 分量提供给 format 函数
return '{}({!r},{!r})'.format(class_name, *self)
def __str__(self):
return str(tuple(self))
def __bytes__(self):
# 我们使用 typecode 转换成字节序列然后返回
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
#为了快速比较所有分量,在操作数中构建元祖,对 Vector2d 实例来说,这样做还有问题,看下面的警告
return tuple(self) == tuple(other)
def __abs__(self):
return math.hypot(self.x, self.y)
def __bool__(self):
return bool(abs(self))
上面的 __eq__
方法,在两个操作数都是 Vector2d 的时候可用,不过拿 Vector2d 实例和其他具有相同数值的可迭代对象相比,结果也是 True(如 Vector2d(3, 4) == [3, 4])。这个行为可以视为特性,也可以视为缺陷在第 13 章运算符重载时候进一步讨论
我们已经定义了许多基本方法,但是显然少了一个操作:使用 bytes() 函数生成的二进制重建 Vecotr2d 实例
我们可以把 Vector2d 实例转成字节序列;同理,也应该能从字节序列转换成 Vector2d 实例。在标准库中探索一番之后,我们发现 array.array 有个类方法 .frombytes
(2.91 章介绍过,从文件读取数据) 正好符合需求。下面为 Vector2d 定义一个同名的类方法
In [13]:
from array import array
import math
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.x = float(x)
self.y = float(y)
@classmethod # 类方法使用 @classmethod 装饰器
def frombytes(cls, octets): # 不用传入 self 参数,相反,要通过 cls 传入类本身
typecode = chr(octets[0]) #从第一个字节中读取 typecode
# 用传入的 octets 字节序列创建一个 memoryview,然后使用 typecode 转换
memv = memoryview(octets[1:]).cast(typecode) # 2.92 章介绍了 cast 方法,将一段内存转换成指定的类型,d 代表 float
return cls(*memv) #拆包转换后的 memoryview,得到构造方法所需的一对参数
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))
In [14]:
v1 = Vector2d(3, 4)
octets = bytes(v1)
print(octets)
v2 = Vector2d.frombytes(octets)
v2
Out[14]:
我们上面用的 classmethod 装饰器是 Python 专用的,下面解释一下
我们来看一下 classmethod 装饰器,上面已经展示了它的用法,定义操作类,而不是操作实例的方法。classmethod 改变了调用方法的方式,因此类方法的第一个参数是类本身,而不是实例。classmethod 最常用的用途是定义备选构造方法。例如上面的 frombytes,注意,frombytes 最后一行使用 cls 参数构建了一个新实例,即 cls(*memv)
,按照约定,类方法的第一个参数为 cls(但不是强制的)
staticmethod 装饰器也会改变方法的调用方式,但是第一个参数不是特殊值。其实,静态方法就是普通的函数,只是碰巧在类的定义体中,而不是在模块层定义。下面对这两种装饰器行为做了对比:
In [15]:
class Demo:
@classmethod
def klassmeth(*args):
return args
@staticmethod
def statmeth(*args):
return args
Demo.klassmeth()
Out[15]:
In [16]:
Demo.klassmeth('kaka')
Out[16]:
In [17]:
Demo.statmeth()
Out[17]:
In [18]:
Demo.statmeth('kaka')
Out[18]:
In [20]:
brl = 1 / 2.43
brl
Out[20]:
In [21]:
format(brl, '0.4f')
Out[21]:
In [22]:
'1 BRL = {rate:0.2f} USD'.format(rate = brl)
Out[22]:
'{0.mass:5.3e}'
这样的格式字符串其实包含两部分,冒号左边的 0.mass 在代换字段语法中是字段名,冒号后面的 5.3e 是格式说明符。如果对这些陌生的话,先学 format() 函数,掌握格式规范微语言,然后再阅读格式字符串语法("Format String Syntax",https://docs.python.org/3/library/string.html#formatspec)。学习 str.format() 方法使用的 {:} 代换字段表示法(包含转换标志 !s, !r, !a)
格式规范微语言为一些内置类型提供了专用的表示代码。例如,b 和 x 分别代表 二进制和十六进制的 int 类型。f 表示 float 类型,而 % 表示百分数形式
In [23]:
format(42, 'b')
Out[23]:
In [24]:
format(2 / 3, '.1%')
Out[24]:
格式规范微语言是可扩展的,因为各个类可以自行决定如何解释 format_spec 参数。例如,datetime 模块中的类,它们的 __format__
方法使用的格式代码与 strftime() 函数一样。下面是内置的 format() 函数和 str.format() 方法的几个示例:
In [25]:
from datetime import datetime
now = datetime.now()
format(now, '%H:%M:%s')
Out[25]:
In [26]:
"It's now {:%I:%M %p}".format(now)
Out[26]:
如果类没有定义 __format__
方法,从 object 继承的方法会返回 str(my_object)。我们为 Vector2d 类定义了 __str__
方法,因此可以这样:
In [27]:
v1 = Vector2d(3, 4)
format(v1)
Out[27]:
然而,传入格式说明符,object.__format__
方法会抛出 TypeError
In [28]:
format(v1, '.3f')
我们将实现自己的微语言解决这个问题。首先,假设用户提供的格式说明符是用于格式化向量中各个浮点数分量的。我们想达到的效果如下:
In [36]:
v1 = Vector2d(3, 4)
format(v1)
Out[36]:
In [37]:
format(v1, '.2f')
Out[37]:
In [38]:
format(v1, '3e')
Out[38]:
实现这种输出的 __format__
方法是:
In [19]:
def __format__(self, fmt_spec=''):
components = (format(c, fmt_spec) for c in self)
return '({}, {})'.format(*components)
下面要在微语言添加一个自定义格式的代码:如果格式说明以 'p' 为结尾,那么在极坐标中显示向量,即 <r, theta>,其中 r 是 模,theta 是弧度,其他部分('p' 之前的部分像往常一样解释)
注意自定义的格式代码不要和已有的重复,整数使用的有 'bcdoxXn', 浮点数使用的有 'eEfFgGn%', 字符串有的是 's'。所以我们选的极坐标代码是 'p',各个类都有自己的方式解释格式代码,自定义格式代码中重复使用代码字母不会出错,但是可能会让用户迷惑。
对于极坐标来说,我们已经定义了计算模的 __abs__
方法,因此还要定义一个简单的 angle 方法,使用 math.atan2() 函数计算角度。angle 方法的代码如下:
In [29]:
def angle(self):
return math.atan2(self.y, self.x)
这样方便增强 __format__
方法,现在我们可以让其计算极坐标:
In [31]:
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1] # 删除 p 后缀
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)
In [32]:
v1 = Vector2d(3, 4)
hash(v1)
为了将其设成可散列的,必须使用 __hash__
方法(还需要 __eq__
方法,前面实现了)。此外,还要让向量不可变。
目前我们可以为分量赋新值,如 v1.x = 7,我们应该让其无法赋值。
In [33]:
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x) # 有两个前导下划线,或一个,把属性标记为私有的
self.__y = float(y)
@property # property 把读值方法标记为特性
def x(self): # 读值方法与公开属性名一样,x
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
# ...下面省略了。
注意,我们让这些向量不可变是有原因的,因为这样才能实现 __hash__
方法。这个方法应该返回一个整数,理想情况下还要考虑对象属性的散列值(__eq__
方法也要使用),因为相等的对象应该具有相同的散列值。根据官方文档,最好使用 异或(^)来混合各个分量的散列值
In [34]:
def __hash__(self):
return hash(self.x) ^ hash(self.y)
如果创建可散列的类型,不一定实现特性,也不一定要保护实例属性。只需要正确的实现 __hash__
和 __eq__
方法即可。但是,实例的散列值绝不应该变化,因此我们借机提到了只读属性。
如果定义的类型有标量数值,可能还要实现 __int__
和 __float__
方法(分别被 int() 和 float() 构造函数调用),以便在某些情况下用于强制类型转换,此外,还有用于支持内置的 complex() 构造函数的 __complex__
方法。
下面是完整的代码:
In [71]:
from array import array
import math
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)
@property # property 把读值方法标记为特性
def x(self): # 读值方法与公开属性名一样,x
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) # 返回欧几里德范数 sqrt(x*x + y*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] # 删除 p 后缀
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 # 类方法使用 @classmethod 装饰器
def frombytes(cls, octets): # 不用传入 self 参数,相反,要通过 cls 传入类本身
typecode = chr(octets[0]) #从第一个字节中读取 typecode
# 用传入的 octets 字节序列创建一个 memoryview,然后使用 typecode 转换
memv = memoryview(octets[1:]).cast(typecode) # 2.92 章介绍了 cast 方法,将一段内存转换成指定的类型,d 代表 float
return cls(*memv) #拆包转换后的 memoryview,得到构造方法所需的一对参数
测试:
In [41]:
v1 = Vector2d(3, 4)
print(v1.x, v1.y)
In [42]:
x, y = v1
x, y
Out[42]:
In [43]:
v1
Out[43]:
In [45]:
v1_clone = eval(repr(v1))
v1 == v1_clone
Out[45]:
In [46]:
print(v1)
In [47]:
octets = bytes(v1)
octets
Out[47]:
In [48]:
abs(v1)
Out[48]:
In [49]:
bool(v1), bool(Vector2d(0, 0))
Out[49]:
In [51]:
v1_clone = Vector2d.frombytes(bytes(v1))
v1_clone
Out[51]:
In [52]:
v1 == v1_clone
Out[52]:
In [53]:
format(v1)
Out[53]:
In [54]:
format(v1, '.2f')
Out[54]:
In [55]:
format(v1, '.3e')
Out[55]:
In [56]:
Vector2d(0, 0).angle()
Out[56]:
In [57]:
Vector2d(1, 0).angle()
Out[57]:
In [59]:
epsilon = 10 ** -8
abs(Vector2d(0, 1).angle() - math.pi / 2) < epsilon
Out[59]:
In [60]:
abs(Vector2d(1, 1).angle() - math.pi / 4) < epsilon
Out[60]:
In [61]:
format(Vector2d(1, 1), 'p')
Out[61]:
In [62]:
format(Vector2d(1, 1), '.3ep')
Out[62]:
In [63]:
format(Vector2d(1, 1), '.5fp')
Out[63]:
In [64]:
v1.x, v1.y
Out[64]:
In [65]:
v1.x = 123
In [66]:
v1 = Vector2d(3, 4)
v2 = Vector2d(3.1, 4.2)
hash(v1), hash(v2)
Out[66]:
In [67]:
len(set([v1, v2]))
Out[67]:
Python 不能像 Java 一样使用 private 修饰符创建私有属性,但是 Python 有个简单的机制,能避免子类意外覆盖 “私有” 属性
举个例子,如果有人编写了一个 Dog 类,有一个 mood 实例属性,但是没有开放,你创建了 Dog 子类,你在毫不知情的情况下又创建了 mood 的实例属性,那么会在继承方法中会把 Dog 类的 mood 属性覆盖掉。这是难以调试的问题
为了避免这种情况,如果以 __mood
的形式(两个前导下划线,尾部没有或最多有一个前导下划线)命名实例属性,Python 会把属性名存到实例的 __dict__
属性中,而且会在前面加上一个下划线和类名。因此,对于 Dog 类来说,__mood
会变成 _Dog__mood
,对于 Beagle 类来说,会变成 _Beagle__mood
。这个语言特性叫名称改写
In [68]:
v1 = Vector2d(3, 4)
v1.__dict__
Out[68]:
名称改写是一种安全措施,不能保证万无一失,它的目的是避免意外访问,不能防止故意做错事。
但不是所有 Python 程序员都喜欢这个功能,他们不喜欢两个下划线的这种特别不对称的写法,所以约定使用一个下划线编写受保护的属性(例如 self.x
)。Python 不会对单下划线进行特殊处理,不过这是很多 Python 程序员严格遵守的约定,他们不会在外部访问这种属性。
Python 文档的某些角落将使用一个下划线前缀标记为受保护的属性。
总之,Vector2d 的分量都是 “私有的”,而 Vector2d 实例都是 “不可变” 的。这里用了两对引号是因为不能真正实现私有和不可变
__slots__
类属性节省空间默认情况下,Python 在各个实例名为 __dict__
字典里存储实例属性,如第三章所说,为了使用底层的散列表提升访问速度,字典会消耗大量内存,通过 __slots__
类属性,能节省大量内存,方法是让解释器在元祖中存储实例属性,而不用字典
继承自超类的
__slots__
属性没有效果。Python 只会使用各个类中定义的__slots__
属性。
定义 __slots__
的方式是,创建一个类属性,使用 __slots__
这个名字,并把它的值设为一个字符串构成的可迭代对象,其中各个元素表示各个实例属性。我喜欢使用元组,因为这样定义的 __slots__
所含的信息不会变化,如下所示:
In [69]:
class Vector2d:
__slots__ = ('__x', '__y')
# 下面是各个方法,省略
在类中定义 __slots__
属性的目的是告诉解释器:这个类中的所有实例属性都在这了,这样,Python 会在各个实例中使用类似元祖的结构存储实例变量,从而避免使用消耗内存的 __dict__
属性。如果有数百万个实例同时活动,这样能大量节省内存
如果要处理数百万个数值对象,应该使用 Numpy 数组,它能高效使用内存,而且提供了高度优化的数值处理函数,其中很多都一次操作整个数组
然而,节省的内存也可能被再次吃掉,如果把 __dict__
这个名称添加到 __slots__
中,实例会在元祖中保存各个实例的属性。此外还支持动态创建属性,这些属性存储在常规的 __dict__
中。当然,把 __dict__
加到 __slots__
中可能完全违背了初衷,这取决于各个实例的静态属性和动态属性的数量及其用法。粗心的优化甚至比提早优化还糟糕
此外,还有一个实例属性值得注意,__weakref__
属性,为了让对象支持弱引用,必须有这个属性,用户定义的类中默认就有 __weakref__
属性。可是如果类中定义了 __slots__
属性,而且想把实例作为弱引用的目标,就要把 __weakref__
添加到 __slots__
中。
综上,__slots__
属性有些需要注意的地方,而且不能滥用。不能使用它来限制用户能赋值的属性。处理列表中数据时,__slots__
属性最有用,例如模式固定的数据库记录,大型数据集。然而,如果你经常处理大量数据,一定要看一下 Numpy,此外 Pandas 也值得了解
__slots__
的问题总之,如果使用得当,__slots__
能显著节省内存,不过有几点需要注意。
__slots__
属性,因为解释器会忽略继承的 __slots__
属性__slots__
列出来的属性,除非把 __dict__
加入 __slots__
中(这样做失去了节省内存的功效)__weakref__
加入 __slots__
,实例就不能作为弱引用的目标如果你的程序不用处理百万个实例,或许不值得费劲去创建不寻常的类,那就禁止它创建动态属性或者不支持弱引用。与其它优化措施一样,仅当权衡当下的需求并仔细搜集资料后证明确实有必要使用,才使用 __slots__
属性
Python 有一个很独特的特性,类属性可用于为实例属性提供默认值。Vector2d 中有个 typecode 类属性,__bytes__
方法两次用到了它,而且都故意使用 self.typecode 读取它的值,因为 Vector2d 实例本身没有 typecode 属性,所以 self.typecode 默认获取的是 Vector2d.typecode 的值
但是,如果为不存在的实例属性赋值,会新建实例属性。假如我们为 typecode 实例属性赋值,那么同名类属性不受影响。然而,自此以后,实例读取的 self.typecode 是实例属性 typecode,也就是把同名类属性遮盖了。借助这一特性,可以为各个实例的 typecode 属性定制不同的值。
Vector2d.typecode 属性默认值是 'd',即转换成字节序列时使用 8 字节双精度浮点数表示各个分量。如果在转换之前把 typecode 属性改成 'f',那么使用 4 字节单精度浮点数表示各个分量。
In [72]:
v1 = Vector2d(1.1, 2.2)
dumpd = bytes(v1)
dumpd
Out[72]:
In [73]:
len(dumpd)
Out[73]:
In [74]:
v1.typecode = 'f'
dumpf = bytes(v1)
dumpf
Out[74]:
In [75]:
len(dumpf)
Out[75]:
这就是我们为什么要在字节序列之前加上 typecode 的值的原因:为了支持不同格式
如果想修改类属性的值,必须直接在类上修改,不能通过实例修改。如果想修改所有实例(没有 typecode 实例变量)的 typecode 属性的默认值,可以这么做:
In [77]:
Vector2d.typecode = 'f'
然而,有种修改方法更符合 Python 风格,而且效果更持久,也更有针对性。类属性是公开的,因此会被子类继承,于是经常会创建一个子类,只用于定制类的属性。Django 基于类的视图就大量使用了这种技术。就像下面这样:
In [80]:
class ShortVector2d(Vector2d):
typecode = 'f'
sv = ShortVector2d(1 / 11, 1 / 27)
sv # 查看 sv 的 repr 形式
Out[80]:
In [81]:
len(bytes(sv))
Out[81]:
这也说明了我们在 Vector2d.__repr__
方法中为什么没有硬编码 class_name 的值,而是使用 type(self).__name__
获取。因为如果硬编码,子类继承的时候还要重写 __repr__
方法,这样从实例读取类名,__repr__
方法就可以放心的继承
至此,我们通过一个简单的类说明了如何利用数据模型处理 Python 的其他功能:提供不同的对象表示形式,实现自定义格式代码,公开只读属性,以及通过 hash() 函数支持集合和映射