一摞Python风格的纸牌
Python 最好的品质之一是一致性。当你使用 Python 工作一会儿后,就会开始理解 Python 语言,并能正确猜测出对你来说全新的语言特征。
用一个非常简单的例子来展示如何实现 __getitme__ 和__len__ 这两个特殊方法,通过这个例子我们也能见识到特殊方法的强大。
示例 1-1 里的代码建立了一个纸牌类。
import collections Card = collections.namedtuple('Card', ['rank', 'suit']) class FrenchDeck: ranks = [str(n) for n in range(2, 11)] + list('JQKA') suits = 'spades diamonds clubs hearts'.split() def __init__(self): self._cards = [Card(rank, suit) for suit in self.suits for rank in self.ranks] def __len__(self): return len(self._cards) def __getitem__(self, position): return self._cards[position]`
如下面这个控制台会话所示,利用 namedtuple,我们可以很轻松地得到一个纸牌对象:
>>> beer_card = Card('7', 'diamonds') >>> beer_card Card(rank='7', suit='diamonds')
这个例子主要还是关注 FrenchDeck 这个类,它既短小又精悍。首先,它跟任何标准 Python 集合类型一样,可以用 len() 函数来查看一叠牌有多少张
>>> deck = FrenchDeck() >>> len(deck) 52
从一叠牌中抽取特定的一张纸牌,比如说第一张或最后一张,是很容易的:deck[0] 或 deck[-1]。这都是由 __getitem__ 方法提供的:
>>> deck[0] Card(rank='2', suit='spades') >>> deck[-1] Card(rank='A', suit='hearts')
随机抽取一张纸牌Python 已经内置了从一个序列中随机选出一个元素的函数 random.choice,我们直接把它用在这一摞纸牌实例上就好:
>>> from random import choice >>> choice(deck) Card(rank='3', suit='hearts') >>> choice(deck) Card(rank='K', suit='spades') >>> choice(deck) Card(rank='2', suit='clubs')
现在已经可以体会到通过实现特殊方法来利用 Python 数据模型的两个好处。
- 作为你的类的用户,他们不必去记住标准操作的各式名称(“怎么得到元素的总数?是 .size() 还是 .length() 还是别的什么?”)。
- 可以更加方便地利用 Python 的标准库,比如 random.choice 函数,从而不用重新发明轮子。
因为 __getitem__ 方法把 [] 操作交给了 self._cards 列表,所以我们的 deck 类自动支持切片(slicing)操作。
仅仅实现了 __getitem__ 方法,这一摞牌就变成可迭代的了:
>>> for card in deck: # doctest: +ELLIPSIS ... print(card) Card(rank='2', suit='spades') Card(rank='3', suit='spades') Card(rank='4', suit='spades') ...
反向迭代也没关系:
>>> for card in reversed(deck): ... print(card) Card(rank='A', suit='hearts') Card(rank='K', suit='hearts') Card(rank='Q', suit='hearts')
迭代通常是隐式的,譬如说一个集合类型没有实现 __contains__ 方法,那么 in 运算符就会按顺序做一次迭代搜索。于是,in 运算符可以用在我们的 FrenchDeck 类上,因为它是可迭代的:
>>> Card('Q', 'hearts') in deck True >>> Card('7', 'beasts') in deck False
当然我们也可以进行排序我们按照常规,用点数来判定扑克牌的大小,2 最小、A最大;同时还要加上对花色的判定,黑桃最大、红桃次之、方块再次、梅花最小。下面就是按照这个规则来给扑克牌排序的函数,梅花 2 的大小是 0,黑桃 A 是 51:
suit_values = dict(spades=3, hearts=2, diamonds=1, clubs=0) def spades_high(card): rank_value = FrenchDeck.ranks.index(card.rank) return rank_value * len(suit_values) + suit_values[card.suit]
>>> for card in sorted(deck, key=spades_high): ... print(card) Card(rank='2', suit='clubs') Card(rank='2', suit='diamonds') Card(rank='2', suit='hearts') ... (46 cards ommitted) Card(rank='A', suit='diamonds') Card(rank='A', suit='hearts') Card(rank='A', suit='spades')
虽然 FrenchDeck 隐式地继承了 object 类, 但功能却不是继承而来的。我们通过数据模型和一些合成来实现这些功能。通过实现 __len__和 __getitem__ 这两个特殊方法,FrenchDeck 就跟一个 Python 自有的序列数据类型一样,可以体现出 Python 的核心语言特性(例如迭代和切片)。同时这个类还可以用于标准库中诸如random.choice、reversed 和 sorted 这些函数。另外,对合成的运用使得 __len__ 和 __getitem__ 的具体实现可以代理给 self._cards
如何使用特殊方法
首先明确一点,特殊方法的存在是为了被 Python 解释器调用的,你自己并不需要调用它们。也就是说没有 my_object.__len__() 这种写法,而应该使用 len(my_object)。在执行 len(my_object) 的时候,如果my_object 是一个自定义类的对象,那么 Python 会自己去调用其中由你实现的 __len__ 方法。然而如果是 Python 内置的类型,比如列表(list)、字符串(str)、字节序列(bytearray)等,那么 CPython 会抄个近路,__len__ 实际上会直接返回 PyVarObject 里的 ob_size 属性。PyVarObject 是表示内存中长度可变的内置对象的 C 语言结构体。直接读取这个值比调用一个方法要快很多。很多时候,特殊方法的调用是隐式的,比如 for i in x: 这个语句,背后其实用的是 iter(x),而这个函数的背后则是 x.__iter__() 方法。当然前提是这个方法在 x 中被实现了。通常你的代码无需直接使用特殊方法。除非有大量的元编程存在,直接调用特殊方法的频率应该远远低于你去实现它们的次数。唯一的例外可能是 __init__ 方法,你的代码里可能经常会用到它,目的是在你自己的子类的 __init__ 方法中调用超类的构造器。通过内置的函数(例如 len、iter、str,等等)来使用特殊方法是最好的选择。这些内置函数不仅会调用特殊方法,通常还提供额外的好处,而且对于内置的类来说,它们的速度更快。
模拟数值类型
利用特殊方法,可以让自定义对象通过加号“+”(或是别的运算符)进行运算。
一个二维向量加法的例子,Vector(2,4) + Vextor(2,1) =Vector(4,5)
下面这一段代码就是向量加法:
>>> v1 = Vector(2, 4) >>> v2 = Vector(2, 1) >>> v1 + v2 Vector(4, 5)
注意其中的 + 运算符所得到的结果也是一个向量,而且结果能被控制台友好地打印出来。
abs 是一个内置函数,如果输入是整数或者浮点数,它返回的是输入值的绝对值;如果输入是复数(complex number),那么返回这个复数的模。为了保持一致性,我们的 API 在碰到 abs 函数的时候,也应该返回该向量的模:
>>> v = Vector(3, 4) >>> abs(v) 5.0
我们还可以利用 * 运算符来实现向量的标量乘法(即向量与数的乘法,得到的结果向量的方向与原向量一致 模变大)
>>> v * 3 Vector(9, 12) >>> abs(v * 3) 15.0
示例 1-2 包含了一个 Vector 类的实现,上面提到的操作在代码里是用这些特殊方法实现的:__repr__、__abs__、__add__ 和 __mul__。
from math import hypot class Vector: def __init__(self, x=0, y=0): self.x = x self.y = y def __repr__(self): return 'Vector(%r, %r)' % (self.x, self.y) def __abs__(self): return hypot(self.x, self.y) def __bool__(self): return bool(abs(self)) def __add__(self, other): x = self.x + other.x y = self.y + other.y return Vector(x, y) def __mul__(self, scalar): return Vector(self.x * scalar, self.y * scalar)
字符串表示形式
Python 有一个内置的函数叫 repr,它能把一个对象用字符串的形式表达出来以便辨认,这就是“字符串表示形式”。repr 就是通过 __repr__这个特殊方法来得到一个对象的字符串表示形式的。如果没有实现
__repr__,当我们在控制台里打印一个向量的实例时,得到的字符串可能会是
交互式控制台和调试程序(debugger)用 repr 函数来获取字符串表示形式;在老的使用 % 符号的字符串格式中,这个函数返回的结果用来代替 %r 所代表的对象;同样,str.format 函数所用到的新式字符串格
式化语法syntax)也是利用了 repr,才把 !r 字段变成字符串。
在 __repr__ 的实现中,我们用到了 %r 来获取对象各个属性的标准字符串表示形式——这是个好习惯,它暗示了一个关键:Vector(1, 2)和 Vector('1', '2') 是不一样的,后者在我们的定义中会报错,因为向量对象的构造函数只接受数值,不接受字符串 。
__repr__ 所返回的字符串应该准确、无歧义,并且尽可能表达出如何用代码创建出这个被打印的对象。因此这里使用了类似调用对象构造器的表达形式(比如 Vector(3, 4) 就是个例子)。
__repr__ 和 __str__ 的区别在于,后者是在 str() 函数被使用,或是在用 print 函数打印一个对象的时候才被调用的,并且它返回的字符串对终端用户更友好。如果你只想实现这两个特殊方法中的一个,__repr__ 是更好的选择,因为如果一个对象没有 __str__ 函数,而 Python 又需要调用它的时候,解释器会用 __repr__ 作为替代。
特殊方法一览
类别 | 方法名 |
字符 |
__repr__ 、 __str__ 、 __format__ 、 __bytes__ |
数值 |
__abs__ 、 __bool__ 、 __complex__ 、 __int__ 、 __float__ 、 __hash__ 、 __index__ |
集合 |
__len__ 、 __getitem__ 、 __setitem__ 、 __delitem__ 、 __contains__ |
迭代 |
__iter__ 、 __reversed__ 、 __next__ |
可调 |
__call__ |
上下 |
__enter__ 、 __exit__ |
实例 |
__new__ 、 __init__ 、 __del__ |
属性 |
__getattr__ 、 __getattribute__ 、 __setattr__ 、 __delattr__ 、 __dir__ |
属性 |
__get__ 、 __set__ 、 __delete__ |
跟类 |
__prepare__ 、 __instancecheck__ 、 __subclasscheck__ |
跟运算符相关的特殊方法
类 |
方法名和对应的运算符 |
一 |
__neg__ - 、 __pos__ + 、 __abs__ abs() |
众 |
__lt__ < 、 __le__ <= 、 __eq__ == 、 __ne__ != 、 __gt__ > 、 __ge__ >= |
算 |
__add__ + 、 __sub__ - 、 __mul__ * 、 __truediv__ / 、 __floordiv__ // 、 __mod__ % 、 __divmod__ |
反 |
__radd__ 、 __rsub__ 、 __rmul__ 、 __rtruediv__ 、 __rfloordiv__ 、 __rmod__ 、 __rdivmod__ 、 __rpow__ |
增 |
__iadd__ 、 __isub__ 、 __imul__ 、 __itruediv__ 、 __ifloordiv__ 、 __imod__ 、 __ipow__ |
位 |
__invert__ ~ 、 __lshift__ << 、 __rshift__ >> 、 __and__ & 、 __or__ | 、 __xor__ ^ |
反 |
__rlshift__ 、 __rrshift__ 、 __rand__ 、 __rxor__ 、 __ror__ |
增 |
__ilshift__ 、 __irshift__ 、 __iand__ 、 __ixor__ 、 __ior__ |
为什么len不是普通方法
如果 x 是一个内置类型的实例,那么 len(x) 的速度会非常快。背后的原因是 CPython 会直接从一个 C 结构体里读取对象的长度,完全不会调用任何方法。获取一个集合中元素的数量是一个很常见的操作,在
str、list、memoryview 等类型上,这个操作必须高效。
换句话说,len 之所以不是一个普通方法,是为了让 Python 自带的数据结构可以走后门,abs 也是同理。但是多亏了它是特殊方法,我们也可以把 len 用于自定义数据类型。这种处理方式在保持内置类型的效率和保证语言的一致性之间找到了一个平衡点.
__ilshift__ 、 __irshift__ 、 __iand__ 、 __ixor__ 、 __ior__