下面用一桌德州扑克的状态做例子,说明赋值、浅拷贝和深拷贝各在什么时候用。先定义用到的对象:
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,改的就是真实手牌。