与面向对象编程最常关联的语言特性是继承。继承是定义一个新的类,该类是现有类的修改版本的能力。在本章中,我将通过表示扑克牌、扑克牌组和扑克手牌的类来演示继承。如果你不玩扑克,不用担心——我会告诉你需要了解的内容。
一副标准的扑克牌有 52 张——每一张牌属于四种花色之一和十三种点数之一。花色有黑桃、红桃、方块和梅花。点数有 Ace(王牌)、2、3、4、5、6、7、8、9、10、J(杰克)、Q(女王)和 K(国王)。根据你玩的游戏规则,Ace 可以比 K 高,也可以比 2 低。
如果我们想定义一个新的对象来表示一张扑克牌,属性应该是显而易见的:rank 和 suit。然而,属性应该是什么类型就不那么明显了。一个可能的选择是使用字符串,比如用 'Spade' 表示花色,'Queen' 表示点数。这个实现的问题在于,比较牌的大小,看看哪张牌的点数或花色更高,将变得不那么容易。
另一种选择是使用整数来编码点数和花色。在这里,“编码”意味着我们将定义一个数字与花色之间,或者数字与点数之间的映射。这种编码并不意味着是保密的(那是“加密”)。
例如,这个表格展示了花色和相应的整数代码:
| 花色 | 代码 |
|---|---|
| 黑桃 | 3 |
| 红桃 | 2 |
| 方块 | 1 |
| 梅花 | 0 |
使用这种编码,我们可以通过比较它们的代码来比较花色。
为了编码点数,我们将使用整数 2 来表示点数 2,3 来表示 3,依此类推,一直到 10。下面的表格展示了面牌的代码。
| 点数 | 代码 |
|---|---|
| 杰克 | 11 |
| 女王 | 12 |
| 国王 | 13 |
我们可以使用 1 或 14 来表示 Ace(王牌),具体取决于我们希望它被视为比其他点数低还是高。
为了表示这些编码,我们将使用两个字符串列表,一个包含花色的名称,另一个包含点数的名称。
这是一个表示扑克牌的类的定义,使用这些字符串列表作为类变量,类变量是定义在类内部,但不在方法内部的变量。
class Card:
"""Represents a standard playing card."""
suit_names = ['Clubs', 'Diamonds', 'Hearts', 'Spades']
rank_names = [None, 'Ace', '2', '3', '4', '5', '6', '7',
'8', '9', '10', 'Jack', 'Queen', 'King', 'Ace'] rank_names 的第一个元素是 None,因为没有点数为零的牌。通过包括 None 作为占位符,我们得到了一个很好的属性:索引 2 映射到字符串 '2',以此类推。
类变量与类相关联,而不是与类的实例相关联,因此我们可以像这样访问它们。
Card.suit_names ['Clubs', 'Diamonds', 'Hearts', 'Spades'] 我们可以使用 suit_names 来查找花色并获取相应的字符串。
Card.suit_names[0] 'Clubs' 并且可以使用 rank_names 来查找点数。
Card.rank_names[11] 'Jack' 这是Card类的__init__方法——它接受花色和点数作为参数,并将它们分配给具有相同名称的属性。
%%add_method_to Card
def __init__(self, suit, rank):
self.suit = suit
self.rank = rank 现在我们可以这样创建一个Card对象。
queen = Card(1, 12) 我们可以使用新的实例来访问属性。
queen.suit, queen.rank (1, 12) 使用实例来访问类变量也是合法的。
queen.suit_names ['Clubs', 'Diamonds', 'Hearts', 'Spades'] 但如果使用类来访问,能够更清楚地表明它们是类变量,而不是实例属性。
这是一个__str__方法,用于Card对象。
%%add_method_to Card
def __str__(self):
rank_name = Card.rank_names[self.rank]
suit_name = Card.suit_names[self.suit]
return f'{rank_name} of {suit_name}' 当我们打印一个Card对象时,Python 会调用__str__方法来获取该卡片的可读表示。
print(queen) Queen of Diamonds 以下是Card类对象和卡片实例的示意图。Card是一个类对象,所以它的类型是type。queen是Card的实例,所以它的类型是Card。为了节省空间,我没有画出suit_names和rank_names的内容。
每个Card实例都有自己的suit和rank属性,但只有一个Card类对象,并且类变量suit_names和rank_names只有一份副本。
假设我们创建了第二个具有相同花色和点数的Card对象。
queen2 = Card(1, 12)
print(queen2) Queen of Diamonds 如果我们使用==运算符来比较它们,它会检查queen和queen2是否指向同一个对象。
queen == queen2 False 它们不相等,所以返回False。我们可以通过定义特殊方法__eq__来改变这种行为。
%%add_method_to Card
def __eq__(self, other):
return self.suit == other.suit and self.rank == other.rank __eq__接受两个Card对象作为参数,如果它们具有相同的花色和点数,即使它们不是同一个对象,也会返回True。换句话说,它会检查它们是否等价,即使它们不是同一个对象。
当我们使用==运算符比较Card对象时,Python 会调用__eq__方法。
queen == queen2 True 作为第二个测试,让我们创建一张具有相同花色但不同点数的卡片。
six = Card(1, 6)
print(six) 6 of Diamonds 我们可以确认queen和six不是等价的。
queen == six False 如果我们使用!=运算符,Python 会调用一个叫做__ne__的特殊方法(如果存在)。如果没有,它会调用__eq__并反转结果——也就是说,如果__eq__返回True,那么!=运算符的结果就是False。
queen != queen2 False queen != six True 现在假设我们想比较两张卡片,看看哪一张更大。如果我们使用关系运算符之一,将会出现TypeError。
queen < queen2 TypeError: '<' not supported between instances of 'Card' and 'Card' 要改变<运算符的行为,我们可以定义一个特殊的方法叫做__lt__,它是“less than”(小于)的缩写。为了简单起见,假设花色比点数更重要——所以所有黑桃的等级高于所有红心,红心又高于所有方块,依此类推。如果两张卡片的花色相同,那么点数较大的卡片获胜。
为了实现这个逻辑,我们将使用以下方法,它返回一个元组,包含卡片的花色和点数,按此顺序。
%%add_method_to Card
def to_tuple(self):
return (self.suit, self.rank) 我们可以使用这个方法来编写__lt__。
%%add_method_to Card
def __lt__(self, other):
return self.to_tuple() < other.to_tuple() 元组比较会比较每个元组的第一个元素,这些元素表示花色。如果它们相同,则比较第二个元素,这些元素表示点数。
现在,如果我们使用<运算符,它会调用__lt__方法。
six < queen True 如果我们使用>运算符,它会调用一个名为__gt__的特殊方法(如果存在)。否则,它会使用__lt__,并将参数顺序调换。
queen < queen2 False queen > queen2 False 最后,如果我们使用<=运算符,它会调用一个名为__le__的特殊方法。
%%add_method_to Card
def __le__(self, other):
return self.to_tuple() <= other.to_tuple() 所以我们可以检查一张牌是否小于或等于另一张牌。
queen <= queen2 True queen <= six False 如果我们使用>=运算符,它会使用__ge__(如果存在)。否则,它会使用__le__,并将参数顺序调换。
queen >= six True 正如我们所定义的,这些方法是完整的,因为我们可以比较任何两个Card对象,而且是相容的,因为不同运算符的结果不互相矛盾。拥有这两个特性,我们可以说Card对象是完全有序的。这意味着,正如我们很快将看到的,它们可以被排序。
现在我们有了表示牌的对象,让我们定义表示牌组的对象。以下是Deck类的定义,其中__init__方法接收一个Card对象列表作为参数,并将其赋值给一个名为cards的属性。
class Deck:
def __init__(self, cards):
self.cards = cards 要创建一个包含标准牌组 52 张牌的列表,我们将使用以下静态方法。
%%add_method_to Deck
def make_cards():
cards = []
for suit in range(4):
for rank in range(2, 15):
card = Card(suit, rank)
cards.append(card)
return cards 在make_cards中,外循环枚举从0到3的花色,内循环枚举从2到14的点数——其中14表示比国王还大的 Ace。每次迭代都会用当前的花色和点数创建一张新的Card,并将其添加到cards列表中。
下面是我们如何制作一组牌并创建一个包含这些牌的Deck对象。
cards = Deck.make_cards()
deck = Deck(cards)
len(deck.cards) 52 它包含 52 张牌,符合预期。
这是Deck的__str__方法。
%%add_method_to Deck
def __str__(self):
res = []
for card in self.cards:
res.append(str(card))
return '\n'.join(res) 这个方法展示了一种高效地积累大字符串的方式——先构建一个字符串列表,然后使用字符串方法join。
我们将用一副只包含两张牌的牌组来测试这个方法。
small_deck = Deck([queen, six]) 如果我们调用str,它会调用__str__方法。
str(small_deck) 'Queen of Diamonds\n6 of Diamonds' 当 Jupyter 显示字符串时,它会显示字符串的“表示”形式,其中换行符用序列\n表示。
然而,如果我们打印结果,Jupyter 会显示字符串的“可打印”形式,其中换行符被显示为空格。
print(small_deck) Queen of Diamonds
6 of Diamonds 所以这些牌会显示在不同的行上。
要发牌,我们需要一个方法,它从牌组中移除一张牌并返回。列表方法pop提供了一个方便的方式来实现这一点。
%%add_method_to Deck
def take_card(self):
return self.cards.pop() 下面是我们如何使用它。
card = deck.take_card()
print(card) Ace of Spades 我们可以确认牌组中还剩下51张牌。
len(deck.cards) 51 要添加一张牌,我们可以使用列表方法append。
%%add_method_to Deck
def put_card(self, card):
self.cards.append(card) 作为示例,我们可以把刚刚弹出的牌放回去。
deck.put_card(card)
len(deck.cards) 52 要洗牌,我们可以使用random模块中的shuffle函数:
import random %%add_method_to Deck
def shuffle(self):
random.shuffle(self.cards) 如果我们洗牌并打印前几张卡片,我们会看到它们的顺序看似随机。
deck.shuffle()
for card in deck.cards[:4]:
print(card) 2 of Diamonds
4 of Hearts
5 of Clubs
8 of Diamonds 要对卡片进行排序,我们可以使用列表方法sort,该方法会“就地”排序元素——也就是说,它修改原列表,而不是创建一个新的列表。
%%add_method_to Deck
def sort(self):
self.cards.sort() 当我们调用sort时,它会使用__lt__方法来比较卡片。
deck.sort() 如果我们打印前几张卡片,可以确认它们是按升序排列的。
for card in deck.cards[:4]:
print(card) 2 of Clubs
3 of Clubs
4 of Clubs
5 of Clubs 在这个例子中,Deck.sort除了调用list.sort之外并不会做其他事情。将责任传递给其他方法的做法称为委托。
继承是定义一个新类的能力,这个新类是现有类的修改版。例如,假设我们想定义一个类来表示“手牌”,也就是一个玩家持有的卡片。
-
Hand类似于Deck——两者都是由卡片集合组成,并且都需要执行像添加和移除卡片这样的操作。 -
Hand和Deck也有不同之处——我们希望对Hand进行的操作在Deck上没有意义。例如,在扑克中,我们可能会比较两副牌,看看哪一副胜出。在桥牌中,我们可能会计算一副牌的分数,以便进行叫牌。
这种类之间的关系——其中一个是另一个的专门化版本——非常适合继承。
要定义一个基于现有类的新类,我们将现有类的名称放在括号中。
class Hand(Deck):
"""Represents a hand of playing cards.""" 这个定义表明Hand继承自Deck,这意味着Hand对象可以访问Deck中定义的方法,如take_card和put_card。
Hand也继承了Deck中的__init__方法,但如果我们在Hand类中定义了__init__,它将覆盖Deck类中的版本。
%%add_method_to Hand
def __init__(self, label=''):
self.label = label
self.cards = [] 这个版本的__init__方法接受一个可选的字符串作为参数,并且总是从一个空的卡片列表开始。当我们创建一个Hand对象时,Python 会调用这个方法,而不是在Deck中的方法——我们可以通过检查结果是否包含label属性来确认这一点。
hand = Hand('player 1')
hand.label 'player 1' 要发一张卡片,我们可以使用take_card从Deck中移除一张卡片,并使用put_card将卡片添加到Hand中。
deck = Deck(cards)
card = deck.take_card()
hand.put_card(card)
print(hand) Ace of Spades 让我们将这段代码封装到一个名为move_cards的Deck方法中。
%%add_method_to Deck
def move_cards(self, other, num):
for i in range(num):
card = self.take_card()
other.put_card(card) 这个方法是多态的——也就是说,它可以与多种类型一起工作:self和other可以是Hand或Deck。因此,我们可以使用这个方法将一张卡片从Deck发给Hand,从一副Hand发给另一副,或者从Hand发回Deck。
当一个新类继承自现有类时,现有类称为父类,新类称为子类。一般来说:
-
子类的实例应该拥有父类的所有属性,但它们可以有额外的属性。
-
子类应该拥有父类的所有方法,但它可以有额外的方法。
-
如果子类重写了父类的方法,则新方法应该采用相同的参数,并返回兼容的结果。
这一套规则被称为“李斯科夫替代原则”,以计算机科学家芭芭拉·李斯科夫的名字命名。
如果你遵循这些规则,任何设计用来处理父类实例的函数或方法,比如Deck,也可以用来处理子类实例,比如Hand。如果违反这些规则,你的代码将像纸牌屋一样崩塌(抱歉)。
让我们创建一个名为BridgeHand的类,用来表示桥牌中的一手牌——这是一种广泛玩的纸牌游戏。我们将从Hand继承,并添加一个名为high_card_point_count的新方法,使用“高牌点数”方法来评估一手牌,该方法会为手中的高牌加总分数。
这是一个类定义,其中包含一个类变量,映射了从卡片名称到其点数值的字典。
class BridgeHand(Hand):
"""Represents a bridge hand."""
hcp_dict = {
'Ace': 4,
'King': 3,
'Queen': 2,
'Jack': 1,
} 给定一张卡片的等级,比如12,我们可以使用Card.rank_names获取该等级的字符串表示,然后使用hcp_dict获取它的分数。
rank = 12
rank_name = Card.rank_names[rank]
score = BridgeHand.hcp_dict.get(rank_name, 0)
rank_name, score ('Queen', 2) 以下方法遍历BridgeHand中的卡片,并加总它们的分数。
%%add_method_to BridgeHand
def high_card_point_count(self):
count = 0
for card in self.cards:
rank_name = Card.rank_names[card.rank]
count += BridgeHand.hcp_dict.get(rank_name, 0)
return count 为了进行测试,我们将发一手五张牌——桥牌通常有十三张,但使用小例子更容易测试代码。
hand = BridgeHand('player 2')
deck.shuffle()
deck.move_cards(hand, 5)
print(hand) 4 of Diamonds
King of Hearts
10 of Hearts
10 of Clubs
Queen of Diamonds 这是国王和皇后的总分。
hand.high_card_point_count() 5 BridgeHand继承了Hand的变量和方法,并增加了一个类变量和一个特定于桥牌的方法。使用这种方式进行继承被称为专门化,因为它定义了一个针对特定用途(如打桥牌)而专门化的新类。
继承是一个有用的特性。一些如果没有继承就会重复的程序,可以用继承更简洁地编写。此外,继承有助于代码复用,因为你可以在不修改父类的情况下定制其行为。在某些情况下,继承结构反映了问题的自然结构,这使得设计更容易理解。
另一方面,继承可能会让程序变得难以阅读。当调用一个方法时,有时不清楚在哪里找到它的定义——相关代码可能分散在多个模块中。
每当你不确定程序的执行流程时,最简单的解决方法是,在相关方法的开始处添加打印语句。如果Deck.shuffle打印一条类似于Running Deck.shuffle的消息,那么程序运行时就能追踪执行流程。
作为替代,你可以使用以下函数,它接受一个对象和一个方法名(作为字符串),并返回提供该方法定义的类。
def find_defining_class(obj, method_name):
"""Find the class where the given method is defined."""
for typ in type(obj).mro():
if method_name in vars(typ):
return typ
return f'Method {method_name} not found.' find_defining_class使用mro方法获取类对象(类型)的列表,该列表将用于搜索方法。“MRO”代表“方法解析顺序”,即 Python 搜索的类的顺序,用于“解析”方法名——也就是说,找到该名称所引用的函数对象。
作为示例,我们实例化一个 BridgeHand,然后找到 shuffle 的定义类。
hand = BridgeHand('player 3')
find_defining_class(hand, 'shuffle') __main__.Deck BridgeHand 对象的 shuffle 方法是 Deck 中的那个。
继承: 定义一个新类,该类是先前定义的类的修改版本。
编码: 使用另一组值来表示一组值,通过在它们之间构建映射。
类变量: 在类定义内部定义的变量,但不在任何方法内部。
完全有序: 如果我们能比较任何两个元素并且比较结果是一致的,那么该集合就是完全有序的。
委托: 当一个方法将责任传递给另一个方法来完成大部分或所有工作时。
父类: 被继承的类。
子类: 继承自另一个类的类。
专门化: 使用继承来创建一个新类,该类是现有类的专门化版本。
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose Exception reporting mode: Verbose 当它运行顺利时,面向对象编程可以使程序更易读、可测试和可重用。但它也可能使程序变得复杂,难以维护。因此,面向对象编程是一个有争议的话题——一些人喜欢它,而另一些人则不喜欢。
要了解更多关于该主题的信息,请请求虚拟助手:
-
面向对象编程有哪些优缺点?
-
当人们说“偏好组合而非继承”时,这是什么意思?
-
里氏替换原则是什么?
-
Python 是面向对象的语言吗?
-
一个集合要是完全有序的,需要满足哪些要求?
和往常一样,考虑使用虚拟助手来帮助完成以下练习。
在桥牌中,“trick” 是一轮比赛,其中四名玩家各出一张牌。为了表示这些牌,我们将定义一个继承自 Deck 的类。
class Trick(Deck):
"""Represents a trick in contract bridge.""" 作为示例,考虑这个技巧,第一位玩家以方块 3 出牌,这意味着方块是“领先花色”。第二位和第三位玩家“跟花色”,也就是出与领先花色相同的牌。第四位玩家出了一张不同花色的牌,这意味着他们不能赢得这一轮。所以这轮的赢家是第三位玩家,因为他们出了领先花色中的最大牌。
cards = [Card(1, 3),
Card(1, 10),
Card(1, 12),
Card(2, 13)]
trick = Trick(cards)
print(trick) 3 of Diamonds
10 of Diamonds
Queen of Diamonds
King of Hearts 编写一个 Trick 方法,名为 find_winner,它遍历 Trick 中的牌,并返回获胜牌的索引。在前面的示例中,获胜牌的索引是 2。
接下来的几个练习要求你编写函数来分类扑克牌型。如果你不熟悉扑克牌,我会解释你需要知道的内容。我们将使用以下类来表示扑克牌型。
class PokerHand(Hand):
"""Represents a poker hand."""
def get_suit_counts(self):
counter = {}
for card in self.cards:
key = card.suit
counter[key] = counter.get(key, 0) + 1
return counter
def get_rank_counts(self):
counter = {}
for card in self.cards:
key = card.rank
counter[key] = counter.get(key, 0) + 1
return counter PokerHand 提供了两个方法,帮助完成练习。
-
get_suit_counts循环遍历PokerHand中的牌,计算每种花色的牌数,并返回一个字典,将每个花色代码映射到它出现的次数。 -
get_rank_counts与牌的等级执行相同的操作,返回一个字典,将每个等级代码映射到它出现的次数。
所有接下来的练习都可以仅使用我们迄今学到的 Python 特性完成,但其中一些比以前的练习更难。我鼓励你寻求虚拟助手的帮助。
对于这样的问题,通常很好地寻求关于策略和算法的一般建议。然后你可以自己编写代码,或者请求代码。如果你请求代码,你可能需要在提示的一部分中提供相关的类定义。
作为第一练习,我们将编写一个名为has_flush的方法,检查一手牌是否有“同花” - 即是否包含至少五张同一花色的牌。
在大多数扑克牌的变体中,一手牌通常包含五张或七张牌,但也有一些异国情调的变体,一手牌包含其他数量的牌。但不管一手牌有多少张牌,只有五张牌才算在内,这五张牌可以组成最好的一手牌。
编写一个名为has_straight的方法,检查一手牌是否包含顺子,即五张具有连续等级的牌。例如,如果一手牌包含等级5、6、7、8和9,那么它就包含顺子。
一张 A 可以出现在 2 之前或 K 之后,所以A、2、3、4、5是顺子,10、J、Q、K、A也是顺子。但顺子不能“绕过”,所以K、A、2、3、4不是顺子。
一手牌有一个顺子同花顺,如果它包含五张既是顺子又是同一花色的牌 - 也就是说,五张具有连续等级的相同花色的牌。编写一个PokerHand方法,检查一手牌是否有顺子同花顺。
一手扑克牌有一对,如果它包含两张或更多张同等级的牌。编写一个PokerHand方法,检查一手牌是否包含一对。
要测试你的方法,这里有一个有一对的手牌。
pair = deepcopy(bad_hand)
pair.put_card(Card(1, 2))
print(pair) 2 of Clubs
3 of Clubs
4 of Hearts
5 of Spades
7 of Clubs
2 of Diamonds pair.has_pair() # should return True True bad_hand.has_pair() # should return False False good_hand.has_pair() # should return False False 一手牌有一个葫芦,如果它包含一组三张同一等级的牌和两张另一等级的牌。编写一个PokerHand方法,检查一手牌是否有葫芦。
这个练习是一个关于一个常见错误的警示故事,这种错误往往很难调试。考虑以下的类定义。
class Kangaroo:
"""A Kangaroo is a marsupial."""
def __init__(self, name, contents=[]):
"""Initialize the pouch contents.
name: string
contents: initial pouch contents.
"""
self.name = name
self.contents = contents
def __str__(self):
"""Return a string representaion of this Kangaroo.
"""
t = [ self.name + ' has pouch contents:' ]
for obj in self.contents:
s = ' ' + object.__str__(obj)
t.append(s)
return '\n'.join(t)
def put_in_pouch(self, item):
"""Adds a new item to the pouch contents.
item: object to be added
"""
self.contents.append(item) __init__接受两个参数:name是必需的,但contents是可选的 - 如果没有提供,则默认值为空列表。
__str__返回对象的字符串表示,包括袋子的名称和内容。
put_in_pouch接受任何对象并将其附加到contents中。
现在让我们看看这个类是如何工作的。我们将创建两个名为'Kanga'和'Roo'的Kangaroo对象。
kanga = Kangaroo('Kanga')
roo = Kangaroo('Roo') 我们将向 Kanga 的袋子中添加两个字符串和 Roo。
kanga.put_in_pouch('wallet')
kanga.put_in_pouch('car keys')
kanga.put_in_pouch(roo) 如果我们打印kanga,似乎一切正常。
print(kanga) Kanga has pouch contents:
'wallet'
'car keys'
<__main__.Kangaroo object at 0x7f44f9b4e500> 但是如果我们打印roo会发生什么呢?
print(roo) Roo has pouch contents:
'wallet'
'car keys'
<__main__.Kangaroo object at 0x7f44f9b4e500> Roo 的袋子里包含与 Kanga 的袋子相同的内容,包括对roo的引用!
看看你能否弄清楚哪里出了问题。然后问虚拟助手:“以下程序有什么问题?”并粘贴Kangaroo的定义。
版权所有 2024 Allen B. Downey
代码许可:MIT 许可证
