
Fluent Python
Chapter 10. Sequence Hacking, Hashing, and Slicing
第十章: 序列的修改、散列和切片
这章以上章的Vector2d为基础,定义多维向量,这个类的行为和Python标准的扁平序列一样。将实现下面的功能:
- 基本的序列协议—— __len__ 和 __getitem__
- 正确表述拥有很多元素的实例
- 适当的切片支持,用于生成新的 Vector 实例
- 综合各个元素的值计算散列值
- 自定义的格式语言扩展
- 此外,我们还将通过 getattr 方法实现属性的动态存取,以此取代 Vector2d 使用的只读特性——不过,序列类型通常不会这么做。
Vector类:用户自定义的序列类型
序列类型的构造方法最好接受可迭代的对象为参数, 首先我们为vector加上这个构造方法
1 | from array import array |
注: reprlib.repr 的方式需要做些说明。这个函数用于生成大型结构或递归结构的安全表示形式,它会限制输出字符串的长度,用 ‘…’ 表示截断的部分。
调用 repr() 函数的目的是调试,因此绝对不能抛出异常。如果 \_repr__ 方法的实现有问题,那么必须处理,尽量输出有用的内容,让用户能够识别目标对象。_
协议和鸭子类型
在 Python 中创建功能完善的序列类型无需使用继承,只需实现符合序列协议的方法。不过,这里说的协议是什么呢?
在面向对象编程中,协议是非正式的接口,只在文档中定义,在代码中不定义。例如,Python 的序列协议只需要 __len__ 和 __getitem__ 两个方法。任何类(如 Spam),只要使用标准的签名和语义实现了这两个方法,就能用在任何期待序列的地方。Spam 是不是哪个类的子类无关紧要,只要提供了所需的方法即可。
注: 鸭子类型:“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”
协议是非正式的,没有强制力,因此如果你知道类的具体使用场景,通常只需要实现一个协议的部分。例如,为了支持迭代,只需实现 __getitem__ 方法,没必要提供 __len__方法。
Vector:可切片的序列
添加__len__ 和 __getitem__方法后就可以实现基本的切片了
1 | class Vector: |
但是这样实现的切片会存在一个问题, 就是切片得到的结果是数组而不是新的Vector类
切片原理
首先通过一个例子查看切片的原理:
1 | class MySeq: |
slice(1, 4, 2) 表示的是 从1开始, 到4结束, step是2(左开右闭)
slice 有一个有趣的方法indices
S.indices(len) -> (start, stop, stride)
给定长度为 len 的序列,计算 S 表示的扩展切片的起始(start)和结尾(stop)索引,以及步幅(stride)。超出边界的索引会被截掉,这与常规切片的处理方式一
样。
换句话说,indices 方法开放了内置序列实现的棘手逻辑,用于优雅地处理缺失索引和负数索引,以及长度超过目标序列的切片。这个方法会“整顿”元组,把 start、stop 和stride 都变成非负数,而且都落在指定长度序列的边界内。
下面举几个例子。假设有个长度为 5 的序列,例如 ‘ABCDE’:
1 | None, 10, 2).indices(5) slice( |
‘ABCDE’[:10:2] 等同于 ‘ABCDE’[0:5:2]
‘ABCDE’[-3:] 等同于 ‘ABCDE’[2:5:1]
能处理切片的__getitem__方法
对上文提到的Vector类的__getitem__方法 做出如下改动:
1 | def __getitem__(self, index): |
传入slice对象时,getitem会将_components 数组的切片构建成一个新的 Vector 实例
Vector 类: 动态存取属性
我们想额外提供下述句法,用于读取向量的前四个分量:
1 | 10)) v = Vector(range( |
首先我们要了解一下Python中对象的属性查找机制:
简单来说,对 my_obj.x 表达式,Python 会检查 my_obj 实例有没有名为 x 的属性;如果没有,到类(my_obj.class)中查找;如果还没有,顺着继承树继续查找。 如果依旧找不到,调用 my_obj 所属类中定义的 getattr 方法,传入 self 和属性名称的字符串形式(如 ‘x’)。
下面我们为Vector类增加 __getattr__方法:
1 | shortcut_names = 'xyzt' |
这样的实现可以初步达到我们需要的效果,但是可能会出现下面的矛盾情形:
1 | 5)) v = Vector(range( |
这里对v.x赋值并没有改变Vector第一分量的值,这样赋值只是让Vector类多了一个名为x的属性。
为了避免这种情况出现我们需要实现一个__setattr__方法:1
2
3
4
5
6
7
8
9
10
11
12
13def __setattr__(self, name, value):
cls = type(self)
if len(name) == 1:
if name in cls.shortcut_names:
error = 'readonly attribute {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 方法,那么也要定义 setattr 方法,以防对象的行为不一致。
如果想允许修改分量,可以使用 setitem 方法,支持 v[0] = 1.1 这样的赋值,以及(或者)实现 setattr 方法,支持 v.x = 1.1 这样的赋值
Vector类: 散列和快速等值测试
实现__hash__方法,加上现有的__eq__方法
我们使用^(异或)运算符依次计算各个分量的散列值,像这样:v[0] ^ v[1] ^ v[2]…。需要用到归约函数reduce
下面提供了三种计算累计异或的方式:1
2
3
4
5
6
7
8
9
100 n =
for i in range(1, 6):
n ^= i
...
n
1 >>> import functools
lambda a, b: a^b, range(6)) functools.reduce(
1 >>> import operator
6)) functools.reduce(operator.xor, range(
1
显然第三种比较简洁, 本书的第五章讲过, 尽量避免lambda表达式的使用。
增加的__hash__方法如下:
1 | def __hash__(self): |
这是一种映射归约运算
映射归约:把函数应用到各个元素上,生成一个新序列(映射,map),然后计算聚合值(归约,reduce)
为了提高比较的效率修改__eq__方法(两种实现方式逻辑一样):1
2
3
4
5
6
7
8
9
10def __eq__(self, other):
if len(self) != len(other):
return False
for a, b in zip(self, other):
if a != b:
return False
return True
def __eq__(self, other):
return len(self) == len(other) and all(a == b for a, b in zip(self, other))
格式化
Vector 类的 __format__ 方法与 Vector2d 类的相似,但是不使用极坐标,而使用球面坐标(也叫超球面坐标),因为 Vector 类支持 n 个维度,而超过四维后,球体变成了“超球体”(n维球体)。 因此,我们会把自定义的格式后缀由 ‘p’ 变成 ‘h’
下面是格式化的实现:
1 | import itertools |
小结
我们经常分析 Python 标准对象的行为,然后进行模仿,让 Vector 的行为符合 Python 风格。