问题的发现
在编写一个拓扑计算图的节点类时,更新实例节点的实例属性 self.inputs
(inputs为list类型)时,发现一个实例的更新,会引起继承该类的所有的inputs均会更新,经过反复的测试后,发现问题出现在__init__()
中inputs设置了默认值[]
。这个inputs在init函数中是对self.inputs
进行初始化操作,从而使得所有的self.inputs都具有同样的地址。
下面将通过简单的demo来示意问题出现的特征,和分析python这门语言的一些原理。
问题解析
问题重现
定义一个Food()类,并定义10个实例,实例属性,self.feature
为list的属性,一开始初始化为[]
.对其中的一部分实例的属性进行self.inputs
进行操作。
class Food():
def __init__(self,feature=[],name=None):
self.price=0
self.feature=feature
Food_dict={}
for key in range(10):
Food_dict[key]=Food()
for key in range(0,10,2):
Food_dict[key].feature.append("撞羊")
for key in range(10):
print(f"当前的食物{key}")
print(Food_dict[key].feature)
其运行结果如下:
当前的食物0
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物1
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物2
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物3
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物4
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物5
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物6
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物7
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物8
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物9
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
分析程序我们知道,我们对10个实例中为偶数的实例的self.feature
进行append(“撞羊”)操作,但每个实例都进行了append(“撞羊”)操作,而且都进行了五次,这说明对一个实例的self.feature
进行append()的时候,其他实例也同时进行操作了。
在我们定义的__init__()方法中,将feature设为可变变量,并给予[]
的默认值。想要达到的效果是:新建Food()的实例时,其feature为空,然后我们动态的将属性添加到feature里。由于feature是实例类型的变量,也就是每一个实例均有一个feature属性,相互之间理应相互独立。但上述却出现了改变一个实例的属性,其他类型的属性也一并修改的情况。
寻找发生原因
我们使用python的内置方法id()
输出每个变量的feature的唯一标识符(内存地址)以及__init__()的feature形参的变量地址,以及类定义前后以及for循环中的[]的id。代表如下:
print(f"类定义前的[]的唯一标识符{id([])}")
class Food():
print(f"类定义开头的[]的唯一标识符{id([])}")
def __init__(self,feature=[],name=None):
print(f"形参feature的唯一标识符{id(feature)}")
self.price=0
self.feature=feature
print(f"实例{name}的feature属性的唯一标识符{id(self.feature)}")
print(f"类定义结束的[]的唯一标识符{id([])}")
print(f"类定义后的[]的唯一标识符{id([])}")
Food_dict={}
for key in range(10):
print(f"循环中的[]的唯一标识符{id([])}")
Food_dict[key]=Food(name=key)
print(f"循环完成后的[]的唯一标识符{id([])}")
for key in range(0,10,2):
Food_dict[key].feature.append("撞羊")
for key in range(10):
print(f"当前的食物{key}")
print(Food_dict[key].feature)
print(f"程序运行结束的[]的唯一标识符{id([])}")
程序输出结果如下:
类定义前的[]的唯一标识符964654623688
类定义开头的[]的唯一标识符964654623688
类定义结束的[]的唯一标识符964654620808
类定义后的[]的唯一标识符964654623624
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例0的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例1的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例2的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例3的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例4的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例5的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例6的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例7的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例8的feature属性的唯一标识符964654623688
循环中的[]的唯一标识符964654623624
形参feature的唯一标识符964654623688
实例9的feature属性的唯一标识符964654623688
循环完成后的[]的唯一标识符964654623624
当前的食物0
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物1
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物2
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物3
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物4
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物5
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物6
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物7
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物8
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
当前的食物9
['撞羊', '撞羊', '撞羊', '撞羊', '撞羊']
程序运行结束的[]的唯一标识符964654623624
从结果分析,可以看到id([])
有三种结果
- 类定义开始前和类定义开头,以及__init__()中的形参feature,以及实例变量self.feature含有同样的id:964654623688
- 类定义结尾[]的id:964654620808
- 类代码段后,以及在生成各实例的循环中的和程序运行结尾的id():964654623624
从结果1,我们变不难发现,他们共享了同一地址。 所以,当我们对self.feature进行append操作时,只改变了内存地址中的值,其他实例变量当然也读取到了变化后的值。
问题背后的python原理分析
这里涉及到python的变量类型和变量不同类型的引用方法的原理。(这一部分经常听人叨叨,没想到我也要叨叨一遍,但是理解这一点很必要)
python可分为可变数据类型和不可变数据类型。可变数据类型,赋值时,诸如a=["a",]
,是把保存[“a”,]这个列表的内存地址给了变量a。如果我们再次赋值b=a
,然后改变b
的值:b.append("b")
,我们可以发现,a
和b
会同时改变,如:
print("*"*50)
a=[1]
print(f"a的ID{id(a)}")
b=a
print(f"b的ID{id(b)}")
c=a+b
print("a+b运算了")
print(f"a的ID{id(a)}")
print(f"b的ID{id(b)}")
b+=[2]
print(b)
print(f"b更改值后,a的id{id(a)}")
print(f"b更改值后,b的id{id(b)}")
程序输出的结果:
**************************************************
a的ID964654623624
b的ID964654623624
a+b运算了
a的ID964654623624
b的ID964654623624
[1, 2]
b更改值后,a的id964654623624
b更改值后,b的id964654623624
不可变数据数据类型,其实一开始赋值时和相互传值时,也是指向同一内存地址的,只有当其值改变时,才会指向不同的内存地址:
print("*"*50)
a=1
print(f"a的ID{id(a)}")
b=a
print(f"b的ID{id(b)}")
c=a+b
print("a+b运算了")
print(f"a的ID{id(a)}")
print(f"b的ID{id(b)}")
b+=1
print(b)
print(f"b更改值后,a的id{id(a)}")
print(f"b更改值后,b的id{id(b)}")
程序输出:
**************************************************
a的ID1448438816
b的ID1448438816
a+b运算了
a的ID1448438816
b的ID1448438816
2
b更改值后,a的id1448438816
b更改值后,b的id1448438848
可以看到,一开始a和b的ID也是一样的
但是上述问题是可变类型的原因嘛?
我们在__init__()赋值的是一个具体的list,可以说是一个常量。并没有引用其他的变量。为什么所有的实例仍然共享同一个地址?
可以看到在类代码段前以及类代码段开始运行的开头的[]
是同一个内存地址,但是类代码段运行到结束[]
的内存地址变化了,而跳出类代码段后,以及接下来的运行过程中,[]
的内存地址又变化了。而在循环生成类实例时,实例所用的[]
的内存地址,却是指向了类代码段前以及类代码段开始运行的开头的[]
的内存地址
这里我认为至少涉及了三个问题,我之前并没有深入的考虑过:
-
python的函数只有在引用的时候执行,但是def 语句出现时,就python就已经为这个函数创建一个函数对象,并将其赋值给函数名。函数中定义的形参作为函数对象的参数,创建一个与函数相关的命名空间。就是函数的形参其实在def 语句运行的时候,就已经把它们存在某一个地方了,函数调用的时候,就先把这些参数的内存地址再传过来。
-
python的类代码在一开始出现的时候,会执行。类中定义的函数的 def 语句也会像1中所描述的那样执行。这个特点也能理解在类属性的定义和引用的问题,如:
class A(): property_1=2 self.property_2=4
就因为在类代码出现的时候,property_1=2
已经执行了,所以我们才能引用A.property_1。而因为运行类代码的时候,根本没有实例,所以self不存在,则A.property_2会报错。
-
当将
[]
赋值给一个变量时,[]
所指向的内存地址会变,解释:上面的:类代码段运行到结束
[]
的内存地址和类代码段运行开头[]
内存地址不一致的问题
这个问题目前不知道到底是什么原理,但是这种做法比较符合逻辑。因为[]
指向的地址不变,那python中所有的list都将指向同一内存地址。可以通过程序来观察这种变化:
print(id([]))
b=[]
print(id([]))
c=[]
print(id([]))
#程序输出
#795369109640
#795369171848
#795369169032
问题的解决方法
- feature作为必选参数,在定义类时,必须传入为[],如代码:
print("*"*50)
a=[1]
print(f"a的ID{id(a)}")
b=a
print(f"b的ID{id(b)}")
c=a+b
print("a+b运算了")
print(f"a的ID{id(a)}")
print(f"b的ID{id(b)}")
b+=[2]
print(b)
print(f"b更改值后,a的id{id(a)}")
print(f"b更改值后,b的id{id(b)}")
print(f"类定义前的[]的唯一标识符{id([])}")
class Food():
print(f"类定义开头的[]的唯一标识符{id([])}")
def __init__(self,feature,name=None):
print(f"形参feature的唯一标识符{id(feature)}")
self.price=0
self.feature=feature
print(f"实例{name}的feature属性的唯一标识符{id(self.feature)}")
print(f"类定义结束的[]的唯一标识符{id([])}")
print(f"类定义后的[]的唯一标识符{id([])}")
Food_dict={}
for key in range(10):
print(f"循环中的[]的唯一标识符{id([])}")
Food_dict[key]=Food(name=key,feature=[])
print(f"循环完成后的[]的唯一标识符{id([])}")
for key in range(0,10,2):
Food_dict[key].feature.append("撞羊")
for key in range(10):
print(f"当前的食物{key}")
print(Food_dict[key].feature)
print(f"程序运行结束的[]的唯一标识符{id([])}")
程序的输出结果:
类定义前的[]的唯一标识符486727131528
类定义开头的[]的唯一标识符486727131528
类定义结束的[]的唯一标识符486727131528
类定义后的[]的唯一标识符486994340616
循环中的[]的唯一标识符486994340616
形参feature的唯一标识符486994340616
实例0的feature属性的唯一标识符486994340616
循环中的[]的唯一标识符486727131528
形参feature的唯一标识符486727131528
实例1的feature属性的唯一标识符486727131528
循环中的[]的唯一标识符486994340744
形参feature的唯一标识符486994340744
实例2的feature属性的唯一标识符486994340744
循环中的[]的唯一标识符486994340680
形参feature的唯一标识符486994340680
实例3的feature属性的唯一标识符486994340680
循环中的[]的唯一标识符486994340040
形参feature的唯一标识符486994340040
实例4的feature属性的唯一标识符486994340040
循环中的[]的唯一标识符486994340296
形参feature的唯一标识符486994340296
实例5的feature属性的唯一标识符486994340296
循环中的[]的唯一标识符486994340104
形参feature的唯一标识符486994340104
实例6的feature属性的唯一标识符486994340104
循环中的[]的唯一标识符486994340168
形参feature的唯一标识符486994340168
实例7的feature属性的唯一标识符486994340168
循环中的[]的唯一标识符486994340488
形参feature的唯一标识符486994340488
实例8的feature属性的唯一标识符486994340488
循环中的[]的唯一标识符486994340232
形参feature的唯一标识符486994340232
实例9的feature属性的唯一标识符486994340232
循环完成后的[]的唯一标识符486994311304
当前的食物0
['撞羊']
当前的食物1
[]
当前的食物2
['撞羊']
当前的食物3
[]
当前的食物4
['撞羊']
当前的食物5
[]
当前的食物6
['撞羊']
当前的食物7
[]
当前的食物8
['撞羊']
当前的食物9
[]
程序运行结束的[]的唯一标识符486994311304
**************************************************
a的ID486994311304
b的ID486994311304
a+b运算了
a的ID486994311304
b的ID486994311304
[1, 2]
b更改值后,a的id486994311304
b更改值后,b的id486994311304
可以看到只有对应实例的变量的feature属性被更改了,
- 方法2:使用关键字参数
print(f"类定义前的[]的唯一标识符{id([])}")
class Food():
print(f"类定义开头的[]的唯一标识符{id([])}")
def __init__(self,name=None,**kwargs):
# 当关键字参数含有featuer就引用,没有默认为[]
feature=kwargs.get("feature",[])
print(f"形参feature的唯一标识符{id(feature)}")
self.price=0
self.feature=feature
print(f"实例{name}的feature属性的唯一标识符{id(self.feature)}")
print(f"类定义结束的[]的唯一标识符{id([])}")
print(f"类定义后的[]的唯一标识符{id([])}")
Food_dict={}
for key in range(10):
print(f"循环中的[]的唯一标识符{id([])}")
Food_dict[key]=Food(name=key,feature=[])
print(f"循环完成后的[]的唯一标识符{id([])}")
for key in range(0,10,2):
Food_dict[key].feature.append("撞羊")
for key in range(10):
print(f"当前的食物{key}")
print(Food_dict[key].feature)
print(f"程序运行结束的[]的唯一标识符{id([])}")
程序输出:
类定义前的[]的唯一标识符486994370696
类定义开头的[]的唯一标识符486994370696
类定义结束的[]的唯一标识符486994370696
类定义后的[]的唯一标识符486994311240
循环中的[]的唯一标识符486994340232
形参feature的唯一标识符486994340232
实例0的feature属性的唯一标识符486994340232
循环中的[]的唯一标识符486994340488
形参feature的唯一标识符486994340488
实例1的feature属性的唯一标识符486994340488
循环中的[]的唯一标识符486994340168
形参feature的唯一标识符486994340168
实例2的feature属性的唯一标识符486994340168
循环中的[]的唯一标识符486994340104
形参feature的唯一标识符486994340104
实例3的feature属性的唯一标识符486994340104
循环中的[]的唯一标识符486994340296
形参feature的唯一标识符486994340296
实例4的feature属性的唯一标识符486994340296
循环中的[]的唯一标识符486994340040
形参feature的唯一标识符486994340040
实例5的feature属性的唯一标识符486994340040
循环中的[]的唯一标识符486994340680
形参feature的唯一标识符486994340680
实例6的feature属性的唯一标识符486994340680
循环中的[]的唯一标识符486994340744
形参feature的唯一标识符486994340744
实例7的feature属性的唯一标识符486994340744
循环中的[]的唯一标识符486727131528
形参feature的唯一标识符486727131528
实例8的feature属性的唯一标识符486727131528
循环中的[]的唯一标识符486994340616
形参feature的唯一标识符486994340616
实例9的feature属性的唯一标识符486994340616
循环完成后的[]的唯一标识符486994311240
当前的食物0
['撞羊']
当前的食物1
[]
当前的食物2
['撞羊']
当前的食物3
[]
当前的食物4
['撞羊']
当前的食物5
[]
当前的食物6
['撞羊']
当前的食物7
[]
当前的食物8
['撞羊']
当前的食物9
[]
程序运行结束的[]的唯一标识符486994311240
额外的发现
为什么上述程序中还要一段关于a和b列表的程序呢,因为观察程序运行结束的[]
和a和b的内存地址,可以发现:他们居然是一样的。都是486994311304
所这里又发现了一个现象,也不太明白原理(有了解的大佬可以评论处告诉我):
python似乎始终对列表预留了一个内存地址,使用(赋给其他变量)之后,又开了一个新的内存地址.
总结
- 类变量形参千万不能将可变类型作为默认值。否则使用实例将共享这个形参提供的内容地址
- 类的代码在程序加载时,其中除函数内部的代码块,其他部分是会运行的
- python对变量进行赋值时,不管是可变数据类型还是不可变数据类型,一开始引用的都是同一内存地址,而不可变数据类型重新赋值或运算后会指向新的内存地址,而可变类型却仅是改变了所指向内存地址中的值。
- python似乎始终对列表预留了一个内存地址,将其使用(赋给其他变量)之后,又开了一个新的内存地址.(这个就很诡异…)