python类初始化__init__()方法中使用[]作为形参的默认值(空列表为默认值初始化类实例属性)导致的问题和分析

问题的发现

在编写一个拓扑计算图的节点类时,更新实例节点的实例属性 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([])有三种结果

  1. 类定义开始前和类定义开头,以及__init__()中的形参feature,以及实例变量self.feature含有同样的id:964654623688
  2. 类定义结尾[]的id:964654620808
  3. 类代码段后,以及在生成各实例的循环中的和程序运行结尾的id():964654623624

从结果1,我们变不难发现,他们共享了同一地址。 所以,当我们对self.feature进行append操作时,只改变了内存地址中的值,其他实例变量当然也读取到了变化后的值。

问题背后的python原理分析

这里涉及到python的变量类型和变量不同类型的引用方法的原理。(这一部分经常听人叨叨,没想到我也要叨叨一遍,但是理解这一点很必要)

python可分为可变数据类型和不可变数据类型。可变数据类型,赋值时,诸如a=["a",],是把保存[“a”,]这个列表的内存地址给了变量a。如果我们再次赋值b=a,然后改变b的值:b.append("b"),我们可以发现,ab会同时改变,如:

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,可以说是一个常量。并没有引用其他的变量。为什么所有的实例仍然共享同一个地址?

可以看到在类代码段前以及类代码段开始运行的开头的[]是同一个内存地址,但是类代码段运行到结束[]的内存地址变化了,而跳出类代码段后,以及接下来的运行过程中,[]的内存地址又变化了。而在循环生成类实例时,实例所用的[]的内存地址,却是指向了类代码段前以及类代码段开始运行的开头的[]的内存地址

这里我认为至少涉及了三个问题,我之前并没有深入的考虑过:

  1. python的函数只有在引用的时候执行,但是def 语句出现时,就python就已经为这个函数创建一个函数对象,并将其赋值给函数名。函数中定义的形参作为函数对象的参数,创建一个与函数相关的命名空间。就是函数的形参其实在def 语句运行的时候,就已经把它们存在某一个地方了,函数调用的时候,就先把这些参数的内存地址再传过来。

  2. python的类代码在一开始出现的时候,会执行。类中定义的函数的 def 语句也会像1中所描述的那样执行。这个特点也能理解在类属性的定义和引用的问题,如:

    class A():
    	property_1=2
    	self.property_2=4
    

就因为在类代码出现的时候,property_1=2已经执行了,所以我们才能引用A.property_1。而因为运行类代码的时候,根本没有实例,所以self不存在,则A.property_2会报错。

  1. 当将[]赋值给一个变量时,[]所指向的内存地址会变,解释:上面的:

    类代码段运行到结束[]的内存地址和类代码段运行开头[]内存地址不一致的问题

这个问题目前不知道到底是什么原理,但是这种做法比较符合逻辑。因为[]指向的地址不变,那python中所有的list都将指向同一内存地址。可以通过程序来观察这种变化:

print(id([]))
b=[]
print(id([]))
c=[]
print(id([]))

#程序输出
#795369109640
#795369171848
#795369169032

问题的解决方法

  1. 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属性被更改了,

  1. 方法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似乎始终对列表预留了一个内存地址,使用(赋给其他变量)之后,又开了一个新的内存地址.

总结

  1. 类变量形参千万不能将可变类型作为默认值。否则使用实例将共享这个形参提供的内容地址
  2. 类的代码在程序加载时,其中除函数内部的代码块,其他部分是会运行的
  3. python对变量进行赋值时,不管是可变数据类型还是不可变数据类型,一开始引用的都是同一内存地址,而不可变数据类型重新赋值或运算后会指向新的内存地址,而可变类型却仅是改变了所指向内存地址中的值。
  4. python似乎始终对列表预留了一个内存地址,将其使用(赋给其他变量)之后,又开了一个新的内存地址.(这个就很诡异…)
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

月司

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值