下面用一桌德州扑克做例子,说明闭包、装饰器、迭代器、生成器和上下文管理器各在什么时候用。顺序按依赖关系排:装饰器建立在闭包之上,生成器是写迭代器的简便方式,上下文管理器又可以用生成器来写。先定义用到的对象:

import copy
import random
from contextlib import contextmanager
from dataclasses import dataclass, field
from functools import wraps

FULL_DECK = tuple(rank + suit for rank in "23456789TJQKA" for suit in "shdc")


@dataclass
class Player:
    name: str
    stack: int
    hole_cards: list = field(default_factory=list)
    bet: int = 0
    folded: bool = False


@dataclass
class Table:
    players: list
    deck: list = field(default_factory=list)
    board: list = field(default_factory=list)
    pot: int = 0
    current_seat: int = 0  # 轮到行动的座位


def new_table():
    deck = list(FULL_DECK)
    random.shuffle(deck)
    players = [Player(name, 1000) for name in ("Alice", "Bob", "Carol")]
    return Table(players=players, deck=deck)

核心区别

概念 是什么 牌桌上的例子 什么时候用
闭包 记住外层函数变量的内层函数 超时自动弃牌的回调 要把函数连同一点上下文交出去,稍后由别人调用
装饰器 接收一个函数、返回加工后函数的函数 检查是否轮到这个座位 同一段前后逻辑要套在很多函数上
迭代器 实现了 __next__ 的对象,for 靠它逐个取值 按行动顺序遍历玩家 让自定义对象支持 for
生成器 含 yield 的函数,调用后得到一个迭代器 一手牌按街推进 按需逐个产出值,或者把分步骤的流程写成顺序代码
上下文管理器 实现了 __enter__ 和 __exit__,配合 with 使用 一组动作要么全部生效、要么全部撤销 成对出现、必须配套的操作

闭包:函数带上一点上下文

轮到的玩家 15 秒不操作,就自动弃牌。计时器到点时,会调用我们交给它的一个函数,这个函数得知道是哪张桌、哪个座位:

def make_auto_fold(table, seat):
    def auto_fold():
        table.players[seat].folded = True

    return auto_fold


table = new_table()
auto_fold = make_auto_fold(table, 1)
auto_fold()  # 计时器到点时调用的就是它
print(table.players[1].folded)  # True

make_auto_fold 已经返回了,auto_fold 仍然记得 table 和 seat,这就是闭包。交给计时器只要一行:threading.Timer(15, make_auto_fold(table, 1)).start()。

闭包记住的是变量本身,不是变量当时的值。给每个座位注册计时器时,如果在循环里直接定义函数:

def register_timeouts(table):
    handlers = []
    for seat in range(len(table.players)):
        def auto_fold():
            table.players[seat].folded = True

        handlers.append(auto_fold)
    return handlers


table = new_table()
register_timeouts(table)[0]()  # 本想让 0 号座位弃牌
print([player.folded for player in table.players])  # [False, False, True]

三个 auto_fold 共用同一个 seat,等到被调用时循环早已结束,seat 是 2,弃牌的成了 Carol。把循环体换成 handlers.append(make_auto_fold(table, seat)) 就对了:每调用一次 make_auto_fold 都会新建一个作用域,每个 auto_fold 记住各自的 seat。

闭包只读外层变量时不用声明;要给外层变量重新赋值,比如计数加一,得先写 nonlocal,否则 Python 会把它当成内层函数自己的局部变量。

要把一个函数连同少量上下文交出去,稍后由别人调用,比如计时器、回调、排序用的 key,又不值得为此写一个类,就用闭包。

装饰器:同一段检查套在所有动作上

下注、跟注、弃牌,每个动作开头都要检查是不是轮到这个座位。把检查写成装饰器,动作函数只管自己的逻辑:

class NotYourTurn(Exception):
    pass


def require_turn(action):
    @wraps(action)
    def wrapper(table, seat, *args, **kwargs):
        if seat != table.current_seat:
            raise NotYourTurn(f"还没轮到 {seat} 号座位")
        return action(table, seat, *args, **kwargs)

    return wrapper


@require_turn
def place_bet(table, seat, amount):
    player = table.players[seat]
    player.stack -= amount
    player.bet += amount
    table.pot += amount


@require_turn
def fold(table, seat):
    table.players[seat].folded = True


table = new_table()
place_bet(table, 0, 20)
try:
    fold(table, 2)
except NotYourTurn as error:
    print(error)  # 还没轮到 2 号座位
print(place_bet.__name__)  # place_bet

@require_turn 写在函数定义上面,等价于定义完之后执行 place_bet = require_turn(place_bet)。wrapper 是一个闭包,记住了被装饰的 action:先检查,再调用原函数。@wraps(action) 把原函数的名字和文档复制到 wrapper 上,不加的话 place_bet.__name__ 会变成 wrapper,日志和报错里分不清是哪个动作。

装饰器需要参数时,比如 @retry(times=3),再包一层:最外层接收参数,返回真正的装饰器。

适合写成装饰器的,是很多函数都要的前置或后置逻辑:权限、校验、计时、重试、日志。只有一个函数用到的逻辑,直接写在函数里更清楚。

迭代器:让 for 按你的规则走

一轮下注从某个座位开始,顺时针问一圈,弃了牌的人跳过。想让 for 直接按这个顺序走,就要实现迭代器协议:

class ActionOrder:
    """从 start 号座位开始转一圈,跳过已经弃牌的玩家"""

    def __init__(self, players, start):
        self.players = players
        self.start = start
        self.offset = 0

    def __iter__(self):
        return self

    def __next__(self):
        while self.offset < len(self.players):
            player = self.players[(self.start + self.offset) % len(self.players)]
            self.offset += 1
            if not player.folded:
                return player
        raise StopIteration


table = new_table()
table.players[1].folded = True
order = ActionOrder(table.players, start=2)
print([player.name for player in order])  # ['Carol', 'Alice']
print([player.name for player in order])  # []

for player in order 先调用 iter(order) 拿到迭代器,再反复调用 next(),直到抛出 StopIteration。__iter__ 返回迭代器本身,__next__ 每次给出下一位还没弃牌的玩家。

第二次遍历什么也拿不到:迭代器是一次性的,走到头就停在那里。列表能反复遍历,是因为每次 iter(列表) 都返回一个新的迭代器。

需要让自定义对象支持 for、list()、sum() 这类按顺序取值的操作时,才用得上迭代器。实际代码里很少手写 __next__,一般用下一节的生成器。

生成器:可以暂停的函数

上一节的 ActionOrder,用生成器写只要几行:

def action_order(players, start):
    for offset in range(len(players)):
        player = players[(start + offset) % len(players)]
        if not player.folded:
            yield player


table = new_table()
table.players[1].folded = True
names = [player.name for player in action_order(table.players, start=2)]
print(names)  # ['Carol', 'Alice']

函数里有 yield,调用它时不会立即执行函数体,而是得到一个生成器对象。生成器就是迭代器:每次 next() 运行到下一个 yield 就暂停,把值交出去,局部变量原样保留,下次从暂停处继续。offset 这样的进度不用再手工存进属性。想让一个类能反复遍历,把它的 __iter__ 写成生成器函数就行,每次 for 都会得到一个新的生成器。

暂停还有更大的用处。一手牌是分阶段推进的:发底牌,下注;发翻牌,下注;发转牌,下注;发河牌,下注。用生成器写,发牌流程就是一段从上到下的顺序代码:

def deal_streets(table):
    for player in table.players:
        player.hole_cards = [table.deck.pop(), table.deck.pop()]
    yield "preflop"
    table.board += [table.deck.pop() for _ in range(3)]
    yield "flop"
    table.board.append(table.deck.pop())
    yield "turn"
    table.board.append(table.deck.pop())
    yield "river"


table = new_table()
for street in deal_streets(table):
    print(street, len(table.board))
    # 这条街的下注在这里进行;只剩一人没弃牌时 break,后面的牌不会再发
# preflop 0
# flop 3
# turn 4
# river 5

每个 yield 是一次暂停:循环体里进行这条街的下注,下注结束,下一次循环才让生成器接着发牌。只剩一个人没弃牌、这手牌提前结束时直接 break,剩下的公共牌根本不会发出来。

算底池这种一次性的汇总,用生成器表达式就够:sum(player.bet for player in table.players),值边产生边累加,不用先建一个列表。

需要按顺序逐个产出值时用生成器,比如遍历、分页读取、无限序列;需要把一个分步骤、中途要等待的流程写成顺序代码时也可以。它和迭代器一样只能走一遍。

上下文管理器:成对的操作不会漏掉后一半

一次请求里要连续执行几个动作,比如重放玩家断线期间排队的操作,要求全部生效,或者一个都不生效。进入时拍快照,出错时恢复,正好是一对配套操作:

@contextmanager
def all_or_nothing(table):
    snapshot = copy.deepcopy(table)
    try:
        yield table
    except Exception:
        vars(table).update(vars(snapshot))  # 恢复成进入 with 之前的样子
        raise


table = new_table()
try:
    with all_or_nothing(table):
        place_bet(table, 0, 20)
        place_bet(table, 2, 20)  # 还没轮到 2 号,抛出 NotYourTurn
except NotYourTurn:
    pass
print(table.pot, table.players[0].stack)  # 0 1000

with 进入时运行 yield 之前的代码,拍下快照;with 块里抛出的异常会在 yield 那一行冒出来,except 把牌桌恢复成快照,再 raise 让调用方知道失败了。第一笔下注已经执行过,也一起撤销了。快照为什么必须用 deepcopy,见德州扑克服务端里的赋值、浅拷贝和深拷贝。

@contextmanager 把生成器变成上下文管理器:yield 之前相当于 __enter__,之后相当于 __exit__,yield 出去的值就是 with ... as x 里的 x。要注意,yield 之后的代码只在 with 块正常结束时才执行,必须执行的清理,比如释放锁,要放进 finally。

成对出现、必须配套的操作都适合交给上下文管理器:加锁和解锁、打开和关闭、开始和回滚。标准库的 threading.Lock 和 open() 返回的文件对象本身就是上下文管理器,写成 with lock:,锁一定会被释放。