为了 1% 情形,牺牲 99% 情形下的性能:蜗牛般的 Python 深拷贝

最近使用 Python 一个项目,发现 Python 的深拷贝 copy.deepcopy 实在是太慢了。

相关背景

在 Python 中, 我们有两种拷贝对象的方式:浅拷贝和深拷贝。浅拷贝和深拷贝都可以在 copy 模块中找到, 其中 copy.copy() 进行浅拷贝操作, copy.deepcopy() 进行深拷贝操作。浅拷贝是在另一块地址中创建一个新的对象,但是新对象内的子对象引用指向源对象的子对象。如果这时候我们修改源对象的子对象的属性, 新对象中子对象的属性也会改变。为了获取一个和源对象没有任何关系的全新对象,我们需要深拷贝。深拷贝是在另一块地址中创建一个新的对象,同时对象内的子对象也是新开辟的,和源对象对比仅仅是值相同。

下面用一个例子说明浅拷贝和深拷贝的区别。下面的代码将输出 “b [2,2,3]” 和 “c [1,2,3]”。

import copy
class 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 也随之发生变化。其实 a.array 和 b.array 指向同一个对象。而 c.array 则保持原样。

深拷贝比浅拷贝符合人类的直觉,代价就是深拷贝实在是太慢了。下面代码中,case1 和 case2 是等价的。在我的机器上测试,case1 不到一秒,而 case2 则达到了 十秒。那么深拷贝为什么那么慢呢?

import copy
class 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 的实现: https://github.com/python/cpython/blob/2.7/Lib/copy.py。其大体结构为:

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 保存着所有拷贝过的对象。为什么要设置 memo 呢?在某些特殊情况下,一个对象的相关对象可以指向它自己,比如双向链表。如果不将拷贝过的对象存着,那程序将陷入死循环。另外一个值得注意的点是 copier = _deepcopy_dispatch.get(cls),这行代码根据待拷贝对象的类型,选择相应的拷贝函数。可选的拷贝函数有:用于拷贝基本数据类型的 _deepcopy_atomic, 用于拷贝列表的 _deepcopy_list,用于拷贝元组 _deepcopy_tuple,用于拷贝字典 的 _deepcopy_dict,用于拷贝自定义对象的 _deepcopy_inst 等等。其中比较重要的拷贝函数 __deepcopy_inst 代码如下所示:如果对象有 _ _ deepcopy _ _ 函数,则使用该函数进行拷贝;如果没有,那么先拷贝初始构造参数,然后构造一个对象,再拷贝对象状态。

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

深拷贝需要维护一个 memo 用于记录已经拷贝的对象,这是它比较慢的原因。在绝大多数情况下,程序里都不存在相互引用。但作为通用模块,Python 深拷贝必须为了这 1% 情形,牺牲 99% 情形下的性能。

一个场景

上面给出了深拷贝效率对比的结果,已经可以看出深拷贝很慢了,但没有办法直观感受深拷贝拖累整个程序的运行速度。下面给一个实际项目的例子,最近在写的一些游戏环境。玩家程序获得游戏环境给出的信息,当前玩家程序选择合适的动作,游戏环境根据该动作推进游戏逻辑;重复上述过程,直到分出胜负;整个过程如下所示。给玩家程序的信息必须从游戏环境深拷贝出来。如果给玩家的信息是从游戏环境浅拷贝出来的,那么玩家程序有可能通过信息获取游戏秘密或者操纵游戏。

我们已经知道默认的深拷贝很慢。为了改进深拷贝的效率,我们在信息类以及相关类都添加了 _ _ deepcopy _ _ 函数,下面是信息类示例。

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

简单做了一个效率对比实验:让随机玩法玩家打一千局梭哈,统计耗时。实验结果如下所示。

使用原始深拷贝,程序运行时间为 143 秒,其中深拷贝时间 134 秒,深拷贝时间占整个程序时间的 94%。使用了改进深拷贝,程序运行时间是 23 秒,其中深拷贝时间 16 秒,占比为 69 %。虽然深拷贝依然占到了 69%,相比之间 94 % 已经下降很多。整个程序运行时间也从 134 秒减少到 23 秒了。

总结

Python 的深拷贝很慢,原因在于深拷贝需要维护一个 memo 用于记录已经拷贝的对象。而维护这个 memo 的原因是为了避免相互引用造成的死循环。绝大多数情况下,程序中不存在相互引用。但作为通用模块,Python 深拷贝必须为了这 1% 情形,牺牲 99% 情形下的性能。真是无奈呀。我们可以通过自己实现 _ _ deepcopy _ _ 函数来改进其效率。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏菜鸟计划

angularjs 控制器、作用域、广播详解

一、控制器 首先列出几种我们平常使用控制器时的几种误区: ? 我们知道angualrJs中一个控制器时可以对应不同的视图模板的,但这种实现方式存在的问题是: 如...

3274
来自专栏决胜机器学习

设计模式专题(十四)——适配器模式

设计模式专题(十四)——适配器模式 (原创内容,转载请注明来源,谢谢) 一、概述 适配器模式(Adapter)是将一个类的接口转换成客户希望的另外一个接口,该...

3587
来自专栏Golang语言社区

Go语言内存分配器的实现

前面断断续续的写了3篇关于Go语言内存分配器的文章,分别是Go语言内存分配器设计、Go语言内存分配器-FixAlloc、Go语言内存分配器-MSpan,这3篇主...

3066
来自专栏知识分享

关于函数指针--简介

函数指针说白了,,,,函数的地址 现在看一个简单的--先定义一个函数指针 void (*f1)();就是这样定义--指向的是一个void类型的 假设定义了另一个...

2848
来自专栏dotnet core相关

jQuery.prop , jQuery.attr ,jQuery.data

jquery.prop 获取匹配的元素中第一个元素特定的属性值,或者是设置多个元素的属性值。

662
来自专栏java闲聊

Vue原理—初探双向绑定

1204
来自专栏葡萄城控件技术团队

带你走近AngularJS - 创建自定义指令

带你走近AngularJS系列: 带你走近AngularJS - 基本功能介绍 带你走近AngularJS - 体验指令实例 带你走近AngularJS - 创...

19510
来自专栏desperate633

浅谈servlet的生命周期servlet的生命周期详解servlet生命周期中三大重要的时刻

servlet从被加载到销毁经历了多个阶段,其中需要我们十分了解每个阶段的意义作用,才能更好地编写相关的servlet程序。

542
来自专栏IMWeb前端团队

也谈 setTimeout

也谈 setTimeout setTimeout ,延迟一段事件执行代码,当然这是最基本的用法,这里不说基本用法。 jQuery 中的轮询 轮询,可能是 set...

20210
来自专栏Lambda

vue2.0知识点汇总

Vue简介 数据流 Vue实例对象 vue常用指令 class结合v-bind使用 methods 和 v-on的使用 v-on高级用法 v-for的使用 简...

6837

扫码关注云+社区