Python:Python对象模型与序列迭代陷阱
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=p2,p1、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)
参考
- [1] https://stackoverflow.com/questions/2612802/list-changes-unexpectedly-after-assignment-why-is-this-and-how-can-i-prevent-it
- [2] https://www.python.org/
- [3] Martelli A, Ravenscroft A, Ascher D. Python cookbook[M]. " O'Reilly Media, Inc.", 2015.
- [4] Ramalho L. Fluent Python 2nd [M]. " O'Reilly Media, Inc.", 2015.
- [5] https://zh.wikipedia.org/wiki/Python
Python:Python对象模型与序列迭代陷阱的更多相关文章
- Python for循环通过序列索引迭代
Python for 循环通过序列索引迭代: 注:集合 和 字典 不可以通过索引进行获取元素,因为集合和字典都是无序的. 使用 len (参数) 方法可以获取到遍历对象的长度. 程序: strs = ...
- Python第三天 序列 数据类型 数值 字符串 列表 元组 字典
Python第三天 序列 数据类型 数值 字符串 列表 元组 字典 数据类型数值字符串列表元组字典 序列序列:字符串.列表.元组序列的两个主要特点是索引操作符和切片操作符- 索引操作符让我 ...
- Python第三天 序列 5种数据类型 数值 字符串 列表 元组 字典 各种数据类型的的xx重写xx表达式
Python第三天 序列 5种数据类型 数值 字符串 列表 元组 字典 各种数据类型的的xx重写xx表达式 目录 Pycharm使用技巧(转载) Python第一天 安装 shell ...
- Python高级特性(切片,迭代,列表生成式,生成器,迭代器)
掌握了Python的数据类型.语句和函数,基本上就可以编写出很多有用的程序了. 比如构造一个1, 3, 5, 7, ..., 99的列表,可以通过循环实现: L = [] n = 1 while n ...
- python魔法方法-自定义序列
自定义序列的相关魔法方法允许我们自己创建的类拥有序列的特性,让其使用起来就像 python 的内置序列(dict,tuple,list,string等). 如果要实现这个功能,就要遵循 python ...
- python魔法方法-自定义序列详解
自定义序列的相关魔法方法允许我们自己创建的类拥有序列的特性,让其使用起来就像 python 的内置序列(dict,tuple,list,string等). 如果要实现这个功能,就要遵循 python ...
- 搞清楚 Python 的迭代器、可迭代对象、生成器
很多伙伴对 Python 的迭代器.可迭代对象.生成器这几个概念有点搞不清楚,我来说说我的理解,希望对需要的朋友有所帮助. 1 迭代器协议 迭代器协议是核心,搞懂了这个,上面的几个概念也就很好理解了. ...
- Python数据类型之“文本序列(Text Sequence)”
Python中的文本序列类型 Python中的文本数据由str对象或字符串进行处理. 1.字符串 字符串是Unicode码值的不可变序列.字符串字面量有多种形式: 单引号:'允许嵌入"双&q ...
- python 实现对象模型
# -*- coding:utf-8 -*- """ python 实现对象模型 创建 bmicalcpage 类 """ class bm ...
- Python学习一:序列基础详解
作者:NiceCui 本文谢绝转载,如需转载需征得作者本人同意,谢谢. 本文链接:http://www.cnblogs.com/NiceCui/p/7858473.html 邮箱:moyi@moyib ...
随机推荐
- C++ 多线程编程和同步机制:详解和实例演示
C++中的多线程编程和同步机制使得程序员可以利用计算机的多核心来提高程序的运行效率和性能.本文将介绍多线程编程和同步机制的基本概念和使用方法. 多线程编程基础 在C++中,使用<thread&g ...
- 2. Solving Linear Equations
2.1 Linear Equations Picture Row Picture 2 by 2 equations Two equations, Two unknowns \[\begin{matri ...
- Linux编译静态库、动态库
一.Linux上编译静态库 # 1.编译成.o文件 gcc -c a.c b.c // 2.编译成静态库 ar -r liba.a a.o b.o // 3.链接成可执行文件 gcc main.c - ...
- ArkUI新能力,助力应用开发更便捷
原文链接:https://mp.weixin.qq.com/s/TAuq1WC6435ebn6L61rZAA,点击链接查看更多技术内容: ArkUI是一套构建分布式应用的声明式UI开发框架.它具 ...
- 【直播回顾】Hello HarmonyOS系列应用篇完美收官!
6月15日晚上19点,Hello HarmonyOS系列应用篇第七期直播 <分布式应用开发>,在HarmonyOS社群内成功举行.随着本系列直播最后一课的完美收官,开发者们在逐渐掌握技术知 ...
- This version of Android Studio cannot open this project, please retry with Android Studio 4.0 or newer.
前言 遇到的问题,This version of Android Studio cannot open this project, please retry with Android Studio 4 ...
- 使用Elasticsearch做手机号和身份证号的模糊检索
使用Elasticsearch做手机号和身份证号的模糊检索 背景 客户想通过人名 四位数值 来检索人的信息 例如 张三 3421,例如需要检索包含张三和且手机号或者身份证里包含3421的数据 过程 e ...
- Vue权限管理该怎么做?控制到按钮级别的权限怎么做?
一.是什么 权限是对特定资源的访问许可,所谓权限控制,也就是确保用户只能访问到被分配的资源 而前端权限归根结底是请求的发起权,请求的发起可能有下面两种形式触发 页面加载触发 页面上的按钮点击触发 总的 ...
- Java使用ganymed工具包执行LINUX命令教程
了解更多开发技巧,请访问,架构师小跟班官网:https://www.jiagou1216.compackage com.jiagou;import ch.ethz.ssh2.Connection;im ...
- 力扣537(java)-复数乘法(中等)
题目: 复数 可以用字符串表示,遵循 "实部+虚部i" 的形式,并满足下述条件: 实部 是一个整数,取值范围是 [-100, 100]虚部 也是一个整数,取值范围是 [-100, ...