概述
Python 的一个最佳特性是其一致性。在使用 Python 一段时间后,你会发现自己能够对那些不熟悉的特性做出明智且正确的猜测。
如果你在学习Python前学过其他面向对象语言(object-oriented language),你会想为什么使用Python中使用len(collection)而不是collection.len()。Python数据模型(Python Data Model),可以被视为对Python框架的描述。它规范化了语言自身构建块的接口,比如序列(sequences)、函数(functions)、迭代器(iterators)、协程(coroutines)、类(classes)、上下文管理器(context managers)等等。
当使用框架时,我们会花费大量时间编写被框架调用的方法。同样,当我们利用 Python 数据模型来构建新类时,也会发生类似的情况。。Python解释器调用特殊方法来执行基本的对象操作,这些操作通常由特殊的语法触发。这些特殊方法的名字往往由两个下划线作为前缀和后缀。例如,语法obj[key]由特殊方法__getitem__实现的。解释器调用my_collection.__getitem__(key)来计算my_collection[key]。
当我们希望我们的对象支持基本语言结构并与之交互时,我们会采用特殊方法。这些基础语言结构包括:
- 集合(Collections)
- 访问属性(Attribute access)
- 迭代器(Itertion, 包括使用
async for的异步迭代) - 运算符重载(Operator overloading)
- 函数与方法调用(Function and method invocation)
- 字符串表示与格式化(String representation and formatting)
- 使用
await的异步编程(Asynchronous programming useingawait) - 对象的创建与销毁(Object creation and destruction)
- 使用
with或者async with语句的上下文管理(Managed contexts using thewithorasync withstatements)
A Pythonic Card Deck
例子1-1很简单,但是它展示了实现__getitem__和__len__仅仅两个特殊方法的力量。
# Example 1-1. A deck as a sequence of playing cards
import collections
Card = collections.namedtuple('Card', ['rank', 'suit'])
class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JKQA')
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]
注:Python中约定使用前缀下划线表示某个属性是内部使用的,不应该类的外部直接访问或修改。如果想公开一个只读属性的cards,可以使用:
@property
def cards(self):
return tuple(self._cards)
使用collections.namedtuple来构建一个表示单张扑克牌的简单的类。namedtuple创建只包含属性,没有自定义方法的轻量级类,其实例类似于不可变的对象,属性可以通过名称访问:
>>> beer_card = Card('7', 'diamonds')
>>> beer_card
Card(rank='7', suit='diamonds)
这个例子的重点是FrenchDeck类。首先,就像所有的标准Python集合一样,一副牌调用len()函数会返回它的牌数:
>>> deck = FrenchDeck()
>>> len(deck)
52
得益于特殊方法__getitem__(),从一副牌中挑出一张特定的牌,例如第一张或者最后一张,是很简单的:
>>> deck[0]
Card(rank='2', suit='spades')
>>> deck[-1]
Card(rank='A', suit='hearts')
我们也不需要创造一个随机取牌的方法。Python已经提供了从一个序列(sequence)中随机选取某一项的函数:random.choice:
>>> from random import choice
>>> choice(deck)
Card(rank='3', suit='hearts')
>>> choice(deck)
Card(rank='K', suit='spades')
现在我们看到使用特殊方法来利用Python数据模型的两个优势:
- 使用你的类的用户不需要为标准操作记住任何方法名。
- 可以从丰富的Python标准库获益,避免重复造类似于
random.choice的轮子。
但是好处不止这些。因为我们的__getitem__委托给了self._cards的[]操作符,我们的deck类自动支持切片(slicing)。下面展示如何取得一副崭新的扑克牌最上面的三张牌,然后通过下标12取到第一个A并且循环跳过十三张牌把所有的A都取到:
>>> deck[:3]
[Card(rank='2', suit='spades'), Card(rank='3', suit='spades'),
Card(rank='4', suit='spades')]
>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'),
Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]
只要实现了__getitem__特殊方法,deck就是可迭代的。
>>> for card in deck:
... print(card)
Card(rank='2', suit='spades')
Card(rank='3', suit='spades')
Card(rank='4', suit='spades')
......
我们也可以逆序迭代deck:
>>> for card in reversed(deck):
... print(card)
Card(rank='A', suit='hearts')
Card(rank='K', suit='hearts')
Card(rank='Q', suit='hearts')
......
迭代常常是隐式的。如果一个集合没有实现__contains__方法,in操作符会按照顺序扫描。例如,FrrenchDeck类可以使用in,因为它是可迭代的:
>>> Card('Q', 'hearts') in deck
True
>>> Card('7', 'beasts') in deck
False
那么排序呢?点数的顺序一般是从小到大(A最大),然后花色的大小是clubs(最小),duamonds,hearts,spades(最大)。这里有一个函数,根据该规则对牌进行排序,下标为0的牌是方片2,下标为51的牌是黑桃A:
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_value) + suit_values[card.suit]
有了spades_high,我们可以将牌那招升序排序了:
>>> for card in sorted(decl, key=spades_high):
... print(card)
Card(rank='2', suit='clubs')
Card(rank='2', suit='diamonds')
Card(rank='2', suit='hearts')
... (46 cards omitted)
Card(rank='A', suit='diamonds')
Card(rank='A', suit='hearts')
Card(rank='A', suit='spades')
尽管FrenchDesk类隐式地继承了Object类,但是它的大部分功能不是继承的,而是利用数据模型和组合实现的。通过特殊方法__len__和__getitem__的使用,我们的FrenchDeck类表现的就像一个而标准Python序列,允许它从核心语言特性(例如迭代和切片)和标准库中获益,就像上面使用random.choice、reversed和sorted的实例所示的那样。得益于组合,len和__getitem__方法的实现可以将所有工作委托给一个列表对象,即self._cards。
注:到目前为止,一个FrenchDeck类实例不能被打乱(shuffled),因为它是不可变的(immutable):这些牌和它们的位置不能被改变,除非违反封装(violating encapsulation)并直接处理属性_cards。在第十三章,我们将通过添加一行__setitem__方法来修复它。
总结:__getitem__是Python的特殊方法,支持通过索引访问对象元素。当使用obj[key]语法时,Python会自动调用obj.__getitem__(key)。Python的内置集合类型,例如列表、元组,都支持通过索引访问元素。实现__getitem__使得FrenchDeck类的行为与内置集合一致。
random.choice是Python标准库random模块中的函数,用于从一个支持索引的序列随机选择一个元素。因为FrenchDeck类实现了__getitem__,所以FrenchDeck被看作是吃索引的序列,这样可以直接对FrenchDeck实例调用random.choice方法。
Python的切片操作 obj\[start:stop:step\] 底层会调用对象的__getitem__方法,并传递一个slice对象作为切片。slice对象包含切片的开始索引、结束索引和步长。通过委托self._cards的[]操作,FrenchDeck类自动支持切片操作。
在Python中,当一个类没有实现__iter__方法时,迭代器协议会退而求其次,尝试使用__getitem__方法。当通过for循环迭代时,Python从索引0开始,依次调用__getitem__获取元素,直到抛出IndexError,迭代结束。因此实现__getitem__的类自动支持迭代功能。
Python内置的reversed()函数需要调用对象的__reversed__方法。如果没有实现__reversed__方法,它会尝试反向索引。FrenchDeck类实现了__getitem__方法,可以通过负索引访问元素,因此FrenchDeck类支持反向迭代。
如果一个集合没有实现__contains__方法,那么in运算符在检查成员是否存在时,会回退到迭代机制,调用__getitem__方法,逐一检查集合中的元素。
FrenchDeck类虽然是继承object类,但是它的功能主要是通过组合,即将_cards视为其类的成员,来实现的。特殊方法__len__和__getitem__的具体操作是委托给_cards这个列表来实现的。假设deck是FrenchDeck类的一个实例,那么调用len(deck)实际上是调用self._cards.__len__();deck[position]也会调用self._cards.__getitem__(position)。
FrenchDeck类实现了__len__和__getitem__两个方法,就自动实现了迭代、切片、随机选择、逆向、排序等功能,充分利用了Python提供的标准功能。*
如何使用特殊方法
关于特殊方法,首先要知道的是,它们应该由Python解释器调用,而不是被你调用。你不会写下my_object.__len__()。你会写len(my_object),如果my_object是个用户定义的类,那么Python解释器会调用你实现的__len__方法。
但是当解释器处理例如list, str, bytearray或者扩展库(例如Numpy数组)这样的内置类型时,解释器会“走捷径”。用C编写的Python可变大小集合包含一个叫做PyVarObject的结构体,它有一个字段ob_size,用于记录集合内部元素的数量。所以,如果my_object是某一个内置类型的实例,那么len(my_object)会检索ob_size字段的值并返回,这比调用一个方法的速度要快的多。
通常情况下,特殊方法调用是隐式的。例如,语句for i in x:实际上调用了iter(x),iter(x)可能会调用x.__iter__()(如果存在),或者调用x.__getitem__(),就像FrenchDeck例子中所示。
正常情况下,你的代码不应该存在许多特殊方法的直接调用调用。你应该更频繁地实现特殊方法而不是显示地调用特殊方法,除非你在进行大量的元编程(metaprogramming)。__init__是唯一经常被用户代码频繁调用的特殊方法,用于在你自己的__init__实现时调用超类的初始化。
如果你必须调用一个特殊方法,最好调用相关的内置函数(例如len, iter, str等等)。这些内置函数调用对应的特殊方法,常常提供其他服务,并且对于内置类型来说,使用内置函数比调用方法更快。具体例子见第17章。
模拟数值类型
有几种特特殊方法允许用户对象响应操作符,例如+。我们将在第16章更详细地介绍这一点,这里我们用一个简单的例子进一步说明特殊方法的作用。
我们要实现一个表示二维向量的类。我们开始为这个类设计API。
>>> v1 = Vector(2, 4)
>>> v2 = Vector(2, 1)
>>> v1 + v2
Vector(4, 5)
注意+运算符是如何生成一个新的Vector的。
Python内置函数abs返回整数和浮点数的绝对值以及复数的模长(magnitude)。因此,为了保持一致,我们的API还使用abs来计算向量的模长。
>>> v = Vector(3, 4)
>>> abs(v)
5.0
我们还可以实现*运算符来执行标量乘法:
>>> v * 3
Vector(9, 12)
>>> abs(v * 3)
15.0
接下来实现一个向量类,实现上述操作,只使用__repr__, __abs__, __add__和__mul__这四种特殊方法。
import math
class Vector:
def __init__(self, x=0, y=0):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x!r}, {self.y!r})"
def __abs__(self):
return math.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)
除了熟悉的__init__,我们还实现了5个特殊方法。注意它们都不是在类内直接调用,也不是在doctest所示的类的典型用法中调用的。正如前面所提到的,Python解释器是大多数特殊方法的唯一调用者。
上个例子中实现了两个运算符:+和*,以显示__add__和__mul__的基本用法。在这两个例子中,这些方法创建并返回了Vector类的新实例,并没有修改任何一个操作数————self和other是只读的。这是中缀运算符(infix operators)的预期行为:创建新对象而不更改其操作数。详细内容见第16章。
字符串表示
为了得到要检查对象的字符串表示,内置函数repr会调用__repr__特殊方法。如果没有自定义的__repr__,Python的控制台会将Vector实例显示为<Vector object at 0x10e100070>。
注意我们__repr__内的f-string使用!r来获取要显示的属性的标准表示。这是一个很好的实践,因为它展示了Vector(1, 2)与Vector('1', '2')的本质区别————后者在本例中不起作用,因为构造函数的参数是数字,不是字符串。
__repr__返回的字符串应该是明确的,并且如果可能的话,与重新创建对象的源代码匹配。这就是为什么我们的Vector的表示看起来跟调用类的构造函数看起来很相似。
相反,__str__是由内置函数str()调用的,并且隐式地被print函数调用。它应该返回一个合适的显示的字符串给最终用户。
有时,__repr__返回的相同字符串是用户友好的,你不必编写__str__,因为从object类继承的实现调用__repr__作为后备。
总结:__repr__的目标是提供一种明确的表达方式,是的使出结果对于开发人员来说没有歧义,并且能通过该表达来创建该对象,即可重建性。__str__返回的是简洁、便于阅读的字符串。如果没有自定义__str__,Python会默认使用__repr__的结果。例如print(vector)中,print函数会调用vector.__repr__。
自定义类型的布尔值
尽管Python有一个bool类型,它接受用于布尔上下文中的任何对象,就像控制if和while语句的表达式,还有操作符and, or, not的操作数。Python使用bool(x)来确定值x(不管它是个什么东西)是真还是假,bool(x)的返回值要么是True,要么是False。
除非实现了__bool__或者__len__方法,否则用户定义类的实例默认为真(truthy)。大体上,bool(x)调用了x.__bool__()并使用了其结果。如果类没有实现__bool__,Python会尝试调用x.__len__(),如果返回值是0,则返回False,否则返回True。
在Vector的例子中,我们对__bool__的实现是很简单的:如果矢量的大小为0,则返回False,否则返回True。我们使用bool(abs(self))把大小变成一个布尔型,因为__bool__期待的返回值是一个布尔型。
集合接口(API)
图片记录了该语言中基本集合类型的接口。示意图中所有的类都是ABCs——abstract base classes,抽象基类。抽象基类和collections.abc模块会在第13章讲到。这个概述的目标是提供Python最重要的集合接口的全景视图,展示它们是如何使用特殊方法构建的。
具有基本集合类型的UML(统一建模语言)类图。斜体的方法名称是抽象方法,因此它们必须由具体的子类实现,如list和dict。其余的方法都有具体的实现,因此子类可以继承它们。
最上面的三个的抽象基类都有一个特殊方法。Collection 抽象基类(Python 3.6新增)统一了每个集合类应该实现的三个基本接口:
Iterable,支持迭代、解包(unpacking)、和其他形式的迭代Sized,支持len内置函数Container,支持·in操作符(operator)
Python 不需要具体的类必须继承抽象基类。只要是实现了__len__的类,都满足Sized接口。
Collection 的三个非常重要的特化(specialization)包括:
Sequence,形式化例如list和str这些内置类的接口Mapping,由dict、collections.defaultdict等实现Set,set和forzenset内置类型的接口
只有Sequence是Reversible,因为序列(sequences)支持对其内容任意排序,而映射(mappings)和集合(sets)不支持。
从Python 3.7开始,字典(dict)类型正式成为“有序的”。但是这直译为这保留了键(key)插入的顺序。你不能随心所欲地重新排列字典中的键。
Set抽象基类的所有特殊方法都实现了中缀运算符(infix operators)。例如,a & b计算了集合a和集合b的交集,是由__add__特殊方法实现的。
总结: Iterable接口定义了一个集合类型的可以进行迭代的能力。所有可迭代对象,例如列表、元组、字典、集合等,都实现了Iterable接口。该接口是通过实现__iter__方法来支持迭代的。Sized接口要求集合类型能够支持获取其大小。实现Sized接口的集合类型会定义__len__方法。Container接口要求集合类型能够支持成员测试操作,即使用运算符in来检测一个元素是否存在于集合中。实现Container接口的集合类型会定义__contains__方法。
Collection 抽象基类是一个重要接口,它将Iterable, Sized和Container三个接口结合在一起,同意了集合类型的核心功能。
Python的动态特性允许类通过鸭子类型满足接口要求,而不需要显示地继承某个抽象基类。例如,FrenchDeck类实现了__len__方法,那么它就会被认为符合Sized接口的规范,即使它没有直接继承Sized。
Collection的三个特化接口,Sequence描述了有序的集合类型接口;Mapping描述了键值对储存的集合类型接口;Set描述了无序集合的接口。Reversible是一个抽象接口,用于描述可反向迭代的对象,只有Sqquence是可反转的。
特殊方法概述
《The Python Language Reference》在Data Model章节列举了超过八十种特殊方法。其中一半以上实现算术(arithmetic)、位运算(bitwise)和比较(comparision)操作符。
上表展示了特殊方法名称,不包括那些用于实现中缀运算符或者核心数学函数(例如abs)的方法名称。这些方法中的大部分将在整本书中介绍。
实现中缀运算符和算数运算符的特殊方法在上表中列出。
为什么len不是一个方法
我们解释过为什么当x是一个内置类型的实例时,len(x)计算的非常快。CPython的内置对象没有调用任何方法:只是简单地从C结构体中读取一个字段就能获取长度。获取一个集合的元素数量是一个常见的操作,并且对像str、list、memoryview等多样的类型都要做到高效处理。换句话说,len不作为方法调用,因为它像abs一样,作为Python数据模型的一部分得到特殊处理。虽然你也可以在自定义的类中实现__len__方法,让len可以作用于你的自定义类,但是为了保持一致性,还是会写成len(obj)而不是obj.len()。
小结
通过实现特殊的方法,您的对象可以像内置类型一样工作,从而实现社区认为Python的表达性编码风格。
Python对象的一个基本要求是提供其自身的可用字符串表示,一个用于调试和日志记录,另一个用于向最终用户演示。这就是数据模型中存在特殊方法__repr_和__str__的原因。
如FrenchDeck示例所示,模拟序列,是特殊方法最常见的用途之一。例如,数据库经常返回类似于序列集合的查询结果。
由于运算符重载,Python提供了丰富的数字类型选择,从内置类型到decimal.Decimal 和 fractions.Fraction,所有支持中缀算术运算符。NumPy数据科学库支持具有矩阵和张量的中缀运算符。
Soapbox
数据模型(Data Model)还是对象模型(Object Model)
Python 社区中,术语 “数据模型” 和 “对象模型” 经常互换使用。本质上,它们讨论的是同一件事——Python 的核心对象结构和行为规则。作者选择“数据模型”,是为了与官方文档术语保持一致。