流畅的 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, 这样的属性是可以动态添加的;
如果你看过上一篇文章 http://www.vpointer.net/articles/Python学习之路28-符合Python风格的对象/ , 那么你可能会想到用__slots__来禁止添加属性, 但我们这里仍然选择实现__setattr__来实现此功能.__slots__属性最好只用于节省内存, 而且仅在内存严重不足时才用它, 别为了秀操作而写一些别人看着很别扭的代码(只写给自己看的除外).
6. 第 4 版 Vector: 散列和快速等值测试
目前这个 Vector 是不可散列的, 现在我们来实现__hash__方法. 具体方法和上一篇 http://www.vpointer.net/articles/Python学习之路28-符合Python风格的对象/ 一样, 也是用各个分量的哈希值进行异或运算, 由于 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 暂时告一段落.
来源: https://juejin.im/post/5b220ca8f265da59961bc093