由于Python的历史很长(诞生于1991年),一直有版本的迭代和优化,因此Python语言是一种自洽性很强的计算机语言,语法自成体系。对于一直以来学习和使用Python的人来说,会形成一种编程习惯,俗称Pythonic——Python风。
但是熟悉C++、Java或其他OOP语言的人会对Python的一些用法不习惯,例如一个集合元素的计数,Python用内置的len()函数:len(collection),而不是调用集合对象的len方法collection.len()。这种区别,是Python语言体系的特点之一,这里的语言体系就是Python数据模型,即日常编程用到的API集合。
如何理解Python数据模型呢?可以把Python语言看成一个完整的产品,而数据模型就是这个产品的组成部件,这些部件包括序列、函数、迭代器、协程(功能类似多线程,但更轻量)、类、上下文管理器(如闭包)等。
维基百科上,对象模型的概念是:某种计算机语言中对象的属性集合。Python数据模型的概念,其实就是Python的数据和语法,也即Python对外(对程序员)的所有API的统称。
在Python中,构建类的时候,编程人员会花相当多的时间编写一些特殊方法,这些方法被Python自动调用,而不是在其他代码中手动显式调用。这些被自动调用的方法的方法名,通常由双下划线开始和结束。例如:当代码中读取obj[key]这个变量时,会自动调用obj对象内的 __getitem__ 这个特殊方法,即Python解释器根据程序员写的代码,自动调用相应的方法。
这类特殊方法,称为魔术方法(Magic method,也有人把__getitem__读作dunder getitem,dunder是双下划线的简洁说法)。
当我们想实现以下功能时,通常会用到魔术方法:
- 集合的操作
- 获取或设置对象的属性
- 元素的迭代与遍历(包括异步迭代)
- 运算符重载
- 某些函数或方法的调用
- 字符串形式(即print一个对象时,显示的字符串)
- 用await进行异步编程
- 实现对象的构造函数和析构函数
- 使用with管理上下文(或异步的async with)
用Python编写Card Deck纸牌游戏
例1-1是一段简单代码,其中包含了两个魔术方法:__getitem__ 和 __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]
这个FrenchDeck类描述了一副扑克牌(不含大小王),ranks是点数,包括2~10、JQKA;suits是花色,包括黑桃、方片、梅花、红桃。
代码中,首先使用collections.namedtuple创建了一个card命名元组(namedtuple,是轻量级的类,详情请参考官方文档)。命名元组对象和类对象相似,包含了一些属性字段,它还类似数据库里的一条记录。
个人认为,命名元组namedtuple与类相比,优势仅限于轻量,代码可以少写一点点,但对于初学者,理解和用法上不太容易。通常情况对性能要求并不非常严格,因此如果不想了解命名元组,直接用类就可以。待以后用到数据库编程,返回大量数据集的时候,再用命名元组可以大幅降低资源使用,提高效率(但多数情况下还是可以不用,因为有ORM)。
命名元组创建的card对象的使用如下:
>>> beer_card = Card('7', 'diamonds')
>>> beer_card
Card(rank='7', suit='diamonds')
示例中的核心类是FrenchDeck,它封装了一些重要信息。这个类可以像普通的集合那样,使用len()函数来返回其内部封装的card对象的数量:
>>> deck = FrenchDeck()
>>> len(deck)
52
使用序号下标写法可以返回card元素,这是因为它调用了__getitem__方法。例如获取第一个和最后一个card对象:
>>> deck[0]
Card(rank='2', suit='spades')
>>> deck[-1]
Card(rank='A', suit='hearts')
如果需要一个随机card,并不需要单独写一个方法。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')
上面展示了魔术方法对数据模型的有两个贡献:
- 使用一些类的基础操作的时候,无需记住各个类的方法名(例如,获取元素数量时,可能会忘了当初定义类的时候到底使用size、length或其他的方法?)
- 因为遵循了魔术方法的定义规则,可以调用很多Python基础库里的函数。例如使用random.choice(这个函数会使用__getitem__魔术方法,所以若不实现这个魔术方法,choice函数根本不知道如何选择元素)。
但是魔术方法的优势可不止于此。
因为__getitem__相当于重载了“[ ]”运算符,deck对象就自然而然的支持切片操作。下例演示了获取牌垛的前三张牌,从第12张牌开始隔13张取一张牌以获取所有Ace:
>>> 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: # doctest: +ELLIPSIS
... print(card)
Card(rank='2', suit='spades')
Card(rank='3', suit='spades')
Card(rank='4', suit='spades')
还可先翻转deck,再迭代:
>>> for card in reversed(deck): # doctest: +ELLIPSIS
... print(card)
Card(rank='A', suit='hearts')
Card(rank='K', suit='hearts')
Card(rank='Q', suit='hearts')
有时候,迭代行为是默默进行的:使用in操作符判断一个序列是否包含某个元素时,如果没有定义__contains__方法,就会遍历整个序列,直至找到这个元素。本例中,FrenchDeck类是可迭代的,下面的操作就运用这个规则:
>>> Card('Q', 'hearts') in deck
True
>>> Card('7', 'beasts') in deck
False
纸牌的排序规则,通常是先按照点数来排序(注意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]
此处把点数和花色进行换算,得出权重值。其实更简单的方法是使用元组比较大小,元组排序时,先按第一个元素排序,第一个元素相同时,按照第二个元素排序。例如(2,50)、(3,20)、(3,30)就是正序排列。
把spades_high函数名作为参数传给sorted,就可以实现deck的升序排序:
>>> for card in sorted(deck, key=spades_high): #spades_high后面一定不要加()
... print(card)
Card(rank='2', suit='clubs')
Card(rank='2', suit='diamonds')
Card(rank='2', suit='hearts')
... (此处省略46 cards )
Card(rank='A', suit='diamonds')
Card(rank='A', suit='hearts')
Card(rank='A', suit='spades')
虽然FrenchDeck隐式继承了object类,但是其绝大多数功能是数据模型和语法体系实现,而不是因为继承于object。通过实现__len__和 __getitem__,让FrenchDeck具有了传统序列的特征,可用于迭代、切片、标准库函数(诸如random.choice、reversed和sorted等)。Python通过__len__和__getItem__将对deck的一系列操作传递给了self._card这个序列型的属性字段。
如何洗牌?
目前为止,FrenchDeck还不能将牌打乱顺序,因为牌序还是固定的。当然可以通过硬编码直接修改_card属性。在第13章,将通过__setitem__魔法方法搞定这个问题。
本文仅是第一章的三分之一,篇幅太长让人看了就想睡觉,所以分开发更好,校对起来也有耐心。致意!