下面用一桌德州扑克的状态做例子,说明赋值、浅拷贝和深拷贝各在什么时候用。先定义用到的对象:

import copy
import random
from dataclasses import dataclass, field

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

牌用 "As" 这样的两字符字符串表示,字符串不可变;Player 和 Table 里都是可变的状态。

核心区别

操作 代码 效果 什么时候用
赋值 b = a 两个名字指向同一个对象 要操作的就是原来那个对象
浅拷贝 copy.copy(a) / a.copy() / a[:] / list(a) 新容器,元素还是原来那些 只改容器本身,元素不可变或本来就该共享
深拷贝 copy.deepcopy(a) 递归复制整个对象图 要在副本上改到底,原来那份一点都不能动

赋值:要改的就是原来那个对象

轮到某位玩家下注,扣的必须是他本人的筹码:

def place_bet(table, seat, amount):
    player = table.players[seat]  # 赋值:player 就是桌上的这位玩家
    player.stack -= amount
    player.bet += amount
    table.pot += amount

这里要的就是同一个对象。如果写成 player = copy.copy(table.players[seat]),扣的是副本的筹码,桌上的玩家一个筹码没少,底池却多了 amount。

反过来,赋值不能用来留底:before = table 之后,table 怎么变,before 就怎么变。

浅拷贝:换一个容器,里面的东西照旧

洗牌。 整副牌的母本 FULL_DECK 所有牌桌共用,每桌开局要一个自己的列表去洗、去发:

def new_deck():
    deck = list(FULL_DECK)  # 浅拷贝:新列表,里面还是那 52 个字符串
    random.shuffle(deck)
    return deck

FULL_DECK 是 tuple,不能原地洗,list() 复制出一个新列表。列表里的 52 个字符串还是母本里那些对象,但字符串不可变,共享没有风险,所以浅拷贝就够。

行动顺序。 一轮下注要按顺序问每位还没弃牌的玩家。弃牌的人要从这一轮的顺序里拿掉,但他的座位还在:

def fold(order, player):
    player.folded = True  # 改的是桌上的玩家
    order.remove(player)  # 只从这一轮的行动顺序里拿掉


table = Table(players=[Player("Alice", 1000), Player("Bob", 1000), Player("Carol", 1000)])
order = table.players[:]  # 浅拷贝:新列表,玩家还是桌上那几位
fold(order, order[1])  # Bob 弃牌

order 是新列表,remove 只影响它,座位表 table.players 还是三个人。列表里的玩家却还是桌上那几位,所以弃牌标记直接记在真实的 Bob 身上,这正是想要的。

这个场景正好能对比三种写法:

  • 写成 order = table.players,remove 会把 Bob 从座位表上删掉;
  • 写成 order = copy.deepcopy(table.players),弃牌标记记在副本上,桌上的 Bob 还在牌局里;
  • 只有浅拷贝两边都对。

深拷贝:在副本上改到底,原来那份不能动

牌局推演。 翻牌之后,AI 想推演「跟注 100,再把转牌和河牌发完」会是什么局面。推演要扣筹码、改底池、从牌堆发牌,真实牌桌一点都不能动:

def simulate(table, seat, amount):
    sim = copy.deepcopy(table)  # 玩家、牌堆、公共牌全部复制一份
    place_bet(sim, seat, amount)
    while len(sim.board) < 5:
        sim.board.append(sim.deck.pop())
    return sim

推演结束,真实牌桌的筹码、牌堆、公共牌都没变。换成 copy.copy(table),sim 虽然是新的 Table,sim.players、sim.deck、sim.board 却还是原来那几个列表:玩家的筹码被扣了,牌堆少了两张,公共牌变成了五张。只有底池没变,因为 pot 是整数,+= 只是让副本的属性指向一个新的整数。

每条街的快照。 记录牌局历史、做回放时,每条街结束存一份 copy.deepcopy(table)。直接存 table,存下的是一个还会继续变的引用。

代价。 深拷贝要递归复制整张对象图。推演一次无所谓,要跑成千上万次时,可以只复制推演中会改的部分:

def clone(table):
    return Table(
        players=[copy.copy(p) for p in table.players],
        deck=list(table.deck),
        board=list(table.board),
        pot=table.pot,
    )

玩家逐个浅拷贝,因为推演只改筹码和下注额,都是整数;牌堆和公共牌要增删,各复制一个新列表;牌本身是字符串,不用复制。在这张三人桌上,它比 copy.deepcopy 快好几倍。前提是推演代码守规矩:副本和原件共用同一个手牌列表,一旦有人往副本的 hole_cards 里 append,改的就是真实手牌。