Python是一门清晰易读的语言,Guido van Rossum在设计Python时希望将其设计为一种没有任何歧义和意外行为的语言。但不幸的是,依然存在某些极端情况Python的行为跟你的预期不同。这些情况往往容易被忽视,理所当然的认为Python会按预期执行,结果给程度带来很多错误和隐患。更糟糕的是,这类问题的debug还很困难。
本系列收集整理了所有Python编程中可能会遇到的坑,我将用两篇文章
- Python避坑指南
- Python避坑指南(续)
教大家如何避开这些坑,写出健壮、高效的Python代码。
本系列会深入到Python的内部,网上很少有人提及到这部分内容,建议大家收藏。
列表生成的坑
我们先看一个例子:
li = [[]] * 3
print(li)
# Out: [[], [], []]
上面的代码用乘法语法创建嵌套列表,输出应该是包含3个空列表的列表。这跟我们的预期相符,完全没有问题。
接下来我们向列表中的第一个元素添加一个数字1
li[0].append(1)
print(li)
# Out: [[1], [1], [1]]
按照常理,输出应该是[[1], [], []]
,但是很不幸,Python的输出与我们的预期并不一致,上面的代码输出[[1], [1], [1]]
。为什么会这样?
这是因为[[]] * 3
并不会创建3个不同的列表,而是只创建一个列表,然后返回这个列表的3个引用。因此当我们向li[0]
中追加数据时,3个引用指向同一个列表,所以三处都发生了改变。
我们可以通过输出列表元素地址进一步证实上面的解释:
li = [[]] * 3
print([id(inner_list) for inner_list in li])
# Out: [1984412007296, 1984412007296, 1984412007296]
从输出可以清楚地看到,li
中3个子列表的地址是相同的,说明他们指向同一对象。
要想让生成的列表中的三个子列表是三个不同对象,我们可以这样写:
li = [[] for _ in range(3)]
上面的代码不再只创建一个列表然后返回3个引用,而是创建3个不同列表。我们同样可以通过输出地址来验证:
print([id(inner_list) for inner_list in li])
# Out: [1984411997888, 1984412000704, 1984411999552]
从输出可以看到,列表中的3个子列表地址都不同,说明是3个不同的列表。
如果你不嫌麻烦,你也可以新建一个列表,然后一个一个加入空列表,代码如下:
li = []
li.append([])
li.append([])
li.append([])
print([id(inner_list) for inner_list in li])
# Out: [1984412008256, 1984411997888, 1984412000704]
参数默认值的坑
将函数参数的默认值设为可变类型会存在潜在问题,请看下面的例子:
def foo(li=[]):
li.append(1)
print(li)
foo([2])
# Out: [2, 1]
foo([3])
# Out: [3, 1]
上面代码的输出都与我们预期相符。但如果我们调用foo()
但不传入参数时会怎样呢?
foo()
# Out: [1] 符合预期...
foo()
# Out: [1, 1] 不符合预期...
这是因为函数参数的默认值是在定义时初始化的,而不是在运行时初始化的。因此我们只有一个li
列表的实例。
要解决这个问题需要将参数默认值换成不可变类型:
def foo(li=None):
if li is None:
li = []
li.append(1)
print(li)
foo()
# Out: [1]
foo()
# Out: [1]
迭代过程中改变迭代序列的坑
切忌不要在for循环中改变遍历序列,尤其是增加或删除系列元素。
遍历中删除元素
请看下面的例子:
alist = [0, 1, 2]
for index, value in enumerate(alist):
alist.pop(index) # pop方法用于从列表中移除指定位置的元素
print(alist)
# Out: [1]
你以为上面的代码会依次将列表alist
的元素删除,但输出结果为[1]
。这是因为for循环会按照下标依次执行。
第一次循环index
值为0,我们将alist
中的第0号元素删除,此时alist=[1, 2]
;
第二次循环index
值为1,我们将alist
中的第1号元素删除,结束后alist=[1]
。
下图描述了整个循环过程:
引起上面问题的原因是下标会依次增加,但我们删除元素时,后面的元素会前移。避免这个问题的一种解决方法是从后往前遍历,这样删除元素时就不会发生移动,不移动下标的对应关系就不会错乱。请看下面例子:
alist = [1,2,3,4,5,6,7]
for index, item in reversed(list(enumerate(alist))):
# 删除所有偶数
if item % 2 == 0:
alist.pop(index)
print(alist)
# Out: [1, 3, 5, 7]
上面的例子中我们从后往前遍历,当我们删除(或添加)列表元素时不会引起其他元素移动,所以能够如我们预期的删除列表中的元素。
遍历中添加元素
边遍历边添加元素也会引起问题,这么做会造成死循环。请看下面的例子:
alist = [0, 1, 2]
for index, value in enumerate(alist):
# 不加这个判断就会死循环,每次循环都会有新元素增加,列表永远遍历不完
if index == 10:
break
alist.insert(index, 'a')
print(alist)
# Out: ['a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 0, 1, 2]
如果没有break
判断,这个循环会一直执行下去。
处理这种情况更好的办法是创建一个新列表,然后遍历原始列表向新列表中添加元素。
遍历中修改元素
遍历列表时,我们不能利用占位元素来修改列表的值。请看下面的例子:
alist = [1,2,3,4]
for item in alist:
if item % 2 == 0:
item = 'even'
print(alist)
# Out: [1,2,3,4]
上面例子中,改变item
的值并不会改变原始列表alist
的对应元素值。你需要通过列表索引来修改列表的值。
alist = [1,2,3,4]
for index, item in enumerate(alist):
if item % 2 == 0:
alist[index] = 'even'
print(alist)
# Out: [1, 'even', 3, 'even']
while
有时比for
更好
大家可能习惯用for
循环多于while
循环。但是某些场景下while
循环比for
循环更好用。比如清空列表元素:
zlist = [0, 1, 2]
while zlist:
zlist.pop(0)
print('After: zlist =', zlist)
# Out: After: zlist = []
上面的代码,会在zlist
为空时结束循环。有时我们可能需要将列表删除到一定数量,此时我们可以用len()
让循环在指定数值结束。
zlist = [0, 1, 2]
x = 1
while len(zlist) > x:
zlist.pop(0)
print('After: zlist =', zlist)
# Out: After: zlist = [2]
用while
循环我们还可以放心的在循环中处理条件分支逻辑
zlist = [1,2,3,4,5]
i = 0
while i < len(zlist):
if zlist[i] % 2 == 0:
zlist.pop(i)
else:
i += 1
print(zlist)
# Out: [1, 3, 5]
有时候我们可以使用逆向思维,删除列表中不需要的元素,可以转换为将列表中需要的元素加入一个新列表。用新列表的话,无论是for
循环还是while
循环都能安全的处理。下面给出的是for
循环的实现:
zlist = [1,2,3,4,5]
z_temp = []
for item in zlist:
if item % 2 != 0:
z_temp.append(item)
zlist = z_temp
print(zlist)
# Out: [1, 3, 5]
基于上面的思想,我们可以用Python最优雅最强大的列表解析式来完成前面将列表中偶数删除的任务:
zlist = [1,2,3,4,5]
[item for item in zlist if item % 2 != 0]
# Out: [1, 3, 5]
原本几行代码才能完成的工作,用列表解析式一行就完成。所以在实际开发中要善用列表解析式带来的简洁强大的表达力。
列表解析式和for循环中的变量泄露
上一节为大家讲了for循环中的坑,最后给大家展示了列表解析式的强大。在for循环中还有一个坑,就是变量泄漏。变量泄露是什么意思?看下面两段代码你就明白了:
i = 0
a = []
for i in range(3):
a.append(i)
print(i)
# Outputs 2
i = 0
a = [i for i in range(3)]
print(i)
# Outputs 0
这两段代码做的事情是一样的,但是执行结束后变量i的值却不同。其中for
循环中的占位变量i
在循环中是不具有局部作用域的,他与外部变量i
是同一个变量,因此循环迭代外部变量i
的值会改变。而列表解析式中占位变量是具有局部作用域的,列表解析式中的i
与前面定义i = 0
的i不是同一变量,因此执行完后不会改变外部变量i
的值。
如果你用的Python版本<=2.7,执行上面的列表解析式会出现变量泄露:
# Python 2.x <= 2.7
i = 0
a = [i for i in range(3)]
print(i)
# Outputs 2
Python2.7及一下版本中,列表解析式也存在变量泄露问题,具体请参考这里。这个问题在Python 3.x得到了解决。因此Python3.x中列表解析式是不存在变量泄露的。但for
依然没有私有的局部作用域,所以依然存在变量泄露。这点也再一次印证了上一节的观点,尽量使用列表解析式来完成工作。
字典是无序的
很多从C++转型的程序员会以为Python的字典也会像C++的std::map
一样,是按key的字典序排序的。而事实是Python中的字典是无序的。请看下面的例子:
myDict = {
'first': 1, 'second': 2, 'third': 3}
print(myDict)
# Out: {'first': 1, 'second': 2, 'third': 3}
print([k for k in myDict])
# Out: ['second', 'third', 'first']
Python中没有内置自动将字典按key排序。然而有些时候我们需要字典记住元素的插入顺序,此时我们应该用collections.OrderedDict
:
from collections import OrderedDict
oDict = OrderedDict([('first', 1), ('second', 2), ('third', 3)])
print([k for k in oDict])
# Out: ['first', 'second', 'third']
这里要特别注意:当我们用一个普通字典初始化OrderedDict
时,OrderedDict
是不会排序的,它的功能仅仅是保留元素的插入顺序。
为了减少内存开销,Python3.6修改了字典实现,其中一处影响是当用关键字形式传参时,函数会保留传递参数的顺序。
def func(**kwargs):
print(kwargs.keys())
func(a=1, b=2, c=3, d=4, e=5)
# Out: dict_keys(['a', 'b', 'c', 'd', 'e'])
如果开发中需要对字典的内容进行排序,就需要用Python内置函数sorted()
,该函数可以对所有可迭代的对象进行排序操作。语法如下:
sorted(iterable, key=None,reverse=False)
参数说明:
iterable
:可迭代对象,即可以用for循环进行迭代的对象;key
:主要是用来进行比较的元素,只有一个参数,具体的函数参数取自于可迭代对象中,用来指定可迭代对象中的一个元素来进行排序;reverse
:排序规则,reverse=False升序(默认),reverse=True降序。
sorted()
功能很强大,对字典来说,既可以按键排序,也可以按值排序。
# 按照字典的值进行排序
sortedDict1 = sorted(myDict.items(), key=lambda x: x[1])
# 按照字典的键进行排序
sortedDict2 = sorted(myDict.items(), key=lambda x: x[0])
总结
今天先给大家介绍以上最常见的5个坑。这5个坑基本都与可变容器类型数据有关,是Python新手最容易犯错,且犯错后又最难排除的坑。上面这些内容很少有教程提价到,希望本文对你有帮助。
另外我还总结一个Python编码最佳实践,建议大家结合着本文一起阅读。