Effective Python:编写高质量Python代码的59个有效方法
楔子
最近看了一下《Effective Python:编写高质量Python代码的59个有效方法》,觉得还不错,打算把里面的内容全部分享出来。但是说实话,里面有一些观点我个人是不敢苟同的,这些我都会指出来。另外,这本书针对的是Python3.4,而我目前使用的是Python3.7,并且我在介绍这本书的内容的时候,会附加一些我认为比较实用的额外的知识点。
那么下面就开始吧。
用 Pythonic 的方式来思考
一门语言的编程习惯是由用户来确立的,Python开发者用Pythonic这个形容词来描述那种符合特定风格的代码。这种Pythonic的风格既不是非常严密的规范,也不是由编译器强行施加给开发者的规则,而是大家在使用Python语言协同工作的过程中逐渐形成的习惯。可以在解释器中输入import this
查看Python之禅,里面告诉了我们编写代码的准则。
第1条:确认自己所用的Python版本
如果是几年前,那么大家可能还会在Python2、Python3之间徘徊,但是现在可以完全放弃Python2了。
查看Python版本,有如下几种方式:
C:\Users\satori>python --version
Python 3.7.1
在终端中直接输入python --version即可查看对应的Python版本,如果是在Linux上,直接输入python可能对应的是Python2,下面我以我阿里云上的CentOS为例
[root@iz2ze3ik2oh85c6hanp0hmz ~]# python --version
Python 2.7.5
[root@iz2ze3ik2oh85c6hanp0hmz ~]# python3 --version
Python 3.6.8
所以在Linux上系统上,大部分都会自带两个版本的Python,一个2、一个3。
查看Python版本还可以进入到Python解释器里面来进行查看,当然这种方式和python --version没什么区别。
最后我们可以通过导入sys模块,来进行查看
import sys
# 打印的是一个字符串,其实返回的结果就是我们在终端进入python交互式环境时返回的信息
print(sys.version) # 3.6.8 (default, Aug 7 2019, 17:28:10) \n[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)]
# 返回的是一个sys.version_info类型的对象,你可以把它看成是一个namedtuple
print(sys.version_info) # sys.version_info(major=3, minor=6, micro=8, releaselevel='final', serial=0)
# 两种访问方式都可以
print(sys.version_info.major, sys.version_info[0]) # 3 3
print(sys.version_info.minor, sys.version_info[1]) # 6 6
# 假设我们在编写代码的时候,要求Python版本必须大于3.6的时候,就可以这么写
if not sys.version_info >= (3, 6):
raise Exception("版本必须大于3.6")
大家在开发的时候,一定要使用合适的Python版本。
第2条:遵循 PEP8 风格指南
《Python Enhancement Proposal #8》(8号Python增强提案) 便是PEP8,它是针对Python代码格式而编订的风格指南。尽管可以在保证语法正确的前提下随意编写Python代码,但是采用一致的风格来书写可以让代码变得更加易读、更加易懂。
至于PEP8都设定了哪些标准,可以自己去网上查阅,这个我就不写了。而且如果你用的是pycharm这种智能化的编辑器,当你的代码不遵循PEP8格式的时候会自动提示你应该怎么做。而且你还可以使用format,会自动将你的代码整理成PEP8风格。
第3条:了解bytes、str、unicode的区别
个人觉得Python3中最让人舒服的地方就是它解决了字符编码的问题,我们知道在Python3中存在bytes和str两种表示字符序列的类型;bytes的实例包含原始的字节,str的实例则包含unicode字符。
而在Python2中,算了不扯Python2了,只需要知道:Python3中的bytes相当于Python2中的str,Python3中的str相当于Python2中的unicode就行。所以str在Python2和Python3中是具备不同的含义的,当然我们现在只用Python3了,所以就把str当成是一个unicode字符串即可。
但是注意:Python中的unicode字符串没有和特定的二进制编码相关联,如果想将unicode字符串转成原始的字节串、或者说二进制数据,那么我们需要使用encode函数;同理将二进制数据转成unicode字符,则使用decode函数。
无论是encode、还是decode,都要指定一个编码。用相同的编码encode,相同的编码decode。
name = "古明地觉"
# 将unicode字符encode成字节串
bytes_name = bytes(name, encoding="utf-8") # 等价于name.encode("utf-8")
print(bytes_name) # b'\xe5\x8f\xa4\xe6\x98\x8e\xe5\x9c\xb0\xe8\xa7\x89'
# 将字节串再decode成unicode字符串,等价于bytes_name.decode("utf-8")
print(str(bytes_name, encoding="utf-8")) # 古明地觉
# 当我们在decode的时候,一定要使用相同的编码,否则无法正确解码
# 比如我们使用gbk进行decode
try:
print(str(bytes_name, encoding="gbk"))
except UnicodeDecodeError as e:
# 会发现无法decode
print(e) # 'gbk' codec can't decode byte 0xa7 in position 10: illegal multibyte sequence
# 但如果我们不知道这个字节串是使用什么编码进行encode,该怎么办呢?
# 解决办法有两种,一种是将errors参数设置为"ignore",默认是errors="strict"
# 这种方法虽然不会报错,但显然无法正确解码
print(str(bytes_name, encoding="gbk", errors="ignore")) # 鍙ゆ槑鍦拌
# 还有一种办法就是我们使用chardet模块来检测出它的编码是什么
from chardet import detect
# 告诉我们编码是utf-8,然后再进行解码
print(detect(bytes_name)) # {'encoding': 'utf-8', 'confidence': 0.938125, 'language': ''}
"""
另外我们使用requests的时候
res = requests.get(url="http://www.xxx.com")
# 直接调用res.text得到文本可能会出现乱码
# 一般会加上一行代码
res.encoding = res.apparent_encoding
# 然后再使用res.text即可获取到正常的文本内容
# 至于这样可以解决乱码问题,其实requests底层也是使用chardet模块检测出了对应的编码,有兴趣可以自己去查看一下
"""
另外,在Python3中,bytes和str是决不可以混用的
# 两者是不相等的
print("" == b"") # False
# 而且除了==之外,两者不能够进行比较、或者相加组合之类的
还有文件操作
我们打开文件,可以用文本模式打开、还可以用二进制模式打开。
# r:以只读、文本模式打开,这时候会指定编码
with open("1.txt", "r", encoding="utf-8") as f:
print(f.read()) # abc123456
# rb:以只读、二进制模式打开,这时候不指定、也不能指定编码。
# 因为任何东西底层都是二进制模式存储的
with open("1.txt", "rb") as f:
print(f.read()) # b'abc123456'
# w:以只写、文本模式打开,这时候也需要指定编码
with open("1.txt", "w", encoding="utf-8") as f:
# 里面只能写入unicode字符串
f.write("")
# wb:以只写、二进制模式打开,这时候不可以指定编码
with open("1.txt", "wb", encoding="utf-8") as f:
# 里面只能写入二进制字节串
f.write(b"")
总之记住一点:bytes类型的数据和str类型的数据绝对不可以混用,使用前一定要先转化。
第4条:用辅助函数来取代复杂的表达式
Python的语法非常精炼,很容易使用一行代码来实现许多逻辑,我们举个栗子:
from urllib.parse import parse_qs
# 从URL中解码查询字符串
values = parse_qs("red=5&blue=0&green=", keep_blank_values=True)
print(values) # {'red': ['5'], 'blue': ['0'], 'green': ['']}
# 由于可以出现多个值,因此每一个key都对应一个列表
# 我们看到green后面没有东西、或者说空字符串,默认是删除的,
# 加上keep_blank_values=True的话,则进行保留
查询字符串中的某些参数可以有多个值,可以有一个值,还可以有空白值,还有些参数没有出现在查询字符串当中。用get方法在values字典中查询不同的参数即可获取不同的返回值。
print(values.get("red")) # ['5']
print(values.get("green")) # ['']
print(values.get("opacity")) # None
问题来了,我们知道values中的每一个key都对应一个列表,下面我们希望获取列表中的第一个元素。并且,如果第一个元素是空字符串,那么就会返回0、并且如果元素不存在的话也返回0。
from urllib.parse import parse_qs
values = parse_qs("red=5&blue=&blue=12&green=", keep_blank_values=True)
print(values) # {'red': ['5'], 'blue': ['', '12'], 'green': ['']}
print(values.get("red", [''])[0] or 0) # 5
print(values.get("green", [''])[0] or 0) # 0
print(values.get("opacity", [''])[0] or 0) # 0
以上结果都是正确的,如果元素存在则一定是至少包含一个元素的列表,我们取出第一个元素。如果这个元素不是空字符串则为真、or后面就不需要看了;如果是空字符串则为假,会得到我们指定的字符串'0'。
至于我们获取一个不存在的元素,那么默认会得到None,但是对于None来说显然无法获取它的一个元素,因此我们可以直接指定为[''],这样获取到第一个元素一定为假。
但是这样的代码,不是很好阅读,而且有时候也未必符合规范。如果我们想在数学表达式中使用这些参数,那么需要首先转化为整型
red = int(values.get("red", [''])[0] or 0)
这样的写法读起来很困难,并且看起来很乱,并且不容易理解。(但是说句实话,这一点我不赞同,我认为这只是充分的利用了Python语言的特性,并且也不是很长)
Python2.5的时候增加了if-else条件表达式、又称三元操作符,我们可以将上面的逻辑简化一些
red = values.get("red", [''])
red = int(red[0]) if red[0] else 0
这种做法比原来好了一些,对于不太复杂的情况来说,三元操作符可以令代码变得清晰。但是还是比不上跨越多行的if-else条件语句,如果将逻辑改写一下,会更加清晰。
red = values.get("red", [''])
if red[0]:
red = int(red[0])
else:
red = 0
这种看起来就更加直观了,但是如果每一个参数都写一个if-else语句,代码又会显得很冗余,所以我们可以设计一个辅助函数。
def get_first_int(values, key, default=0):
found = values.get(key, [''])
if found[0]:
found = int(found[0])
else:
found = default
return found
print(get_first_int(values, "red")) # 5
print(get_first_int(values, "blue")) # 0
print(get_first_int(values, "green")) # 0
调用这个辅助函数时所使用的代码要比使用or操作的长表达式版本、以及使用if-else表达式的两行版本更加清晰。
表达式如果过于复杂,那就应该考虑将其拆解成小块,并把逻辑嵌入到辅助函数中。这会令代码更容易阅读,比原来那种密集的写法要好。编写Python程序时,不要一味地追求过去紧凑的写法,那样会写出非常复杂的表达式。
下面是我个人的一点感受,这个第4条我个人是无法接受的,主要原因就是长逻辑。个人觉得充分利用Python的语法特性不是件坏事,只要不滥用即可,至少上面的例子使用or 0这种方式我个人觉得是完全没有问题的。
举一个LeetCode刷题的例子,估计有的人不推荐使用Python这种动态语言去刷LeetCode,其实是有一定道理的。一方面是因为Python里面有太多的内置函数,可以很方便地解决问题,对你本身的逻辑的训练起不到太大帮助
(如果你不使用的话则另说)
;另一方面就是一些Pythonic写法甚至比逻辑本身还重要,比如一个for循环,这在其它静态语言中是非常标准的写法,但是在Python中我们推荐使用列表解析,因为它的效率比普通的for循环要高出很多;因此为了希望你能全身投入在逻辑中,所以不推荐你使用动态语言。说了这么多,其实主要想说明:如果能够提高效率,追求Pythonic写法一点问题都没有,只要不滥用即可。如果代码比较长,我们可以通过换行的方式展示的更清晰。
第5条:了解序列切割的办法
Python提供了将序列切成小块的写法,这种切片(slice)操作,使得开发者能够轻易地访问由序列中的部分元素所构成的子集。最简单的用法就是对内置的部分Sequence对象,如:字符串、元组、列表等进行切割。切割操作还延伸到实现了__getitem__
和__setitem__
这两个魔法方法的Python类身上(后面会说)
。
切割操作的基本写法是sequence[start: end],其中start是起始位置、end是结束位置。其实位置从0开始,不包含结束位置。
sequence = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
print("前四个元素:", sequence[: 4]) # 前四个元素: ['a', 'b', 'c', 'd']
print("后四个元素:", sequence[-4:]) # 后四个元素: ['d', 'e', 'f', 'g']
print("中间三个元素:", sequence[2: -2]) # 中间三个元素: ['c', 'd', 'e']
# 如果从开头获取切片,那么可以不在start哪里写上0
# 两种方式等价
assert sequence[: 4] == sequence[0: 4]
# 同理end也是如此
assert sequence[4:] == sequence[4: len(sequence)]
# 我们在使用索引的时候,除了使用正数、也可以使用负数
# 比如:['a', 'b', 'c', 'd', 'e']它们的正数索引分别是:0 1 2 3 4
# 至于负数索引,则是对应的正数索引减去整个序列的长度,也就是-5 -4 -3 -2 -1
# 我们在指定的时候可以使用正数,也可以使用负数
sequence = ['a', 'b', 'c', 'd', 'e']
# 不指定start和end,则是从头取到结尾
print(sequence[:]) # ['a', 'b', 'c', 'd', 'e']
print(sequence[1: 3] == sequence[1: -2]) # True
print(sequence[-3: -1]) # ['c', 'd']
# 取值是从前往后取,所以start 要小于 end,否则取不到值
print(sequence[-1: -3]) # []
# 如果是切片的话,即便越界也不会报错
print(sequence[3: 20]) # ['d', 'e']
print(sequence[10: 20]) # []
# 但是访问单个元素的时候,索引则不可以越界,否则会抛出IndexError
我们知道Python中的变量、以及序列中存储的都是对象的引用(或者理解为一个PyObject *,如果你了解python解释器底层的话)
。对原来的列表进行切割之后,那么会得到一个新的列表。但是新列表里面的引用和原列表里面的引用指向的都是相同的对象,也就是说切片这种操作只是将引用拷贝了一份,引用指向的值并没有拷贝。
sequence = [1, "古明地觉", {"a": 1}]
new_seq = sequence[1:]
print(new_seq) # ['古明地觉', {'a': 1}]
print(new_seq[0] is sequence[1]) # True
print(new_seq[1] is sequence[2]) # True
如果是对列表里面的可变对象进行本地修改,那么对任何一个列表操作都会影响另一个,因为它们指向的是同一个对象;如果是重新赋值,那么相当于指向了新的对象,修改其中一个列表不会影响另一个列表。
sequence = [1, "古明地觉", {"a": 1}]
new_seq = sequence[1:]
# 修改new_seq中的字典,我们说字典、列表、集合这些元素
# 它们是支持本地修改的,所以叫做可变对象,但是字符串、整型、元组不支持本地修改,所以是不可变对象
new_seq[1]["b"] = 2 # 增加一个键值对
# 我们看到sequence已经变了
print(sequence) # [1, '古明地觉', {'a': 1, 'b': 2}]
"""
因为传递的话,会传递引用;但是操作引用,则本质上是操作引用指向的对象
而sequence的索引为2的元素和new_seq中索引为1的元素都指向了同一个字典,因此对任何一个做操作都会影响另一个
"""
# 但如果是重新赋值的话,是不会影响的
new_seq[1] = 123
# 此时sequence还是原来的
print(sequence) # [1, '古明地觉', {'a': 1, 'b': 2}]
"""
重新赋值的话,相当于该引用指向了新的对象
但是原来的引用还指向原来的对象,两者没有任何关系。
所以对于不可变对象来讲,由于它无法本地操作,所以无论如何都不会产生印象
对于可变对象,如果本地操作会影响;但是重新赋值,则不会,因为是指向了新的对象
"""
比如女孩A和女孩B指向了同一个男票,一天女孩A给男票左脸删了一耳光,那么女孩B见到男票的时候左脸肯定是被删过的,因为它们指向了同一个男票。这时候女孩B为了对称,又在男票右脸删了一耳光,那么女孩A再见到男票的时候,肯定是左脸右脸都被删过的。
而某天男孩脚踩两只船的事情暴露了,被B发现了,于是女孩B和男票分手了,又找到了新的男票。但是对于A,则还是原来的男票,因为B找新男票跟A没有关系。而这时候无论B对新男票做什么,都不会影响到A的男票,A对男票做什么也不会影响到B的男票,因为此时她们指向的是不同的对象。
下面再来看看切片的赋值操作
seq = ['a', 'b', 'c', 'd', 'e']
seq[1: 4] = [1, 2, 3]
print(seq) # ['a', 1, 2, 3, 'e']
# 使用slice替换的时候,个数可以不一样,但是右边一定是一个序列
seq[:] = [1]
# 此时seq整体就变成了[1]
print(seq) # [1]
# 其实python删除元素也是这么做的
# 比如删除索引为3的元素,就是将[3: 4]的位置换成空列表
seq = ['a', 'b', 'c', 'd', 'e']
seq[3: 4] = []
print(seq) # ['a', 'b', 'c', 'e']
# 另外,python中还有一个类slice
# seq[start: end] == seq[slice(start, end)]
print(seq[: 2] == seq[slice(2)])
print(seq[1: 3] == seq[slice(1, 3)])
"""
如果slice接收一个参数:那么代表stop
接收两个参数:那么代表start,stop
"""
# 所以seq[1:]这种方式,只能使用seq[slice(1, len(seq))]
print(seq[1:] == seq[slice(1, len(seq))]) # True
第6条:在单次切片操作中不要同时指定start、end、step
我们在第5条中介绍了基础的切片操作,我们切片的形式是seq[start: end]或者seq[slice(start, end)],但其实完整的形式应该是seq[start: end: step]。这个step就是步长,默认为1。
seq = ['a', 'b', 'c', 'd', 'e', 'f']
# 我们说这是从开头取到结尾,每一次取出一个。
# 因为step默认为1,每次取出一个会调到相邻的后一个
print(seq[:]) # ['a', 'b', 'c', 'd', 'e', 'f']
# 此时指定步进值为2,每次取出一个就会跳到相邻的后两个
# 从索引为0的地方开始取,然后取索引为2、索引为4、索引为6...的元素,如果越界则停止
# 索引我们这里相当于实现了取索引为偶数的元素
print(seq[:: 2]) # ['a', 'c', 'e']
# 同理,下面实现筛选索引为奇数的元素
# 取出的元素的索引分别为1、3、5
print(seq[1:: 2]) # ['b', 'd', 'f']
# 我们之前说start必须小于end,否则获取不到元素
print(seq[1: 4], seq[4: 1]) # ['b', 'c', 'd'] []
# 那是因为step默认是1,是正数,所以是从前往后的
# 如果我们指定为负数呢?
print(seq[4: 1: -1]) # ['e', 'd', 'c']
# 我们看到指定为负数,表示从后往前获取
# 以上操作就等价于
def select(lst, start, end, step=1):
ret = []
if step == 0:
raise ValueError("step cannot be zero")
elif step > 0:
while start < end:
try:
item = lst[start]
ret.append(item)
except IndexError:
return ret
start += step
else:
while start > end:
try:
item = lst[start]
ret.append(item)
except IndexError:
return ret
start += step
return ret
# 当然还可以省略start、stop,我们这个函数就没有处理了
# 有兴趣可以自己完善一下
print(seq[1: 40], select(seq, 1, 40)) # ['b', 'c', 'd', 'e', 'f'] ['b', 'c', 'd', 'e', 'f']
print(seq[4: 1: -1], select(seq, 4, 1, -1)) # ['e', 'd', 'c'] ['e', 'd', 'c']
以上的方法同样作用于slice,我们可以像slice传递三个参数。注意:不可以传两个,如果传两个那么代表start和end,可能有人想到了关键字。slice里面不可以通过关键字参数传递。
这个方法这么方便,那么为什么不推荐使用呢?
这本书说的是"指定了step,代码会变得让人费解,并且写起来比较拥挤",但我说句实话哈,这在numpy里面是再常见不过的操作了,总觉得这本书有点太刻意了。
此外,这种方式不适合非ascii字节串。
seq = "古明地觉"
# unicode字符串是可以的
print(seq[:: -1]) # 觉地明古
# ascii字节串也是可以的
print(b"abc"[:: -1].decode("utf-8")) # cba
# 但是非ascii字节串往往不行,并不是说不能使用[:: -1]这种操作
# 而是使用之后没办法再decode
print(bytes("你好呀", encoding="utf-8")[:: -1]) # b'\x80\x91\xe5\xbd\xa5\xe5\xa0\xbd\xe4'
try:
bytes("你好呀", encoding="utf-8")[:: -1].decode("utf-8")
except UnicodeDecodeError as e:
print(e) # 'utf-8' codec can't decode byte 0x80 in position 0: invalid start byte
很好理解,因为汉字一个字符串占三个字节,假设"你好啊"编码得到的是"abc ijk xyz",那么翻转就变成了"zyx kji cba",这样是无法正确解码的,应该是"xyz ijk abc"。所以为什么ascii字节串翻转之后可以decode,就是因为它每个字符只用一个字节进行存储。
s = bytes("你好呀", encoding="utf-8")[:: -1]
s = s[0: 3][::-1] + s[3: 6][::-1] + s[6:][::-1]
print(s.decode("utf-8")) # 呀好你
如果每三个字节作为整体进行翻转是可以的
此外在itertools中有一个类islice和切片比较类似,但它返回的是一个迭代器。事实上itertools这个模块返回的基本上都是迭代器,从模块的名字上也可以看出来。目的就是省内存
from itertools import islice
seq = ['a', 'b', 'c', 'd', 'f']
# 如果只指定一个参数,那么相当于结束位置
print(list(islice(seq, 2))) # ['a', 'b']
# 如果指定两个参数,那么相当于起始位置和结束位置
print(list(islice(seq, 2, 4))) # ['c', 'd']
# 如果指定三个参数,那么相当于指定起始位置、结束位置、步长
print(list(islice(seq, 0, 4, 2))) # ['a', 'c']
# 我们看到参数的传递和slice这个类非常相似,几乎是一样的
# 只是islice返回的是一个迭代器
# 除此之外,对于islice来说,它不支持负数索引或者步长
"""
为什么islice不支持负数呢?事实上很好理解,因为它返回的是一个迭代器。
就是为了省内存,比如读取大文件,我们通过islice(f, 10, 50)既可在不加载全部文件的情况下,读取指定行
但如果使用负数索引,那么就必须全部读取,才知道-1究竟代表多少,如果全部读取的话,那么islice不就失去意义了吗
所以islice不支持负数索引
"""
第7条:用列表解析式来取代map和filter
python提供了一种精炼的写法,可以根据一个列表生成另一个列表。这种表达式称之为列表解析(list comprehension)
。比如:我们要将列表里面的每一个元素(整型)
,变成它的平方。
lst = [1, 2, 3, 4, 5]
print([item ** 2 for item in lst]) # [1, 4, 9, 16, 25]
# 如果使用map函数,返回的是迭代器
# 想要查看需要转换成list,或者tuple等等,将里面的值都迭代出来
print(map(lambda x: x ** 2, lst)) # <map object at 0x00000205A8702610>
print(list(map(lambda x: x ** 2, lst))) # [1, 4, 9, 16, 25]
如果我们希望过滤掉某个元素的话
lst = [1, 2, 3, 4, 5]
# 这里只选择奇数
print([item for item in lst if item % 2 != 0]) # [1, 4, 9, 16, 25]
# filter返回的也是迭代器,会自动将满足条件的值筛选出来
print(filter(lambda x: x % 2 != 0, lst)) # <filter object at 0x00000226EAF32610>
print(list(filter(lambda x: x % 2 != 0, lst))) # [1, 3, 5]
如果我们希望过滤掉某个元素的同时,还进行平方的话
lst = [1, 2, 3, 4, 5]
print([item ** 2 for item in lst if item % 2 != 0]) # [1, 9, 25]
# 此时需要map和filter结合
print(list(
map(lambda x: x ** 2, filter(lambda x: x % 2 != 0, lst))
)) # [1, 9, 25]
使用列表解析会比使用map和filter,更加清晰。但是其实两者实现的功能是类似的,只不过python是先有map、filter,然后才有的列表解析、生成器解析,以及集合解析和字典解析。
至于要不要使用map和filter,看你自己的了,我个人是蛮喜欢用的。另外关于两者的效率问题,其实map在很多时候是比列表解析要高一些的。
%timeit [str(i) for i in range(1000000)]
194 ms ± 1.73 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit list(map(str, range(1000000)))
158 ms ± 1.38 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
但是下面就不一定了
%timeit [i ** 2 for i in range(1000000)]
211 ms ± 1.22 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit list(map(lambda x: x ** 2, range(1000000)))
246 ms ± 689 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)
我们看到此时列表解析效率比map要高,其原因就是map里面的第一个参数必须要传递一个函数或者类,而函数的调用要涉及参数数量检测、栈帧的创建、销毁等等,是需要额外的开销的。所以在python中lst = []比lst = list()的效率要高,其原因就是前者直接使用的内置的数据结构,而后者是一个function call。
%timeit [(lambda x: x ** 2)(i) for i in range(1000000)]
303 ms ± 1.93 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
如果列表解析里面也使用函数的话,相比map就处于劣势了。所以列表解析的优势就在于里面可以是函数调用、也可以是一个普通的表达式。如果是普通的表达式,那么使用列表解析,如果是函数调用,那么使用map。
除了列表解析,还有生成器解析,集合解析,字典解析
lst = [1, 2, 3, 4, 5]
# 生成器解析,直接换成括号即可
t = (x for x in lst)
print(t) # <generator object <genexpr> at 0x000001FF9BAC7890>
# 一个小技巧,我们知道生成器解析是小括号,而函数调用也是小括号
# 那么就可以简写成如下,无需使用print((x for x in lst))
print(x for x in lst) # <generator object <genexpr> at 0x000001FF9BAC7890>
print(sum(x for x in lst if x % 2 != 0)) # 9
# 集合解析,直接把中括号换成{}即可
# 会进行去重
print({x for x in [1, 1, 2, 2, 3]}) # {1, 2, 3}
# 字典解析,也是{},只不过元素是键值对
print({x: x + 1 for x in [1, 1, 2, 4]}) # {1: 2, 2: 3, 4: 5}
# key相同,后面的会将前面的顶掉
print({x: y for x, y in [(1, 1), (1, 2), (2, 3)]}) # {1: 2, 2: 3}
第8条:不要使用含有两个表达式以上的列表推导
除了基本的用法之外,列表推导也支持多重循环,比如说:
lst = [[1, 2, 3], [4, 5, 6]]
print([x for row in lst for x in row]) # [1, 2, 3, 4, 5, 6]
# 上面等价于
new_lst1 = []
for row in lst:
for x in row:
new_lst1.append(x)
print(new_lst1) # [1, 2, 3, 4, 5, 6]
# 或者
new_lst2 = []
for row in lst:
new_lst2.extend(row)
print(new_lst2) # [1, 2, 3, 4, 5, 6]
受到extend的启发,笔者曾经这么试过:
lst = [[1, 2, 3], [4, 5, 6]]
print([*row for row in lst])
然而这么做是不允许的,编译都不会通过,会报出SyntaxError: iterable unpacking cannot be used in comprehension
,意思是不能在解析式中使用可迭代对象解包这种方式。
列表解析这种方式可以做很多事情
lst = [[0, 1, 2, 3], [4, 5, 6], [7, 8], 9]
# 比如我需要实现如下逻辑,对列表lst里面的每个元素执行如下操作
# 如果该元素不是列表,那么不要。
# 如果该元素也是列表,并且元素个数大于2,那么每个元素变成原来的平方,结构不变
# 如果该元素也是列表,并且元素个数不大于2,那么每个元素加1,结构不变
print(
[[x ** 2 for x in row] if len(row) > 2 else [x + 1 for x in row] for row in lst if isinstance(row, list)]
)# [[0, 1, 4, 9], [16, 25, 36], [8, 9]]
# 除此之外,里面还可以嵌入三元表达式
# 如果元素不是列表,不要;列表长度为4,每个元素乘以4;长度为3,每个元素乘以三;元素个数为2,每个元素乘以2
print(
[[x * 4 for x in row] if len(row) == 4 else [x * 3 for x in row] if len(row) == 3 else [x * 2 for x in row]
for row in lst if isinstance(row, list)]
) # [[0, 4, 8, 12], [12, 15, 18], [14, 16]]
for循环后面只可以跟一个if,用来筛选满足if条件的元素。然后对每个元素我们可以执行三元表达式操作,其实三元表达式最终得到的也是一个元素,就把它看成是一个元素即可。当然列表解析是可以支持多级循环的,如果循环得到的元素还是一个列表,那么可以继续使用循环,并且if后面也可以跟多个条件,使用and或or组合起来进行筛选,具体可以自己尝试。
列表解析式确实不要嵌套特别多,最好是不超过两个,并且如果代码长的话记得分行显示。
第9条:用生成器解析来改写数据量较大的列表解析
列表解析的缺点是:在推导过程中,需要遍历序列中的每个值。当输入的数据量比较少时,不会出问题,但如果数据量非常多,那么就会消耗掉大量的内存,甚至会导致程序运行崩溃。
比如我想统计一个大文件里面每一行的字数,我们就可以使用生成器解析。
values = (len(x) for x in open("1.txt"))
print(values.__next__()) # 89
print(values.__next__()) # 54
生成器解析会立刻返回一个生成器,它没有立刻执行,而是等到我们调用__next__()
的时候会将值一个一个的迭代出来,但是只能迭代一轮,一轮过后就不能再迭代了。
解析式是可以嵌套的
lst = [1, 2, 3, 4, 5]
print(
[x for x in [x ** 2 for x in lst] if x < 16]
) # [1, 4, 9]
print(
(x for x in (x ** 2 for x in lst) if x < 16)
) # <generator object <genexpr> at 0x000001AF33EC1F90>
gen = (x for x in (x ** 2 for x in lst) if x < 16)
print(gen.__next__()) # 1
print(gen.__next__()) # 4
外围生成器前进的时候,都会推动内部的生成器前进,这样就形成了连锁效应,使得执行循环、评估条件表达式、对接输入和输出等逻辑等组合在了一起。
生成器表达式执行的速度很快,如果是操作大批量的数据,最好使用生成器来实现。
下面来看看关于生成器解析的三道题,坑向的。
s = "abc"
gen = (s + c for c in s)
s = "123"
print(list(gen)) # ['123a', '123b', '123c']
这个例子很简单,就是将s和s的每一个元素进行组合。从结果上来看,我们遍历的s显然是"abc",但是我们执行s + c中的s却是"123"。为什么?
我们说,对于生成器表达式来讲,尽管它不会立刻执行,但是in后面的参数是已经确定好了的。在gen = (s + c for c in s)
这段代码执行之后,in后面的对象就已经确定了,是"abc"。但是前面的s,究竟是什么,只有当真正产出值的时候才会去找这个s究竟是什么。
s = "abc"
gen = (s + c for c in s)
s = "123"
print(gen.__next__()) # 123a
# 如果再次将s给换掉,那么也会使用新的s
# 但是in后面的是不会变得,在我们还没有产出值的时候就已经指向了字符串"abc"
s = "666"
print(gen.__next__()) # 666b
# 这段代码是不会报错的,因为它不会检测"憨八嘎"这个变量究竟代表什么
(憨八嘎 for c in s)
try:
# 只有当执行的时候才会去找"憨八嘎"这个变量
(憨八嘎 for c in s).__next__()
except NameError as e:
print(e) # name '憨八嘎' is not defined
# 但如果将"憨八嘎"写在in的后面就不行了
try:
# 尽管此时没有执行,但是in后面的变量到底是什么,会提前确定好
# 检测发现没有"憨八嘎"这个变量,所以报错
(x for x in 憨八嘎)
except NameError as e:
print(e) # name '憨八嘎' is not defined
然后看看第二道题,网上流传的比较经典的一道题。
gen = (x for x in [1, 2, 3, 4])
for x in [1, 10]:
gen = (x + y for y in gen)
print(list(gen)) # [21, 22, 23, 24]
可能有人分析的是[12, 13, 14, 15],至于答案为什么不是这个,我们来分析一下。我们说x + y,这个y是遍历gen得到的,而gen最初遍历[1, 2, 3, 4]得到的,所以这个y是确定的,但是x则不是,这个x是什么只有当生成值的时候才会去找。所以当第一次循环的时候,gen相当于是generator<x+1, x+2, x+3, x+4>
,当第二次循环的时候,gen相当于是generator<2*x+1, 2*x+2, 2*x+3, 2*x+4>
,当我们调用list的时候,去找这个x是什么,由于此时循环执行完毕,x是10。因此结果是[21, 22, 23, 24],而不是[12, 13, 14, 15]。
网上给出的关于这道题的例子,到此就结束了,但我个人又扩展了一下,看看你会不会掉进去。
gen = (x for x in [1, 2, 3, 4])
for x in [1, 10]:
gen = (x + y for y in gen)
for x in gen:
print(x)
"""
21
44
91
186
"""
为什么会是这个结果?这里要提一句for循环时候的变量问题。首先for循环的机制是,如果in后面的是一个迭代器或者生成器,那么直接调用__next__()
;如果不是迭代器,而是一个可迭代对象,那么会先调用__iter__()
,将其变成一个迭代器,然后再调用__next__()
方法将值一个一个的迭代出来。比如:for i in [1, 2, 3]
,那么会先将这个列表变成一个迭代器,然后调用__next__()
将1迭代出来,并赋值给i(注意:是先迭代再赋值)
;第二轮再将2迭代出来,赋值给i,第三轮将3迭代出来赋值给i。然后迭代第4轮,发现迭代器没有值了,于是抛出StopIteration异常,然后for循环捕捉,终止循环。
i = 100
# 这里的i是全局的
# 当我们进行第一次迭代的时候,i = 100,就已经变成了 i = 1
# 循环结束i = 3
for i in [1, 2, 3]:
pass
print(i) # 3
# 由于in后面是一个空列表,所以没有迭代出值
# 而我们说是先将值迭代出来,然后赋值给循环变量
# 但是我们说这里没有迭代出值,所以也就不存在j这个变量
for j in []:
pass
try:
print(j)
except NameError as e:
print(e) # name 'j' is not defined
说了这么多,只是为了表示在全局for循环使用的循环变量等价于全局变量,或者说for循环使用的循环变量在该for循环所处的整个作用域都是可见的,如果成功进入循环,那么后面的代码在相应的作用域中是可以直接使用这个循环变量的。
那么回到这个这个例子,我们来分析一下为什么结果又变了
gen = (x for x in [1, 2, 3, 4])
for x in [1, 10]:
gen = (x + y for y in gen)
for x in gen:
print(x)
"""
21
44
91
186
"""
我们说上面的循环结束,gen等价于generator<2*x+1, 2*x+2, 2*x+3, 2*x+4>
,那么当我们第一次遍历,得到的是21,这没有问题,但是注意:我们将21赋值给了谁,或者说我们循环变量用的是谁?是x,而我们说此时的x相当于是全局变量,因为该for循环是位于全局中的,所以下一轮循环再去找x的时候,x已经是21了,不是原来的10,所以第二次迭代得到的结果是21 * 2 + 2 = 44
,同理第三次迭代44 * 2 + 3 = 91
,第四次迭代91 * 2 + 4 = 186
如果使用列表解析的话:
gen = (x for x in [1, 2, 3, 4])
for x in [1, 10]:
gen = (x + y for y in gen)
print([x for x in gen]) # [21, 22, 23, 24]
为什么结果又变回和原来一样了,因为对于解析式来讲(无论生成器解析、还是列表解析)
,for循环使用的循环变量是被保护的,它作用域只存在于对应的解析式中,是不会影响外部的变量的。比如:[x for x in [1, 2, 3]]
,如果你外面不存在x这个变量,那么当解析式执行完毕时,后面的代码也无法使用x这个变量,因为这个x只在当前的解析式中有效,它是被隔离的。而gen在产出值的时候,去找x也不会受列表解析中变量的x的影响,它找到依旧是外面的x,因此x依旧是10。
再来看看第三道题。
def foo():
x = 1
gen = (x for _ in [1, 2, 3])
return gen
print(list(foo())) # [1, 1, 1]
class A:
x = 1
gen = (x for _ in [1, 2, 3])
def foo(self):
x = 1
gen = (x for _ in [1, 2, 3])
return gen
print(list(A().foo())) # [1, 1, 1]
try:
list(A.gen)
except NameError as e:
print(e) # name 'x' is not defined
为什么函数、方法都可以,但是在类里面就找不到x这个变量了?事实上不仅是生成器解析找不到,列表解析也找不到。而且如果是列表解析,那么由于类变量在类创建的时候就已经执行了,所以会直接报错,都不给你实例化的机会。而生成器解析,由于其特性,当我们生成值的时候才会报错。但是不管怎么样,我们的问题是为什么会出现这个结果呢?
事实上,如果解析式作为类变量的话,那么它会在类的外部执行。也就是说它的作用域是在类的外面,而不是类的里面,因此类里面的变量是找不到的。
class A:
x = 2
class B:
x = 1
gen = (x for _ in [1, 2, 3])
try:
print(A.B.gen.__next__())
except NameError as e:
print(e) # name 'x' is not defined
我们看到依旧是未定义,我们说作用域在类的外面实际上是不太准确的,作用域应该是全局变量
class A:
x = 2
class B:
x = 1
gen = (x for _ in [1, 2, 3])
x = "憨八嘎"
print(list(A.B.gen)) # ['憨八嘎', '憨八嘎', '憨八嘎']
那么有没有更加优雅的办法解决这个问题呢?
class A:
x = 1
# 我们通过指定A.x,来显式的指定给我找A下面的x
# 如果你用的是pycharm这种智能编辑器,你会发现A下面飘红了,因为在类里面是没办法使用类名的。
# 但我们这里为什么可以使用呢?就是因为这个表达式是在类的外部执行的,所以才可以找到A
gen = (A.x for _ in [1, 2, 3])
print(list(A.gen)) # [1, 1, 1]
class A:
class B:
x = 2
# 同理这里就不可以写B.x了,因为这个解析式是在类(最外层)的外面,也就是全局中执行的
# 所以需要是A.B.x,如果是B.x,那么是找不到的
gen = (A.B.x for _ in [1, 2, 3])
print(list(A.B.gen)) # [2, 2, 2]
# 或者使用lambda表达式
class B:
x = 1
# 这个是可以的,因为在拿到外层执行的时候,直接把这里的x也带走了
gen = (lambda x: (A.x for _ in [1, 2, 3]))(x)
print(list(B.gen)) # [1, 1, 1]
最后看一道笔者工作中遇到的一个坑,当时把我搞懵了半天。
# 我们知道eval这个内置函数,就是把字符串给剥掉,将字符串里面的内容当成表达式来执行
# 比如:
a = "憨八嘎"
# eval("a") 等价于a,所以下面就等价于print(a)
print(eval("a")) # 憨八嘎
# 同理eval(eval(a))就报错了,因为eval(eval(a))等价于eval("憨八嘎"),然后等价于 憨八嘎
# 而 憨八嘎 这个变量显然没有定义
try:
eval(eval(a))
except NameError as e:
print(e) # name '憨八嘎' is not defined
print(eval("1 + 1")) # 2
name = "憨八嘎"
l = ["name"]
# eval(f"{l[0]}") 等价于 eval("name") 等价于 name,结果是"憨八嘎"
print(eval(f"{l[0]}")) # 憨八嘎
# eval(f"{l[0]!r}") 等价于 eval("'name'"),结果是'name'
print(eval(f"{l[0]!r}")) # name
# 如果加上了!r,那么我们需要再调用一次eval
print(eval(eval(f"{l[0]!r}"))) # 憨八嘎
前置的知识介绍完毕,下面看问题。
def foo1():
name = "hanser"
return eval("name")
print(foo1()) # hanser
# 这没有问题,我们继续
def foo2():
name = "hanser"
return [name + _ for _ in ["小天使", "陈阿姨"]]
print(foo2()) # ['hanser小天使', 'hanser陈阿姨']
# 这也没有问题,我们再继续
def foo3():
name = "hanser"
return [eval("name") + _ for _ in ["小天使", "陈阿姨"]]
try:
foo3()
except NameError as e:
print(e) # name 'name' is not defined
# why?为什么没有定义
def foo4(name="hanser"):
return [eval("name") + _ for _ in ["小天使", "陈阿姨"]]
try:
foo4()
except NameError as e:
print(e) # name 'name' is not defined
# 作为参数也不行,依旧发现name找不到
当初遇到的问题就类似于上面这样,可把我坑惨了,差点让我一度认为是python解释器出了问题,但想想也觉得不太可能。但当时就觉得整个人都不好了,其实这个问题我要冷静下来是可以找到的,python解释器源码我是读过的。然而当时产品已经上线了,结果突然给我来了这么一出,把我给整懵了,我是始终没有找到问题。明明这个变量定义了呀,为什么找不到呢?后来我不知道咋回事,将列表解析改成了普通的for循环,然后成功了。
def foo3():
name = "hanser"
ret = []
for _ in ["小天使", "陈阿姨"]:
ret.append(eval("name") + _)
return ret
print(foo3()) # ['hanser小天使', 'hanser陈阿姨']
后来冷静下来之后,我定位了原因。我们知道全局变量的存储是通过字典的方式动态存储的,所有的变量和值都以键值对的形式存储在global命名空间里面,我们通过globals()可以拿到这个字典,比如:a = 1
,就等于向global命名空间中加入了"a": 1
这个键值对,同理globals()["b"] = "xxx",就相当于创建了变量b = "xxx";但python函数中的变量是通过静态方式存储的,函数内部有哪些变量在编译成字节码的时候就已经确定了,而对于列表解析来说,如果里面是eval,那么就跟上面第三个问题一样,也会忽略掉当前的作用域,去全局变量中找。注意:是全局变量,闭包也不行。
第10条:尽量使用enumerate取代range
enumerate比较简单,不用我多说。
for _ in enumerate(["hanser", "yousa"]):
print(_) #
"""
(0, 'hanser')
(1, 'yousa')
"""
for _ in enumerate(["hanser", "yousa"], 1):
print(_) #
"""
(1, 'hanser')
(2, 'yousa')
"""
其实我们也可以自己实现一个enumerate
from itertools import count
def my_enumerate(seq, start=0, step=1):
return zip(seq, count(start, step))
lst = ["憨八嘎", "hanser", "小天使", "烤箱", "陈阿姨"]
print(
[_ for _ in my_enumerate(lst)]
) # [('憨八嘎', 0), ('hanser', 1), ('小天使', 2), ('烤箱', 3), ('陈阿姨', 4)]
print(
[_ for _ in my_enumerate(lst, 1)]
) # [('憨八嘎', 1), ('hanser', 2), ('小天使', 3), ('烤箱', 4), ('陈阿姨', 5)]
print(
[_ for _ in my_enumerate(lst, 1, 2)]
) # [('憨八嘎', 1), ('hanser', 3), ('小天使', 5), ('烤箱', 7), ('陈阿姨', 9)]
比较简单,没啥可说的。
第11条:用zip函数同时遍历两个迭代器
这个zip函数,也非常简单,个人觉得也没有什么可以值得细说的。
name = ["hanser", "憨八嘎", "陈小只"]
# 统计每个元素的长度,并进行组合
print(
zip(name, map(len, name))
) # <zip object at 0x00000295703D4E00>
print(
list(zip(name, map(len, name)))
) # [('hanser', 6), ('憨八嘎', 3), ('陈小只', 3)]
print(
dict(zip(name, map(len, name)))
) # {'hanser': 6, '憨八嘎': 3, '陈小只': 3}
像map、filter、zip等内置函数,为了节省内存,在python3中都返回的是迭代器。
name = ['a', 'b', 'c', 'd']
age = [1, 2, 3]
print(list(zip(name, age))) # [('a', 1), ('b', 2), ('c', 3)]
# 我们看到在使用zip的时候,如果有一方结束了,那么整体就停止了
# 如果想以长的为基准,那么可以使用itertools中zip_longest
from itertools import zip_longest
# 没有对应元素匹配的则使用None填充
print(list(zip_longest(name, age))) # [('a', 1), ('b', 2), ('c', 3), ('d', None)]
# 我们还可以通过fillvalue自己指定填充的值,必须通过关键字传递,否则它代表和name、age进行组合
print(list(zip_longest(name, age, fillvalue="未知"))) # [('a', 1), ('b', 2), ('c', 3), ('d', '未知')]
第12条:不要在for和while循环后面写else块
python提供了一个别的大多数语言都没有的语法,那就是可以在循环后面编写else块。
我们知道出现else的地方,一般会有三种情况。
if-else:要么这样,要么那样。要么if要么else
for-else、while-else:如果不这样,就别想那样。如果for、while没有执行完,就别想执行else
try-except-else:要是没这样,那就那样吧。如果except没有捕获到异常,那么执行else
我们看看循环后面接else
for i in range(1, 10):
pass
else:
print("循环执行完毕") # 循环执行完毕
for i in range(1, 10):
if i == 9:
break
else:
print("循环执行完毕")
for循环和while循环都是一样的,只要循环没有执行完,那么就别想执行else。怎么才算循环结束呢?对于for循环来说,当下一次迭代产生StopIteration异常、被for循环捕捉到、然后退出时才算执行完毕;而对于while循环来讲,当下一次循环时,由于检测到条件不满足、而退出while循环,才算执行完毕。
或者说,只要循环退出、并且不是因为break而导致的退出,都算执行完毕了。
for i in range(1, 10):
continue
else:
print("循环执行完毕") # 循环执行完毕
while i < 100:
i += 1
continue
else:
print("执行完毕") # 执行完毕
所以这是一个很特别的语法,它指的是当循环执行完毕的时候,才会执行else。很多初学者会觉得它是循环不成功,也会执行else,其实恰好相反。
如果没有进入循环,那么会立即执行else块。
for i in []:
continue
else:
print("循环执行完毕") # 循环执行完毕
i = 100
while i < 0:
pass
else:
print("执行完毕") # 执行完毕
而该本书之所以不推荐这种写法,就是因为容易让人误解。(我说句实话,如果这么小心翼翼的话,干脆就别用Python了)。
个人认为,这个语法使用起来完全没有问题。
第13条:合理使用try-except-else-finally结构中的每个代码块
python异常处理可以考虑四种时机,这些时机可以用try、except、else、finally来表述。并且一旦出现了try:,那么必须出现except,可以出现多次,至于else和finally出不出现均可。
try:
pass
except NameError:
pass
except ValueError:
pass
except Exception:
pass
else:
pass
finally:
pass
把我们可能引发异常的代码块放在try语句里面,然后使用except捕捉异常。如果except捕捉不到,那么异常还是会抛出,所以我们可以多写几个except,跟上具体的异常,这样能够精确定位错误原因。如果还存在其它未知的原因,那么最后面可以使用except Exception,也就是万能异常来确保程序不会挂掉。如果所有的except语句都没有执行(注意:是所有的except)
,那么会执行else,所以为了减少try语句块的代码量,我们可以把一定不会出错的代码放在else语句块里面,finally则是无论什么情况,都会执行该语句块。所以finally一般用于资源的释放,清理等等。
try:
1 / 0
except NameError as e:
print(e)
except ZeroDivisionError as e:
print(e) # division by zero
except Exception as e:
print(e)
else:
print("我是else,我执行了")
finally:
print("我是finally,我执行了") # 我是finally,我执行了
这里看一下返回值的问题
def foo1():
try:
return 123
except Exception:
return 456
def foo2():
try:
return 1 / 0
except Exception:
return 456
def foo3():
try:
return 123
except Exception:
return 456
finally:
return 789
print(foo1()) # 123
print(foo2()) # 456
print(foo3()) # 789
如果没有finally,那么在没有出异常的情况下,结果就是try指定的返回值,如果出异常,那么是except指定的返回值。至于else,只要try和except都指定了返回值,那么无论什么时候都轮不到else,可以仔细想想为什么?如果执行了finally,那么不好意思,finally表示:无论except有没有执行,返回值都是我返回的,我全都要。
总之一句话,谁最后执行,返回值就是谁返回的。前提是必须显式地指定了return
def foo():
try:
return 123
except Exception:
return 456
finally:
pass
# 这里返回值依旧是123,finally虽然最后执行,但是它没有显式的return
# 所有结果不是None,如果finally语句里面写了一个return,那么不好意思,结果就是None了
print(foo()) # 123
然后再来看一个比较诡异的问题:
e = 2.7
try:
1 / 0
except Exception as e:
pass
print(e)
"""
NameError: name 'e' is not defined
"""
这里报错了,为什么?其实这有一点tricky,原因是当except语句块结束后,异常赋值给的变量会被删掉。
try:
1 / 0
except Exception as e:
"""执行逻辑"""
# 等价于
try:
1 / 0
except Exception as e:
try:
"""执行逻辑"""
finally:
del e
所以我们将异常赋值给一个变量的时候,要确保这个变量在之后不会被使用到。但是为什么要这么做呢?根据官方的解释:由于异常具有回溯栈,它们会和栈帧形成一个循环引用,使得该栈帧中所有的局部变量在下一次垃圾回收发生之前都保持活跃状态,换句话说就是不会被回收,因此要清除这个异常。
函数
Python中的函数和其它编程语言类似,都是对一段逻辑的封装,可以提高代码的复用性。
第14条:尽量使用异常来表示特殊情况,而不是返回None
编写工具函数(utility function)时,Python程序猿喜欢给None这个返回值赋予特殊意义。这么做有时是合理的,比如计算两个数的商,如果除数为0,那么计算结果是没有意义、是会报错的,这个时候似乎应该返回None
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None
print(divide(2, 1)) # 2.0
print(divide(2, 0)) # None
print(divide(0, 2)) # 0
但是0和None在布尔值上都为假,所以我们可以像golang一样,返回两个值,一个结果以及一个表示是否发生错误的布尔值
def divide(a, b):
try:
return a / b, True
except ZeroDivisionError:
return None, False
result, ok = divide(2, 1)
if ok:
print(result) # 2.0
else:
print("发生异常")
但是这么做的话,就意味着异常无法复现了,可能调用者都不一定知道为什么会出错。因此,还有一种办法就是,我们将异常再重新引发出来,让调用者不得不面对它
def divide(a, b):
try:
return a / b
except ZeroDivisionError as e:
# raise Exception("xxx") 表示引发一个异常
# raise Exception("xxx") from e 表示引发的异常是由上一级的异常产生的
raise ValueError("无效的参数") from e
print(divide(2, 0))
"""
ZeroDivisionError: division by zero
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "D:/satori/a.py", line 10, in <module>
print(divide(2, 0))
File "D:/satori/a.py", line 7, in divide
raise ValueError("无效的参数") from e
ValueError: 无效的参数
"""
这些都比较简单了,没什么可说的。但是事实上我们为了保证程序能够正常执行,是会经常主动地规避掉一些异常的,不一定非要将异常引发出来,尤其当产品上线的时候,稳定性是放在第一位的。
第15条:了解如何在闭包里面使用外围作用域中的变量
闭包实际上就是函数内部嵌套函数,而python中装饰器的本质也是高阶函数加上闭包组成的。我们需要了解一下,如何在闭包里面使用外围作用域中的变量。
def foo():
a = 1
b = 2
c = 3
def bar():
nonlocal a
nonlocal b
global c
c = "xxx"
return a, b
return bar
print(foo()()) # (1, 2)
print(c) # xxx
"""
我们知道通过global关键字代表声明的变量是全局的
如果是nonlocal,那么声明的变量必须在内层函数里面
"""
闭包这种东西应该是比较简单了,我们看看如何在外层访问闭包中的变量
def foo():
a = 1
b = 2
c = 3
def bar():
nonlocal a
nonlocal b
c = 3
return a, b
return bar
# 我们通过foo的字节码调用co_cellvars即可获取内层函数中引用外层函数的变量
print(foo.__code__.co_cellvars) # ('a', 'b')
# 如果是通过bar的字节码的话,那么要调用co_freevars来获取
print(foo().__code__.co_freevars) # ('a', 'b')
# 但是这样只能获取到变量名,却获取不到值,那么怎么才能获取到值呢?
print(
foo().__closure__
) # (<cell at 0x00000216106A82B0: int object at 0x00007FFFBDBEC6A0>,
# <cell at 0x00000216106A85E0: int object at 0x00007FFFBDBEC6C0>)
# 里面每一个元素都是一个cell对象,cell在python中也是一个类
# 调用cell_contents可以获取对应的值
print(foo().__closure__[0].cell_contents) # 1
print(foo().__closure__[1].cell_contents) # 2
下面来看看装饰器,其实装饰器就是高阶函数加上闭包,如果你了解了闭包,那么装饰器不过就是一个语法糖罢了
def deco(func):
def inner(*args, **kwargs):
print("我装饰了")
ret = func(*args, **kwargs)
print("我执行了")
return ret
return inner
def foo(a, b):
return a + b
# 此时的foo就变成了inner
foo = deco(foo)
print(foo(1, 2))
"""
我装饰了
我执行了
3
"""
如果使用装饰器的话:
def deco(func):
def inner(*args, **kwargs):
print("我装饰了")
ret = func(*args, **kwargs)
print("我执行了")
return ret
return inner
@deco # 这一步就等价于foo = deco(foo)
def foo(a, b):
return a + b
# 此时调用foo已经不再是原来的foo了,而是inner
print(foo(1, 2))
"""
我装饰了
我执行了
3
"""
# 但是这样存在一个问题
print(foo.__name__) # inner
所以装饰器的本质就是高阶函数加上闭包,但是我们看到foo的函数信息已经变成了inner内部的信息了,那么后面要是使用到函数的注释、名称等等,就不再是foo了。那么我们如何在装饰完之后还能将被装饰的函数的信息进行保留呢?
from functools import wraps
def deco(func):
# 加上这么一个装饰器即可,相当于把func的信息又加到了inner上面了,这样返回inner的时候
# 信息还是原来func的函数信息
@wraps(func)
def inner(*args, **kwargs):
print("我装饰了")
ret = func(*args, **kwargs)
print("我执行了")
return ret
return inner
@deco # 这一步就等价于foo = deco(foo)
def foo(a, b):
return a + b
# 此时调用foo已经不再是原来的foo了,而是inner
print(foo(1, 2))
"""
我装饰了
我执行了
3
"""
print(foo.__name__) # foo
第16条:考虑用生成器来改写直接返回列表的函数
如果我们要返回一个列表的话,那么推荐的方式是使用生成器。当然也不一定非要这个样子,如果当返回的列表比较长的时候推荐这么做。
def fib1(n: int):
res = []
a, b = 0, 1
for _ in range(0, n):
a, b = a + b, a
res.append(a)
return res
print(fib1(10)) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
# 如果输入的n比较大,那么就不推荐这么写了
def fib2(n: int):
a, b = 0, 1
for _ in range(0, n):
a, b = a + b, a
yield a
print(fib2(10)) # <generator object fib2 at 0x0000026321817890>
print(list(fib2(10))) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
# 我们看到使用生成器的时候,不仅写法变得简单了,而且占用的内存也少了
# 我们可以用多少个就生成多少个
关于生成器的运行原理我这里就不解释了,如果函数中出现了yield,那么在编译的时候就被标记为生成器了。调用的函数的时候会产生栈帧,会一直执行完毕。但如果函数中出现了yield,那么在执行到yield,那么会将当前的栈帧从栈帧链当中移除,我们调用__next__
或者send
的时候,又会将栈帧插入到当前的栈帧链中执行。
所以生成器是不是和协程比较类似呢?答案是没错的,在早期python还没有async def这个语法让我们原生定义协程的时候,很多框架都是通过yield和yield from来模拟协程的,比如tornado框架、twisted框架等等。详细信息可以自行搜索,这里就不赘述了。
需要注意的是,生成器是有状态的,生成完值之后就不要再继续了,否则会抛出StopIteration异常
def fib2(n: int):
a, b = 0, 1
for _ in range(0, n):
a, b = a + b, a
yield a
gen = fib2(10)
print(list(gen)) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
print(list(gen)) # []
# 第一次将值全部生成完毕,如果是list的话,那么会将每一个值都迭代出来
# 第二次再调用list(gen)得到的就是空列表了
try:
gen.__next__()
except StopIteration as e:
print("值已经没有了") # 值已经没有了
# 除非我们重新调用
print(list(fib2(10))) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
使用生成器,会使得结果更加的清晰,而且耗费的内存极少
第17条:在参数上面迭代时要多加小心
我们以上面的斐波那契为例:
def fib2(n: int):
a, b = 0, 1
for _ in range(0, n):
a, b = a + b, a
yield a
gen = fib2(10)
print(list(gen)) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
print(list(gen)) # []
我们知道list里面传入生成器,相当于把生成器里面的值一个一个的迭代出来然后放在列表里面的,当出现StopIteration异常的时候停止迭代。所以for循环、list、tuple、甚至是内置函数sum、max等等在将值迭代出来的时候,都具有捕获StopIteration异常的能力。而对已经引发过StopIteration的生成器来说,已经没办法产出值了,所以第二次调用list(gen)得到的就是空列表。
如果我们希望,能够让生成器在抛出StopIteration异常之后,能够从头生产值的话该怎么做呢?答案是手动实现迭代协议。这里多提一句生成器和迭代器,生成器实际上是特殊的迭代器,生成器相当于是在迭代器的基础上多实现了send、throw、close三个方法,详细信息可以去:collections.abc
里面进行查看,里面定义了大量的抽象基类。总之,迭代器和生成器都是可以进行循环的。
如果for循环可以作用在我们自定义的类的实例对象上,那么这个类应该实现__iter__方法和__next__方法
class A:
def __iter__(self):
# 首先__iter__要么返回一个迭代器,要么返回一个self
# 如果返回的是迭代器,那么跟__next__就没啥事了
return ["hanser", "憨八嘎", "陈小只"].__iter__() # 直接返回列表是不行的,我们需要手动调用其__iter__方法,将其变成迭代器
a = A()
for _ in a:
print(_)
"""
hanser
憨八嘎
陈小只
"""
当使用for循环的时候,首先调用的是__iter__
方法,如果该方法返回了迭代器,那么就遍历这个迭代器了、或者说调用返回的迭代器的__next__
,遍历完了就结束了。如果__iter__
方法返回的是self,那么我们必须也在类里面实现__next__
方法。最终表现形式也是:先调用一次__iter__
,然后不断循环__next__
。
class A:
def __init__(self, n):
self.a, self.b = 0, 1
self.n = n
self.__idx = 0
def __iter__(self):
return self
def __next__(self):
while self.__idx < self.n:
self.a, self.b = self.a + self.b, self.a
self.__idx += 1
return self.a
else:
# 这里一定要手动raise一个StopIteration,否则for循环不会退出,因为退出的条件就是捕捉到这个异常
# 如果不引发这个异常,那么当self.__idx不满足小于self.n的时候,会一直返回None
# 所以我们要手动引发StopIteration,然后让for循环捕捉到,进而退出循环
raise StopIteration
# 为了保证正确执行,我们设置了一个私有变量self.__idx,因为__iter__只会执行一次,返回self
# 然后每一次都会执行该self的__next__方法,所以每执行一次都会将self.__idx加1,直到不满足条件
# 如果要是将这个变量设置在了__next__里面会怎么样?假设叫idx=0,然后每次循环都idx += 1,会怎么样呢?
# 答案是会无限循环,得到的值每一次都是1,因为在__next__里面,那么每一次执行__next__的时候都会将idx重新赋值为0
# 但self是不变的,所以我们要将这个变量绑定在self上面,这样self.__idx自增1之后,下一次循环就是新的self.__idx了
a = A(10)
print(list(a)) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
# 同理,再次获取,会上来就引发StopIteration,因为此时的self.__idx已经不满足小于self.n了,所以列表里面一个元素也没有
print(list(a)) # []
所以迭代协议、或者说想要被for循环的话,那么就需要实现__iter__
和__next__
方法,但是这仍然没有解决我们的问题,我们的目的是希望每一次迭代完毕之后都能够重头再次生产值,该怎么做呢?
class A:
def __init__(self, n):
self.a, self.b = 0, 1
self.n = n
self.__idx = 0
def __iter__(self):
return self
def __next__(self):
while self.__idx < self.n:
self.a, self.b = self.a + self.b, self.a
self.__idx += 1
return self.a
else:
# 在引发StopIteration之前,重新将self.__idx赋值为0不就行了吗?
# 但是不要忘记,还要将self.a和self.b也赋值为初始值
self.__idx = 0
self.a, self.b = 0, 1
raise StopIteration
a = A(10)
print(list(a)) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
print(list(a)) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
这里再深入一下,我们说想要被for循环,那么要实现__iter__
和__next__
,但如果这两个方法没有实现,那么解释器会退而求其次,去寻找__getitem__
方法。
class A:
def __getitem__(self, item):
return item
a = A()
# 我们知道对于__getitem__来说,可以让我们通过a["xxx"]的方式来调用,等价于字典
# 如果是__getattr__,那么可以通过a.xxx的方式来调用
# 当我们调用的时候,"你好呀"这个字符串就会传递给item元素,所以这是一个字符串
print(a["你好呀"]) # 你好呀
# 如果我们想获取元素,那么就通过self.__dict__[item]、也就是属性字典的方式来获取
# 我们说除了这个作用之外,__getitem__还可以用于for循环,是的
for idx, value in enumerate(a):
if idx == 5:
break
print(value)
"""
0
1
2
3
4
"""
# 我们看到,如果是for循环的话,那么在调用__getitem__的时候,会默认传递0 1 2 3 4 5....
我们上面的例子就可以使用__getitem__
进行改写
class A:
def __init__(self, n):
self.a, self.b = 0, 1
self.n = n
def __getitem__(self, item):
if item < self.n:
self.a, self.b = self.a + self.b, self.a
return self.a
else:
self.a, self.b = 0, 1
raise StopIteration
a = A(10)
print(list(a)) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
print(list(a)) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
"""
我们说这个item是从0开始,随着每一次循环不断的自增1的
但是当出现了StopIteration异常的时候,那么下一次再调用的时候,这个item就又会从0开始
"""
class B:
def __getitem__(self, item):
if item > 3:
raise StopIteration
return item
# 所以我们看到,当出现StopIteration的时候,下一次item就又会从0开始
print(list(B())) # [0, 1, 2, 3]
print(list(B())) # [0, 1, 2, 3]
有了这些知识,希望你可以在迭代器上面任意的发挥。
第18条:用数量可变参数来减少视觉疲劳(雾)
如果我们需要接收参数比较多,可以使用可变参数。可变参数有两种,一种是*args、另一种是**kwargs。前者接收位置参数,后者接收关键字参数。
def foo(a, b, *args):
print(a, b)
print(args)
foo(1, 2, 3, 4, 5, 6)
"""
1 2
(3, 4, 5, 6)
"""
此时我们看到,3、4、5、6全部给了args,而args是一个元组。
def foo(a, b, **kwargs):
print(a, b)
print(kwargs)
foo(1, 2, name="hanser", age="18")
"""
1 2
{'name': 'hanser', 'age': '18'}
"""
而如果通过kwargs接收的是关键字参数,如果通过关键字参数传递的话,那么函数中参数不存在的话,就会交给kwargs。比如:a = 1,肯定是优先传递给a,因为函数中有a这个参数。但是name没有,所以name和age就会交给kwargs,而这个kwargs是一个字典。
需要注意的是,定义函数时,*args和**kwargs一定要放在参数的最后面,并且*args要在前面。所以当一个函数需要接收参数时,但是又不确定会接收多少参数,那么就定义函数时就使用*args和**kwargs即可。
def deco(func):
def inner(*args, **kwargs):
"""附加功能"""
return func(*args, **kwargs)
return inner
# 比如这里的装饰器,这个装饰器可以装饰任意的func
# 但是不同的func所接收的参数不同,因此使用*args和**kwargs的话,就可以接收任意的参数。
通过可变参数,我们还可以实现一个技巧,那就是可以让某些参数只能通过关键字参数来传递。
def foo(name, age, *args, hobby):
print(name, age, hobby)
# 这里的hobby可不可以通过位置参数来传递呢?
# 显然是不可以的,因为如果是位置参数,无论多少个都会止步于*args,无论如何都是传递不到hobby哪里去的
# 如果想给hobby传递参数,那么只能通过关键字
foo("hanser", 18, hobby="唱歌") # hanser 18 唱歌
# 但是这里的*args我们没有用到,而且我们说*args要放在最后面
# 我们之所以使用*args,并不是我们需要接收多个参数,只是希望hobby必须通过关键字参数来传递
# 所以可以这么定义
def bar(name, age, *, hobby):
print(name, age, hobby)
bar("hanser", 18, hobby="唱歌") # hanser 18 唱歌
*args和**kwargs只可以出现一次。
不仅定义函数时可以使用这个技巧,在传递参数的时候也是可以使用的
def foo(name, age, hobby):
print(name, age, hobby)
# 我们知道*args可以接收任意的参数,比如:a, b, c, d, ...
# 那么args就是一个元组(a, b, c, d, ...)
# 同理我们对一个元组(其它的序列也可以)使用*,也可以将其打散
# 所以以下等价于 foo("hanser", 18, "唱歌")
foo(*["hanser", 18, "唱歌"]) # hanser 18 唱歌
# 同理关键字参数也是一样
# 传递a=1, b=2, c=3, 那么kwargs就是{"a": 1, "b": 2, "c": 3}
# 如果对一个字典使用**,也可以将这个字典打散。
# 所以以下等价于 foo(name="hanser", age=18, hobby="唱歌")
foo(**{"name": "hanser", "age": 18, "hobby": "唱歌"})
当然这种技巧还可以用在其它地方
seq = [1, 2, 3, 4, 5, 6, 7, 8, 9]
# 我们只想讲列表的第一个元素和最后一个元素赋值给变量
a, *_, b = seq
print(a, b) # 1 9
print(_) # [2, 3, 4, 5, 6, 7, 8]
# 即便对一个迭代器也是可以使用的
print((x for x in range(5))) # <generator object <genexpr> at 0x000002A693827890>
print(*(x for x in range(5))) # 0 1 2 3 4
print(map("{:!r}".format, ['a', 'b', 'c'])) # <map object at 0x000002358E933940>
print(*map("{!r}".format, ['a', 'b', 'c'])) # 'a' 'b' 'c'
第19条:使用关键字参数来表达可选的行为
python中传递参数可以使用关键字参数,在Python3.6中所有的位置参数,都可以通过关键字参数来传递。和位置参数不同,关键字参数的传递不要求顺序,只要保证数量能够对应即可。当然两者也可以组合起来,我们看个例子:
def foo(name, age):
pass
# 以下几种方式都是等效的
foo("hanser", 18)
foo("hanser", age=18)
foo(name="hanser", age=18)
foo(age=18, name="hanser")
# 但是注意:关键字参数一定要在位置参数之后,否则会报出语法错误,编译都通不过
# 并且要注意:参数不可以重复传递,
# 下面这种方式就是错误的
"""
foo(name="hanser", name="hanser") # 会报出语法错误:关键字重复
# 同样是错误的,因为关键字参数虽然不需要保证顺序,但是位置参数是按照顺序传递的
# 所以第一个参数会传递给name,但是我们通过关键字又指定了name,所以也会报错
# 只不过这个错误是一个运行时错误:TypeError: foo() got multiple values for argument 'name'
foo("hanser", name="hanser")
"""
所以当参数比较多的时候,我们推荐一部分参数使用关键字的方式传递,最好是两者结合起来,最直观。当然一般函数也可以有默认参数,我们来看一下:
def foo(name, age=18):
print(name, age)
foo("hanser") # hanser 18
foo("hanser", 28) # hanser 28
foo("hanser", age=28) # hanser 28
foo(age=28, name="hanser") # hanser 28
# 像函数在定义的时候给一个初始值,那么这个参数就是默认参数、或者叫做缺省参数
# 如果我们在传参的时候指定了,那么使用我们指定的值
# 如果没有传递,那么使用默认值。
# 但是注意:正如位置参数和关键字参数一样,默认参数一定要在非默认参数之后,否则同样报出语法错误。
第20条:用None和文档字符串来描述具有动态默认值的参数
比如:我们打印日志,希望加上当前的时间。
from datetime import datetime
import time
def log(message, dt=datetime.now()):
return f"{dt}时间, 打印`{message}`"
print(log("你好呀")) # 2020-05-19 21:09:06.188760时间, 打印`你好呀`
time.sleep(2)
print(log("古明地觉")) # 2020-05-19 21:09:06.188760时间, 打印`古明地觉`
惊了,两次打印的时间都是一样的。因为我们说,函数的参数是静态存储的,在编译的时候就已经确定了,只是在将字节码编译成函数的时候执行了一次,然后拿到结果之后就不会再变了,我们举个栗子:
def log(message, dt=print(123)):
return f"{dt}时间, 打印`{message}`"
"""
123
"""
print(log("你好呀")) # None时间, 打印`你好呀`
print(log("你好呀")) # None时间, 打印`你好呀`
在我们调用函数之前,这个print(123)就已经打印了,因为我们说参数的默认是会在将字节码编译成函数的时候就已经确定好。所以dt会一直是None,因为print(123)返回的就是None。
所以如果在python中想要实现具有动态默认值的参数,应该设置默认值为None,然后代码块中再进行检测。
from datetime import datetime
import time
def log(message, dt=None):
if dt is None:
dt = datetime.now()
return f"{dt}时间, 打印`{message}`"
print(log("你好呀")) # 2020-05-19 21:19:07.409122时间, 打印`你好呀`
time.sleep(2)
print(log("你好呀")) # 2020-05-19 21:19:09.409845时间, 打印`你好呀`
当然,动态默认值的话,可能有人想到将默认值设置为可变对象。但是建议:一定不要将默认值设置为可变对象
def foo(value, lst=[]):
lst.append(value)
return lst
print(foo(123)) # [123]
print(foo(456, [])) # [456]
print(foo(789)) # [123, 789]
如果我们将默认是设置为可变对象,那么由于操作的是同一个默认值,因此会造成意想不到的结果。
第21条:用只能以关键字参数传递参数的方式来确保代码清晰
这个我们在上面就已经说过了,所以这里就不提了。
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏