跳轉到內容

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),並進行了一些簡化。
華夏公益教科書