《流畅的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
>>> 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
的初始版本如下:
# 代码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
实例,下面添加一个方法实现此功能:
# 代码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
,就是用户传入的普通参数。以下是它们的用法对比:
# 代码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()
方法的格式字符串,{}
里替换字段中冒号后面的部分,例如:# 代码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()
函数:
# 代码6
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
复制代码
但现在的Vector2d
在格式化显示上还有缺陷,不能向format()
传入格式说明符:
>>> format(v1, ".3f")
Traceback (most recent call last):
-- snip --
TypeError: non-empty format string passed to object.__format__
复制代码
现在我们来为它定义__format__
方法。添加自定义的格式代码,如果格式说明符以'p'
结尾,则以极坐标的形式输出向量,即<r, θ>
,'p'
之前的部分做正常处理;如果没有'p'
,则按笛卡尔坐标形式输出。为此,我们还需要一个计算弧度的方法angle
:
# 代码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)
复制代码
以下是实际示例:
# 代码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.x
和self.y
的值不能被修改。之所以要让向量不可变,是因为我们在计算向量的哈希值时需要用到self.x
和self.y
的哈希值,如果这两个值可变,那向量的哈希值就能随时变化,这将不是一个可散列的对象。
补充:
- 在文章《Python学习之路22》中说道,用户自定义的对象默认是可散列的,它的散列值等于
id()
的返回值。但是此处的Vector2d
却是不可散列的,这是为什么?其实,如果我们要让自定义类变为可散列的,正确的做法是同时实现__hash__
和__eq__
这两个特殊方法。当这两个方法都没有重写时,自定义类的哈希值就是id()
的返回值,此时自定义类可散列;当我们只重写了__hash__
方法时,自定义类也是可散列的,哈希值就是__hash__
的返回值;但是,如果只重写了__eq__
方法,而没有重写__hash__
方法,此时自定义类便不可散列。 - 这里再次给出可散列对象必须满足的三个条件:
- 支持
hash()
函数,并且通过__hash__
方法所得到的哈希值是不变的; - 支持通过
__eq__
方法来检测相等性; - 若
a == b
为真,则hash(a) == hash(b)
也必须为真。
- 支持
根据官方文档,最好使用异或运算^
混合各分量的哈希值,下面是Vector2d
的改进:
# 代码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
为例:
# 代码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
属性
# 代码11
>>> v = Vector2d(1, 2)
>>> v.__dict__
{'_Vector2d__x': 1.0, '_Vector2d__y': 2.0} # 实例中并没有typecode属性
复制代码
如果为不存在的实例属性赋值,则会新建该实例属性。假如我们为typecode
实例属性赋值,同名类属性不会受到影响,但会被实例属性给覆盖掉(类似于之前在函数闭包中讲的局部变量和全局变量的区别)。借助这一特性,可以为各个实例的typecode
属性定制不同的值,比如在生成字节序列时,将实例转换成4字节的单精度浮点数:
# 代码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
属性的默认值,可以这么做:
# 代码13
Vector2d.typecode = "f"
复制代码
然而有种方式更符合Python风格,而且效果持久,也更有针对性。通过继承的方式修改类属性,生成专门的子类。Django基于类的视图就大量使用了这个技术:
# 代码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__
版本:
# 代码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风格而给自己增加负担。毕竟,简洁胜于复杂。
迎大家关注我的微信公众号"代码港" & 个人网站 www.vpointer.net ~