继承
与面向对象编程最常相关的语言特性是继承(inheritance)。继承是定义一个新类作为现有类的修改版本的能力。
在本章中,我使用表示扑克牌、牌组和牌手的类来演示继承。如果你不玩扑克,别担心 -- 我会告诉你需要知道的内容。
表示牌
一副标准扑克牌有 52 张牌 -- 每张牌属于四种花色之一和十三个点数之一。花色是黑桃、红心、方块和梅花。点数是 A、2、3、4、5、6、7、8、9、10、J、Q 和 K。
如果我们想定义一个新对象来表示扑克牌,属性应该是显而易见的:rank 和 suit。一种可能是使用字符串如 'Spade' 表示花色,'Queen' 表示点数。但这种实现的问题是很难比较牌的大小。
另一种方法是使用整数来编码(encode)点数和花色。在这种情况下,"编码"意味着我们在数字和花色之间,或数字和点数之间定义映射。
| 花色 | 编码 |
|---|---|
| 黑桃 | 3 |
| 红心 | 2 |
| 方块 | 1 |
| 梅花 | 0 |
为了编码点数,我们使用整数 2 表示点数 2,3 表示 3,依此类推直到 10。
| 点数 | 编码 |
|---|---|
| J | 11 |
| Q | 12 |
| K | 13 |
我们可以使用 1 或 14 来表示 A,取决于我们希望它比其他点数低还是高。
为了表示这些编码,我们将使用两个字符串列表作为模块级变量,一个是花色名称,另一个是点数名称。
type Card {
suit: int,
rank: int,
}
let suit_names = ["Clubs", "Diamonds", "Hearts", "Spades"]
let rank_names = [None, "Ace", "2", "3", "4", "5", "6", "7",
"8", "9", "10", "Jack", "Queen", "King", "Ace"]rank_names 的第一个元素是 None,因为没有点数为零的牌。通过包含 None 作为占位符,我们得到了一个具有良好属性的列表:索引 2 映射到字符串 '2',依此类推。
牌的属性
下面是 Card 类的 init 方法 -- 它接受 suit 和 rank 作为参数并将它们赋给同名属性。
fn init(&self, suit: int, rank: int) {
self.suit = suit
self.rank = rank
}现在我们可以这样创建一个 Card 对象:
let queen = Card(1, 12)我们可以使用新实例来访问属性:
print(queen.suit) // 1
print(queen.rank) // 12打印牌
下面是 Card 对象的 to_string 方法:
fn to_string(&self) -> str {
let rank_name = rank_names[self.rank]
let suit_name = suit_names[self.suit]
return f"{rank_name} of {suit_name}"
}当我们打印一个 Card 时,Auto 调用 to_string 方法来获取牌的人类可读表示。
print(queen) // Queen of Diamonds比较牌
假设我们创建第二个具有相同花色和点数的 Card 对象:
let queen2 = Card(1, 12)
print(queen2) // Queen of Diamonds如果我们在没有定义 __eq__ 的情况下使用 == 运算符比较它们,默认行为检查 queen 和 queen2 是否引用同一个对象。它们不是,所以返回 false。
我们可以通过定义特殊方法 __eq__ 来改变这种行为:
fn __eq__(&self, other: Card) -> bool {
return self.suit == other.suit and self.rank == other.rank
}__eq__ 接受两个 Card 对象作为参数,如果它们具有相同的花色和点数则返回 true,即使它们不是同一个对象。当我们对 Card 对象使用 == 运算符时,Auto 调用 __eq__ 方法。
print(queen == queen2) // true现在假设我们想比较两张牌看哪张大。要改变 < 运算符的行为,我们可以定义一个名为 __lt__ 的特殊方法,是 "less than" 的缩写。
为了这个示例,我们假设花色比点数更重要 -- 所以所有黑桃大于所有红心,红心大于所有方块,依此类推。如果两张牌花色相同,点数较高的获胜。
fn __lt__(&self, other: Card) -> bool {
return (self.suit, self.rank) < (other.suit, other.rank)
}元组比较首先比较每个元组的第一个元素(代表花色)。如果相同,再比较第二个元素(代表点数)。
let six = Card(1, 6)
print(six < queen) // true工作原理
Card 类型使用整数编码表示 suit 和 rank。模块级列表 suit_names 和 rank_names 为这些编码提供人类可读的名称。__eq__ 方法基于花色和点数编码检查等价性。__lt__ 和 __le__ 方法使用元组比较,先比较花色(花色编码高的获胜),再比较点数(点数高的获胜)。这使得 Card 对象是全序的(totally ordered),即可以排序。
Python 程序员注意:
在 Auto 中,模块级变量如
suit_names和rank_names的作用与 Python 的类变量相同。在 Auto 中通过名称直接访问,而在 Python 中你会使用Card.suit_names。
牌组
既然我们有了表示牌的对象,让我们定义表示牌组的对象。以下是 Deck 的类定义,带有一个 init 方法,它接受一个 Card 对象列表作为参数并将其赋给名为 cards 的属性。
type Deck {
cards: list,
}
fn init(&self, cards: list) {
self.cards = cards
}要创建包含一副标准 52 张牌的列表,我们将使用以下静态方法:
fn make_cards() -> list {
let cards = []
for suit in range(4) {
for rank in range(2, 15) {
cards.append(Card(suit, rank))
}
}
return cards
}在 make_cards 中,外层循环遍历花色从 0 到 3。内层循环遍历点数从 2 到 14 -- 其中 14 表示大于 K 的 A。每次迭代创建一个具有当前花色和点数的新 Card,并将其追加到 cards 中。
以下是我们创建牌列表和包含它的 Deck 对象的方式:
let cards = Deck.make_cards()
let deck = Deck(cards)
print(f"Deck has {len(deck.cards)} cards") // Deck has 52 cards打印牌组
以下是 Deck 的 to_string 方法:
fn to_string(&self) -> str {
let res = []
for card in self.cards {
res.append(card.to_string())
}
return "\n".join(res)
}这个方法演示了一种积累大字符串的高效方法 -- 构建一个字符串列表,然后使用字符串方法 join。
添加、移除、洗牌和排序
要发牌,我们需要一个从牌组中移除一张牌并返回它的方法。列表方法 pop 提供了一种方便的方式。
fn take_card(&self) -> Card {
return self.cards.pop()
}要添加一张牌,我们可以使用列表方法 append:
fn put_card(&self, card: Card) {
self.cards.append(card)
}要洗牌,我们可以使用 random 模块的 shuffle 函数:
fn shuffle(&self) {
random.shuffle(self.cards)
}要排序,我们可以使用列表方法 sort,它"就地"排序元素 -- 即修改列表而不是创建新列表。
fn sort(&self) {
self.cards.sort()
}当我们调用 sort 时,它使用 __lt__ 方法来比较牌。这种将责任传递下去的方式称为委托(delegation)。
工作原理
Deck 类型包装了一个 Card 对象列表。静态方法 make_cards 通过遍历花色(0-3)和点数(2-14)创建所有 52 张牌。take_card 方法从列表中移除并返回最后一张牌(使用 pop),put_card 将牌添加回去。shuffle 方法使用 random.shuffle 随机化顺序,sort 使用列表的内置排序和 Card.__lt__ 进行比较。move_cards 方法将指定数量的牌从一个牌组发到另一个。
父类和子类
继承是定义一个新类作为现有类的修改版本的能力。例如,假设我们想要一个类来表示"手牌",即一个玩家手中的牌。
手牌类似于牌组 -- 两者都是由一组牌组成的,都需要添加和移除牌的操作。
手牌也与牌组不同 -- 有些对手牌有意义的操作对牌组没有意义。
这种类之间的关系 -- 其中一个是另一个的特化版本 -- 适合使用继承。
要基于现有类定义新类,我们在冒号后放置现有类的名称。
type Hand: Deck {
label: str,
}
fn init(&self, label: str = "") {
self.label = label
self.cards = []
}这个定义表示 Hand 继承自 Deck,这意味着 Hand 对象可以访问 Deck 中定义的方法,如 take_card 和 put_card。
Hand 也从 Deck 继承了 init,但如果我们在 Hand 类中定义 init,它会覆盖 Deck 类中的那个。这个版本接受一个可选字符串作为参数,并总是以空牌列表开始。
当我们创建一个 Hand 时,Auto 调用这个方法,而不是 Deck 中的那个。
let hand = Hand("player 1")
print(f"Hand label: {hand.label}") // Hand label: player 1要发牌,我们可以使用 take_card 从 Deck 中移除一张牌,使用 put_card 将牌添加到 Hand 中。Deck 中的 move_cards 方法自动完成这个过程。
这个方法是多态的 -- 即它适用于多种类型:self 和 other 可以是 Hand 或 Deck。
当新类继承现有类时,现有类称为父类(parent),新类称为子类(child)。一般来说:
子类的实例应该具有父类的所有属性,但可以有额外的属性。
子类应该具有父类的所有方法,但可以有额外的方法。
如果子类覆盖了父类的方法,新方法应该接受相同的参数并返回兼容的结果。
这组规则称为"Liskov 替换原则"。如果你遵循这些规则,任何设计用于处理父类实例的函数或方法也能处理子类的实例。
工作原理
在 Auto 中,type Hand: Deck { ... } 将 Hand 定义为 Deck 的子类。这等价于 Python 的 class Hand(Deck)。Hand 类型从 Deck 继承所有方法(take_card、put_card、sort、shuffle、move_cards),但覆盖了 init 以添加 label 字段并从空牌列表开始。move_cards 方法是多态的 -- 它适用于任何 Deck 子类,因此你可以在 Deck 和 Hand 对象之间互换地发牌。
Python 程序员注意:
Auto 使用
type Hand: Deck { ... }代替 Python 的class Hand(Deck)。冒号语法表示继承。a2p转译器会将此转换为 Python 的类继承语法。
特化
让我们创建一个名为 BridgeHand 的类来表示桥牌中的手牌 -- 桥牌是一种广泛流行的纸牌游戏。我们将从 Hand 继承并添加一个名为 high_card_point_count 的新方法,使用"大牌点"方法来评估手牌。
下面是一个包含将牌名映射到其分值字典的类定义:
type BridgeHand: Hand {
hcp_dict: dict,
}
let hcp_dict = {
"Ace": 4,
"King": 3,
"Queen": 2,
"Jack": 1,
}给定一张牌的点数(如 12),我们可以使用 rank_names 获取点数的字符串表示,然后使用 hcp_dict 获取其分数。
以下方法遍历 BridgeHand 中的牌并累加它们的分数:
fn high_card_point_count(&self) -> int {
let count = 0
for card in self.cards {
let rank_name = rank_names[card.rank]
count += hcp_dict.get(rank_name, 0)
}
return count
}BridgeHand 继承了 Hand 的变量和方法,并添加了一个字典和一个特定于桥牌的方法。这种使用继承的方式称为特化(specialization),因为它定义了一个为特定用途(如打桥牌)特化的新类。
工作原理
此 listing 演示了完整的继承链:BridgeHand 继承自 Hand,Hand 继承自 Deck。BridgeHand 添加了 hcp_dict 和 high_card_point_count 方法,根据大牌(A=4,K=3,Q=2,J=1)为手牌计分。尽管有特化,BridgeHand 仍然可以访问其祖先的所有方法 -- 它可以通过 move_cards 被发牌,通过 sort 排序,通过 to_string 打印。这就是继承的本质:通过扩展实现复用。
调试
继承是一个有用的特性。没有继承会显得重复的程序可以通过继承更简洁地编写。此外,继承可以促进代码复用,因为你可以自定义父类的行为而不必修改它。
另一方面,继承会使程序难以阅读。当一个方法被调用时,有时不清楚在哪里找到它的定义 -- 相关代码可能分布在多个模块中。
每当你在程序中不确定执行流程时,最简单的解决方案是在相关方法的开头添加 print 语句。如果一个方法打印类似"Running Deck.shuffle"的消息,那么随着程序运行,它会跟踪执行流程。
术语表
继承: 定义一个新类作为先前定义的类的修改版本的能力。
编码: 通过在两组值之间构建映射来用一组值表示另一组值。
类变量: 在类定义内部定义但不在任何方法内部的变量。在 Auto 中,这些是与类型关联的模块级变量。
全序: 如果可以比较任何两个元素且结果一致,则一组对象是全序的。
委托: 当一个方法将大部分或全部工作传递给另一个方法时。
父类: 被继承的类。
子类: 从另一个类继承的类。
特化: 使用继承创建一个作为现有类的特化版本的新类的方式。
练习
练习
在合约桥牌中,"一墩"是每个玩家各出一张牌的一轮比赛。编写一个 Trick 方法 find_winner,遍历 Trick(继承自 Deck)中的牌并返回获胜牌的索引。赢家是"领出花色"(第一张打出的牌的花色)中最大的牌。
练习
编写一个 PokerHand 方法 has_flush,检查手牌是否有"同花" -- 即是否包含至少五张相同花色的牌。
练习
编写一个 PokerHand 方法 has_straight,检查手牌是否包含"顺子",即五张点数连续的牌。记住 A 可以是高(14)或低(1),但顺子不能"环绕"(K、A、2、3、4 不是顺子)。
练习
编写一个 PokerHand 方法 has_pair,检查手牌是否包含两张或更多相同点数的牌。