目录
一、列表和元组
基本概念
列表和元组存储方式的差异
列表和元组的性能
列表和元组的使用场景
二、字典与集合
基本概念
字典和集合性能
字典和集合的工作原理
插入操作
查找操作
删除操作
三、字符串
基本概念
字符串的常用操作
字符串的格式化
四、输入和输出
基础输入和输出
文件输入输出
JSON 序列化
五、条件与循环
条件语句
循环语句
条件与循环的复用
六、异常处理
错误与异常
如何处理异常
用户自定义异常
异常的使用场景与注意点
七、自定义函数
函数基础
函数变量作用域
闭包
八、匿名函数
基本概念
为什么使用匿名函数
Python 函数式编程
九、面向对象编程
类
面向对象编程四要素是什么?它们的关系又是什么?
类的继承
十、Python 模块化
简单模块化
项目模块化
if __name__ == '__main__'
十一、Python对象的比较、拷贝
'==' 与 'is'
浅拷贝和深度拷贝
十二、Python中的参数传递方式
值传递和引用传递
Python 变量及其赋值
Python 函数的参数传递
十二、装饰器
基本概念
函数作为装饰器的用法
类作为装饰器的用法
缓存装饰器
装饰器用法实例
身份认证:
日志记录:
输入合理性检查:
十三、迭代器和生成器
容器
迭代器和可迭代对象
生成器
十四、Python 协程
基本概念
揭秘协程运行时
十五、并发编程之 Futures
并发和并行
单线程与多线程性能比较
Python 中的 Futures 模块
十六、并发编程之Asyncio
Sync(同步)和Async(异步)
Asyncio 工作原理
Asyncio 用法
多线程还是 Asyncio
十七、Python GIL(全局解释器锁)
基本概念
GIL工作原理
绕过 GIL
十八、Python 垃圾回收机制
计数引用
循环引用
调试内存泄漏
一、列表和元组
基本概念
列表和元组,都是一个可以放置任意数据类型的有序集合。其中列表是动态的,长度大小不固定,可以随意地增加、删减或者改变元素;而元组是静态的,长度大小固定,无法增加删减或者改变。
-
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核心技术与实战》