02-一副-Pythonic-的纸牌-基础实现
约 3235 字大约 11 分钟
2026-09-28
这一节要实现一个表示整副扑克牌的 FrenchDeck 类。
我们不会为它设计 size()、get_card() 之类的专用接口,而是让它接入 Python 已有的对象协议。完成后,使用者可以通过 len(deck) 获取纸牌数量,也可以通过 deck[index] 读取指定位置的纸牌。
最终的使用方式如下:
deck = FrenchDeck()
print(len(deck)) # 52
print(deck[0]) # Card(rank='2', suit='spades')
print(deck[-1]) # Card(rank='A', suit='hearts')表面上,这些操作与 list、tuple 等内置序列没有区别。背后真正起作用的是 FrenchDeck 实现的两个特殊方法:
len(deck) -> FrenchDeck.__len__(deck)
deck[key] -> FrenchDeck.__getitem__(deck, key)先看完整实现,再逐步拆解其中的设计。
1. 完整可运行示例
下面的代码只使用 Python 标准库,可以直接保存为一个 .py 文件运行。
from collections import namedtuple
Card = namedtuple("Card", ["rank", "suit"])
class FrenchDeck:
ranks = [str(number) for number in range(2, 11)] + list("JQKA")
suits = "spades diamonds clubs hearts".split()
def __init__(self):
self._cards = [
Card(rank, suit)
for suit in self.suits
for rank in self.ranks
]
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]
if __name__ == "__main__":
beer_card = Card("7", "diamonds")
print(beer_card)
deck = FrenchDeck()
print(len(deck))
print(deck[0])
print(deck[-1])运行结果为:
Card(rank='7', suit='diamonds')
52
Card(rank='2', suit='spades')
Card(rank='A', suit='hearts')这段代码做了三件事:
- 使用
Card表示一张具有点数和花色的纸牌; - 在
FrenchDeck内部生成并保存 52 张牌; - 通过
__len__和__getitem__,把内部列表已有的能力公开为标准的 Python 操作。
2. 使用 namedtuple 表示一张纸牌
一张纸牌只需要记录两个值:
rank:点数,例如"2"、"10"、"J"或"A";suit:花色,例如"spades"或"hearts"。
原例使用 collections.namedtuple 创建 Card:
from collections import namedtuple
Card = namedtuple("Card", ["rank", "suit"])namedtuple 是一个工厂函数。它接收类型名称和字段名称,动态创建一个 tuple 的子类,并返回这个新类型。
这里的两组参数分别表示:
- 第一个参数
"Card"是生成类型的名称; - 第二个参数
["rank", "suit"]定义两个字段及其顺序。
Card 创建完成后,可以像普通类一样实例化:
card = Card("7", "diamonds")
print(card.rank)
print(card.suit)
print(card[0])输出为:
7
diamonds
7因为生成的类型继承自 tuple,所以 Card 同时具有两类访问方式:
- 通过名称访问字段,例如
card.rank; - 通过位置访问字段,例如
card[0]。
它还会自动获得适合调试的字符串表示:
print(Card("7", "diamonds"))Card(rank='7', suit='diamonds')与直接使用二元组 ("7", "diamonds") 相比,命名字段能直接表达每个值的含义。与手写完整类相比,namedtuple 又省去了初始化、字段读取、比较和字符串表示等样板代码。
Card 对象和普通元组一样不可变。下面的赋值会失败:
card = Card("7", "diamonds")
card.rank = "8"Python 会抛出 AttributeError。这恰好符合当前模型:一张已经创建的牌不需要在运行中改变点数或花色。
现代 Python 还提供 typing.NamedTuple 和带 frozen=True 的 dataclasses.dataclass 等选择。本章保留 collections.namedtuple,因为它足以表达这个简单值对象,也能把注意力集中在数据模型上。
3. 定义点数和花色
FrenchDeck 通过两个类属性定义一副牌可能出现的点数和花色:
class FrenchDeck:
ranks = [str(number) for number in range(2, 11)] + list("JQKA")
suits = "spades diamonds clubs hearts".split()ranks 最终得到:
["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]这里将所有点数统一保存为字符串,避免数字牌和字母牌混用不同数据类型。
suits 最终得到:
["spades", "diamonds", "clubs", "hearts"]它们定义在类上,而不是每个实例上,因为所有标准 FrenchDeck 实例都共享相同的点数和花色集合。实例真正独立持有的是随后生成的 _cards 列表。
需要注意,当前的类属性仍然是可变列表。教学示例不会修改它们;真实项目如果需要更强的不变性约束,可以改用元组:
ranks = tuple(str(number) for number in range(2, 11)) + tuple("JQKA")
suits = ("spades", "diamonds", "clubs", "hearts")这不是本例接入 Python 数据模型的必要条件,只是一项可以根据项目需求做出的工程选择。
4. 构造整副纸牌
初始化时,FrenchDeck 使用列表推导式生成全部纸牌:
def __init__(self):
self._cards = [
Card(rank, suit)
for suit in self.suits
for rank in self.ranks
]它等价于下面的嵌套循环:
cards = []
for suit in self.suits:
for rank in self.ranks:
cards.append(Card(rank, suit))
self._cards = cards循环顺序决定了纸牌在列表中的排列方式:先固定花色,再按点数依次生成纸牌。因此,列表开始部分是:
2 of spades
3 of spades
4 of spades
...
A of spades
2 of diamonds
...总数量是 4 种花色乘以 13 种点数,即 52 张。
如果交换列表推导式中两个 for 子句的顺序:
self._cards = [
Card(rank, suit)
for rank in self.ranks
for suit in self.suits
]纸牌总数仍然是 52,但排列顺序会改变。原实现的前两张牌是黑桃 2 和黑桃 3;交换顺序后,前两张牌会变成黑桃 2 和方片 2。阅读包含多个 for 子句的列表推导式时,可以把它从左到右还原为同样顺序的嵌套循环。
属性名 _cards 以前导单下划线开头,表示它是类的内部实现细节。这只是 Python 社区遵循的命名约定,并不会阻止外部代码访问它。类的使用者应通过 len(deck)、deck[index] 等公开操作访问纸牌,而不应依赖 _cards 一定是列表。
这种设计使用了组合(composition):FrenchDeck 自己负责表达“一副纸牌”的语义,具体的存储、长度计算和索引规则则交给内部的 list。它没有继承 list,因而也不会无意间公开 append、remove 等可能破坏整副牌结构的修改操作。
5. 使用 __len__ 接入 len()
FrenchDeck 的 __len__ 方法非常短:
def __len__(self):
return len(self._cards)当代码执行:
len(deck)可以先用下面的概念流程理解:
len(deck)
|
| 发现 deck 的类型定义了 __len__
v
FrenchDeck.__len__(deck)
|
| 将长度计算委托给内部列表
v
len(deck._cards)
|
v
52因此,FrenchDeck 不需要自己维护一个容易失去同步的 size 字段。内部列表才是纸牌数据的真实来源,长度也直接从它获得。
__len__ 的返回值约定
__len__ 必须返回一个非负整数。下面的实现都违反协议:
class BrokenDeck:
def __len__(self):
return "52" # 错误:返回了字符串返回 52.0 这样的浮点数同样不符合协议。即使它在数值上等于整数,len() 也不会替实现者执行隐式取整,而是抛出 TypeError。
class AnotherBrokenDeck:
def __len__(self):
return -1 # 错误:长度不能为负数调用 len() 时,第一种情况会引发 TypeError,第二种情况会引发 ValueError。
在 CPython 中,len() 的结果还不能超过 sys.maxsize。普通应用对象通常不会接近这个限制,但实现虚拟超大集合时需要留意。
为什么不直接调用 deck.__len__()
对于这个简单自定义类,直接调用 deck.__len__() 可能得到相同结果,但业务代码仍应写成:
len(deck)原因不只是代码更短:
len()是协议公开给调用者的统一入口;- 它适用于内置类型和自定义类型;
- Python 可以针对内置类型采用不同于普通方法调用的快速实现;
- 返回值的协议检查由内置函数统一完成。
特殊方法的隐式查找还有更具体的规则,后面的“特殊方法是如何使用的”一节会专门展开。
6. 使用 __getitem__ 接入下标语法
__getitem__ 负责处理方括号读取操作:
def __getitem__(self, position):
return self._cards[position]当代码读取第一张牌时:
deck[0]调用过程可以理解为:
deck[0]
|
| 下标语法触发 __getitem__
v
FrenchDeck.__getitem__(deck, 0)
|
| 把同一个下标交给内部列表
v
deck._cards[0]
|
v
Card(rank='2', suit='spades')当前方法没有自己判断 position 是否为负数,也没有手动检查下标是否越界,而是完整复用列表的规则。因此:
print(deck[-1])会得到最后一张纸牌:
Card(rank='A', suit='hearts')读取越界位置:
deck[52]则会由内部列表抛出:
IndexError: list index out of range这种异常不应该被随意吞掉。IndexError 不仅告诉调用者下标无效,Python 的一些序列回退机制也会把它作为“已经到达末尾”的信号。
position 这个参数名只是为了表达用途;协议并不要求它必须叫这个名称。更重要的是,它目前会原样传递给列表,所以不仅可以接收整数,也能接收 slice 对象。这将使 FrenchDeck 自动支持切片。下一节会专门分析该行为,以及它进一步带来的迭代、反向遍历和成员判断能力。
7. 两个方法为什么能构成一个最小序列对象
截至目前,FrenchDeck 只显式提供了两个读取协议:
__len__回答对象包含多少项;__getitem__回答指定位置有什么。
类并没有继承 list,也没有继承 collections.abc.Sequence,但使用方式已经接近一个只读序列。这体现了 Python 常见的协议式设计:调用者关心对象能做什么,而不是它必须属于哪棵继承树。
严格来说,FrenchDeck 当前没有实现修改操作:
deck[0] = Card("A", "spades")这会抛出 TypeError,因为类没有定义 __setitem__。从公开接口看,它是只读的;但内部的 _cards 仍是可变列表,所以这里的“只读”是接口层面的约束,并不等于深度不可变。
这项限制与当前案例的职责相符:本节先建模一副固定顺序的标准纸牌,不让调用者通过任意位置赋值破坏它。洗牌等可变行为应当在明确设计对象职责后再加入,而不是因为内部使用列表就自动公开全部列表 API。
8. 常见误解与实现错误
误解一:特殊方法是给业务代码直接调用的
不推荐这样写:
deck.__len__()
deck.__getitem__(0)应使用对应的公开语法:
len(deck)
deck[0]定义特殊方法是在实现协议;使用对象时则应优先使用协议提供的公开入口。
误解二:类必须继承 list 才能使用下标
下标语法依赖的是 __getitem__ 协议,而不是某个固定父类。继承可以复用实现,但不是接入协议的唯一方式。
错误三:手动复制列表已有的边界逻辑
下面的代码增加了没有必要的重复:
def __getitem__(self, position):
if position < 0:
position += len(self._cards)
if position < 0 or position >= len(self._cards):
raise IndexError("card index out of range")
return self._cards[position]在本例中,内部列表已经正确处理负数索引和越界异常,直接委托即可。手写重复逻辑还可能误伤切片,因为 slice 对象不能与整数直接比较。
错误四:另外保存一份纸牌数量
下面的设计容易产生两个事实来源:
def __init__(self):
self._cards = create_cards()
self._size = 52
def __len__(self):
return self._size如果以后纸牌集合发生变化,_size 可能与 _cards 不一致。除非长度计算本身非常昂贵并且有明确的缓存策略,否则应从真实数据结构计算长度。
9. 本节小结
这一节完成了一个最小但符合 Python 使用习惯的纸牌对象:
namedtuple用少量代码定义了不可变的Card值对象;FrenchDeck使用组合,把真实纸牌存放在内部列表中;__len__让对象接入len();__getitem__让对象接入下标读取语法;- 委托给列表使负数索引和越界异常自然保持一致;
- 对象只公开当前需要的读取协议,没有因为内部使用列表而暴露全部修改能力。
下一节将在相同实现上继续验证:为什么一个简单的 __getitem__ 不只带来下标读取,还能带来切片、迭代、反向遍历、成员判断、随机选择和排序等能力。
拓展阅读与查阅
以下资料用于进一步研究或日后查阅。理解本节内容不依赖这些链接。
