Think Python/继承
在本章中,我们将开发类来表示扑克牌、牌堆和扑克牌型。如果您不玩扑克,您可以在wikipedia.org/wiki/Poker
上阅读相关信息,但您不必这样做;我会告诉您在练习中需要了解的内容。
如果您不熟悉英美扑克牌,您可以在wikipedia.org/wiki/Playing_cards
上阅读相关信息。
一副牌中有52张牌,每张牌都属于四种花色中的一种和十三种点数中的一种。花色依次是黑桃、红心、方块和梅花(在桥牌中降序排列)。点数是A、2、3、4、5、6、7、8、9、10、J、Q和K。根据您正在玩的游戏,A可能比K大或比2小。
如果我们想定义一个新的对象来表示一张扑克牌,那么属性是什么就很明显了:rank
(点数)和suit
(花色)。属性的类型就不那么明显了。一种可能性是使用包含诸如'Spade'
(黑桃)的花色字符串和'Queen'
(Q)的点数字符串。这种实现的一个问题是,比较卡片以查看哪张卡片的点数或花色更高并不容易。
另一种方法是使用整数来编码点数和花色。在这种情况下,“编码”意味着我们将定义数字和花色之间的映射,或者数字和点数之间的映射。这种编码并非旨在保密(那是“加密”)。
例如,此表显示了花色和相应的整数代码
黑桃 | ↦ | 3 |
红心 | ↦ | 2 |
方块 | ↦ | 1 |
梅花 | ↦ | 0 |
此代码使比较卡片变得容易;因为较高的花色映射到较高的数字,所以我们可以通过比较它们的代码来比较花色。
点数的映射相当明显;每个数字点数都映射到相应的整数,对于花牌
J | ↦ | 11 |
Q | ↦ | 12 |
K | ↦ | 13 |
我使用↦符号来明确这些映射不是 Python 程序的一部分。它们是程序设计的一部分,但它们不会在代码中显式出现。
Card
类的定义如下所示
class Card:
"""represents a standard playing card."""
def __init__(self, suit=0, rank=2):
self.suit = suit
self.rank = rank
像往常一样,init 方法为每个属性都接受一个可选参数。默认卡片是梅花2。
要创建一张卡片,您可以使用您想要的卡片的花色和点数来调用Card
。
queen_of_diamonds = Card(1, 12)
为了以人们易于阅读的方式打印卡片对象,我们需要一个从整数代码到相应点数和花色的映射。一种自然的方法是使用字符串列表。我们将这些列表分配给类属性
# inside class Card:
suit_names = ['Clubs', 'Diamonds', 'Hearts', 'Spades']
rank_names = [None, 'Ace', '2', '3', '4', '5', '6', '7',
'8', '9', '10', 'Jack', 'Queen', 'King']
def __str__(self):
return '%s of %s' % (Card.rank_names[self.rank],
Card.suit_names[self.suit])
像suit_names
和rank_names
这样的变量,它们在类内部但在任何方法之外定义,被称为类属性,因为它们与类对象Card
相关联。
这个术语将它们与诸如suit
和rank
这样的变量区分开来,这些变量被称为实例属性,因为它们与特定的实例相关联。
两种类型的属性都是使用点表示法访问的。例如,在__str__
中,self
是一个卡片对象,self.rank
是它的点数。类似地,Card
是一个类对象,Card.rank_names
是与类关联的字符串列表。
每张卡片都有自己的suit
和rank
,但只有一份suit_names
和rank_names
的副本。
综上所述,表达式Card.rank_names[self.rank]
表示“使用对象self
中的属性rank
作为类Card
中的列表rank_names
的索引,并选择相应的字符串”。
rank_names
的第一个元素是None
,因为没有点数为零的卡片。通过包含None
作为占位符,我们得到一个映射,它具有很好的特性,即索引2映射到字符串'2'
,依此类推。为了避免这种调整,我们可以使用字典而不是列表。
使用我们目前拥有的方法,我们可以创建和打印卡片
>>> card1 = Card(2, 11)
>>> print card1
Jack of Hearts
这是一个显示Card
类对象和一个卡片实例的图
Card
是一个类对象,因此它的类型是type
。card1
的类型是Card
。(为了节省空间,我没有绘制suit_names
和rank_names
的内容)。
对于内置类型,存在条件运算符(<
、>
、==
等),它们比较值并确定一个值何时大于、小于或等于另一个值。对于用户定义的类型,我们可以通过提供名为__cmp__
的方法来覆盖内置运算符的行为。
__cmp__
接受两个参数,self
和other
,如果第一个对象更大则返回正数,如果第二个对象更大则返回负数,如果它们彼此相等则返回0。
卡片的正确排序并不明显。例如,哪张更好,梅花3还是方块2?一张点数更高,但另一张花色更高。为了比较卡片,您必须决定点数或花色哪个更重要。
答案可能取决于您正在玩什么游戏,但为了简单起见,我们将做出任意选择,即花色更重要,因此所有黑桃都大于所有方块,依此类推。
确定这一点后,我们可以编写__cmp__
# inside class Card:
def __cmp__(self, other):
# check the suits
if self.suit > other.suit: return 1
if self.suit < other.suit: return -1
# suits are the same... check ranks
if self.rank > other.rank: return 1
if self.rank < other.rank: return -1
# ranks are the same... it's a tie
return 0
您可以使用元组比较更简洁地编写此代码
# inside class Card:
def __cmp__(self, other):
t1 = self.suit, self.rank
t2 = other.suit, other.rank
return cmp(t1, t2)
内置函数cmp
与方法__cmp__
具有相同的接口:它接受两个值,如果第一个值更大则返回正数,如果第二个值更大则返回负数,如果它们相等则返回0。
为 Time 对象编写一个__cmp__
方法。提示:您可以使用元组比较,但您也可以考虑使用整数减法。
现在我们有了卡片,下一步是定义牌堆。由于牌堆由卡片组成,因此每个牌堆自然包含一个卡片列表作为属性。
以下是Deck
类的定义。init 方法创建属性cards
并生成标准的52张卡片集
class Deck:
def __init__(self):
self.cards = []
for suit in range(4):
for rank in range(1, 14):
card = Card(suit, rank)
self.cards.append(card)
填充牌堆最简单的方法是使用嵌套循环。外部循环枚举花色从0到3。内部循环枚举点数从1到13。每次迭代都会创建一个新的卡片,具有当前花色和点数,并将其追加到self.cards
中。
以下是Deck
的__str__
方法
#inside class Deck:
def __str__(self):
res = [str(card) for card in self.cards]
return '\n'.join(res)
此方法演示了一种高效累积长字符串的方式:构建字符串列表,然后使用join
。内置函数str
会调用每个卡片的__str__
方法并返回其字符串表示形式。
由于我们在换行符上调用join
,因此卡片之间用换行符分隔。结果如下所示
>>> deck = Deck()
>>> print deck
Ace of Clubs
2 of Clubs
3 of Clubs
...
10 of Spades
Jack of Spades
Queen of Spades
King of Spades
即使结果显示在 52 行上,它仍然是一个包含换行符的长字符串。
为了发牌,我们需要一个从牌堆中移除一张牌并返回它的方法。列表方法pop
提供了一种便捷的方式来做到这一点
#inside class Deck:
def pop_card(self):
return self.cards.pop()
由于pop
移除列表中的最后一张牌,所以我们是从牌堆底部发牌。在现实生活中,从底部发牌是不被认可的1,但在这种情况下是可以的。
要添加一张牌,我们可以使用列表方法append
#inside class Deck:
def add_card(self, card):
self.cards.append(card)
像这样使用其他函数而不做太多实际工作的方法有时被称为饰面。这个比喻来自木工,在木工中,通常会将一层优质木材粘贴到较便宜的木材表面。
在这种情况下,我们正在定义一个“薄”方法,该方法以适合牌堆的术语表达列表操作。
再举一个例子,我们可以使用random
模块中的shuffle
函数编写一个名为shuffle
的Deck方法
# inside class Deck:
def shuffle(self):
random.shuffle(self.cards)
不要忘记导入random
。
编写一个名为'sort
'的Deck方法,使用列表方法'sort
'对'Deck
'中的卡片进行排序。'sort
'使用我们定义的__cmp__
方法来确定排序顺序。
与面向对象编程最常关联的语言特性是继承。继承是指定义一个新的类,它是现有类的修改版本的能力。
它被称为“继承”,因为新类继承了现有类的函数。扩展此比喻,现有类称为父类,新类称为子类。
例如,假设我们想要一个类来表示“手牌”,即一个玩家持有的牌集。手牌类似于牌堆:两者都由一组牌组成,并且都需要添加和移除牌等操作。
手牌也与牌堆不同;对于手牌,我们希望进行一些对牌堆没有意义的操作。例如,在扑克中,我们可以比较两副手牌以查看哪一副获胜。在桥牌中,我们可能会计算一副手牌的分数以进行叫牌。
类之间的这种关系——相似但不同——适合使用继承。
子类的定义类似于其他类的定义,但父类的名称出现在括号中
class Hand(Deck):
"""represents a hand of playing cards"""
此定义表明Hand
继承自Deck
;这意味着我们可以像对Deck一样对Hand使用pop_card
和add_card
等函数。
Hand
也继承了Deck
的__init__
,但它并没有真正做到我们想要的效果:与其用 52 张新牌填充手牌,不如让Hand的init函数将cards
初始化为空列表。
如果我们在Hand
类中提供一个init函数,它将覆盖Deck
类中的那个函数
# inside class Hand:
def __init__(self, label=''):
self.cards = []
self.label = label
因此,当你创建一个Hand时,Python会调用此init函数
>>> hand = Hand('new hand')
>>> print hand.cards
[]
>>> print hand.label
new hand
但是其他函数是从Deck
继承的,因此我们可以使用pop_card
和add_card
来发牌
>>> deck = Deck()
>>> card = deck.pop_card()
>>> hand.add_card(card)
>>> print hand
King of Spades
一个自然的下一步是将此代码封装在一个名为move_cards
的函数中
#inside class Deck:
def move_cards(self, hand, num):
for i in range(num):
hand.add_card(self.pop_card())
move_cards
接受两个参数,一个Hand对象和要发的牌数。它修改self
和hand
,并返回None
。
在一些游戏中,牌会从一副手牌移到另一副手牌,或者从手牌移回牌堆。你可以使用move_cards
进行任何这些操作:self
可以是Deck或Hand,而hand
,尽管名称如此,也可以是Deck
。
deal_hands
的Deck函数,它接受两个参数,手牌数量和每副手牌的牌数,创建新的Hand对象,发放相应数量的牌,并返回一个Hand对象列表。继承是一个有用的特性。一些在没有继承的情况下会重复的程序可以使用继承更优雅地编写。继承可以促进代码重用,因为你可以自定义父类的行为,而无需修改它们。在某些情况下,继承结构反映了问题的自然结构,这使得程序更容易理解。
另一方面,继承也可能使程序难以阅读。当调用一个函数时,有时不清楚在哪里找到它的定义。相关代码可能散布在多个模块中。此外,许多可以使用继承完成的事情也可以在没有继承的情况下完成,甚至做得更好。
到目前为止,我们已经看到了栈图,它显示了程序的状态,以及对象图,它显示了对象的属性及其值。这些图表示程序执行中的一个快照,因此它们会随着程序的运行而改变。
它们也高度详细;对于某些目的来说,过于详细。类图是对程序结构的更抽象的表示。它不显示单个对象,而是显示类以及它们之间的关系。
类之间有几种关系
- 一个类中的对象可能包含对另一个类中对象的引用。例如,每个Rectangle都包含对一个Point的引用,每个Deck都包含对许多Card的引用。这种关系称为HAS-A,即“一个Rectangle包含一个Point”。
- 一个类可能继承自另一个类。这种关系称为IS-A,即“一个Hand是一种Deck”。
- 一个类可能依赖于另一个类,因为一个类的更改可能需要另一个类的更改。
类图是这些关系的图形表示2。例如,此图显示了Card
、Deck
和Hand
之间的关系。
带有空心三角形头的箭头表示IS-A关系;在这种情况下,它表示Hand继承自Deck。
标准箭头头表示HAS-A关系;在这种情况下,Deck包含对Card对象的引用。
箭头头附近的星号(*
)是基数;它表示Deck包含多少个Card。基数可以是一个简单的数字,例如52
,一个范围,例如5..7
,或者一个星号,表示Deck可以包含任意数量的Card。
更详细的图可能显示Deck实际上包含一个Card的列表,但像列表和字典这样的内置类型通常不包含在类图中。
阅读'TurtleWorld.py
'、'World.py
'和'Gui.py
',并绘制一个类图,显示其中定义的类之间的关系。
继承可能使调试成为一项挑战,因为当你调用对象上的函数时,你可能不知道将调用哪个函数。
假设你正在编写一个处理Hand对象的函数。你希望它能够处理所有类型的手牌,例如PokerHands、BridgeHands等。如果你调用像shuffle
这样的函数,你可能会得到Deck
中定义的那个函数,但如果任何子类覆盖了此函数,你将得到该版本。
任何时候,如果你不确定程序的执行流程,最简单的解决方案是在相关函数的开头添加print语句。如果Deck.shuffle
打印一条消息,例如正在运行Deck.shuffle
,那么随着程序的运行,它将跟踪执行流程。
或者,你可以使用此函数,它接受一个对象和一个函数名称(作为字符串),并返回提供函数定义的类
def find_defining_class(obj, meth_name):
for ty in type(obj).mro():
if meth_name in ty.__dict__:
return ty
这是一个例子
>>> hand = Hand()
>>> print find_defining_class(hand, 'shuffle')
<class 'Card.Deck'>
因此,此Hand的shuffle
函数是Deck
中的那个函数。
find_defining_class
使用mro
函数获取将搜索函数的类对象(类型)列表。“MRO”代表“方法解析顺序”。
这是一个程序设计建议:每当你覆盖一个函数时,新函数的接口都应该与旧函数相同。它应该接受相同的参数,返回相同的类型,并遵守相同的先决条件和后置条件。如果你遵守此规则,你将发现任何设计用于处理超类实例(例如Deck)的函数也将适用于子类实例(例如Hand或PokerHand)。
如果你违反了此规则,你的代码将像(抱歉)纸牌屋一样崩溃。
- 编码
- 通过构建它们之间的映射,使用另一组值来表示一组值。
- 类属性
- 与类对象关联的属性。类属性在类定义内部但任何函数外部定义。
- 实例属性
- 与类的实例关联的属性。
- 饰面
- 提供不同接口给另一个函数的方法或函数,而无需进行大量计算。
- 继承
- 定义一个新类,它是先前定义的类的修改版本的能力。
- 父类
- 子类继承的类。
- 子类
- 通过继承现有类创建的新类;也称为“子类”。
- IS-A 关系
- 子类与其父类之间的关系。
- HAS-A 关系
- 两个类之间的关系,其中一个类的实例包含对另一个类的实例的引用。
- 类图
- 显示程序中的类及其之间关系的图。
- 多重性
- 类图中的一个表示法,用于显示 HAS-A 关系中对另一个类实例的引用数量。
以下是扑克牌中可能的牌型,按价值递增(和概率递减)的顺序排列
- 一对
- 两张相同牌面的牌
- ''两对:''
- 两对相同牌面的牌
- ''三条:''
- 三张相同牌面的牌
- ''顺子:''
- 五张牌面连续的牌(A 可以是大或小,所以 '
A-2-3-4-5
' 是顺子,'10-J-Q-K-A
' 也是,但 'Q-K-A-2-3
' 不是。) - ''同花:''
- 五张相同花色的牌
- ''葫芦:''
- 三张相同牌面的牌,两张另一相同牌面的牌
- ''四条:''
- 四张相同牌面的牌
- ''同花顺:''
- 五张牌面连续(如上定义)且相同花色的牌
这些练习的目标是估计抽到这些不同牌型的概率。
- 从 '
thinkpython.com/code
' 下载以下文件
Card.py
- : 本章中 '
Card
'、'Deck
' 和 'Hand
' 类的完整版本。 - ''
PokerHand.py
'' - : 表示扑克牌的类的未完成实现,以及一些测试它的代码。
- '如果你运行 '''
PokerHand.py
''',它会发六副七张扑克牌并检查其中任何一副是否包含同花。在继续之前,请仔细阅读此代码。'
- '在 '''
PokerHand.py
''' 中添加名为 ''has_pair'
'、''has_twopair'
' 等方法,根据手牌是否满足相关条件返回 True 或 False。你的代码应该能够正确处理包含任意数量牌的“手牌”(尽管 5 和 7 是最常见的大小)。'
- '编写一个名为 '''
classify
''' 的方法,该方法找出手牌的最高价值分类并相应地设置 '''label
''' 属性。例如,一副七张牌的手牌可能包含同花和一对;它应该被标记为“同花”。'
- '当你确信你的分类方法有效后,下一步是估计各种手牌的概率。在 '''
PokerHand.py
''' 中编写一个函数,该函数洗牌,将牌分成手牌,对牌进行分类,并计算各种分类出现的次数。'
- '打印一个包含分类及其概率的表格。使用越来越多的手牌运行你的程序,直到输出值收敛到合理的精度。将你的结果与 '''
wikipedia.org/wiki/Hand_rankings
''' 中的值进行比较。'
此练习使用第 '4' 章中的 TurtleWorld。你将编写代码让海龟玩老鹰抓小鸡。如果你不熟悉老鹰抓小鸡的规则,请参阅 'wikipedia.org/wiki/Tag_(game)
'。
- 下载 '
thinkpython.com/code/Wobbler.py
' 并运行它。你应该会看到一个带有三个海龟的 TurtleWorld。如果你按下 '运行' 按钮,海龟会随机游荡。
- 阅读代码并确保你理解它的工作原理。'
Wobbler
' 类继承自 'Turtle
',这意味着 'Turtle
' 方法 'lt
'、'rt
'、'fd
' 和 'bk
' 对 Wobbler 有效。 'step
' 方法由 TurtleWorld 调用。它调用 'steer
',后者将海龟转向所需的方向,'wobble
',后者根据海龟的笨拙程度进行随机转向,以及 'move
',后者根据海龟的速度向前移动几个像素。
- 创建一个名为 '
Tagger.py
' 的文件。从 'Wobbler
' 中导入所有内容,然后定义一个名为 'Tagger
' 的类,该类继承自 'Wobbler
'。调用make_world
,将 'Tagger
' 类对象作为参数传递。
- 向 '
Tagger
' 添加一个 'steer
' 方法以覆盖 'Wobbler
' 中的方法。作为起点,编写一个始终将海龟指向原点的版本。提示:使用数学函数 'atan2
' 和海龟属性 'x
'、'y
' 和 'heading
'。
- 修改 '
steer
' 以使海龟保持在边界内。为了调试,你可能希望使用 '步进' 按钮,它会在每个海龟上调用一次 'step
'。
- 修改 '
steer
' 以使每个海龟指向其最近的邻居。提示:海龟有一个属性 'world
',它是对它们所在 TurtleWorld 的引用,而 TurtleWorld 有一个属性 'animals
',它是世界中所有海龟的列表。
- 修改 '
steer
' 以使海龟玩老鹰抓小鸡。你可以向 'Tagger
' 添加方法,也可以覆盖 'steer
' 和__init__
,但你不能修改或覆盖 'step
'、'wobble
' 或 'move
'。此外,'steer
' 允许更改海龟的方向,但不允许更改位置。 调整规则和你的 'steer
' 方法以获得高质量的游戏;例如,慢速海龟最终应该能够抓到快速海龟。
你可以从 'thinkpython.com/code/Tagger.py
' 获取我的解决方案。
- 1
- 参见
wikipedia.org/wiki/Bottom_dealing
。 - 2
- 我在这里使用的图表类似于 UML(参见
wikipedia.org/wiki/Unified_Modeling_Language
),并进行了一些简化。