目录
一、列表和元组
基本概念
列表和元组,都是一个可以放置任意数据类型的有序集合。其中列表是动态的,长度大小不固定,可以随意地增加、删减或者改变元素;而元组是静态的,长度大小固定,无法增加删减或者改变。
-
Python 中的列表和元组都支持负数索引,-1 表示最后一个元素,-2 表示倒数第二个元素,以此类推。
-
列表和元组都支持切片操作
-
列表和元组都可以随意嵌套
-
两者也可以通过 list() 和 tuple() 函数相互转换
列表和元组常用的内置函数:
-
count(item) 表示统计列表 / 元组中 item 出现的次数。
-
index(item) 表示返回列表 / 元组中 item 第一次出现的索引。
-
list.reverse() 和 list.sort() 分别表示原地倒转列表和排序(注意,元组没有内置的这两个函数)。
-
reversed() 和 sorted() 同样表示对列表 / 元组进行倒转和排序,reversed() 返回一个倒转后的迭代器;sorted() 返回排好序的新列表。
列表和元组存储方式的差异
列表是动态的,需要存储指针来指向对应的元素;另外,由于列表可变,需要额外存储已经分配的长度大小(over-allocating),这样才可以实时追踪列表空间的使用情况,当空间不足时,及时分配额外空间。增加 / 删除的时间复杂度均为 O(1)。
元组长度大小固定,元素不可变,所以存储空间固定。
列表和元组的性能
存取性能:由于元组要比列表更加轻量级,总体上来说,元组的性能速度要略优于列表。
初始化性能:元组的初始化速度>列表的初始化速度。由于Python 会在后台,对静态数据做一些资源缓存。如果一些变量不被使用了,Python 就会回收它们所占用的内存,返还给操作系统,以便其他变量或其他应用使用。但是对于一些静态变量,比如元组,如果它不被使用并且占用空间不大时,Python 会暂时缓存这部分内存。以便下次我们再创建同样大小的元组时,Python 可以不用再向操作系统发出请求,去寻找内存,而是可以直接分配之前缓存的内存空间,这样就能大大加快程序的运行速度。
索引操作性能:两者的速度差别非常小,几乎可以忽略不计
如果想要增加、删减或者改变元素,那么列表更优;但对于元组,须要新建一个元组来完成
列表和元组的使用场景
-
如果存储的数据和数量不变,元组更合适。
- 如果存储的数据或数量是可变的,列表更合适。
二、字典与集合
基本概念
字典是一系列由键(key)和值(value)配对组成的元素的集合。相比于列表和元组,字典的性能更优,特别是对于查找、添加和删除操作,字典都能在常数时间复杂度内完成。
-
创建:无论是键还是值,都可以是混合类型。
-
查询:字典可以直接索引键,也可以使用 get(key, default) 函数来进行索引;集合并不支持索引操作,因为集合本质上是一个哈希表,和列表不一样。要判断一个元素在不在字典或集合内,可以用 value in dict/set 来判断。
-
更新:字典增加、更新时指定键和对应的值对即可,删除可用pop() 操作;集合增加可用add()函数,删除可用remove()函数。
-
排序:字典可使用函数sorted()并且指定键或值,进行升序或降序排序;集合排序直接调用 sorted(set) 即可。
字典和集合性能
不同于其他数据结构,字典和集合的内部结构都是一张哈希表。特别是对于查找、添加和删除操作。字典的内部组成是一张哈希表,可以直接通过键的哈希值,找到其对应的值;
集合是高度优化的哈希表,里面元素不能重复,并且其添加和查找操作只需 O(1) 的复杂度。
字典和集合的工作原理
现在的哈希表除了字典本身的结构,会把索引和哈希值、键、值单独分开,也就是下面这样新的结构:
Indices
----------------------------------------------------
None | index | None | None | index | None | index ...
----------------------------------------------------
Entries
--------------------
hash0 key0 value0
---------------------
hash1 key1 value1
---------------------
hash2 key2 value2
---------------------
...
---------------------
插入操作
每次向字典或集合插入一个元素时,Python 会首先计算键的哈希值(hash(key)),再和 mask = PyDicMinSize - 1 做与操作,计算这个元素应该插入哈希表的位置 index = hash(key) & mask。
如果哈希表中此位置是空的,那么这个元素就会被插入其中;而如果此位置已被占用,Python 便会比较两个元素的哈希值和键是否相等。
-
若两者都相等,则表明这个元素已经存在,如果值不同,则更新值。
-
若两者中有一个不相等,这种情况我们通常称为哈希冲突(hash collision),意思是两个元素的键不相等,但是哈希值相等。这种情况下,Python 便会继续寻找表中空余的位置,直到找到位置为止。通常来说,遇到这种情况,最简单的方式是线性寻找,即从这个位置开始,挨个往后寻找空位。Python 内部对此进行了优化让这个步骤更加高效。
查找操作
与插入操作类似。如果相等,则直接返回;如果不等,则继续查找,直到找到空位或者抛出异常为止。
删除操作
对于删除操作,Python 会暂时对这个位置的元素,赋于一个特殊的值,等到重新调整哈希表的大小时,再将其删除。
字典和集合内的哈希表,通常会保证其至少留有 1/3 的剩余空间。随着元素的不停插入,当剩余空间小于 1/3 时,Python 会重新获取更大的内存空间,扩充哈希表,表内所有的元素位置都会被重新排放。但是这种情况发生的次数极少。所以,平均情况下,插入、查找和删除的时间复杂度为 O(1)。
三、字符串
基本概念
字符串是由独立字符组成的一个序列,通常包含在单引号('…')双引号("…")或者三引号之中('''…'''或"""…""")。三引号字符串,主要应用于多行字符串的情境,比如函数的注释等等。
Python 也支持转义字符:
字符串的常用操作
- Python 的字符串支持索引,切片和遍历等操作。
- Python 的字符串是不可变的,想要改变,通常只能通过创建新的字符串来完成。
新版本 Python 中拼接操作’+='是个例外,str1 += str2,Python 首先会检测 str1 还有没有其他的引用。如果没有的话,就会尝试原地扩充字符串 buffer 的大小,而不是重新分配一块内存来创建新的字符串并拷贝。还可以使用string.join(iterable)实现拼接字符串。
常用函数:
- string.split(separator),把字符串按照 separator 分割成子字符串,并返回一个分割后子字符串组合的列表;
- string.strip(str),去掉首尾的 str 字符串;
- string.lstrip(str),只去掉开头的 str 字符串;
- string.rstrip(str),只去掉尾部的 str 字符串。
字符串的格式化
Python 中字符串的格式化(string.format)常常用在输出、日志的记录等场景。
print('no data available for person with id: {}, name: {}'.format(id, name))
string.format(),格式化函数;而大括号{}就是所谓的格式符,用来为后面的真实值——变量 name 预留位置。如果id = '123'、name='jason',那么输出则是:'no data available for person with id: 123, name: jason'
四、输入和输出
基础输入和输出
input() 函数会暂停程序运行,同时等待键盘输入;直到回车被按下,函数的参数即为提示语,输入的类型永远是字符串型(str)。int() 将str 强制转换为 int ; float()将str 强制转换为浮点数。在生产环境中使用强制转换时,必须加上 try except异常处理。
Python 对 int 类型没有最大限制,但是对 float 类型依然有精度限制。
文件输入输出
-
用 open() 函数拿到文件的指针,其中第一个参数指定文件位置;第二个参数,如果是 'r'表示读取,如果是'w' 则表示写入,当然也可以用 'rw' ,表示读写都要。'a' 表示追加(append)即从原始文件的最末尾开始写入。
-
拿到指针后,通过 read() 函数,来读取文件的全部内容。
-
给 read 指定参数 size ,表示读取的最大长度。还可以通过 readline() 函数,每次读取一行,这种做法常用于数据挖掘(Data Mining)中的数据清洗,在写一些小的程序时非常轻便。如果每行之间没有关联,这种做法也可以降低内存的压力。
-
write() 函数,可以把参数中的字符串输出到文件中。
-
注意所有 I/O 都应该进行错误处理。
JSON 序列化
JSON是一种轻量级的数据交换格式,它的设计意图是把所有事情都用设计的字符串来表示。
实际应用中遇到多种数据类型混在一起的情况可使用JSON序列化处理:
- json.dumps() 函数,接受 Python 的基本数据类型,然后将其序列化为 string;
- json.loads() 函数,接受一个合法字符串,然后将其反序列化为 Python 的基本数据类型。
当开发一个第三方应用程序时,可以通过 JSON 将用户的个人配置输出到文件,方便下次程序启动时自动读取。这也是现在普遍运用的成熟做法。
五、条件与循环
条件语句
Python 不支持 switch 语句,因此,当存在多个条件判断时,我们需要用 else if 来实现,这在 Python 中的表达是 elif。
循环语句
本质上就是遍历集合中的元素。Python 中的循环一般通过 for 循环和 while 循环实现。
Python 中的数据结构只要是可迭代的(iterable),比如列表、集合等等,那么都可以通过下面这种方式遍历:
for item in <iterable>:
...
单独强调一下字典。字典本身只有键是可迭代的,如果要遍历它的值或者是键值对,就需要通过其内置的函数 values() 或者 items() 实现。其中,values() 返回字典的值的集合,items() 返回键值对的集合。
遍历集合中的元素:可以通过 range() 函数拿到索引,再去遍历访问集合中的元素。如果需要同时访问索引和元素,可以使用 enumerate() 函数来简化代码。
continue 和 break:continue,让程序跳过当前这层循环,继续执行下面的循环;break 是指完全跳出所在的整个循环体。
for和while的使用场景:当遍历一个已知的集合,找到满足条件的元素并进行操作时用for;当无特定集合去遍历时用while循环。
for和while的效率比较:for使用的range()函数是由C语言写的,调用速度快;while 循环中的“i += 1”的操作,需要通过 Python 解释器间接调用底层的 C 语言,由于i 是整型,i += 1 相当于 i = new int(i + 1)。显然for效率>while效率。
条件与循环的复用
expression1 if condition else expression2 for item in iterable
#将这个表达式分解开来,其实就等同于下面这样的嵌套结构:
for item in iterable:
if condition:
expression1
else:
expression2
#还可以处理文件中的字符串
text = ' Today, is, Sunday'
text_list = [s.strip() for s in text.split(',') if len(s.strip()) > 5]
print(text_list)
['Today', 'Sunday']
简单功能往往可以用一行直接完成,极大地提高代码质量与效率。
六、异常处理
错误与异常
程序中的错误至少包括两种,一种是语法错误,另一种则是异常。语法错误,即代码不符合编程规范,无法被识别与执行。而异常是指程序的语法正确,也可以被执行,但在执行过程中遇到了错误,抛出了异常
如何处理异常
-
通常使用 try except 语句去处理异常,这样程序就不会被终止,仍能继续执行。
-
处理异常时,如果有必须执行的语句,比如文件打开后必须关闭等等,则可以放在 finally block 中。
用户自定义异常
如果内置的异常类型无法满足我们的需求,或者为了让异常更加详细、可读,想增加一些异常类型的其他功能,我们可以自定义所需异常类型。
异常的使用场景与注意点
异常处理,通常用在不确定某段代码能否成功执行,也无法轻易判断的情况下,比如数据库的连接、读取等等。正常的 flow-control 逻辑,不要使用异常处理,直接用条件语句解决就可以了。
七、自定义函数
函数基础
def my_func(param1, param2, ..., paramN):
statements
return/yield value # optional
def 是函数的声明;my_func 是函数的名称;括号里面的 param 则是函数的参数;而 print 那行则是函数的主体部分,可以执行相应的语句;在函数最后,你可以返回调用结果(return 或 yield),也可以不返回。
多态: Python 中函数的参数可以接受任意的数据类型,使用起来需要注意,必要时请在函数开头加入数据类型的检查;
嵌套函数,即函数里面又有函数:
-
函数的嵌套能够保证内部函数的隐私
-
合理的使用函数嵌套,能够提高程序的运行效率(可用作在计算之前检查输入是否合法等实际情况)
函数变量作用域
Python 函数中变量的作用域和其他语言类似。如果变量是在函数内部定义的,就称为局部变量,只在函数内部有效,一旦函数执行完毕,局部变量就会被回收,无法访问;
相对应的,全局变量则是定义在整个文件层次上的。
注:不能在函数内部随意改变全局变量的值,Python 的解释器会默认函数内部的变量为局部变量,但是又发现局部变量 并没有声明,因此就无法执行相关操作。
如果一定要在函数内部改变全局变量的值,须加上 global声明以告诉Python解释器函数内部的变量就是之前定义的全局变量。
对于嵌套函数来说,内部函数可以访问外部函数定义的变量,但是无法修改,若要修改,必须加上 nonlocal 这个关键字。
闭包
闭包和嵌套函数类似,不同的是外部函数返回的是一个函数,而不是具体的值。返回的函数通常赋于一个变量,这个变量可以在后面被继续执行调用。合理地使用闭包,则可以简化程序的复杂度,提高可读性。
八、匿名函数
基本概念
lambda argument1, argument2,... argumentN : expression
匿名函数的关键字是 lambda,之后是一系列的参数,然后用冒号隔开,最后则是由这些参数组成的表达式。
-
lambda 是一个表达式,并不是一个语句。
-
所谓的表达式,就是用一系列“公式”去表达一个东西,比如x + 2、 x**2等等;
- 而所谓的语句,则一定是完成了某些功能,比如赋值语句x = 1完成了赋值,print 语句print(x)完成了打印,条件语句 if x < 0:完成了选择功能等等。
2.lambda 的主体是只有一行的简单表达式,并不能扩展成一个多行的代码块。
为什么使用匿名函数
- 主要用途是减少代码的复杂度。
- 需要注意的是 lambda 是一个表达式,并不是一个语句;它只能写成一行的表达形式,语法上并不支持多行。
- 匿名函数通常的使用场景是:程序中需要使用一个函数完成一个简单的功能,并且该函数只调用一次。
Python 函数式编程
函数式编程,是指代码中每一块都是不可变的,都由纯函数的形式组成。纯函数,是指函数本身相互独立、互不影响,对于相同的输入,总会有相同的输出,没有任何副作用。
Python 主要提供了这么几个函数:map()、filter() 和 reduce(),通常结合匿名函数 lambda 一起使用:
map(function, iterable) 函数:对 iterable 中的每个元素,都运用 function 这个函数,最后返回一个新的可遍历的集合。
l = [1, 2, 3, 4, 5]
new_list = map(lambda x: x * 2, l) # [2, 4, 6, 8, 10]
filter(function, iterable) 函数:对 iterable 中的每个元素,都使用 function 判断,并返回 True 或者 False,最后将返回 True 的元素组成一个新的可遍历的集合。
l = [1, 2, 3, 4, 5]
new_list = filter(lambda x: x % 2 == 0, l) # [2, 4]
reduce(function, iterable) 函数:它通常用来对一个集合做一些累积操作。比如要计算某个列表元素的乘积。
l = [1, 2, 3, 4, 5]
product = reduce(lambda x, y: x * y, l) # 1*2*3*4*5 = 120
filter() 和 reduce() 的功能,也可以用 for 循环或者 list comprehension 来实现。
-
通常来说,如果要对集合中的元素做简单操作,比如相加、累积,优先考虑 map()、filter()、reduce() 这类或者 list comprehension 的形式。
-
在数据量非常多的情况下,比如机器学习的应用,那我们一般更倾向于函数式编程的表示,因为效率更高;
-
在数据量不多的情况下,并且你想要程序更加 Pythonic 的话,那么 list comprehension 也不失为一个好选择。
-
如果要对集合中的元素做一些比较复杂的操作,通常会使用 for 循环,这样更加清晰明了。
九、面向对象编程
类
一群有着相同属性和函数的对象的集合。
init 表示构造函数,即一个对象生成时会被自动调用的函数。如果一个属性以 __ (注意,此处有两个 _) 开头,我们就默认这个属性是私有属性。私有属性,是指不希望在类的函数之外的地方被访问和修改的属性。
三个问题:
- 如何在一个类中定义一些常量,每个对象都可以方便访问这些常量而不用重新构造?
- 如果一个函数不涉及到访问修改这个类的属性,而放到类外面有点不恰当,怎么做才能更优雅呢?
- 既然类是一群相似的对象的集合,那么可不可以是一群相似的类的集合呢?
class Document():
WELCOME_STR = 'Welcome! The context for this book is {}.'
def __init__(self, title, author, context):
print('init function called')
self.title = title
self.author = author
self.__context = context
# 类函数
@classmethod
def create_empty_book(cls, title, author):
return cls(title=title, author=author, context='nothing')
# 成员函数
def get_context_length(self):
return len(self.__context)
# 静态函数
@staticmethod
def get_welcome(context):
return Document.WELCOME_STR.format(context)
empty_book = Document.create_empty_book('What Every Man Thinks About Apart from Sex', 'Professor Sheridan Simove')
print(empty_book.get_context_length())
print(empty_book.get_welcome('indeed nothing'))
########## 输出 ##########
init function called
7
Welcome! The context for this book is indeed nothing.
第一个问题,在 Python 的类里,只需要和函数并列地声明并赋值,即可实现,例如这段代码中的 WELCOME_STR,用全大写来表示常量。我们可以在类中使用 self.WELCOME_STR ,或者在类外使用 Entity.WELCOME_STR ,来表达这个字符串。
第二个问题,类函数、成员函数和静态函数中,前两者产生的影响是动态的,能够访问或者修改对象的属性;而静态函数则与类没有什么关联,最明显的特征便是,静态函数的第一个参数没有任何特殊性。一般而言,静态函数可以用来做一些简单独立的任务,既方便测试,也能优化代码结构。静态函数还可以通过在函数前一行加上 @staticmethod 来表示。而类函数的第一个参数一般为 cls,表示必须传一个类进来。类函数最常用的功能是实现不同的 init 构造函数。
面向对象编程四要素是什么?它们的关系又是什么?
封装、继承、多态、抽象。
- 封装就是把功能封装抽象的方法和其他属性和方法,使得代码更加模块化,代码复用度更高;
- 继承使得子类不仅拥有自己的属性和方法,还能使用父类的属性和方法;
- 多态可以实现函数重写,使得相同方法具有不同功能。
- 抽象不同子类的相同方法和属性形成父类,在通过继承,多态,封装使得代码更加紧凑,简洁易读
封装是基础。抽象和多态有赖于继承实现。
类的继承
类: 一群有着相同属性和函数(方法)的对象(实例)的集合,也可以具象化的理解为是一群有着相似特征的事物的集合;用class来声明。
抽象类:是一种特殊的类,只能作为父类存在,一旦对象化(或叫实例化)就会报错;一般使用class Classname(metaclass=ABCMeta)来声明。
类的继承:子类继承父类,子类可以使用父类的属性和函数,同时子类可以有自己独特的属性和函数;子类在生成对象的时候(实例化时),是不会自动调用父类的构造函数的,必须在子类的构造函数中显示的调用父类的构造函数;继承的优势是减少重复代码,降低系统熵值(即复杂度)。
属性:用"self.属性名"来表示,通过构造函数传入;表示对象(实例)的某个静态特征。
私有属性:,以 __ 开始但不以 __ 结束的属性,举例:self.__属性名,只能在类内部调用,类外部无法访问。
公有属性:和函数并列声明的属性,可以理解为常量,一般用全大写表示;在类中通过"self.常量名"来调用,在类外使用"对象名.常量名"或者"类名.常量名"来调用。
函数:表示对象(实例)的某个动态能力。
构造函数:用def __init__(self, args...)声明,第一个参数self代表当前对象的引用,其他参数是在对象化时需要传入的属性值;构造函数在一个对象生成时(即实例化时)会被自动调用。
成员函数:是正常的类的函数,第一个参数必须是self;可通过此函数来实现查询或修改类的属性等功能。
静态函数:属于当前类的命名空间下,且对第一个参数没有要求;一般用来做一些简单独立的任务,既方便测试也能优化代码结构;一般使用装饰器@staticmethod来声明。
类函数:类函数的第一个参数一般为cls,表示必须传一个类进来;最常用的功能是实现不同的init构造函数;需要装饰器@classmethod来声明。
抽象函数:抽象函数定义在抽象类之中,子类必须重写该函数才能使用,相应的抽象函数,则是使用装饰器 @abstractmethod 来表示。
十、Python 模块化
简单模块化
把函数、类、常量拆分到不同的文件,把它们放在同一个文件夹,然后使用 from your_file import function_name, class_name 的方式调用。也可以在此基础上新建子文件夹。
调用上级目录可以使用sys.path.append("..")表示当前位置向上提了一级。
import使用时需要放在程序的最前端,且同一个模块只会被执行一次。
项目模块化
以 / 开头,来表示从根目录到叶子节点的路径,例如 /home/ubuntu/Desktop/my_project/test.py,这种表示方法叫作绝对路径。从 test.py 访问到 example.json,需要写成 '../../Downloads/example.json',其中 .. 表示上一层目录。这种表示方法叫作相对路径。在大型工程中模块化非常重要,模块的索引要通过绝对路径来做,而绝对路径从程序的根目录开始;以项目的根目录作为最基本的目录,所有的模块调用,都要通过根目录一层层向下索引的方式来 import。
if __name__ == '__main__'
import 在导入文件的时候,会自动把所有暴露在外面的代码全都执行一遍。因此,如果你要把一个东西封装成模块,又想让它可以执行的话,你必须将要执行的代码放在 if __name__ == '__main__'下面。
__name__ 作为 Python 的魔术内置参数,本质上是模块对象的一个属性。我们使用 import 语句时,__name__ 就会被赋值为该模块的名字,自然就不等于 __main__了。
十一、Python对象的比较、拷贝
'==' 与 'is'
'=='操作符比较对象之间的值是否相等。执行a == b相当于是去执行a.__eq__(b),而 Python 大部分的数据类型都会去重载__eq__这个函数,其内部的处理通常会复杂一些。
比较操作符'is'效率优于'==',因为'is'操作符无法被重载,执行'is'操作只是简单的获取对象的 ID,并进行比较;而'=='操作符则会递归地遍历对象的所有值,并逐一比较。
浅拷贝和深度拷贝
浅拷贝,是指重新分配一块内存,创建新对象,其内容非原对象本身的引用,而是原对象内第一层对象的引用。浅拷贝有三种形式:切片操作、工厂函数、copy 模块中的 copy 函数。
深度拷贝,是指重新分配一块内存,创建一个新的对象,并且将原对象中的元素,以递归的方式,通过创建新的子对象拷贝到新对象中。深拷贝只有一种形式,copy 模块中的 deepcopy()函数。深拷贝和浅拷贝对应,深拷贝拷贝了对象的所有元素,包括多层嵌套的元素。因此,它的时间和空间开销要高。
对于元组,使用 tuple() 或者切片操作符':'不会创建一份浅拷贝,相反,它会返回一个指向相同元组的引用。
拷贝注意点:
- 对于非容器类型,如数字、字符,以及其他的“原子”类型,没有拷贝一说,产生的都是原对象的引用。
- 如果元组变量值包含原子类型对象,即使采用了深拷贝,也只能得到浅拷贝。
十二、Python中的参数传递方式
值传递和引用传递
值传递:拷贝参数的值,然后传递给函数里的新变量
引用传递:把参数的引用传给新的变量,即原变量和新变量就会指向同一块内存地址。如果改变了其中任何一个变量的值,那么另外一个变量也会相应地随之改变。
Python 变量及其赋值
- 变量的赋值,只是表示让变量指向了某个对象,并不表示拷贝对象给变量;而一个对象,可以被多个变量所指向。
- 可变对象(列表,字典,集合等等)的改变,会影响所有指向该对象的变量。
- 对于不可变对象(字符串、整型、元组等等),所有指向该对象的变量的值总是一样的,也不会改变。但是通过某些操作(+= 等等)更新不可变对象的值时,会返回一个新的对象。
- 变量可以被删除,但是对象无法被删除。
Python 函数的参数传递
引用传递,不是指向一个具体的内存地址,而是指向一个具体的对象。
- 如果对象是可变的,当其改变时,所有指向这个对象的变量都会改变。
- 如果对象不可变,简单的赋值只能改变其中一个变量的值,其余变量则不受影响。
通过一个函数来改变某个变量的值,通常有两种方法:第一种直接将可变数据类型(比如列表,字典,集合)当作参数传入,直接在其上修改;第二种是创建一个新变量,来保存修改后的值,然后将其返回给原变量。在实际工作中,我们更倾向于使用后者,因为其表达清晰明了,不易出错。
十二、装饰器
基本概念
def my_decorator(func):
def wrapper():
print('wrapper of decorator')
func()
return wrapper
@my_decorator
def greet():
print('hello world')
greet()
函数 my_decorator() 就是一个装饰器,它把真正需要执行的函数 greet() 包裹在其中,并且改变了它的行为,但是原函数 greet() 不变。
其中的@,称之为语法糖,@my_decorator就相当于前面的greet=my_decorator(greet)语句,只不过更加简洁。
函数作为装饰器的用法
def my_decorator(func):
def wrapper(message):
print('wrapper of decorator')
func(message)
return wrapper
@my_decorator
def greet(message):
print(message)
greet('hello world')
# 输出
wrapper of decorator
hello world
当遇到带有参数的装饰器时:*args和**kwargs,表示接受任意数量和类型的参数
类作为装饰器的用法
class Count:
def __init__(self, func):
self.func = func
self.num_calls = 0
def __call__(self, *args, **kwargs):
self.num_calls += 1
print('num of calls is: {}'.format(self.num_calls))
return self.func(*args, **kwargs)
@Count
def example():
print("hello world")
example()
# 输出
num of calls is: 1
hello world
example()
# 输出
num of calls is: 2
hello world
...
类也可以作为装饰器。类装饰器主要依赖于函数__call__(),每当你调用一个类的示例时,函数__call__()就会被执行一次。
缓存装饰器
LRU cache,在 Python 中的表示形式是@lru_cache。@lru_cache会缓存进程中的函数参数和结果,当缓存满了以后,会删除 least recenly used 的数据
大型公司服务器端的代码中往往存在很多关于设备的检查,比如你使用的设备是安卓还是 iPhone,版本号是多少,通常使用缓存装饰器,来包裹这些检查函数,避免其被反复调用:
@lru_cache
def check(param1, param2, ...) # 检查用户设备类型,版本号等等
...
装饰器用法实例
身份认证:
import functools
def authenticate(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
request = args[0]
if check_user_logged_in(request): # 如果用户处于登录状态
return func(*args, **kwargs) # 执行函数post_comment()
else:
raise Exception('Authentication failed')
return wrapper
@authenticate
def post_comment(request, ...)
...
日志记录:
import time
import functools
def log_execution_time(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
res = func(*args, **kwargs)
end = time.perf_counter()
print('{} took {} ms'.format(func.__name__, (end - start) * 1000))
return res
return wrapper
@log_execution_time
def calculate_similarity(items):
...
输入合理性检查:
import functools
def validation_check(input):
@functools.wraps(func)
def wrapper(*args, **kwargs):
... # 检查输入是否合法
@validation_check
def neural_network_training(param1, param2, ...):
...
十三、迭代器和生成器
容器
在 Python 中一切皆对象,对象的抽象就是类,而对象的集合就是容器。列表(list: [0, 1, 2]),元组(tuple: (0, 1, 2)),字典(dict: {0:0, 1:1, 2:2}),集合(set: set([0, 1, 2]))都是容器。对于容器,你可以很直观地想象成多个元素在一起的单元;而不同容器的区别,正是在于内部数据结构的实现方法。
迭代器和可迭代对象
容器是可迭代对象,可迭代对象调用 iter() 函数,可以得到一个迭代器。迭代器可以通过 next() 函数来得到下一个元素,从而支持遍历。调用next()方法后,你要么得到这个容器的下一个对象,要么得到一个 StopIteration 的错误。
生成器
生成器是一种特殊的迭代器(注意这个逻辑关系反之不成立)。使用生成器,你可以写出来更加清晰的代码;合理使用生成器,可以降低内存占用、优化程序结构、提高程序速度。
举例一:验证恒等式 (1 + 2 + 3 + ... + n)^2 = 1^3 + 2^3 + 3^3 + ... + n^3:
def generator(k):
i = 1
while True:
yield i ** k
i += 1
gen_1 = generator(1)
gen_3 = generator(3)
print(gen_1)
print(gen_3)
def get_sum(n):
sum_1, sum_3 = 0, 0
for i in range(n):
next_1 = next(gen_1)
next_3 = next(gen_3)
print('next_1 = {}, next_3 = {}'.format(next_1, next_3))
sum_1 += next_1
sum_3 += next_3
print(sum_1 * sum_1, sum_3)
get_sum(8)
########## 输出 ##########
<generator object generator at 0x000001E70651C4F8>
<generator object generator at 0x000001E70651C390>
next_1 = 1, next_3 = 1
next_1 = 2, next_3 = 8
next_1 = 3, next_3 = 27
next_1 = 4, next_3 = 64
next_1 = 5, next_3 = 125
next_1 = 6, next_3 = 216
next_1 = 7, next_3 = 343
next_1 = 8, next_3 = 512
1296 1296
其中generator() 函数,返回了一个生成器。当执行到yield这一行时,程序会暂停,之后跳到next()函数中,i ** k即为next()函数的返回值,即从 yield 这里向下继续执行;注意这里局部变量 i 并没有被清除掉,而是会继续累加。,事实上,迭代器是一个有限集合,生成器则可以成为一个无限集。我只管调用 next(),生成器根据运算会自动生成新的元素,然后返回给你。
举例二:给定一个 list 和一个指定数字,求这个数字在 list 中的位置。
思路:枚举列表中每个元素以及它的index,判断后加入result,最后返回。
def index_normal(L, target):
result = []
for i, num in enumerate(L):
if num == target:
result.append(i)
return result
print(index_normal([1, 6, 2, 4, 5, 2, 8, 6, 3, 2], 2))
#使用迭代器
def index_generator(L, target):
for i, num in enumerate(L):
if num == target:
yield i
print(list(index_generator([1, 6, 2, 4, 5, 2, 8, 6, 3, 2], 2)))
########## 输出 ##########
[2, 5, 9]
举例三:给定两个序列,判定第一个是不是第二个的子序列。
思路:使用贪心算法。我们维护两个指针指向两个列表的最开始,然后对第二个序列一路扫过去,如果某个数字和第一个指针指的一样,那么就把第一个指针前进一步。第一个指针移出第一个序列最后一个元素的时候,返回 True,否则返回 False。
不过当使用迭代器和生成器时代码如下:
def is_subsequence(a, b):
b = iter(b)
return all(i in b for i in a)
print(is_subsequence([1, 3, 5], [1, 2, 3, 4, 5]))
print(is_subsequence([1, 4, 3], [1, 2, 3, 4, 5]))
########## 输出 ##########
True
False
十四、Python 协程
基本概念
协程是实现并发编程的一种方式。以下为使用协程写异步爬虫程序:
import asyncio
async def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url))
async def main(urls):
for url in urls:
await crawl_page(url)
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
async 修饰词声明异步函数,于是,这里的 crawl_page 和 main 都变成了异步函数。而调用异步函数,我们便可得到一个协程对象(coroutine object)。
协程执行的三种方式:
-
通过 await 来调用
-
通过 asyncio.create_task() 来创建任务
-
使用asyncio.run 来触发运行
由于crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。于是,以上代码相当于用异步接口写了个同步代码。
改进如下:
import asyncio
async def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url))
async def main(urls):
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
for task in tasks:
await task
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
########## 输出 ##########
crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
Wall time: 3.99 s
揭秘协程运行时
import asyncio
async def worker_1():
print('worker_1 start')
await asyncio.sleep(1)
print('worker_1 done')
async def worker_2():
print('worker_2 start')
await asyncio.sleep(2)
print('worker_2 done')
async def main():
task1 = asyncio.create_task(worker_1())
task2 = asyncio.create_task(worker_2())
print('before await')
await task1
print('awaited worker_1')
await task2
print('awaited worker_2')
%time asyncio.run(main())
########## 输出 ##########
before await
worker_1 start
worker_2 start
worker_1 done
awaited worker_1
worker_2 done
awaited worker_2
Wall time: 2.01 s
- asyncio.run(main()),程序进入 main() 函数,事件循环开启;
- task1 和 task2 任务被创建,并进入事件循环等待运行;运行到 print,输出 'before await';
- await task1 执行,用户选择从当前的主任务中切出,事件调度器开始调度 worker_1;
- worker_1 开始运行,运行 print 输出'worker_1 start',然后运行到 await asyncio.sleep(1), 从当前任务切出,事件调度器开始调度 worker_2;
- worker_2 开始运行,运行 print 输出 'worker_2 start',然后运行 await asyncio.sleep(2) 从当前任务切出;
- 以上所有事件的运行时间,都应该在 1ms 到 10ms 之间,甚至可能更短,事件调度器从这个时候开始暂停调度;
- 一秒钟后,worker_1 的 sleep 完成,事件调度器将控制权重新传给 task_1,输出 'worker_1 done',task_1 完成任务,从事件循环中退出;
- await task1 完成,事件调度器将控制器传给主任务,输出 'awaited worker_1',·然后在 await task2 处继续等待;
- 两秒钟后,worker_2 的 sleep 完成,事件调度器将控制权重新传给 task_2,输出 'worker_2 done',task_2 完成任务,从事件循环中退出;
- 主任务输出 'awaited worker_2',协程全任务结束,事件循环结束。
需要注意的点:
- 协程和多线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
- 协程的写法更加简洁清晰,把 async / await 语法和 create_task 结合来用,对于中小级别的并发需求已经毫无压力。
- 写协程程序的时候,脑海中要有清晰的事件循环概念,知道程序在什么时候需要暂停、等待 I/O,什么时候需要一并执行到底。
十五、并发编程之 Futures
并发和并行
并发,通过线程和任务之间互相切换的方式实现,但同一时刻,只允许有一个线程或任务执行。通常应用于 I/O 操作频繁的场景,比如从网站上下载多个文件,I/O 操作的时间可能会比 CPU 运行处理的时间长得多。
并行,则是指多个进程同时执行。更多应用于 CPU heavy 的场景,比如 MapReduce 中的并行计算,为了加快运行速度,一般会用多台机器、多个处理器来完成。
单线程与多线程性能比较
多线程和单线程实现的主要区别在于:
#单线程实现方式
def download_all(sites):
for site in sites:
download_one(site)
#多线程实现方式
def download_all(sites):
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
executor.map(download_one, sites)
这里创建了一个线程池,总共有 5 个线程可以分配使用。executer.map() 与前面所讲的 Python 内置的 map() 函数类似,表示对 sites 中的每一个元素,并发地调用函数 download_one()。
Python 中的 Futures 模块
Python 中的 Futures 模块,位于 concurrent.futures 和 asyncio 中,它们都表示带有延迟的操作。Futures 会将处于等待状态的操作包裹起来放到队列中。Futures模块中常用函数:
-
函数 done(),表示相对应的操作是否完成——True 表示完成,False 表示没有完成。done() 是 non-blocking 的,会立即返回结果。
-
函数add_done_callback(fn),则表示 Futures 完成后,相对应的参数函数 fn,会被通知并执行调用。
-
函数result(),它表示当 future 完成后,返回其对应的结果或异常。
-
函数as_completed(fs),则是针对给定的 future 迭代器 fs,在其完成后,返回完成后的迭代器。
import concurrent.futures
def download_all(sites):
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
to_do = []
for site in sites:
future = executor.submit(download_one, site)
to_do.append(future)
for future in concurrent.futures.as_completed(to_do):
future.result()
首先调用 executor.submit(),将下载每一个网站的内容都放进 future 队列 to_do,等待执行。然后是 as_completed() 函数,在 future 完成后,便输出结果。future 列表中每个 future 完成的顺序,取决于系统的调度和每个 future 的执行时间。
十六、并发编程之Asyncio
Sync(同步)和Async(异步)
Sync同步,是指操作一个接一个地执行,下一个操作必须等上一个操作完成后才能执行。
Async异步,是指不同操作间可以相互交替执行,如果其中的某个操作被 block 了,程序并不会等待,而是会找出可执行的操作继续执行。
Asyncio 工作原理
Asyncio 是单线程的,但其内部 event loop 的机制,可以让它并发地运行多个不同的任务,并且比多线程享有更大的自主控制权。
假设任务只有两个状态:一是预备状态;二是等待状态。event loop 会维护两个任务列表,分别对应这两种状态;并且选取预备状态的一个任务,使其运行,一直到这个任务把控制权交还给 event loop 为止。当任务把控制权交还给 event loop 时,event loop 会根据其是否完成,把任务放到预备或等待状态的列表,然后遍历等待状态列表的任务,查看他们是否完成。如果完成,则将其放到预备状态的列表;如果未完成,则继续放在等待状态的列表。周而复始,直到所有任务完成。
Asyncio 用法
Asyncio 版本的函数 download_all():
tasks = [asyncio.create_task(download_one(site)) for site in sites]
await asyncio.gather(*task)
Asyncio 中的任务,在运行过程中不会被打断,因此不会出现 race condition 的情况。尤其是在 I/O 操作 heavy 的场景下,Asyncio 比多线程的运行效率更高。因为 Asyncio 内部任务切换的损耗,远比线程切换的损耗要小;并且 Asyncio 可以开启的任务数量,也比多线程中的线程数量多得多。
多线程还是 Asyncio
伪代码如下:
if io_bound:
if io_slow:
print('Use Asyncio')
else:
print('Use multi-threading')
else if cpu_bound:
print('Use multi-processing')
- 如果是 I/O bound,并且 I/O 操作很慢,需要很多任务 / 线程协同实现,那么使用 Asyncio 更合适。
- 如果是 I/O bound,但是 I/O 操作很快,只需要有限数量的任务 / 线程,那么使用多线程就可以了。
- 如果是 CPU bound,则需要使用多进程来提高程序运行效率。
十七、Python GIL(全局解释器锁)
基本概念
GIL 全局解释器锁,每一个 Python 线程,在 CPython 解释器中执行时,都会先锁住自己的线程,阻止别的线程执行。
CPython 引进 GIL 的原因:
- 设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition);
- 由于 CPython 大量使用 C 语言库,但大部分 C 语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。
GIL工作原理
Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。
CPython 中还有另一个机制, check_interval,意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况。每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。
但注意尽管有了GIL仍需注意线程安全。
绕过 GIL
- 绕过 CPython,使用 JPython(Java 实现的 Python 解释器)等别的实现;
- 把关键性能代码,放到别的语言(一般是 C++)中实现。
十八、Python 垃圾回收机制
Python 程序在运行的时候,需要在内存中开辟出一块空间,用于存放运行时产生的临时变量;计算完成后,再将结果输出到永久性存储器中。如果数据量过大,内存空间管理不善就很容易出现 OOM(out of memory),俗称爆内存,程序可能被操作系统中止。
计数引用
函数内部声明的局部变量,在函数返回后,局部变量的引用会注销掉;此时变量指代对象的引用数为 0,Python 便会执行垃圾回收。
s.getrefcount() 这个函数,可以查看一个变量的引用次数。
手动释放内存方法:先调用” del 变量名 “删除对象的引用;然后强制调用 gc.collect(),清除没有引用的对象
循环引用
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
show_memory_info('after a, b created')
a.append(b)
b.append(a)
func()
show_memory_info('finished')
Python 使用标记清除(mark-sweep)算法和分代收集(generational),来启用针对循环引用的自动垃圾回收。
-
标记清除算法:遍历并标记一个有向图,在遍历结束后,未被标记的节点即为不可达节点,需要进行垃圾回收。(实现方法: dfs (深度优先搜索)遍历,从起点开始遍历,对遍历到的节点做个记号。遍历完成后,再对所有节点扫一遍,没有被做记号的,就是需要垃圾回收的。)
实际上,标记清除算法中使用双向链表维护了一个数据结构,并且只考虑容器类的对象(只有容器类对象才有可能产生循环引用)。
-
分代收集:Python 将所有对象分为三代。刚刚创立的对象是第 0 代;经过一次垃圾回收后,依然存在的对象,便会依次从上一代挪到下一代。而每一代启动自动垃圾回收的阈值,则是可以单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收。
调试内存泄漏
import objgraph
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_refs([a])
#objgraph.show_backrefs([a])
objgraph 是很好的可视化分析工具:
- 函数show_refs(),它可以生成清晰的引用关系图:
- 以及函数show_backrefs():
——修改整理自极客时间景霄老师的专栏《Python核心技术与实战》