跳转到内容

Think Python/继承

来自Wikibooks,开放世界中的开放书籍

在本章中,我们将开发类来表示扑克牌、牌堆和扑克牌型。如果您不玩扑克,您可以在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_namesrank_names这样的变量,它们在类内部但在任何方法之外定义,被称为类属性,因为它们与类对象Card相关联。

这个术语将它们与诸如suitrank这样的变量区分开来,这些变量被称为实例属性,因为它们与特定的实例相关联。

两种类型的属性都是使用点表示法访问的。例如,在__str__中,self是一个卡片对象,self.rank是它的点数。类似地,Card是一个类对象,Card.rank_names是与类关联的字符串列表。

每张卡片都有自己的suitrank,但只有一份suit_namesrank_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类对象和一个卡片实例的图

<IMG SRC="book026.png">

Card是一个类对象,因此它的类型是typecard1的类型是Card。(为了节省空间,我没有绘制suit_namesrank_names的内容)。

比较卡片

[编辑 | 编辑源代码]

对于内置类型,存在条件运算符(<>==等),它们比较值并确定一个值何时大于、小于或等于另一个值。对于用户定义的类型,我们可以通过提供名为__cmp__的方法来覆盖内置运算符的行为。

__cmp__接受两个参数,selfother,如果第一个对象更大则返回正数,如果第二个对象更大则返回负数,如果它们彼此相等则返回0。

卡片的正确排序并不明显。例如,哪张更好,梅花3还是方块2?一张点数更高,但另一张花色更高。为了比较卡片,您必须决定点数或花色哪个更重要。

答案可能取决于您正在玩什么游戏,但为了简单起见,我们将做出任意选择,即花色更重要,因此所有黑桃都大于所有方块,依此类推。

确定这一点后,我们可以编写__cmp__

# inside class Card:

    def __cmp__(self, other):
        # check the suits
        if self.suit > other.suit: return 1
        if self.suit &lt; other.suit: return -1

        # suits are the same... check ranks
        if self.rank > other.rank: return 1
        if self.rank &lt; 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_cardadd_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_cardadd_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对象和要发的牌数。它修改selfhand,并返回None

在一些游戏中,牌会从一副手牌移到另一副手牌,或者从手牌移回牌堆。你可以使用move_cards进行任何这些操作:self可以是Deck或Hand,而hand,尽管名称如此,也可以是Deck

练习 3 编写一个名为deal_hands的Deck函数,它接受两个参数,手牌数量和每副手牌的牌数,创建新的Hand对象,发放相应数量的牌,并返回一个Hand对象列表。

继承是一个有用的特性。一些在没有继承的情况下会重复的程序可以使用继承更优雅地编写。继承可以促进代码重用,因为你可以自定义父类的行为,而无需修改它们。在某些情况下,继承结构反映了问题的自然结构,这使得程序更容易理解。

另一方面,继承也可能使程序难以阅读。当调用一个函数时,有时不清楚在哪里找到它的定义。相关代码可能散布在多个模块中。此外,许多可以使用继承完成的事情也可以在没有继承的情况下完成,甚至做得更好。

到目前为止,我们已经看到了栈图,它显示了程序的状态,以及对象图,它显示了对象的属性及其值。这些图表示程序执行中的一个快照,因此它们会随着程序的运行而改变。

它们也高度详细;对于某些目的来说,过于详细。类图是对程序结构的更抽象的表示。它不显示单个对象,而是显示类以及它们之间的关系。

类之间有几种关系

  • 一个类中的对象可能包含对另一个类中对象的引用。例如,每个Rectangle都包含对一个Point的引用,每个Deck都包含对许多Card的引用。这种关系称为HAS-A,即“一个Rectangle包含一个Point”。
  • 一个类可能继承自另一个类。这种关系称为IS-A,即“一个Hand是一种Deck”。
  • 一个类可能依赖于另一个类,因为一个类的更改可能需要另一个类的更改。

类图是这些关系的图形表示2。例如,此图显示了CardDeckHand之间的关系。

<IMG SRC="book027.png">

带有空心三角形头的箭头表示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')
&lt;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),并进行了一些简化。
华夏公益教科书