python的大坑:使用空列表作为默认参数,让我怀疑遇到了灵异代码

在python中,不要使用列表或者其他可变类型的数据容器作为默认参数。否则你很可能会遇到奇奇怪怪的问题。

如果你在调用某一个函数时,传了同样的参数,手动执行,每次结果都正确。但是用循环遍历重复多次执行,每次得到的结果都不一样,并且每执行一次,它返回的数据都是在上一次的基础上继续加多一次。那么恭喜你,很可能遇到了跟我一样的问题。

一、问题背景

最近在用django框架开发一个web应用,专门写了个函数从某个接口调取数据,对数据进行处理后,返回给前端页面。

但是出现了奇怪的问题。当我直接在pycharm上单独执行这个函数所在的模块或者单独调用这个函数时,它执行后,返回的数据是没问题的,而且就算在短时间内,重复run多次,它的结果都没问题,都是一样的。

但是,当我把它放在django项目中,整个项目一起运行,在前端页面点击发起请求,后端调用这个函数时,第一次是没问题的,但如果很短时间内再次发起同样的请求,获得的数据会叠加上一次的数据一起返回。而如果是隔一段时间之后再发起同样的请求的话,它第一次又没问题了,而第二次执行又有问题了。

这个让我懵圈了好几天,以为遇到了灵异代码了。想破脑袋都搞不明白到底问题出在哪里。中间甚至怀疑过是不是前端ajax在一次请求下,多次调用了那个函数。也怀疑过是不是接口服务器的响应机制有问题。或者是我写的程序逻辑上有问题。但通过一步步排查,这些都被否定了,全都没问题。

这时候是很麻烦了,当你知道问题出在哪里时,查一下也许就有答案了,如果连问题出在哪里都看不出,那就只能一点一点排查了。

在排查的过程中,我一步一步缩小范围,最终把“病根”锁定到这个函数的参数上。根本原因就是:我在函数的参数上,使用了一个列表作为默认参数。

这个做法如果脱离具体应用场景来讲,它在逻辑上是没问题的,python解释器允许这么做,执行了也不会报错。但关键就是python这门语言的内存引用机制导致了2种情况的出现:如果你短时间内手动重复执行它多次,不会有问题;但如果在一次执行中,使用循环去重复调用,就可能会有异常情况出现。

那么到底为什么使用空列表作为默认参数会有问题呢?

准确来说应该是:使用所有可变类型的数据容器(例如列表、字典)作为默认参数,都有可能出现这个问题。

二、模拟重现这个问题:

原项目中整个函数的代码太长了,为了凸显重点问题所在,我把原来整个函数的内容缩减,重新写了2个示例函数来进行对比:
在这里插入图片描述

1、简单说说这2个函数的作用和区别:

2个函数的作用都一样:
都是传进去一个店名store_name,判断页码page_num是不是第2页。如果不是,就把数据添加到page_data这个列表中,把页码加1,重新再执行一遍这个函数。如果page_num是第2页的话,就把整个处理后的列表返回。

2个函数的区别:一个直接使用空列表作为默认参数,一个先把空列表赋值给变量,再将这个变量作为位置参数传递给函数;

2、单次运行中单次调用它们会怎么样?

我们来直接运行这个模块,单次调用这2个函数,给它传一个店名“肯德基”,执行看看效果是怎么样的。
(注意:第一个函数它有个默认的空列表参数了,不用我们传参,它也能默认page_data是个空列表来执行,而要调用第二个函数,必须先定义一个空列表,把它作为参数传进去,这样效果看起来就是一样的)

2个函数都只调用一次,调用代码如下:

在这里插入图片描述

按照正常的思维,我们可能会觉得,2个函数的执行结果是一样的。

那么我们看看输出的结果:
在这里插入图片描述
没问题,输出结果确实也是一样的。证明单次执行没有问题。手动多次重复执行,得到的结果也是没问题的,每次都一样。

3、单次运行中多次调用这个函数会怎么样?

用for循环将2个函数都各调用2次,代码如下:
在这里插入图片描述
结果如下:
在这里插入图片描述
这个结果是不正常的。

当空列表作为默认参数时:

在第一次for循环中,get_data_1函数内部循环执行了2次,得到了一个列表[ ‘肯德基’, ’ 肯德基 ’ ];

而第二次for循环刚开始,它就把上一次for循环得到的列表,作为第二次for循环的默认参数继续使用,并在函数内部执行了2次添加,所以第2次for循环执行的结果是[ ‘肯德基’, ’ 肯德基 ',‘肯德基’, ’ 肯德基 ’ ]。

然而,我们在第二次for循环调用它之前, 并没有给它的page_data赋任何值啊,是希望它使用默认参数来执行的,那就奇怪了,没赋值为什么它却有值?而且还是使用上一次for循环得到的数据。

而当空列表作为位置参数的时候:

即使短时间内执行2次,它的2次输出结果还是一样的。这是符合我们预期的。

正常来说,如果我们不打算对一个函数的调用次数作限制,那么当我们在相同参数的条件下重复调用这个函数,得到的结果就必须是一样的,不能因为调用次数多了,输出结果就不一致。所以很明显,get_data_1这个函数是有问题的,而get_data_2这个函数是没问题的。

那么是什么问题导致了这种情况?

get_data_1和get_data_2的不同之处只是get_data_1使用了空列表作为默认参数。所以通过继续研究,我最后得出的结论是:这个问题是python这门语言的特性导致的。

三、分析背后隐藏的问题

这里必须先说4个相关的问题,就是这4个问题同时出现导致了在项目下运行会出现异常,而单独在模块中手动重复调用没问题,循环重复调用会有问题。

1、为一个变量赋值的过程中,python解释器做了什么事?

当我们为一个变量赋值时,python解释器会先在计算机内存中,开辟一个内存空间,用于存放这个变量的值。当你为另一个变量赋值时,如果要赋的值跟第一个值是一样的,那么python解释器不会重新再开辟一个内存空间给你第二个变量使用,它只会调整你第二个变量的指向,让它指向第一个值存储的内存空间,你每次调用第二个变量时,它会直接到第一个值存储的内存空间去拿出数据。

演示一下:
打开ipython,在交互环境下,分别给a和b赋值,通过id(a)和id(b)可以获取到它们的内存地址。
在这里插入图片描述
我们可以看到,当你给a和b赋值同样的内容时,它们的内存地址都是一样的。证实了上面所说的这个python语言特性。

2、当对一个列表执行append()操作时,改变了它的值,会不会改变它的内存地址?

答案是:不会。
这个也可以演示一下:
在这里插入图片描述
当我们定义了一个空列表之后,即使你使用append(),给列表追加了内容,让它的值不一样,但执行前后2个变量的id还是不变的。

这说明了什么?在改变一个可变类型的数据时,python解释器只是将原来在内存中存放的值给你修改了,而不是重新给你一个内存空间去存放新的值。

3、python解释器什么时候释放内存?

当我们在一个模块下执行一整片代码时,python解释器是会从头到尾执行的,在执行结束之前,所有变量的赋值都会保持在内存中,随时可以调用。只有整个模块运行结束,内存才会被释放,所有变量的赋值会从内存中释放出来。

4、Django项目运行下,python的变量赋值是否会一直保存在内存中。

分为2种情况:
debug模式下(debug=Ture),django会加载reloader,隔一段时间重启一次服务,在每次服务重启之前,整个项目的变量赋值都会保存在内存中,只有重启服务才会释放内存后重新分配内存空间。
生产模式下(debug=False),不会自动重启服务,所以变量赋值会一直保存在内存空间中。

搞明白这4个问题之后,我们就可以重新分析一下,使用空列表作为默认参数为什么会产生这个问题。

四、我们来分析一下整个调用的过程中,python解释器都做了什么事:

分析细节请看图片上的文字
在这里插入图片描述
在这里插入图片描述
为了证实我的分析是正确的,我们可以分别在get_data_1和get_data_2之后,对page_data的内存地址进行打印,看看输出的结果。
在这里插入图片描述
输出结果:
在这里插入图片描述
结果正如所料:
第一次for循环中,get_data_1执行了2次,而输出的page_data内存地址始终没变化,说明python解释器始终调用的是同一个内存地址存放的值,即使它的值在第一次被调用的时候已经改变,也不影响它再次调用。

第二次for循环中,输出的page_data内存地址2次都不一样,说明python解释器在循环2次调用get_data_2之前,都有对变量page_data进行初始化重新赋值,所以不管你前一次对page_data的值做了什么改变,它最终都是先从空列表开始引用。

所以结论就是:python的内存引用机制,导致了当我们使用了可变类型的数据容器作为默认参数后,你的程序可能在某些操作下改变了它的值,而这个操作刚好是不会改变它的内存地址的,这时候python解释器只会在内存中对这个值进行修改,但暂时不会把它释放出来,所以你再一次引用这个默认参数时,会以为它还是你设置的默认参数值(空列表),但实际上它已经被修改过了。

五、如何避免这个问题:

1、不要使用可变类型的数据容器作为默认参数(包括列表,字典),但是可以作为位置参数使用。

你可以使用get_data_2这种写法,把列表或可变数据类型的数据容器,在每次调用之前都进行赋值,然后使用位置参数传参;

2、如果你一定要使用,需要在函数内部进行判断,判断它是不是你想要的初始值,如果不是,就再次进行赋值。

但这种方法只适用于部分情况,当你的函数本身会循环执行,而且你在外部需要循环调用时,你是无法确定它到底是处在外部循环中还是处在内部循环中的,所以根本无法对它每一次的值进行判断。

例如我上面这个get_data_1函数就不适合这么改,因为它是会根据page_num的值,来决定本身的循环次数,并且自动对参数page_data的值进行调整。所以我无法判断当我在外部进行循环调用的时候,到底它是处在我外部的循环中,还是处在它函数内部的循环中,因此无法确定参数的值应该是多少。

最后感慨一下:感觉遇到了我这个段位不应该遇到的问题。

人生苦短,我学python,学了python,人生更短。

发布了21 篇原创文章 · 获赞 32 · 访问量 3075

猜你喜欢

转载自blog.csdn.net/Jacky_kplin/article/details/103625734