1. Python对象模型与浅拷贝/深拷贝

1.1 Python对象模型和引用

在我们讲解Python的序列修改陷阱之前,先巩固一下Python的对象模型和浅拷贝/深拷贝的知识。

众所周知,Python是一个多范式的编程语言,支持函数式、指令式、反射式、结构化和面向对象编程。不过需要注意的是,Python之下一切皆是对象。基础数据类型(如整形、字符串等)、复合数据类型(如列表)以及一切函数(包括匿名函数)的类型(type)都是类(class):

def my_func(x):
return 2*x print(type(1), type("a"), type([1,2,3]), type(my_func), type(lambda x:2*x))
# <class 'int'> <class 'str'> <class 'list'> <class 'function'> <class 'function'>

而Python的赋值语句 = 对于所有对象都是默认传引用,也就是说赋值运算符的左值地址和右值是一样的(当然这个地址并非真实的物理内存地址,不过可以与其类比,每次运行时由Python解释器随机分配)。

最简单的,我们在用 = 声明变量时,其实就是在创建指向某个对象的引用了,可以理解为给对象 "贴标签" ,或者理解为加一个 "手柄" 来控制该对象(注意,Python的变量理解为“标签”/"手柄",和C语言的变量理解为放东西的“盒子”有很大区别)。当一个对象的引用数为0时,它就会被做为垃圾回收(GC)。

a = 1
my_str = "a"
my_list = [1, 2, 3]
def my_func(x):
return 2*x
my_func2 = my_func
my_func3 = lambda x:2*x

Python的对象分为mutable和immutable两种。基础数据类型(整形、字符串等)等为immutable,复合数据类型(列表等)为mutable。当immutable对象被修改后,Python会重新返回一个新的对象(地址不同),而mutable被修改则是原地(in-place)进行的(地址不变)。

比如,我们若修改基础数据类型对象,则其引用就会转去引用另一个新的对象,地址不再是原来的:

a = 1
print(id(a)) # 4348078384
a += 1
print(id(a)) # 4348078416

复合数据类型则不然:

my_list = [1, 2, 3]
print(id(my_list)) # 4398733184
my_list[0] = 999
print(id(my_list)) # 4398733184

Python函数中实例化的对象默认在返回时解除引用并被垃圾回收(如果没有返回该对象的引用的话)。 当然,如果返回了该对象的引用,那么该对象的引用计数仍然为1,其生命周期由接受该函数返回值的新引用决定。如下所示:

def func():
my_list = 1
print(id(my_list)) # 4378716464
return my_list my_list = func()
print(id(my_list)) # 4378716464

函数内的my_list引用和函数外的my_list引用都关联了同一个列表对象,func函数结束时函数内部的my_list对象并未被垃圾回收。在解释器底层实现机制上,函数将要结束时,会先将对象赋给接收函数返回值的那个引用(引用计数+1),然后再将函数内部的引用解除(引用计数-1),最终引用计数不变,仍然为1(类似于C++语言中在函数return时会先将这个对象复制到返回接收的那个对象,然后执行该对象的析构)。这样,只有当函数外的my_list引用去关联其他对象时,即引用计数为0时,该列表对象才被垃圾回收。

当然容易出错的是一个对象被二次引用。对于a=1,b=a,我们将引用a称为原本,引用b称为副本。如我们上面所说,任何对象的副本地址也和原本对象一样:

a = 1
b = a
print(id(a), id(b)) # 4301351216 4301351216 str_1 = "a"
str_2 = str_1
print(id(str_1),id(str_2)) # 4300638704 4300638704 list_1 = [1, 2, 3, 4]
list_2 = list_1
print(id(list_1), id(list_2)) # 4323982400 4323982400

不过唯一的区别是,如我们上面所说,基础数据类型(整形、字符串等)若副本有改变时,则副本会重新引用一个拥有新地址的对象(反之,若原本改变,则为原本会重新引用一个拥有新地址的对象)。而对于复合数据类型,不管如何改变,基于直接赋值产生的对象副本都和原本地址相同:

a = 1
b = a
b += 1
print(id(a), id(b)) # 4301351216 4301351248 a = 1
b = a
a += 1
print(id(a), id(b)) # 4301351248 4301351216 a = 1
b = a
a = None
print(id(a), id(b)) # 4300768720 4301351216 str_1 = "a"
str_2 = str_1
str_2 += "b"
print(id(str_1),id(str_2)) # 4351445488 4371855792 list_1 = [1, 2, 3, 4]
list_2 = list_1
list_2[0] = 999
print(id(list_1), id(list_2)) # 4323982400 4323982400
print(list_1) # [999, 2, 3, 4]

直观化地描述对列表对象list_1进行 = 赋值的伪"copy"如下图:

此处还有一个坑,当处理复合数据类型时,我们常常想借修改副本来达到修改原本的目的,我们可能会写下如下错误的代码:

list_1 = [1, 2, 3, 4]
list_2 = list_1
list_2 = [999]
print(id(list_1), id(list_2)) # 4404879936 4405090624
print(list_1) # [1, 2, 3, 4]

这段代码在语义上不是将副本所引用的对象修改为[999],而是我们将其副本list_2拿去重新引用一个新对象了,当然对副本的修改就达不到目的的。要得到类似的语义,需要对副本所引用的对象本身进行修改,即

list_1 = [1, 2, 3, 4]
list_2 = list_1
list_2[:] = [999]
print(id(list_1), id(list_2)) # 4322021504 4322021504
print(list_1) # [999]

list_2 = [999]list2[:]=[999]的区别,类似C语言里p1=p2(*p)=obj的区别,这里p1、p2、p均为C语言里的指针,obj为另一个非指针变量。

由于引用只是一个标签,一个已知函数的引用也可以拿去指向新的整型对象:

def func():
return
func = 1
print(func) # 1

甚至引用名称和内置函数同名也行(此处type是做为左值存在的引用):

type = int
x = type(42)
print(x) # 42

上面这段代码等同于x = int(42),是在创建一个int对象x。其中type的类型为<class 'type'>x的类型为<class 'int'>type(42)实际上可视为调用int这个类的__new__()方法来创建int对象。

1.2 浅拷贝

那么对于复合数据类型,我们如何对其构建一个与原本独立的拷贝副本呢?最简单的是list_2 = list_1.copy(),这是一种浅拷贝方式,即副本对象的地址和原来不同,但副本内部元素的地址和原来一样:

list_a = [1, 2, 3, 4]
list_b = list_a.copy()
print(id(list_a)) # 4361954880
print(id(list_b)) # 4362160000
print(id(list_a[0])) # 4339509552
print(id(list_b[0])) # 4339509552

这里有个前提是,正因为python的列表是对象,它才有一个单独的地址,该地址和里面元素的地址独立,我们浅拷贝也就是拷贝的最外层对象。形式化的描述对列表对象的浅拷贝如下图所示:

平时我们在对包含基础数据类型的一维列表进行浅拷贝时,因为内部元素是基础数据类型(整形或字符串等),虽然它被引用了两次,但若有一个引用做出改变就会被拿去引用另一个新对象,所以对包含基础数据类型的一维列表用浅拷贝没有问题

除了该方法之外,Python提供了多种方法完成浅拷贝,下面是我自己对这些方法拷贝500000000个数所测试的时间消耗对比(单位:秒):

METHOD                TIME TAKEN
b = a.copy() 7.530620
b = [*a] 7.580211
b = a * 1 7.588134
b = a[:] 7.647908
b = a[0:len(a)] 7.749725
*b, = a 7.779827
b = copy.copy(a) 7.814963
b = []; b.extend(a) 7.870235
b = list(a) 7.881842 b = [i for i in a] 19.780064
b = []
for item in a:
b.append(item) 53.774572

可以看到,前9种耗时差不多,因为Python列表存储同种数据类型且值连续时,底层为连续存储(大家可以打印诸如[1,2,3,4]列表的元素地址看看),我猜测底层应该使用了cache对齐/大块内存连续访存之类技术的原因,所以能快速拷贝。而最后两种属于离散访存,自然速度就受到限制。

1.3 深拷贝

然而正如我们上面所说,上面我们所讲的拷贝方式为浅拷贝,副本列表对象的地址和原先不一样的,但副本列表内元素的地址和原先是一样的。平时我们在对一维列表进行浅拷贝时,因为内部元素是数值类型,一带改变就会另外分配一个地址,所以用浅拷贝没有问题。但是如果列表内部元素是复合对象(比如子列表),那么浅拷贝就会出现问题:

list_a = [[1], [2], [3], [4]]
list_b = list_a.copy()
list_b[0][0] = 999
print(list_a) # [[999], [2], [3], [4]]

此时的浅拷贝只拷贝最上层,不拷贝内层,如下图所示:

此时,就要使用深拷贝解决问题:

import copy
list_a = [[1], [2], [3], [4]]
list_b = copy.deepcopy(list_a)
print(id(list_a[0])) # 4327715008
print(id(list_b[0])) # 4327929024
print(id(list_a[0][0])) # 4305316144
print(id(list_b[0][0])) # 4305316144
list_b[0][0] = 999
print(list_a) # [[1], [2], [3], [4]]

深拷贝会递归地将该对象所有子对象都拷贝一份(当然,基础数据类型由于其"一动就返回引用新对象"性质,没必要拷贝),而不是像浅拷贝一样只对最上层对象拷贝一份。深拷贝直观地表示如下图所示:

2.Python序列迭代的陷阱

2.1 列表迭代与修改

有了前面Python对象模型的基础,我们来分析以下对Python序列修改的代码中可能产生的错误。

我们有时会错误地遍历修改一维列表:

my_list = [1, 2, 3, 4]
for x in my_list:
x += 1
print(my_list)
# [1, 2, 3, 4]

改代码中的迭代实际上等价于隐式调用迭代器

my_list = [1, 2, 3, 4]
list_iterator = iter(my_list)
try:
while True:
x = next(list_iterator)
x += 1
except StopIteration:
pass

迭代器返回的是序列中对象的引用。也就是说x=next(list_iterator)语句实际上在创建对列表元素中的二次引用(一次引用为列表本身自带的)。我们前面说过,基础数据类型被二次引用时,一旦副本发生改变,则副本马上被拿去引用一个新对象,此时副本x地址就完全和列表元素地址本身独立了。我们可以看下列打印结果:

my_list = [1]
for idx, x in enumerate(my_list):
print(id(my_list[idx])) # 4378913072
print(id(x)) # 4378913072
x += 1
print(id(x)) # 4378913104
print(my_list) # [1]

当然,直接修改列表元素自带的引用肯定也会产生一个新的对象,但此时由于只存在一个引用(列表自带的),所以只是把列表元素的对象换成新的,而不会出现不一致的问题。

my_list = [1, 2, 3]
print(id(my_list[0])) # 4338772272
my_list[0] = 999
print(id(my_list[0])) # 4361377904
print(my_list) # [999, 2, 3]

不过对于复合列表的遍历,我们直接修改其内部子列表对象的二次引用sub_list是可以的:

my_list = [[1]]
for idx, sub_list in enumerate(my_list):
print(id(sub_list)) # 4366113408
sub_list[0] = 999
print(id(sub_list)) # 4366113408
print(my_list) # [[999]]

但是,如果我们这样写则不可:

my_list = [[1]]
for idx, sub_list in enumerate(my_list):
print(id(sub_list)) # 4393919680
sub_list = [999]
print(id(sub_list)) # 4394126592
print(my_list) # [[1]]

正如在 1.1 中所说,此时我们相当于把对列表元素的二次引用sub_list拿去引用另外一个列表(类似于C语言中的p1=p2p1、p2为指针),当然对列表元素本身不会有修改了。

我们要达到类似上述修改的目的只能去修改二次引用sub_list所引用的对象本身(类似于C语言中的(*p)= obj, 此处p为指针,obj为另一个非指针变量),也即对sub_list引用列表的内部元素进行修改:

my_list = [[1]]
for idx, sub_list in enumerate(my_list):
print(id(sub_list)) # 4378913072
sub_list[:] = [999]
print(id(sub_list)) # 4378913104
print(my_list) # [[999]]

或者不使用迭代器产生的二次引用,直接用索引去使列表自身的(一次)引用转向一个新的对象(也类似C语言中的p1=p2,但因为此处为修改一次引用,故不存在不一致问题):

my_list = [[1]]
print(id(my_list[0])) # 4370125120
my_list[0] = [999]
print(id(my_list[0])) # 4370488000
print(my_list) # [[999]]

2.2 字典迭代与修改

Python中对字典的迭代本质上等值于对列表的迭代,即:

my_dict = {'A':4, 'B':4}
print(list(my_dict)) # ['A', 'B']
print(list(my_dict.keys())) # ['A', 'B']
print(list(my_dict.values())) # [4, 4]
print(list(my_dict.items())) # [('A', 4), ('B', 4)]

所以我们上面对于列表的迭代注意事项可以原封不动地搬到字典这里。像经典的安装key、value的形式来遍历字典的items。若value是基本数据类型(int,float,字符串等),则根据我们上面介绍的理论,是不能直接在迭代中修改的(这样修改的实际是基础类型的二次引用,一经修改则会被拿去引用新对象):

dict2 = {'A':4, 'B':4}
for _, num in dict2.items():
num += 1
print(dict2) # {'A': 4, 'B': 4}

这种情况下,若要在迭代中修改value,只能按照my_dict[key] = ...的形式来修改(即修改字典元素引用本身)。

for key, num in dict2.items():
dict2[key] += 1
print(dict2) # {'A': 5, 'B': 5}

但是如果value是一个列表或者自定义类的对象,那么根据我们上面的理论,即使迭代中传的是二次引用,对于复合数据类型也是可以直接修改的

如下所示:

dict1 = {'A':[1,2,3,4],'B':[3,4,5,6]}
for _, indices in dict1.items():
indices.append(9)
print(dict1) # {'A': [1, 2, 3, 4, 9], 'B': [3, 4, 5, 6, 9]}

注意,到这里读者可能会有个问个问题,调用items()函数遍历字典不就相当于遍历元组构成的列表,那我们这里在迭代过程中相当于要对元组 (_, indices)进行修改,岂不是与元组的不可变性相违背?原来,元组的不可变性仅限对元组所包含的第一层对象本身,如我们运行以下两段代码:

tuple_1 = (1, 2)
tuple_1[1] = 999
print(tuple_1)
tuple_2 = (1, [2])
tuple_2[1] = [999]
print(tuple_2)

都会抛出TypeError异常:"'tuple' object does not support item assignment"

但如果元组元素是复合数据对象,我们可以在保持复合数据对象不变的情况下,修改复合数据类型内部的元素(在本例中可以直观理解列表对象地址不变,但列表内部元素变化),如下面两段代码所示:

tuple_2 = (1, [2])
print(id(tuple_2[1])) # 4369942912
tuple_2[1][:] = [999]
print(id(tuple_2[1])) # 4369942912
print(tuple_2) # (1, [999])
tuple_2 = (1, [2])
print(id(tuple_2[1])) # 4367398208
tuple_2[1].append(999)
print(id(tuple_2[1])) # 4367398208
print(tuple_2) # (1, [2, 999])

言归正传,我们再看下面这个字典修改的例子;

```python
class MyClass:
def __init__(self, value):
self.value = value my_dict = dict([(i, MyClass(i)) for i in range(3)])
for _, my_obj in my_dict.items():
print(my_obj.value) print('\n') for _, my_obj in my_dict.items():
my_obj.value += 1 for _, my_obj in my_dict.items():
print(my_obj.value)

最后打印输出:

0
1
2 1
2
3

value对于对象传引用有许多好处,比如我们可以将numpy.random.shuffle()作用于做为字典value的列表,使该列表被打乱:

import random
dict1 = {'A':[1,2,3,4],'B':[3,4,5,6]}
for _, indices in dict1.items():
random.shuffle(indices)
print(dict1) # {'A': [4, 1, 3, 2], 'B': [4, 5, 6, 3]}

这个例子是我研究一篇联邦学习论文的开源代码时发现的,论文中用下列代码将每个cluster对应的样本索引列表打乱:

for _, cluster in clusters.items():
rng.shuffle(cluster)

另外,该论文也使用下列代码将全局模型的各分量模型拷贝到各client模型:

for learner_id, learner in enumerate(client.learners_ensemble):
copy_model(learner.model, self.global_learners_ensemble[learner_id].model)

参考

Python:Python对象模型与序列迭代陷阱的更多相关文章

  1. Python for循环通过序列索引迭代

    Python for 循环通过序列索引迭代: 注:集合 和 字典 不可以通过索引进行获取元素,因为集合和字典都是无序的. 使用 len (参数) 方法可以获取到遍历对象的长度. 程序: strs = ...

  2. Python第三天 序列 数据类型 数值 字符串 列表 元组 字典

    Python第三天 序列  数据类型  数值  字符串  列表  元组  字典 数据类型数值字符串列表元组字典 序列序列:字符串.列表.元组序列的两个主要特点是索引操作符和切片操作符- 索引操作符让我 ...

  3. Python第三天 序列 5种数据类型 数值 字符串 列表 元组 字典 各种数据类型的的xx重写xx表达式

    Python第三天 序列  5种数据类型  数值  字符串  列表  元组  字典 各种数据类型的的xx重写xx表达式 目录 Pycharm使用技巧(转载) Python第一天  安装  shell ...

  4. Python高级特性(切片,迭代,列表生成式,生成器,迭代器)

    掌握了Python的数据类型.语句和函数,基本上就可以编写出很多有用的程序了. 比如构造一个1, 3, 5, 7, ..., 99的列表,可以通过循环实现: L = [] n = 1 while n ...

  5. python魔法方法-自定义序列

    自定义序列的相关魔法方法允许我们自己创建的类拥有序列的特性,让其使用起来就像 python 的内置序列(dict,tuple,list,string等). 如果要实现这个功能,就要遵循 python ...

  6. python魔法方法-自定义序列详解

    自定义序列的相关魔法方法允许我们自己创建的类拥有序列的特性,让其使用起来就像 python 的内置序列(dict,tuple,list,string等). 如果要实现这个功能,就要遵循 python ...

  7. 搞清楚 Python 的迭代器、可迭代对象、生成器

    很多伙伴对 Python 的迭代器.可迭代对象.生成器这几个概念有点搞不清楚,我来说说我的理解,希望对需要的朋友有所帮助. 1 迭代器协议 迭代器协议是核心,搞懂了这个,上面的几个概念也就很好理解了. ...

  8. Python数据类型之“文本序列(Text Sequence)”

    Python中的文本序列类型 Python中的文本数据由str对象或字符串进行处理. 1.字符串 字符串是Unicode码值的不可变序列.字符串字面量有多种形式: 单引号:'允许嵌入"双&q ...

  9. python 实现对象模型

    # -*- coding:utf-8 -*- """ python 实现对象模型 创建 bmicalcpage 类 """ class bm ...

  10. Python学习一:序列基础详解

    作者:NiceCui 本文谢绝转载,如需转载需征得作者本人同意,谢谢. 本文链接:http://www.cnblogs.com/NiceCui/p/7858473.html 邮箱:moyi@moyib ...

随机推荐

  1. 【开源三方库】bignumber.js:一个大数数学库

    OpenHarmony(OpenAtom OpenHarmony简称"OpenHarmony")三方库,是经过验证可在OpenHarmony系统上可重复使用的软件组件,可帮助开发者 ...

  2. OpenHarmony 3.2 Release新特性解读之驱动HCS

      OpenAtom OpenHarmony(以下简称"OpenHarmony")开源社区,在今年4月正式发布了OpenHarmony 3.2 Release版本,标准系统能力进一 ...

  3. OpenHarmony社区运营报告(2022年10月)

    本月快讯 ● <深圳市推动软件产业高质量发展的若干措施>于10月24日发布. ● 社区共发展逾5000位贡献者累计为社区提交超过11万个PR,深圳市优博终端科技有限公司(以下简称" ...

  4. Spring内存马分析

    环境搭建 踩了很多坑....,不过还好最后还是成功了 IDEA直接新建javaEE项目,然后记得把index.jsp删了,不然DispatcherServlet会失效 导入依赖: <depend ...

  5. 鸿蒙HarmonyOS实战-ArkUI组件(CustomDialog)

    一.CustomDialog CustomDialog组件是一种自定义对话框,可以通过开发人员根据特定的要求定制内容和布局.它允许开发人员创建一个完全可定制的对话框,可以显示任何类型的内容,例如文本. ...

  6. HMS Core手语服务荣获2022中国互联网大会“特别推荐案例”:助力建设数字社会

    11月15日,HMS Core手语服务在2022(第二十一届)中国互联网大会 "互联网助力经济社会数字化转型"案例评选活动中,荣获"特别推荐案例". 经过一年多 ...

  7. IDEA快捷键快速补齐类和对象名

    CTRL+ALT+V  ----------快速补齐 类和对象名 如:   new String("123") 光标放到最后 按下快捷键补齐为红色部分  String s = ne ...

  8. linux 性能自我学习 ———— 理解平均负载 [一]

    前言 linux 系统上性能调查的自我学习. 正文 什么是平均负载? 使用uptime: 可以看到后面有: 0.03, 0.06, 0.09 这个表示1分钟,5分钟,15分钟的平均负载. 平均负债是指 ...

  9. Vue 项目 invalid host header 问题 配置 disableHostCheck:true报错

    项目场景:解决 Vue 项目 invalid host header 问题disableHostCheck:true报错 问题描述使用内网穿透时出现 invalid host header找了好多都是 ...

  10. JavaScript中的变量提升本质

    JavaScript中奇怪的一点是你可以在变量和函数声明之前使用它们.就好像是变量声明和函数声明被提升了代码的顶部一样. sayHi() // Hi there! function sayHi() { ...