03-一副-Pythonic-的纸牌-协议带来的能力
约 3876 字大约 13 分钟
2026-09-28
上一节的 FrenchDeck 只显式实现了两个特殊方法:
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]它们直接支持 len(deck) 和 deck[position]。但这个类最终能做的事情远不止获取长度和读取单张纸牌。
在不增加新方法的情况下,FrenchDeck 还可以:
- 使用负数索引;
- 使用切片读取多张牌;
- 在
for循环中正向遍历; - 通过
reversed()反向遍历; - 使用
in进行成员判断; - 交给
random.choice()随机抽牌; - 交给
sorted()按指定规则排序。
这些能力并不神秘。它们分别来自三个层次:
__getitem__把索引和切片委托给了内部列表;- Python 在缺少更专用方法时,会尝试使用基础序列协议作为回退;
- 标准库函数只依赖对象协议,不要求参数必须是
list或tuple。
这一节将逐项验证这些行为,并讲清每项能力的调用过程和边界。
1. 本节使用的完整类型
为了让本文能够独立阅读,下面重新给出完整定义:
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]__getitem__ 没有自行解释 position,而是把它原样传给列表:
return self._cards[position]这条简单的委托是后续多项能力的基础。
2. 正数索引和负数索引
读取第一张和最后一张牌:
deck = FrenchDeck()
print(deck[0])
print(deck[-1])输出为:
Card(rank='2', suit='spades')
Card(rank='A', suit='hearts')FrenchDeck 没有编写负数索引的转换逻辑。-1 被直接传给 self._cards,由列表按照标准序列规则解释为最后一项。
调用过程如下:
deck[-1]
-> FrenchDeck.__getitem__(deck, -1)
-> deck._cards[-1]
-> Card(rank='A', suit='hearts')读取越界位置时,内部列表会抛出 IndexError:
deck[52]IndexError: list index out of range这个异常应当继续传递给调用者。后面会看到,基于 __getitem__ 的迭代回退正是通过 IndexError 判断序列已经结束。
如果传入列表不接受的下标类型,例如字符串:
deck["top"]则会抛出 TypeError。当前类没有定义字符串键的语义,因此复用列表的拒绝行为是合理的。
3. 切片为什么自动生效
方括号中出现冒号时,Python 不会把切片拆成多次整数索引,而是先创建一个 slice 对象,再将它传给 __getitem__。
例如:
deck[:3]可以理解为:
deck[:3]
-> FrenchDeck.__getitem__(deck, slice(None, 3, None))
-> deck._cards[slice(None, 3, None)]内部列表本来就支持 slice,所以 FrenchDeck 不需要增加任何分支。
读取最上面的三张牌:
print(deck[:3])输出为:
[Card(rank='2', suit='spades'), Card(rank='3', suit='spades'), Card(rank='4', suit='spades')]切片的第三个位置表示步长。下面从索引 12 开始,每隔 13 张取一张:
print(deck[12::13])输出为四张 A:
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'), Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]原因是每种花色正好有 13 张牌,而每组中的第 13 张都是 A。
切片结果为什么是 list
当前实现直接返回 self._cards[position]。当 position 是整数时,列表返回一个 Card;当它是切片时,列表返回一个新的 list。
因此:
top_three = deck[:3]
print(type(top_three))输出为:
<class 'list'>切片结果不是新的 FrenchDeck。这不是协议强制规定,而是当前委托设计自然产生的结果。如果领域模型要求切片后仍然得到牌组对象,就需要在 __getitem__ 中识别 slice,再显式构造相应类型。
还要注意,步长不能是 0:
deck[::0]列表会抛出 ValueError。FrenchDeck 同样保留了这项标准序列约束。
4. 没有 __iter__,为什么仍然可以迭代
FrenchDeck 没有实现 __iter__,下面的代码却能够正常运行:
for card in deck:
print(card)原因是 Python 保留了一条基础序列的迭代回退路径。
概念上的调用过程如下:
for card in deck
-> 请求 iter(deck)
-> 没有找到 __iter__
-> 创建基于序列协议的迭代器
-> 依次请求 deck[0]、deck[1]、deck[2] ...
-> __getitem__ 抛出 IndexError 时结束可以用一个记录访问位置的类直接观察该过程:
class LoggedSequence:
def __getitem__(self, index):
print(f"读取索引:{index}")
if index >= 3:
raise IndexError
return index * 10
for value in LoggedSequence():
print(f"得到数值:{value}")输出为:
读取索引:0
得到数值:0
读取索引:1
得到数值:10
读取索引:2
得到数值:20
读取索引:3索引 3 没有产生循环体输出,因为该次访问抛出了 IndexError,序列迭代器据此结束。
这也解释了为什么 __getitem__ 不应把越界访问转换成 None:
class BrokenSequence:
def __getitem__(self, index):
if index >= 3:
return None # 错误:迭代器收不到结束信号
return index遍历这个对象时,Python 会继续请求索引 3、4、5,持续得到 None,迭代无法自然结束。
能迭代不等于显式实现了 Iterable
这里存在一个容易混淆的边界:
from collections.abc import Iterable
deck = FrenchDeck()
print(iter(deck))
print(isinstance(deck, Iterable))第一行能够创建迭代器,但第二行通常得到:
Falseiter(deck) 支持旧式的 __getitem__ 序列回退,而 Iterable 抽象基类的结构化检查关注类是否实现了 __iter__。因此,“运行时可以被 for 遍历”和“被 Iterable 的检查识别”不是完全相同的问题。
在新设计的通用可迭代类型中,通常更适合显式实现 __iter__。本例保留最小实现,是为了展示基础序列协议能够提供哪些回退行为。容器抽象基类将在后续小节中单独展开。
5. 使用 reversed() 反向遍历
reversed(deck) 同样可以工作:
deck = FrenchDeck()
reverse_iterator = reversed(deck)
print(next(reverse_iterator))
print(next(reverse_iterator))
print(next(reverse_iterator))输出为:
Card(rank='A', suit='hearts')
Card(rank='K', suit='hearts')
Card(rank='Q', suit='hearts')reversed() 的处理顺序是:
reversed(deck)
|
| 如果类型定义了 __reversed__,使用专用实现
|
| 否则尝试序列回退:
| 1. 通过 __len__ 获取长度
| 2. 通过 __getitem__ 从最后一个索引读到 0
v
反向迭代器FrenchDeck 恰好同时具备 __len__ 和接受整数索引的 __getitem__,所以不需要单独实现 __reversed__。
如果对象能够用更低成本生成反向结果,或者反向顺序不能通过整数索引表达,再考虑提供专用的 __reversed__。
6. 使用 in 判断成员是否存在
下面的成员判断也能直接运行:
print(Card("Q", "hearts") in deck)
print(Card("7", "beasts") in deck)输出为:
True
FalseFrenchDeck 没有实现专用的 __contains__。Python 在执行 item in container 时,会按协议寻找可用路径:
item in deck
-> 优先尝试 deck.__contains__(item)
-> 当前类型没有 __contains__,尝试迭代 deck
-> 当前类型没有 __iter__,迭代再回退到 __getitem__
-> 从索引 0 开始逐项比较,找到则为 True
-> 直到 IndexError 仍未找到则为 False这里使用的是线性扫描,时间复杂度为 O(n)。一副牌只有 52 项,这样的实现已经足够。如果容器规模很大,或者内部另有哈希索引等快速查询结构,就应实现 __contains__,直接表达更高效或更符合领域语义的成员判断。
成员比较依赖 Card 的相等性。namedtuple 已经提供按字段值比较的行为,所以新创建的 Card("Q", "hearts") 可以和牌组中具有相同字段的对象判定相等。
7. 标准库如何使用对象协议
协议的价值不只体现在语法上。标准库函数也会通过协议使用对象,而不是要求参数必须继承某个具体内置类型。
使用 random.choice() 随机抽牌
from random import choice
deck = FrenchDeck()
picked_card = choice(deck)
print(picked_card in deck)无论随机抽中了哪张牌,最后都会输出:
True从使用要求上看,choice() 需要知道候选序列的长度,并能读取某个随机位置。FrenchDeck 已经通过 __len__ 和 __getitem__ 提供了这两项能力,因此不需要为“随机抽牌”再写一个专用方法。
概念上可以把它理解成:
choice(deck)
-> 通过 len(deck) 获得有效位置范围
-> 生成其中一个随机位置
-> 通过 deck[random_index] 读取该项标准库的内部实现可能随 Python 版本调整,因此业务代码不应依赖它具体调用了几次方法;应该依赖的是 choice() 接受非空序列这一公开约定。对空序列调用 choice() 会抛出 IndexError。
这种设计避免了重复造轮子。只要对象正确实现基础协议,现有工具就能组合使用它。
使用 sorted() 排列纸牌
现在定义一条纸牌大小规则:
- 点数从 2 到 A 逐渐增大;
- 点数相同时,花色从梅花、方片、红桃到黑桃逐渐增大。
先给每种花色一个权重:
suit_values = {
"spades": 3,
"hearts": 2,
"diamonds": 1,
"clubs": 0,
}再定义一个排序键函数:
def spades_high(card):
rank_value = FrenchDeck.ranks.index(card.rank)
return rank_value * len(suit_values) + suit_values[card.suit]rank_value 的范围是 0 到 12。乘以花色数量 4 后,会为每个点数留出连续四个位置;再加上花色权重,就能得到 0 到 51 之间且不重复的排序分值。
以几张牌为例:
梅花 2:0 * 4 + 0 = 0
方片 2:0 * 4 + 1 = 1
黑桃 2:0 * 4 + 3 = 3
梅花 3:1 * 4 + 0 = 4
黑桃 A:12 * 4 + 3 = 51把函数传给 sorted() 的 key 参数:
ordered_cards = sorted(deck, key=spades_high)
print(ordered_cards[:4])
print(ordered_cards[-4:])输出为:
[Card(rank='2', suit='clubs'), Card(rank='2', suit='diamonds'), Card(rank='2', suit='hearts'), Card(rank='2', suit='spades')]
[Card(rank='A', suit='clubs'), Card(rank='A', suit='diamonds'), Card(rank='A', suit='hearts'), Card(rank='A', suit='spades')]这里有几个值得注意的行为:
sorted()通过迭代协议读取deck,而FrenchDeck的迭代又回退到__getitem__;key函数对每个元素计算一次用于比较的键;- 排序比较的是整数键,不要求
Card自己实现__lt__; sorted()返回一个新的list,不会修改deck的内部顺序。
当前 spades_high() 每次使用 list.index() 查找点数位置。纸牌只有 13 种点数,成本可以忽略。如果值域很大或排序频繁,可以预先建立字典,避免重复线性查找:
rank_values = {
rank: value
for value, rank in enumerate(FrenchDeck.ranks)
}
def spades_high(card):
return rank_values[card.rank] * 4 + suit_values[card.suit]这是实现细节上的优化,不改变 sorted() 与 key 函数之间的接口。
8. 关于示例中的省略输出
完整遍历 52 张纸牌会产生很长的输出。技术文档有时只展示开头和结尾,并用 ... 表示中间内容被省略:
Card(rank='2', suit='spades')
Card(rank='3', suit='spades')
...
Card(rank='K', suit='hearts')
Card(rank='A', suit='hearts')这里的 ... 是文档排版标记,不代表程序真的输出了三个点。
如果把交互式示例写成 doctest,普通的 ... 默认不会自动匹配任意内容。需要使用 # doctest: +ELLIPSIS 指令,才会启用省略匹配:
>>> deck = FrenchDeck()
>>> deck # doctest: +ELLIPSIS
<...FrenchDeck object at ...>本教程中的输出块主要用于解释运行结果,不把每个代码块都设计成 doctest。理解某段输出时,应根据正文判断省略号表示 Python 语法、交互式续行提示,还是人为省略的输出。
9. 为什么当前牌组不能直接洗牌
既然 FrenchDeck 看起来像序列,可能会自然地尝试:
from random import shuffle
deck = FrenchDeck()
shuffle(deck)这会抛出 TypeError,因为 shuffle() 不只需要读取序列,还要交换其中不同位置的元素。换句话说,它需要一个可变序列。
当前 FrenchDeck 实现了 __getitem__,却没有实现 __setitem__:
deck[index] -> 可以读取
deck[index] = value -> 不支持赋值这不是协议失效,而是对象只实现了读取能力。是否允许洗牌属于领域设计决策:
- 如果牌组应保持初始化顺序,可以继续维持只读接口;
- 如果牌组需要模拟真实牌堆,可以设计明确的
shuffle()行为; - 如果希望兼容通用的
random.shuffle(),则需要提供位置赋值协议,并考虑外部代码能否随意替换任意纸牌。
特殊方法不是实现得越多越好。每增加一项协议,都是在扩大对象对外承诺的行为范围。
10. 完整运行示例
下面把本节的主要能力放在同一个程序中。随机抽牌只验证结果确实来自牌组,不固定某一张随机结果。
from collections import namedtuple
from random import choice
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]
suit_values = {
"spades": 3,
"hearts": 2,
"diamonds": 1,
"clubs": 0,
}
def spades_high(card):
rank_value = FrenchDeck.ranks.index(card.rank)
return rank_value * len(suit_values) + suit_values[card.suit]
if __name__ == "__main__":
deck = FrenchDeck()
print(deck[0])
print(deck[-1])
print(deck[:3])
print(deck[12::13])
reverse_iterator = reversed(deck)
print(next(reverse_iterator))
print(next(reverse_iterator))
print(next(reverse_iterator))
print(Card("Q", "hearts") in deck)
print(Card("7", "beasts") in deck)
picked_card = choice(deck)
print(picked_card in deck)
ordered_cards = sorted(deck, key=spades_high)
print(ordered_cards[:4])
print(ordered_cards[-4:])除随机抽到的具体纸牌未打印外,程序输出是确定的:
Card(rank='2', suit='spades')
Card(rank='A', suit='hearts')
[Card(rank='2', suit='spades'), Card(rank='3', suit='spades'), Card(rank='4', suit='spades')]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'), Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]
Card(rank='A', suit='hearts')
Card(rank='K', suit='hearts')
Card(rank='Q', suit='hearts')
True
False
True
[Card(rank='2', suit='clubs'), Card(rank='2', suit='diamonds'), Card(rank='2', suit='hearts'), Card(rank='2', suit='spades')]
[Card(rank='A', suit='clubs'), Card(rank='A', suit='diamonds'), Card(rank='A', suit='hearts'), Card(rank='A', suit='spades')]11. 本节小结
FrenchDeck 展示了 Python 协议组合的核心特点:少量基础实现可以接入大量现有能力。
- 整数索引和切片都通过
__getitem__进入对象; - 委托给内部列表后,负数索引、切片步长和标准异常随之生效;
- 没有
__iter__时,Python 可以从索引 0 开始调用__getitem__,直到遇到IndexError; - 没有
__reversed__时,reversed()可以结合__len__和__getitem__反向读取; - 没有
__contains__时,in可以回退到迭代扫描; random.choice()通过序列协议随机取项;sorted()通过迭代协议读取对象,并通过key函数分离排序规则;- 当前对象只承诺读取协议,因此不能直接交给需要位置赋值的
random.shuffle()。
真正需要记住的不是每个工具的内部实现,而是它们依赖的能力。设计自定义类型时,优先实现语义明确的基础协议,再让 Python 的语法、内置函数和标准库在这些协议上组合工作。
拓展阅读与查阅
以下资料用于进一步研究或日后查阅。理解本节内容不依赖这些链接。
