更多课程 选择中心


Python培训

400-111-8989

那些年,我写python踩过的坑

  • 发布:一口大黄牙
  • 来源:量化研究所
  • 时间:2017-09-30 13:48

言归正传,当年我在学python的时候也是同样的感觉,学完python基础的我觉得我已经了解了整个python语言,就像打游戏时候打通关的感觉。但是后来一次次踩坑的时候有一种很奇怪的感觉,我对python似曾相识,又觉得陌生。打游戏发现新的隐藏关卡带来的是喜悦,而python的坑带来的是自我怀疑,甚至可能是实实在在的经济损失,所以,坑,我们踩过的,一定带你绕过去。

下面让我们一起看看python隐藏的坑,排名不分先后。

1. 基础篇

基础的坑,最重要的是举一反三,最怕的是在黑板上老师写了‘一’,回家在笔记本上写一个横线就不认识这种事情。

忘记写冒号。在def,if,while,for等语句第一行某位输入”:”。

缩进要一致。避免在缩进时混合使用制表符和空格。

不要认为在原处修改对象的函数会返回结果。例如a = list.append(),a是不会被赋值的。

不要在变量赋值前就使用变量。例如a = b写这行代码时,b在此之前一定要被赋值过的。

函数调用一定要加括号。例如dataframe.dropna()。

不要写死循环。例如while True: 之后的代码块中没有跳出循环的代码。

判断两个变量是否相等用的是‘==’号。大多数初学者,觉得‘=’号就可以了。

写字符串,写各种样式的括号时注意他们是成对出现的。常见['a', b']这样的错误,然后觉得自己代码没有问题的初学者。

2. 进阶篇

有一句歌词唱的挺好的,“跨过这道山,越过那道岭,眼前又是一座峰”。基础的坑排完了,还会有更多,更隐藏的坑等着你去踩。(比较熟悉这个歌的同学,我觉得你大概率上是北方人,更具体点的话,是东北人)

2.1 赋值仅仅生成引用

a = [1, 2, 3]

b = a

这里你认为自己构建了两个相同的列表,准备用在不同的用途。然而当开开心心的进行接下来的其他操作时,比如,对a进行修改,就发生了其他的事情。

a[1] = 100000a

out: [1, 100000, 3]

b

out: [1, 100000, 3]

结果发现,本来希望只对a进行修改,结果发现b也受到了影响,其原因在于通过b = a的方式进行对b赋值,其实a、b指向同一个列表对象。我们可以通过查看a、b的内存地址或用is进行验证

print(id(a), id(b))

out: 124006856 124006856

可以看到a、b指向的其实是同一块内存

a is b

out: True

用is也检测出a和b完全就是一个东西的不同名字而已。

上面的赋值方法还是比较容易看出,因为有等号,那么下面的赋值方法可能就稍微难一点看出来了。

c = [1, a, 3]

c

out: [1, [1, 100000, 3], 3]

当对a修改时,c同样也会受到影响

a.append(100000)

a

out: [1, 100000, 3, 100000]

c

out: [1, [1, 100000, 3, 100000], 3]

所以,不要觉得写完了,print出来的东西看着和自己想的一样就万事大吉,不实际踩一下肯定不知道接下来有坑。

那么,如何解决呢?用 .copy(),这样就会产生两个不相干的对象,当然如果不嫌麻烦的话,可以把相同的东西再打一遍,然后,你有没有看到同行鄙视的眼神?

我们看一下效果。

a = [1, 2, 3]

b = a.copy()

a is b

out: False

print(id(a), id(b))

out:125323528 87389000

可以看到a,b指向了不同的内存地址,并且用is检测显示是不同对象。

接下来修改a。

a[1] = 100000a

out: [1, 100000, 3]

b

out: [1, 2, 3]

可以看到修改a已经不会对b产生影响了,此坑已填。

2.2 乘法与嵌套列表

编程的某个时候,你希望生成这样一个嵌套列表[[],[],[],..., [],[]],里面的列表为空或者默认值,那么第一选择肯定是利用列表乘法一次性生成多个列表,像这样

a = [[]] * 5a

out: [[], [], [], [], []]

确实满足了需求,然后当你开开心心的使用时,发觉事情有点不太对。比如对a列表中作为元素的某一个列表进行修改

a[0].append(1000)

a[0]

out: [1000]

a

out: [[1000], [1000], [1000], [1000], [1000]]

然后发现,怎么所有作为元素的列表全都发生了变化。这次同样可以用id或这is进行检测。

print(id(a[0]), id(a[1]))

out: 125325256 125325256

a[0] is a[1]

out: True

可以看出,原来a列表中作为元素的每一个列表,其实都是同一个东西,这也就解释了为什么对其中一个作为元素的列表进行原地修改时,其他所有作为元素的列表也发生了变化。

那么,解决方案如下

a = [[] for i in range(5)]

a

out: [[], [], [], [], []]

a[0] is a[1]

out: False

print(id(a[0]), id(a[1]))

out: 125323144 125321544

可以看到,a中作为元素的列表已经不是同一个了,这样对其中的列表进行修改时候就不会影响其他列表。

a[0].append(1000)

a

out: [[1000], [], [], [], []]

此坑已填

2.3 本地变量静态检测

刚刚了解作用域的同学应该对LEGB原则有一定了解,然后实践中可能大胆的写出了这样的函数。

a = 1def print_a():

print(a)

a = 2

这个函数的目的也比较容易理解,打印a的值之后将a的值修改为2,但是,实际运行时发生了这样的事情。

print_a()

---------------------------------------------------------------------------

out: UnboundLocalError Traceback (most recent call last)

<ipython-input-24-4bb72463237f> in <module>()

----> 1 print_a()

<ipython-input-23-feb3b9a58246> in print_a()

1 a = 1

2 def print_a():

----> 3 print(a)

4 a = 2

UnboundLocalError: local variable 'a' referenced before assignment

发生这样问题的原因是在python读入并编译这段代码时,发现def里面有一个对a赋值的语句,然后决定a在函数中属于本地变量名。那么当print_a执行时,就会对print(a)按照LEGB原则执行搜索,发现a属于本地变量名但还未赋值。也就是我们在前面基础坑里面提到的在变量未赋值前进行使用。

解决方案需要使用global声明a是一个全局变量。

a = 1def print_a():

global a

print(a)

a = 2

print_a()

out: 1

a

out: 2

可以看到,函数已经可以正常使用,并且全局变量a按照预期进行了修改。此坑已填。

2.4 可变对象作函数默认参数

默认参数在def语句运行时完成了保存,对于可变对象而言,当函数被调用并对该参数进行原地修改,默认参数将发生变化并进而影响接下来的函数调用。

先用可变对象作为默认参数编写一个函数。

def test(a=[]):

a.append(1)

print(a)

接下来多次调用test函数,你一定以为每次打出来的都是一个包含1的列表。但是,事实并不是这样。

test()

out: [1]

test()

out: [1, 1]

test()

out: [1, 1, 1]

是不是感觉三观尽毁啊。

当然,有坑就会有填坑的方案。官方给出的标准解决方案是这样的。

def test(a=None):

if a is None:

a = []

a.append(1)

print(a)

test()

out: [1]

test()

out: [1]

可以看到,test多次被调用已经不会出现之前的情况了。此坑已填。

2.5 嵌套在循环中的lambda

在知道python中一切皆对象之后,也许你就会尝试把一些特别的东西放进一个list,虽然不清楚未来实际会不会用到这样的写法,反正先试试。然后你就想将一套简单的函数放进一个列表,然后用lambda编写。

func_list = []for i in range(5):

func_list.append(lambda x: x + i)

这样生成了一个元素为函数的列表,其中每个函数都可以传入一个参数,然后返回的是传入值和0,1,2,3,4的和。当然理想情况下是这样,现实并不会如此。

func_list[0](1)

out: 5

func_list[1](1)

out: 5

func_list[2](1)

out: 5

可以发现,所有结果都是4+1的和。发生这种现象的原因在于变量i在函数调用时才进行查找,而此时循环已经结束,i=4,所以所有函数对象在被调用时参数i全部都为5.

那么解决方案就是按照本文2.4中写的,默认参数在def语句运行时完成了保存,也就是使用默认参数的方式解决该问题。

func_list = []for i in range(5):

func_list.append(lambda x, i=i: x + i)

func_list[0](1)

out: 1

func_list[1](1)

out: 2

func_list[2](1)

out: 3

可以看到,问题已经得到解决。此坑已填。

2.6 列表和enumerate

熟悉列表,学习python算是入门;熟悉enumerate,学习python可以说不是纯小白了。enumerate返回下标和元素的方式还是为python使用者提供了不大不小的便利,不过同时也挖下了一个坑。

比如你写了一个循环,想从列表中删除偶数。

a = [1,2,3,4,5,6]for i, item in enumerate(a): if a[i] % 2==0: del a[i]

a

out: [1, 3, 5]

是不是想说没什么错误啊,然后在给你看看这个。

a = [1,2,6,3,4,4]for i, item in enumerate(a): if a[i] % 2==0: del a[i]

a

out: [1, 6, 3, 4]

enumerate(a)

out: <enumerate at 0x78e6d80>

产生这个问题的原因是在当i=1时,a[1]的值为2,符合条件,那么删除2这个值之后,a整个列表发生了变化,6这个数值前进一个位置到了a[1]的位置,然后i在执行下一次循环时候值取了2,我们以为应该对应着检验6,其实6被前移一个位置,也就被遗漏了。

下面我们将for 循环的部分手动执行,看现实是不是和我们理解的一样。

a = [1,2,6,3,4,4]

e = enumerate(a)

e

out: <enumerate at 0x78edc18>

i, item = e.__next__()

(i, item)

out: (0, 1)

if a[i] % 2==0: del a[i]

a

out: [1, 2, 6, 3, 4, 4]

此时我们可以看到,执行完循环的第一步,a并没有发生变化。接下来我们继续第二步。

i, item = e.__next__()

(i, item)

out: (1, 2)

if a[i] % 2==0: del a[i]

a

out: [1, 6, 3, 4, 4]

可以发现,a已经发生了变化,那么我们接下来只要看在enumerate提供新的下标好元素是不是还按照未调整的a进行提供的。当然,可以告诉你们答案,肯定不是了。

i, item = e.__next__()

(i, item)

out: (2, 3)

可以看到6已经不是第三个元素, 它通过向前移动一个位置的方式逃过了检查。这个坑真的是非常难发现,因为有时候碰巧你的运算方式和提供的列表刚刚好结果是你想要的,然后让你觉得这样用就是正确的。这种时候就非常可怕了。

这个坑的解决方案可以使用列表解析式添加筛选条件即可。

a = [1, 2, 6, 3, 4, 4]

a = [x for x in a if x%2 != 0]

a

[1, 3]

此坑已填。

3. 高阶篇

看这里。

不用怀疑你的眼睛和设备,上面就是空白的。

如果我在这儿就把高阶篇写完了,这个系列我还能写啥别的?

写到这里,python编程一些比较基础的坑也已经描述的比较详细了。我从来不产生失落感,我只是信心的粉碎机。当你觉得自己python已经学的差不多的时候,总会有那么一个人、或者一篇文章告诉你,你懂得还不够多。

不过,坑总是有的,学习的时候你可以抱着填坑的心态,也可以怀着获取的目的,两者都取决于你的选择。而且,个人觉的,怀着获取的态度,容易满足,不断追求,也就一直快乐。

最后,还是用毒翼神龙的一段话结个尾:

“几盘卡带就可以陪我们走过整个童年的那个时代恐怕也一去不复返了吧

每天的生活被工作、学习、应酬充斥着

也让我们渐渐忘记了那个传说

虽然它是一段美丽的谣言

但想起那个信以为真的年纪,仍然有许多美好的回忆

我怀念那时单纯的快乐

怀念那个很容易就能满足的童心”

(修改一句)

“现在,这些美好的东西还可以存在

我们可以做的

还有保持一颗向前走的心

多学python”

3. 高阶篇

高阶的坑,想弄明白,就要详细的了解python语言运行的机制,而每一个坑相关的机制,在大多数python教学书籍里面,基本上都会写成一个完整的章节。那么,为了真正了解每个坑,又不给各位阅读增加太多负担,我在这里将会对相关的运行机制或相关知识进行一定的介绍,然后再讲解坑和其解决方法。

3.1 import模块导致的潜在问题

在每一个.py文件的开头,大家要么见过,要么自己写过类似这样的语句:

import model_name as md

from model_name import func

from model_name import *

对于import一个模块时python是如何进行搜索的,这里就不进行详细介绍了,这里要说的是import一个模块之后发生了哪些变化。

import pandas

我们可以通过dir()内置函数查看当前命名空间内所有的变量名、函数名以及导入的模块的名称。(为了方便大家找到我们想要的变量名,我在这里对jupyter otebook输出的格式进行了一定的调整)

dir()

Out:

['In', 'Out', '_', '__', '___', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '_dh', '_i', '_i1', '_i2', '_ih', '_ii', '_iii', '_oh', '_sh', 'exit', 'get_ipython','quit',

'pandas',

]

输出结果里面,带横线的我们不用去看,In,Out也不用看,直接看最后的变量名。我们可以看到,经过import之后,当前的命名空间增加了pandas的模块名。对于from module_name import func也是一样。

from pandas import DataFrame

dir()

Out:

[ 'In', 'Out', '_', '_2', '__', '___', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '_dh', '_i', '_i1', '_i2', '_i3', '_i4', '_ih', '_ii', '_iii', '_oh', '_sh', 'exit', 'get_ipython', 'quit'

'pandas',

'DataFrame',

]

经过from import,当前命名空间中多了DataFrame的变量名。

对于import 和from语句来说,他们都属于隐形赋值语句。import语句会在运行时将对应的整个模块当成一个对象赋值给一个变量名,比如import pandas就是将pandas模块导入后赋值给了pandas这个变量名,用一个更容易理解的句子来说明就是import pandas 与import pandas as pandas 是一致的;而from则是将一个模块中的一个变量名所对应的对象,比如函数,或者数字等等,赋值给当前命名空间的一个变量名,那么from pandas import DataFrame用更容易理解的句子就是from pandas import DataFrame as DataFrame.

那么,这样一种导入机制就会带来一个问题,如果我们是从不同的模块导入同一个名称的对象,比如函数,那么我们在使用的时候究竟用的是哪一个呢?

下面我们看一个具体的例子。

from numpy import sin

首先,我们从numpy模块导入了sin函数

dir()

Out:

['DataFrame', 'In', 'Out', '_', '_2', '_4', '__', '___', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '_dh', '_i', '_i1', '_i2', '_i3', '_i4', '_i5', '_i6', '_ih', '_ii', '_iii', '_oh', '_sh', 'exit', 'get_ipython', 'pandas', 'quit',

'sin']

当前的命名空间有了sin这个变量名。

我们知道math模块里面同样也有sin函数,那么我们同样导入sin函数。

from math import sin

dir()

Out:

['DataFrame', 'In', 'Out', '_', '_2', '_4', '_6', '__', '___', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '_dh', '_i', '_i1', '_i2', '_i3', '_i4', '_i5', '_i6', '_i7', '_i8', '_ih', '_ii', '_iii', '_oh', '_sh', 'exit', 'get_ipython', 'pandas', 'quit',

'sin']

可以看到,当前命名空间里面还是只有一个sin变量名,那么这个变量名对应哪一个模块的函数呢?我们可以通过使用它验证一下。对于math模块中的sin函数而言,他每次只能处理一个值,而numpy模块中的sin函数则可以处理ndarray。

import numpy as np

a = np.arange(4)

b = 0.5print(type(a), '\n', type(b))

Out:

<class 'numpy.ndarray'>

<class 'float'>

我们已经构造好了测试用的两种类型数据,然后看一下用当前命名空间中的sin函数处理的结果。

# 测试ndarraysin(a)

---------------------------------------------------------------------------

Out: TypeError Traceback (most recent call last)

<ipython-input-10-2b3aa36ef665> in <module>()

1 # 测试ndarray

----> 2 sin(a)

TypeError: only length-1 arrays can be converted to Python scalars

# 测试单个数字sin(b)

Out:

0.479425538604203

这样我们可以确定,经过两次from import最终导入到当前命名空间的sin函数是来自math模块的。

出现这个问题的原理很简单,前面已经讲过,import和from import两种导入语句都是隐形赋值语句,那么两次导入操作我们都是将模块中的一个函数对象赋值给了变量名sin,那么sin变量名在我们使用时当然是用的最后一次赋值时候导入的函数对象。

如果这段文字描述看不懂,那么下面这段代码应该非常容易理解。

sin = 'func from numpy'sin = 'func from math'sin

Out:

'func from math'

这样讲就非常容易理解了,其实对于import numpy as np,或者from numpy import sin as sin1这种通过as自己定义一个变量名的方式可以在一定程度上避免这种重复对一个变量名进行赋值问题的发生,但是这就带来另外一个问题,就是对于其他阅读你编写程序的人来说,阅读你的每一个模块都要记住导入函数时在最开始import语句中体现出来的模块来源。这就非常麻烦了,而且很有可能时间长了,你自己也会忘记自己在一个模块中为了区分不同来源、同一个名称的函数,曾经给他们起的不同的名字到底是对应哪一个模块的函数,比如你起了sin1,sin2两个变量名分别对应来自numpy和math两个模块的函数,时间长了,你怎么可能想起sin1会对应numpy中的sin函数。对于这个问题最好的解决方案就是导入模块,然后通过module.function的方式调用函数,这样可以非常清晰的知道函数的来源,并且在程序编写时也会提供一定的便利。

下面是sin函数的句子比较好的导入及使用方法。

import numpy as npimport math

a = np.arange(4)

b = 0.5a_sin = np.sin(a)

b_sin = math.sin(b)

print(a_sin, '\n', b_sin)

Out:

[ 0. 0.84147098 0.90929743 0.14112001]

0.479425538604203

写到这里,基本上会有很多同学产生一类困惑,就是我以前就是这么用的啊,我从来就没有像你介绍的导入方法那样使用numpy和math啊,怎么会有人使用numpy的时候不用import umpy as np而是跑去使用from numpy import sin这种方式啊,这不是自己挖坑然后说自己填了坑吗?对于这样的问题,确实我们在使用numpy的时候不会使用from numpy import p这样的语句,因为网上所有能看到的教程都会告诉大家numpy约定俗成的导入方式是import numpy as p,但是网上很多其他模块就很多都会用到from module import func类似的方式,这种方式是非常多见的。

那么另外一批同学就会产生另外一类困惑,就是我用标准库的时候很少遇到相同名字的函数呀,我用from import这种方法导入应该遇到同一个变量名的概率应该很小了。这种想法确实没问题,标准库的设计上,实现不同功能的标准库内部函数名称基本上不会一样,实现同一类功能,比如都是绘图的库,实现同一个具体功能的函数名称也很有可能不同。但是,如果是自己写的.py文件呢?特别是在一个公司,使用相同的命名规则,编写相同作用的函数时函数的名称就很有可能是一样的。所以,一劳永逸,清晰直白的方式就是导入库,然后使用module.function的方式进行函数调用。

讲完了import 和from import可能带来的问题,那么最后说一句使用*号的导入语句。这个语句理解起来很简单,就是把你选中模块中所有的对象都导入当前命名空间,不论函数,类等等,统统导入。是不是有一种一大波僵尸来袭的既视感?所以,from module import *类似带*号的句子能不用就不用,因为在这里我敢肯定的说,99.99%的某一个模块使用者是没有记住一个复杂模块里面所有可能导入的变量名的,那么你就不清楚这种导入一旦运行会不会覆盖掉你已经赋值过的变量名。当然我们还知道,使用*导入还会影响效率,这一点和导入时引发的赋值冲突相比,效率是不值得一提的小事情。

这个坑的解决方案在上面也已经介绍过了。这就是所谓大神埋下的坑,而且,学习python过程中,很多博文都会用*的方法,介绍一个方面的问题这样写是可以的,因为用着方便,但是现实自己编程时要特别注意,尤其是要导入很多功能相似模块的时候。

3.2 链式赋值(附dis偷窥工具讲解)

使用=赋值,对于所有学习python的同学来说都是在熟悉不过了,估计大多数人写的第一行代码是

print('hello world!')

Out: hello world!

第二行代码就是

a = 1

那么,链式赋值是一个什么概念呢?听起来很高端是不是,但是用起来,一点也不觉的高端,就是同时对几个变量进行赋值。

a = b = 1print(a, b)

Out:

1 1

可以看到,通过这一个式子,一次完成了对两个变量的赋值。

看过前面文章,我们都知道,很多坑都和可变对象有关,那么我们在这里同样再看看可变对象的表现。

a = [1, 1, 1, 1]

b = 2

如果我们同时对列表a中的某一个元素和b同时进行修改,看一下结果会怎么样。

b = a[1] = 3print(a, b)

Out:

[1, 3, 1, 1] 3

可以看到我们通过一行代码,完成了对列表a中一个元素的修改和b的赋值。没有任何意外发生。

写python代码多了,大家都会对a = a + 1非常熟悉,知道程序的运算会先调用已经赋值过的a的值完成+1的运算,然后再对a进行赋值,似乎可以说代码中等号两侧是从右到左这样的一个运算顺序,然后经过多次实践,不断的强化这样一个观点。

然后,突然有一天,熟悉链式赋值并且怀着等号两侧从右到左程序运行信念的你想做这样一个事情,首先有一个表示位置的变量,有一个待修改的列表,你希望根据表示位置的变量对列表进行修改赋予另一个值后,将表示位置的变量修改为修改时使用的新值。也许,你没多想,先写出了这样一段代码。

a = [1, 1, 1, 1]

position = 1 # 根据postion对a进行修改a[position] = 3 # 对列表修改完成,将修改用的新值3赋值给positionposition=3print(position, '\n', a)

Out:

3

[1, 3, 1, 1]

已经实现了自己最初的需求,然后熟悉链式法则的你突然灵机一动,觉得有一个看起来很高端的写法。你考虑了一下代码从左到右的运行顺序,然后写下了下面这段代码。

a = [1, 1, 1, 1]

position = 1 # 根据postion对a进行修改,同时,将修改后的新值3赋值给positionposition = a[position] = 3print(position, '\n', a)

Out:

3

[1, 1, 1, 3]

然后,你就发现,自己写了这么多代码才形成的所谓经验,等号两侧代码是从右到左运行的,这样一个历经多次编程考验的精辟总结就这么被推翻了,你开始怀疑之前自己看论坛时候各种大神都是如何总结出同样的结论,你甚至还在自己博客把这个结论在网上推广过,然后,你开始怀疑人生。

想要了解为什么会出现这样的现象,需要借用python底层运行偷窥神器dis。下面介绍一下dis我们在这里使用的方法。

# 将需要测试的代码写入函数def test():

a = [1, 1, 1, 1]

position = 1

position = a[position] = 3

导入dis模块,并将test函数传入dis

import dis

dis.dis(test)

3 0 LOAD_CONST 1 (1)

2 LOAD_CONST 1 (1)

4 LOAD_CONST 1 (1)

6 LOAD_CONST 1 (1)

8 BUILD_LIST 4

10 STORE_FAST 0 (a)

4 12 LOAD_CONST 1 (1)

14 STORE_FAST 1 (position)

5 16 LOAD_CONST 2 (3)

18 DUP_TOP

20 STORE_FAST 1 (position)

22 LOAD_FAST 0 (a)

24 LOAD_FAST 1 (position)

26 STORE_SUBSCR

28 LOAD_CONST 0 (None)

30 RETURN_VALUE

在dis的返回结果里面:

1. 第一列数字表示的是对应源代码的行数。例如最开始的一部分是指源代码中第三行,即a = [1, 1, 1, 1]

2. 第二列数字表示字节码索引。对于字节码,大家都知道,我们编写了源代码运行时,python先将源代码编译成字节码指令,交给虚拟机,然后再逐条执行字节码指令。字节码是运行中间过程产生的文件,所以一般我们关注源代码就可以了。

3. 第三列是指令的名字,我们可以通过指令的名字了解python执行时的具体动作

4. 第四列表示指令的参数

5. 第五列表示运行后的实际参数

以第一列为3,也就是源代码的第三行为例,前面5行告诉我们为了创建列表,我们加载了(LOAD_CONST)4个1,然后创建了一个长度为4的列表(BUILD_LIST),然后将列表赋值给a。

那么,第一列为5,对应源代码第5行的是我们希望深入研究的。首先,加载了数字3(LOAD_CONST),然后进行DUP_TOP操作。对于DUP_TOP操作,就不带大家去看底层C语言的代码了,我们知道这里对3进行了一次复制,也就是有了两个数字3。接下来执行了赋值操作(STORE_FAST),我们可以看到,这里的操作将数字先赋值给了position!后面才通过LOAD_FAST加载了a和position,然后通过STORE_SUBSCR完成用3对列表中相应位置元素修改的操作。这和我们之前的经验确实是不同的。使用链式操作,python先对position完成了修改,那么之后再执行对指定位置进行赋值也就和我们的最初需求完全不同。

我们已经明白了这种所谓高级的写法在python中的实现过程并不像我们想的一样,那么如何解决呢?不这么写就行了嘛。其实这类坑是很难发现的,因为思维惯性。对于任何一个事情,如果你形成了观点,或者说立场,那么,或多或少,你在之后都会受到影响。所以,似乎最理想的方式就是没有立场,少有立场,活出一颗树的姿态,但,怎么可能?不过,获得好的结果的立场会被称为信念,不好结果的立场会被称为执念,也就没了立场是否应该存在的问题,实现需求也就不用纠结立场惯性带来的问题。所以,多些知道,多些信念。

此坑已填。

3.3 装饰器内部编写逻辑

坑这个概念在这里我们要先区分一下,有一类坑是我们不清楚python的运行机制,以为python是这样运行的,但事实是python是那样运行的。这类坑,虽然比较难以发现,但是可以通过多写代码,踩过几次,清楚之后,绕过去就行;还有另外一类,世界上本来没有这类坑,用的人驾驭不了了,也就成了坑。

学习python的同学在开始阶段肯定都有过我已经学完python了,python好简单啊,编程好简单啊的感受,直到开始学习python中面向对象编程。学完面向对象编程之后,觉得自己再学些模块自己就可以统治世界了,然后就碰到了编程技巧——装饰器。然后很多人在装饰器基础的部分完成了常规学习中从入门到放弃的过程。所以,这部分的开头,我会帮助各位重拾自信,让大家清楚的认识到,装饰器学不懂,一定不是你自己没有坚持,一定是文章写的不好,老师教的不好的问题。

我们先来看一下装饰器的简单介绍。

首先我们假设要执行问候这个动作,然后通过函数实现。那么我们就会写一个函数。

def hello():

print('Hello, world!')

hello()

Out:

Hello, world!

然后,我们希望在问候动作执行前,显示动作执行者的名字。那么其中一种方式就是重新编写一下函数。

def hello():

print('Yellow Teeth :')

print('Hello,world!')

hello()

Out:

Yellow Teeth :

Hello,world!

这里看到,显示动作执行者名字的需求已经实现了,但是,突然有一天,动作执行者换了个人,然后要求把动作执行者的名字换成他的,那么,在这种编程方式下,就需要对每一个写了名字的地方进行更改,这就非常麻烦了。所以,有些人提出,可以用参数表示名字嘛,然后就把问候函数进行了这样的修改。

def hello(name):

print(name)

print('Hello,world!')

hello('Red Teeth')

Out:

Red Teeth

Hello,world!

再然后,之前的动作执行者回来了,然后两个动作执行者提出要求,说我每给你讲一次问候都是要钱的啊,你要给我计次。然后就对函数编写了这样一段计次的代码。

counter = 0hello('Yellow Teeth')

counter += 1counter

Out:

Yellow Teeth

Hello,world!

1

这个时候被问候者说了,你们现在有两个人,我要是每次都记一下你们问候,还要写一个代码,而且,还要在你们问候完了,单独给你们计次数,万一出错了呢?这样,你们自己数着自己问候的多少次,这样就不会出错了。于是接下来,计次功能的代码就写进了问候的函数中。

counter = 0def hello(name):

global counter

counter += 1

print(name)

print('Hello, world!')

hello('Yellow Teeth')

hello('Red Teeth')

counter

Out:

Yellow Teeth

Hello, world!

Red Teeth

Hello, world!

2

看了这个解决方案,Red Teeth不开心了,说你这明明算的是我们两个问问候的总数啊,根本就没实现分别计次的方案。

hello('Red Teeth')

counter

Out:

Red Teeth

Hello, world!

3

编程的人一看,确实是啊,那怎么实现呢,这个时候就可以用上装饰器了。我们先来编写一个装饰器。

def greeting_and_counter(func):

counter = 0

def wrapper():

nonlocal counter

counter = counter + 1

print('Yellow Teeth')

print('counter = ', counter)

print(func()) return wrapper

可以看到,装饰器是由两层def组成,最外层函数返回的结果是内层的函数,注意返回的是一个函数。那么我们使用一下装饰器看一下效果。我们先重新定义一下hello函数。

def hello():

return 'Hello, world!'

然后使用装饰器

hello = greeting_and_counter(hello)

hello()

Out:

Yellow Teeth

counter = 1

Hello, world!

hello()

Out:

Yellow Teeth

counter = 2

Hello, world!

看到这,两个动作执行者Yellow Teeth和Red Teeth说,这个记数功能单人的实现了,我们要对不同人能计次。然后编程的人就把代码改成了可以传入名字的装饰器。

def greeting_and_counter(func):

counter = 0

def wrapper(name):

nonlocal counter

counter += 1

print(name)

print('counter = ', counter)

print(func()) return wrapper

我们重新定义一下hello函数

def hello():

return 'Hello, world!'

我们传入不同的名字测试一下,看看能否实现分别计次。

hello_by_yellow_teeth = greeting_and_counter(hello)

hello_by_red_teeth = greeting_and_counter(hello)

hello_by_yellow_teeth('Yellow Teeth')

hello_by_yellow_teeth('Yellow Teeth')

hello_by_yellow_teeth('Yellow Teeth')

hello_by_red_teeth('Red Teeth')

Out:

Yellow Teeth

counter = 1

Hello, world!

Yellow Teeth

counter = 2

Hello, world!

Yellow Teeth

counter = 3

Hello, world!

Red Teeth

counter = 1

Hello, world!

可以看到,分别计次的需求已经实现了。

这个时候我们停下来,一起看一下最近这次修改的程序运行过程,从本次装饰器的定义开始。

1.创建一个函数对象并赋值给greeting_and_counter变量名。大家都知道python是一门动态语言,函数在定义时,内部语句并不运行。

2. 定义要被装饰器修饰的函数hello

3. 调用greeting_and_counter函数,并将hello函数作为参数传入。在greeting_and_counter函数被调用时,首先是当前命名空间,我们先将这个命名空间称为命名空间1,在命名空间1,创建了一个整数0,并将其赋值给了counter变量名,然后定义了wrapper函数,同样,此时wraper函数还是没有运行。最后将wrapper函数返回。

4. 将调用greeting_and_counter函数返回的结果,也就是命名空间1中的wrapper函数返回,并赋值给了变量名hello_by_yellow_teeth

5. 重复3,4过程将另一命名空间2中的wrapper函数赋值给hello_by_red_teeth

6. 调用hello_by_yellow_teeth并传入name参数,由于这个函数是位于装饰器内部的wrapper函数,所以将运行wrapper函数内的代码。wrapper内部,首先对命名空间1中的counter属性进行修改加1,然后打印传入的name,打印counter,最后打印hello函数返回的结果。

7. 按照6步骤运行再两次hello_by_yellow_teeth

8. 运行一次hello_by_red_teeth

我们从结果可以看到,按照这样一种运行方式,已经实现对不同动作执行人的记数要求。当然,对python命名空间比较熟悉的同学会发现,这种编写方式其实更像是一个工厂函数,其实并没有将执行者名称和命名空间通过装饰器绑定,只是构建了两个独立的函数命名空间而已,当然我们可以通过构建可以传入参数的装饰器完成这个操作,但是为了将和装饰器内部编写逻辑这个事情说清楚,这些已经足够了。

如果,使用编辑器的时候都是像我们上面写的hello_by_yellow_teeth = greeting_and_counter(hello)这种方式,那么装饰器还是比较理解的。但是,python里面有一个装饰器的语法糖就是将装饰器的使用写成这样:

@greeting_and_counterdef hello():

return 'Hello, world!'

这种写法可以这样理解:

hello = greeting_and_counter(hello)

那么对于这种更常见的方式进行装饰器使用的方法,对于一个函数,只能返回一个被装饰后的函数并赋值给和原函数相同的变量名。所以如果想通过这种更容易阅读的方式使用装饰器完成之前两个动作执行者分别计次的要求,就需要再进一步改进装饰器的代码。这里面就不再进一步讲解传入参数的装饰器写法。

装饰器的原理已经介绍清楚了,那么对于装饰器的坑,我们就以不带参数的装饰器进行讲解。

首先,我们编写两个装饰器。

def decorator1(func):

print('Enter decorator1') def wrapper1():

print('Enter wrapper1')

print(func())

print('Exit wrapper1')

print('Exit decorator1') return wrapper1

def decorator2(func):

print('Enter decorator2') def wrapper2():

print('Enter wrapper2')

print(func())

print('Exit wrapper2')

print('Exit decorator2') return wrapper2

之所以说在装饰器中会产生一些坑是因为用的人驾驭不了,是因为对于刚刚编写好的decorator1这个装饰器来说,每一个print都会在什么时候打印出来需要花点时间弄明白。那么我们可以看一下。

我们先使用decorator1对一个函数进行装饰

@decorator1def help():

return 'help me'

Out:

Enter decorator1

Exit decorator1

此时运行了enter decorator1和exit decorator1部分,这是最开始初学装饰器非常容易弄错的一点,对于enter decorator 部分还是比较容易理解的,但是调用一个函数的时候定义了另一个函数,然后学习者可能就会忽略掉exit decorator的部分。

然后我们调用help函数

help()

Out:

Enter wrapper1

help me

Exit wrapper1

那么运行在wraper之内的部分就稍微好理解很多,就是正常的一个函数调用时候内部代码的运行顺序,从上到下。

其实说到这里,虽然稍微有点绕,但是还是可以弄清楚然后进行使用的,但是,如果在装饰器各个部分之间内部编写上相互之间配合的逻辑就很容易因为命名空间的问题导致代码运行出错,另外,在装饰器里面的逻辑编写因为其运行顺序,并不是从上到下这样一种人容易理解的顺序,那么如果编写复杂后也很难理解,代码的可读性就会大打折扣。

如果说花些时间,然后冒着被同事打的风险还是可以在装饰器里面编写一些能够理解的逻辑,那么多重装饰器,特别是在装饰器中附带装饰器可能理解起来就真的需要花些精力梳理一下了,例子如下。

@decorator1@decorator2def help1():

print('help me1')

Out:

Enter decorator2

Exit decorator2

Enter decorator1

Exit decorator1

或者这样

def decorator3(func):

print('Enter decorator3') @decorator1

def wrapper3():

print('Enter wrapper3')

print(func())

print('Exit wrapper3')

print('Exit decorator3')

@decorator3def help():

return 'help me3'

Out:

Enter decorator3

Enter decorator1

Exit decorator1

Exit decorator3

那么,这个时候,如果有人还在decorator1里面写了比较复杂的逻辑,而且写错了的话,我想大家就只剩下打人的冲动了。

所以说,对于装饰器,一个非常大的坑就是你觉得你已经掌握的很熟练了,希望弄得与众不同,然后就在一些别人貌似不敢尝试的地方做些文章,最后,即使你还是完成了一个装饰器内部非常复杂的运行逻辑,但是,工作环境中,你就会发现最经常用的还是效率比较高的写法。

以上就是本篇文章介绍的内容了,写到这,本来想将这个系列直接写个文章的下集就结束的,但是发现要是想把一些高级一些的坑讲清楚,都是要花一些时间。那么,我为什么会花这么多精力去写一系列文章,原因有这样几个:一是自己学python时候遇到了无数的坑,初级的坑靠自己,高级的坑也靠自己,虽然可以通过各种的博客和书籍找到相应的解决方案,或者自己根据python的运行原理推出潜在的坑,但是由于网络文章鱼目混珠,观看书籍又如大海捞针,浪费了很多时间,当时我就特别希望有人能够给我讲讲,或者有那么几篇不错的文章,可惜没有,求不得的苦也就没必要让众人重复,算是行善积德;二是介绍一个观点,就是编程必然是一个不断遇到错误的过程,编程的能力不仅仅包括读代码、写代码的能力,还包括debug的能力。如果你不能debug,那么你就不算学会了编程,太多python初学者遇到bug喜欢请教别人,其实放弃了掌握必备技能的机会,算是指明去路;三,也是关键,我长得这么丑,又没文化,写一些好的技术文章也许是我出名最后的机会了。

预约申请免费试听课

填写下面表单即可预约申请免费试听! 怕学不会?助教全程陪读,随时解惑!担心就业?一地学习,可全国推荐就业!

上一篇:Python会不会站在编程语言高峰?
下一篇:编写高质量Python代码的5个优化技巧

2021年Python全套免费视频教程在哪里?

Python编程学习路线

Python最高有几级?

人工智能与语音遥控的区别?

  • 扫码领取资料

    回复关键字:视频资料

    免费领取 达内课程视频学习资料

Copyright © 2023 Tedu.cn All Rights Reserved 京ICP备08000853号-56 京公网安备 11010802029508号 达内时代科技集团有限公司 版权所有

选择城市和中心
黑龙江省

吉林省

河北省

湖南省

贵州省

云南省

广西省

海南省