《流畅的Python》笔记。
本篇是“面向对象惯用方法”的第三篇。本篇将以上一篇中的Vector2d为基础,定义多维向量Vector。
1. 前言
自定义Vector
类的行为将与Python标准中的不可变扁平序列一样,它将支持如下功能:
- 基本的序列协议:
__len__
和__getitem__
; - 正确表述拥有很多元素的实例;
- 适当的切片支持,用于生成新的
Vector
实例; - 综合各个元素的值计算散列值;
- 自定义的格式语言扩展。
本篇还将通过__getattr__
方法实现属性的动态存取(虽然序列类型通常不会这么做),以及穿插讨论一个概念:把协议当做正式接口。我们将说明协议和鸭子类型之间的关系,以及对自定义类型的影响。
2. 初版Vector
Vector
的构造方法将和所有内置序列类型一样,以可迭代对象为参数。如果其中元素过多,repr()
函数返回的字符串将会使用...
省略一部分内容,它的初始版本如下:
# 代码1
from array import array
import reprlib
import math
class Vector:
typecode = "d"
def __init__(self, components): # 以可迭代对象为参数
self._components = array(self.typecode, components)
def __iter__(self):
return iter(self._components)
def __repr__(self):
components = reprlib.repr(self._components)
components = components[components.find("["):-1]
return "Vector({})".format(components)
def __str__(self): # 和Vector2d相同
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) + bytes(self._components))
def __eq__(self, other): # 和Vector2d相同
return tuple(self) == tuple(other)
def __abs__(self):
return math.sqrt(sum(x * x for x in self))
def __bool__(self): # 和Vector2d相同
return bool(abs(self))
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0])
memv = memoryview(octets[1:]).cast(typecode)
return cls(memv) # 去掉了Vector2d中的星号*
复制代码
之所以没有直接继承制Vector2d
,既是因为这两个类的构造方法不兼容,也是因为我们要为Vector
实现序列协议。
3. 协议和鸭子类型
协议和鸭子类型在之前的文章中也有所提及。在面向对象编程中,协议是非正式的接口,只在文档中定义,在代码中不定义。
在Python中,只要实现了协议需要的某些方法,其实就算实现了协议,而不一定需要继承。比如只要实现了__len__
和__getitem__
这两个方法,那么这个类就是满足序列协议的,而不需要从什么“序列基类”继承。
鸭子类型:和现实中相反,Python中确定一个东西是不是“鸭子”,不是测它的“DNA”是不是”鸭子“的DNA,而是看这东西像不像只鸭子。只要像”鸭子“,那它就是“鸭子”。比如,只要一个类实现了__len__
和__getitem__
方法,那它就是序列类,而不必管它是从哪来的;文件类对象也常是鸭子类型。
4. 第2版Vector:支持切片
让Vector
变为序列类型,并能正确返回切片:
# 代码2,将以下代码添加到初版Vector中
class Vector:
-- snip --
def __len__(self):
return len(self._components)
def __getitem__(self, index):
cls = type(self)
if isinstance(index, slice): # 如果index是个切片类型,则构造新实例
return cls(self._components[index])
elif isinstance(index, numbers.Integral): # 如果index是个数,则直接返回
return self._components[index]
else:
msg = "{cls.__name__} indices must be integers"
raise TypeError(msg.format(cls=cls))
复制代码
如果__getitem__
函数直接返回切片:return self._components[index]
,那么得到的数据将是array
类型,而不是Vector
类型。正是为了使切片的类型正确,这里才做了类型判断。
上述代码中用到了slice
类型,它是Python的内置类型,这里顺便补充一下切片原理,直接上代码:
# 代码3
>>> class MySeq:
... def __getitem__(self, index):
... return index # 直接返回传给它的值
...
>>> s = MySeq()
>>> s[1]
1 # 单索引,没啥新奇的
>>> s[1:3]
slice(1, 3, None) # 返回来一个slice类型
>>> s[1:10:2]
slice(1, 10, 2) # 注意slice类型的结构
>>> s[1:10:2, 9]
(slice(1, 10, 2), 9) # 如果[]中有逗号,__getitem__收到的是元组
>>> s[1:10:2, 7:9]
(slice(1, 10, 2), slice(7, 9, None))
>>> dir(slice) # 注意最后四个元素
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__',
'__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__',
'__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', 'indices', 'start', 'step', 'stop']
复制代码
当我们用dir()
函数获取slice
的属性时,发现它有start
,stop
和step
数据属性,并且还有一个indices
方法,这里重点说说这个indices
方法。它接收一个长度参数len
,并根据这个len
将slice
类型的start
,stop
和step
三个参数正确转换成在长度范围内的非负数,具体用法如下:
# 代码4
>>> slice(None, 10, 2).indices(5)
(0, 5, 2) # 将这些烦人的索引统统转换成明确的正向索引
>>> slice(-3, None, None).indices(5)
(2, 5, 1)
复制代码
自定义Vector
类中并没有使用这个方法,因为Vector
的底层我们使用了array.array
数据类型,切片的具体操作不用我们自行编写。但如果你的类没有这样的底层序列类型做支撑,那么slice.indices
方法将为你节省大量时间。
5. 第3版Vector:动态存储属性
目前版本的Vector
中,没有办法通过名称访问向量的分量(如v.x
和v.y
),而且现在的Vector
可能存在大量分量。不过,如果能通过单个字母访问前几个分量的话,这样将很方便,也更人性化。现在,我们想用x
,y
,z
,t
四个字母分别代替v[0]
,v[1]
,v[2]
和v[3]
,但具体做法并不是为实例添加这四个属性,并且我们也不想在运行时实例能动态添加单个字母的属性,更不想实例能通过这四个字母修改Vector
中self._components
的值。换句话说,我们只想通过这四个字母提供一种较为方便的访问方式,仅此而已。而要实现这样的功能,则需要实现__getattr__
和__setattr__
方法,以下是它们的代码:
# 代码5.1
class Vector:
-- snip --
shortcut_name = "xyzt"
def __getattr__(self, name):
cls = type(self)
if len(name) == 1: # 如果属性是单个字母
pos = cls.shortcut_name.find(name)
if 0 <= pos < len(self._components): # 判断是不是xyzt中的一个
return self._components[pos]
msg = "{.__name__!r} object has no attribute {!r}" # 想要获取其他属性时则抛出异常
raise AttributeError(msg.format(cls, name))
def __setattr__(self, name, value):
cls = type(self)
if len(name) == 1: # 不允许创建单字母实例属性,即便是x,y,z,t
if name in cls.shortcut_name: # 如果name是xyzt中的一个,设置特殊的错误信息
error = "readonly attibute {attr_name!r}"
elif name.islower(): # 为小写字母设置特殊的错误信息
error = "can't set attributes 'a' to 'z' in {cls_name!r}"
else:
error = ""
if error: # 当用户试图动态创建属性时抛出异常
msg = error.format(cls_name=cls.__name__, attr_name=name)
raise AttributeError(msg)
super().__setattr__(name, value)
复制代码
解释:
-
属性查找失败后,解释器会调用
__getattr__
方法。简单来说,对my_obj.x
表达式,Python会检查my_obj
实例有没有名为x
的实例属性;如果没有,则到它所属的类中查找有没有名为x
的类属性;如果还是没有,则顺着继承树继续查找。如果依然找不到,则会调用my_obj
所属类中定义的__getattr__
方法,传入self
和属性名的字符串形式(如'x'
); -
__getattr__
和__setattr_
方法一般同时定义,否则对象的行为很容易出现不一致。比如,如果这里只定义__getattr__
方法,则会出现如下尴尬的代码:# 代码5.2 >>> v = Vector(range(5)) >>> v Vector([0.0, 1.0, 2.0, 3.0, 4.0]) >>> v.x 0.0 >>> v.x = 10 # 按理说这里应该报错才对,因为不允许修改 >>> v.x 10 >>> v # 其实是v创建了新实例属性x,这也是为什么我们要定义__setattr__ Vector([0.0, 1.0, 2.0, 3.0, 4.0]) # 行为不一致 复制代码
-
我们没有禁止动态添加属性,只是禁止为单个字母属性赋值,如果属性名的长度大于1,这样的属性是可以动态添加的;
-
如果你看过上一篇文章,那么你可能会想到用
__slots__
来禁止添加属性,但我们这里仍然选择实现__setattr__
来实现此功能。__slots__
属性最好只用于节省内存,而且仅在内存严重不足时才用它,别为了秀操作而写一些别人看着很别扭的代码(只写给自己看的除外)。
6. 第4版Vector:散列和快速等值测试
目前这个Vector
是不可散列的,现在我们来实现__hash__
方法。具体方法和上一篇一样,也是用各个分量的哈希值进行异或运算,由于Vector
的分量可能很多,这里我们使用functools.reduce
函数来归约异或值。同时,我们还将改写之前那个简洁版的__eq__
,使其更高效(至少对大型向量来说更高效):
# 代码6,请自行导入所需的模块
class Vector:
-- snip --
def __hash__(self):
hashs = (hash(x) for x in self._components) # 先求各个分量的哈希值
return functools.reduce(operator.xor, hashs, 0) # 然后将所有哈希值归约成一个值
def __eq__(self, other): # 不用像之前那样:生成元组只为使用元组的__eq__方法
return len(self) == len(self) and all(a == b for a, b in zip(self, other))
复制代码
解释:
- 此处的
__hash__
方法实际上执行的是一个映射归约的过程。每个分量被映射成了它们的哈希值,这些哈希值再归约成一个值; - 这里的
functool.reduce
传入了第三个参数,并且建议最好传入第三个参数。传入第三个参数能避免这个异常:TypeError: reduce() of empty sequence with no initial value
。如果序列为空,第三个参数就是返回值;否则,在归约中它将作为第一个参数; - 在
__eq__
方法中先比较两序列的长度并不仅仅是一种捷径。zip
函数并行遍历多个可迭代对象,如果其中一个耗尽,它会立即停止生成值,而且不发出警告;
补充一个小知识:
zip
函数和文件压缩没有关系,它的名字取自拉链头(zipper fastener),这个小物件把两个拉链条的链牙要合在一起,是不是很形象?
7. 第5版Vector:格式化
Vector2d
中,当传入'p'
时,以极坐标的形式格式化数据;由于Vector
的维度可能大于2,现在,当传入参数'h'
时,我们使用球面坐标格式化数据,即'<r, Φ1, Φ2, Φ3>'
。同时,还需要定义两个辅助方法:
angle(n)
,用于计算某个角坐标;angles()
,返回由所有角坐标构成的可迭代对象。
至于这两个的数学原理就不解释了。以下是最后要添加的代码:
# 代码7
class Vector:
-- snip --
def angle(self, n):
r = math.sqrt(sum(x * x for x in self[n:]))
a = math.atan2(r, self[n - 1])
if (n == len(self) - 1) and (self[-1] < 0):
return math.pi * 2 - a
return a
def angles(self):
return (self.angle(n) for n in range(1, len(self)))
def __format__(self, format_spec=""):
if format_spec.endswith("h"): # 如果格式说明符以'h'结尾
format_spec = format_spec[:-1] # 格式说明符前面部分保持不变
coords = itertools.chain([abs(self)], self.angles()) #
outer_fmt = "<{}>"
else:
coords = self
outer_fmt = "({})"
components = (format(c, format_spec) for c in coords)
return outer_fmt.format(", ".join(components))
复制代码
itertools.chain
函数生成生成器表达式,将多个可迭代对象连接成在一起进行迭代。关于生成器的更多内容将在以后的文章中介绍。
至此,多维Vector
暂时告一段落。
迎大家关注我的微信公众号"代码港" & 个人网站 www.vpointer.net ~