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"如下图:

NLP多任务学习

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

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的列表是对象,它才有一个单独的地址,该地址和里面元素的地址独立,我们浅拷贝也就是拷贝的最外层对象。形式化的描述对列表对象的浅拷贝如下图所示:

NLP多任务学习

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

除了该方法之外,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]]

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

NLP多任务学习

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

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]]

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

NLP多任务学习

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)

参考

posted @ 2021-12-02 09:43  orion-orion  阅读(442)  评论(2编辑  收藏  举报