Python的数据模型
数据模型简介
数据模型其实是对Python框架的描述,它规范了这门语言自身构建模块的接口,这些模块包括但不限于序列、迭代器、函数、类和上下文管理器。
不管在哪种框架下写程序,都会花费大量时间去实现那些会被框架本身调用的方法, Python也不例外。Python解释器碰到特殊的句法时,会使用特殊方法去激活一些基本的对象操作,这些特殊方法的名字以两个下划线开头,以两个下划线结尾(例如__getitem__
)。比如obj[key]的背后就是__getitem__
法,为了能求得my_collection[key]的值,解释器实际上会调用my_collection.__getitem__(key)
。
这些特殊方法名能让你自己的对象实现和支持以下的语言构架,并与之交互:
- 迭代
- 集合类
- 属性访问
- 运算符重载
- 函数和方法的调用
- 对象的创建和销毁
- 字符串表示形式和格式化
- 管理上下文(即with块)
初识特殊方法
魔术方法(magic method)是特殊方法的昵称。
接下来我会用一个非常简单的例子来展示如何实现__getitem__
和__len__
这两个特殊方法,通过这个例子我们也能见识到特殊方法的强大。
import collections
Card = collections.namedtuple('Card', ['rank', 'suit']) # 具名元组
class Poker:
# ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'] 初始化之纸牌:2-10和JQKA
ranks = [str(n) for n in range(2, 11)] + list('JQKA') # 列表推导式
# 黑桃、方块、梅花、红心
suits = '黑桃 方块 梅花 红桃'.split() # 使用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]
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
首先,我们用 collections.namedtuple
构建了一个简单的类来表示一张纸牌,collections 作为 Python 的内建集合模块,实现了许多十分高效的特殊容器数据类型,即除了 Python 通用内置容器: dict、list、set 和 tuple 等的替代方案。利用 namedtuple,我们可以很 轻松地得到一个扑克牌对象。Poker这个类它也跟任何标准 Python 集合类型一样,我们可以用 len()这个方法来查看一叠牌有多少张(这是由__len__
方法提供的),也可以使用如 deck[0]
或 deck[-1]
来去一张特定的牌(这是由__getitem__
方法提供的),我们也可以使用random.choice
函数随机抽取一张牌
beer_card = Card('7', '黑桃')
print("创建一张牌", beer_card)
deck = Poker()
print("总共有多少张牌:", len(deck))
print("取出第一张牌", deck[0])
print("取出最后一张牌", deck[-1])
from random import choice
print("随机生成一张牌", choice(deck))
print("随机生成一张牌", choice(deck))
print("随机生成一张牌", choice(deck))
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
# 输出
创建一张牌 Card(rank='7', suit='黑桃')
总共有多少张牌: 52
取出第一张牌 Card(rank='2', suit='黑桃')
取出最后一张牌 Card(rank='A', suit='红桃')
随机生成一张牌 Card(rank='5', suit='梅花')
随机生成一张牌 Card(rank='4', suit='方块')
随机生成一张牌 Card(rank='Q', suit='方块')
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
现在已经可以体会到通过实现特殊方法来利用 Python 数据模型的两个好处。
- 作为你的类的用户,他们不必去记住标准操作的各式名称(“怎么得到元素的总数?是 .size() 还是 .length() 还是别的什么?”)。
- 可以更加方便地利用 Python 的标准库,比如 random.choice 函数,从而不用重新发明轮子。
而因为__getitem__
方法把 [] 操作交给了 self._cards 列表,所以我们的 deck 类自动支持切片(slicing)操作,下面列出了查看一摞牌最上面 3 张和只看牌面是 A 的牌的操作。其中第二种操作的具体方法是,先抽出索引是 12 的那张牌,然后每隔 13 张牌拿 1张:
print(deck[:3])
print(deck[12::13])
# 输出
[Card(rank='2', suit='黑桃'), Card(rank='3', suit='黑桃'), Card(rank='4', suit='黑桃')]
[Card(rank='A', suit='黑桃'), Card(rank='A', suit='方块'), Card(rank='A', suit='梅花'), Card(rank='A',suit='红桃')]
- 1
- 2
- 3
- 4
- 5
- 6
另外,仅仅实现了 getitem 方法,这一摞牌就变成可迭代的了:
# 正向迭代
for card in deck:
print(card)
# 反向迭代
for card in reversed(deck):
print(card)
# 输出所有的牌
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
如何洗牌:按照目前的设计,Poker 是不能洗牌的,因为这摞牌是不可变的(immutable):卡牌和它们的位置都是固定的,除非我们破坏这个类的封装性,直
接对 _cards 进行操作。第 11 章会讲到,其实只需要一行代码来实现__setitem__
方法,洗牌功能就不是问题了。
如何使用特殊方法
首先明确一点,特殊方法的存在是为了被 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,等等)来使用特殊方法是最好的选择。这些内置函数不仅会调用特殊方法,通常还提供额外的好处,而且对于内置的类来说,它们的速度更快。
注:不要自己想当然地随意添加特殊方法,比如 __foo__
之类的,因为虽然现在这个名字没有被 Python 内部使用,以后就不一定了。
len不是普通方法
如果 x 是一个内置类型的实例,那么 len(x) 的速度会非常快。背后的原因是 CPython 会直接从一个 C 结构体里读取对象的长度,完全不会调用任何方法。获取一个集合中元素的数量是一个很常见的操作,在 str、list、memoryview等类型上,这个操作必须高效。
换句话说,len 之所以不是一个普通方法,是为了让 Python 自带的数据结构可以走后门,abs 也是同理。但是多亏了它是特殊方法,我们也可以把 len 用于自定义数据类型这种处理方式在保持内置类型的效率和保证语言的一致性之间找到了一个平衡点,也印证了“Python 之禅”中的另外一句话:“不能让特例特殊到开始破坏既定规则。”
总结
通过实现特殊方法,自定义数据类型可以表现得跟内置类型一样,从而让我们写出更具表达力的代码——或者说,更具 Python 风格的代码。Python 对象的一个基本要求就是它得有合理的字符串表示形式,我们可以通过 repr 和 str 来满足这个要求。前者方便我们调试和记录日志,后者则是给终端用户看的。这就是数据模型中存在特殊方法 repr 和 str 的原因。对序列数据类型的模拟是特殊方法用得最多的地方,这一点在 Poker 类的示例中有所展现。Python 通过运算符重载这一模式提供了丰富的数值类型,除了内置的那些之外,还有
decimal.Decimal
和 fractions.Fraction
。这些数据类型都支持中缀算术运算符。除此以外Python 数据模型的特殊方法还有很多。