為了 1% 情形,犧牲 99% 情形下的性能: 蝸牛般的 Python 深拷貝
最近使用 Python 一個項目,發(fā)現(xiàn) Python 的深拷貝 copy.deepcopy 實(shí)在是太慢了。
相關(guān)背景
在 Python 中, 我們有兩種拷貝對象的方式:淺拷貝和深拷貝。淺拷貝和深拷貝都可以在 copy 模塊中找到, 其中 copy.copy() 進(jìn)行淺拷貝操作, copy.deepcopy() 進(jìn)行深拷貝操作。淺拷貝是在另一塊地址中創(chuàng)建一個新的對象,但是新對象內(nèi)的子對象引用指向源對象的子對象。如果這時候我們修改源對象的子對象的屬性, 新對象中子對象的屬性也會改變。為了獲取一個和源對象沒有任何關(guān)系的全新對象,我們需要深拷貝。深拷貝是在另一塊地址中創(chuàng)建一個新的對象,同時對象內(nèi)的子對象也是新開辟的,和源對象對比僅僅是值相同。
下面用一個例子說明淺拷貝和深拷貝的區(qū)別,下面的代碼將輸出 "b [2,2,3]" 和 "c [1,2,3]"。
- import copyclass A:
- def __init__(self):
- array = [1,2,3]
- a = A()
- b = copy.copy(a)
- c = copy.deepcopy(a)
- a.array[0] = 2
- print "b",b.array
- print "c",c.array
b 是由 a 淺拷貝而來,c 是由 a 深拷貝而來。修改 a.array 之后, b.array 也隨之發(fā)生變化。其實(shí) a.array 和 b.array 指向同一個對象。而 c.array 則保持原樣。
深拷貝比淺拷貝符合人類的直覺,代價就是深拷貝實(shí)在是太慢了。下面代碼中,case1 和 case2 是等價的。在我的機(jī)器上測試,case1 不到一秒,而 case2 則達(dá)到了 十秒。那么深拷貝為什么那么慢呢?
- import copyclass A:
- def __init__(self):
- array = [1,2,3]
- a = [A() for i in xrange(100000)]
- a[10].array[0] = 100
- ### case1
- b = [A() for i in xrange(100000)]
- for i in xrange(100000):
- for j in xrange(3):
- b[i].array[j] = a[i].array[j]
- ### case2
- c = copy.deepcopy(a)
深拷貝分析
為了搞清楚為什么 Python 深拷貝這么慢,我閱讀了下 Python 2.7 版本的 copy.deepcopy 的實(shí)現(xiàn): https://github.com/python/cpython/blob/2.7/Lib/copy.py。其大體結(jié)構(gòu)為:
- def deepcopy(x, memo=None, _nil=[]):
- if memo is None:
- memo = {}
- d = id(x)
- y = memo.get(d, _nil)
- if y is not _nil:
- return y
- cls = type(x)
- copier = _deepcopy_dispatch.get(cls)
- if copier:
- y = copier(x, memo)
- else:
- ... ## 其他特殊的拷貝方式
- memo[d] = y
- return y
其中 memo 保存著所有拷貝過的對象。為什么要設(shè)置 memo 呢?在某些特殊情況下,一個對象的相關(guān)對象可以指向它自己,比如雙向鏈表。如果不將拷貝過的對象存著,那程序?qū)⑾萑胨姥h(huán)。另外一個值得注意的點(diǎn)是 _deepcopy_dispatch,這行代碼根據(jù)待拷貝對象的類型,選擇相應(yīng)的拷貝函數(shù)??蛇x的拷貝函數(shù)有:用于拷貝基本數(shù)據(jù)類型的 _deepcopy_atomic, 用于拷貝列表的 _deepcopy_list,用于拷貝元組 _deepcopy_tuple,用于拷貝字典 的 _deepcopy_dict,用于拷貝自定義對象的 _deepcopy_inst 等等。其中比較重要的拷貝函數(shù) __deepcopy_inst 代碼如下所示:如果對象有 _ _ deepcopy _ _ 函數(shù),則使用該函數(shù)進(jìn)行拷貝;如果沒有,那么先拷貝初始構(gòu)造參數(shù),然后構(gòu)造一個對象,再拷貝對象狀態(tài)。
- def _deepcopy_inst(x, memo):
- if hasattr(x,'__deepcopy__'):
- return x.__deepcopy__(memo)
- if hasattr(x,'__getinitargs__'):
- args = x.__getinitargs__()
- args = deepcopy(args, memo)
- y = x.__class__(*args)
- else:
- y = _EmptyClass()
- y.__class__ = x.__class__
- memo[id(x)] = y
- if hasattr(x, '__getstate__'):
- state = x.__getstate__()
- else:
- state = x.__dict__
- state = deepcopy(state, memo)
- if hasattr(y, '__setstate__'):
- y.__setstate__(state)
- else:
- y.__dict__.update(state)
- return y
深拷貝需要維護(hù)一個 memo 用于記錄已經(jīng)拷貝的對象,這是它比較慢的原因。在絕大多數(shù)情況下,程序里都不存在相互引用。但作為一個通用的模塊,Python 深拷貝必須為了這 1% 情形,犧牲 99% 情形下的性能。
一個場景
上面給出了深拷貝效率對比的結(jié)果,已經(jīng)可以看出深拷貝很慢了,但沒有辦法直觀感受深拷貝拖累整個程序的運(yùn)行速度。下面給一個實(shí)際項目的例子,最近在寫的一些游戲環(huán)境。玩家程序獲得游戲環(huán)境給出的信息,當(dāng)前玩家程序選擇合適的動作,游戲環(huán)境根據(jù)該動作推進(jìn)游戲邏輯;重復(fù)上述過程,直到分出勝負(fù);整個過程如下所示。給玩家程序的信息必須從游戲環(huán)境深拷貝出來。如果給玩家的信息是從游戲環(huán)境淺拷貝出來的,那么玩家程序有可能通過信息獲取游戲秘密或者操縱游戲。
我們已經(jīng)知道默認(rèn)的深拷貝很慢。為了改進(jìn)深拷貝的效率,我們在信息類以及相關(guān)類都添加了 _ _ deepcopy _ _ 函數(shù),下面是信息類示例。
- class FiveCardStudInfo(roomai.abstract.AbstractInfo):
- public_state = None
- person_state = None
- def __deepcopy__(self, memodict={}):
- info = FiveCardStudInfo()
- info.public_state = self.public_state.__deepcopy__()
- info.public_state = self.person_state.__deepcopy__()
- return info
簡單做了一個效率對比實(shí)驗:讓隨機(jī)玩法玩家打一千局梭哈,統(tǒng)計耗時。實(shí)驗結(jié)果如下所示。
使用原始深拷貝,程序運(yùn)行時間為 143 秒,其中深拷貝時間 134 秒,深拷貝時間占整個程序時間的 94%。使用了改進(jìn)深拷貝,程序運(yùn)行時間是 23 秒,其中深拷貝時間 16 秒,占比為 69 %。雖然深拷貝依然占到了 69%,相比之間 94 % 已經(jīng)下降很多。整個程序運(yùn)行時間也從 134 秒減少到 23 秒了。
總結(jié)
Python 的深拷貝很慢,原因在于深拷貝需要維護(hù)一個 memo 用于記錄已經(jīng)拷貝的對象。而維護(hù)這個 memo 的原因是為了避免相互引用造成的死循環(huán)。但作為一個通用的模塊,Python 深拷貝必須為了這 1% 情形,犧牲 99% 情形下的性能。我們可以通過自己實(shí)現(xiàn) _ _ deepcopy _ _ 函數(shù)來改進(jìn)其效率。
【本文為51CTO專欄作者“李立”的原創(chuàng)稿件,轉(zhuǎn)載請通過51CTO獲取聯(lián)系和授權(quán)】