Skip to content

简介

Python优缺点

  • 简单优雅,跟其他很多编程语言相比,Python 更容易上手。
  • 能用更少的代码做更多的事情,提升开发效率。
  • 开放源代码,拥有强大的社区和生态圈。
  • 能够做的事情非常多,有极强的适应性。
  • 胶水语言,能够黏合其他语言开发的东西。
  • 解释型语言,更容易跨平台,能够在多种操作系统上运行。
  • Python 最主要的缺点是执行效率低(解释型语言的通病),如果更看重代码的执行效率,C、C++ 或 Go 可能是你更好的选择。

变量

布尔型(bool

布尔型只有TrueFalse两种值,要么是True,要么是False

变量命名

  • 惯例1:变量名通常使用小写英文字母多个单词用下划线进行连接
  • 惯例2:受保护的变量用单个下划线开头。
  • 惯例3:私有的变量用两个下划线开头。

改变变量的类型

可以通过 Python 内置的函数来改变变量的类型,下面是一些常用的和变量类型相关的函数。

  • int():将一个数值或字符串转换成整数,可以指定进制。
  • float():将一个字符串(在可能的情况下)转换成浮点数。
  • str():将指定的对象转换成字符串形式,可以指定编码方式。
  • chr():将整数(字符编码)转换成对应的(一个字符的)字符串。
  • ord():将(一个字符的)字符串转换成对应的整数(字符编码)。

type函数

在 Python 中可以使用type函数对变量的类型进行检查。

python
"""
使用type函数检查变量的类型

Version: 1.0
Author: 骆昊
"""
a = 100
b = 123.45
c = 'hello, world'
d = True
print(type(a))  # <class 'int'>
print(type(b))  # <class 'float'>
print(type(c))  # <class 'str'>
print(type(d))  # <class 'bool'>

Python语言中的运算符

运算符描述
[][:]索引、切片
**
~+-按位取反、正号、负号
*/%//乘、除、模、整除
+-加、减
>><<右移、左移
&按位与
^、``
<=<>>=小于等于、小于、大于、大于等于
==!=等于、不等于
isis not身份运算符
innot in成员运算符
notorand逻辑运算符
=+=-=*=/=%=//=**=&=、`=^=>>=<<=`

TIP

python
"""
BMI计算器

Version: 1.0
Author: 骆昊
"""
height = float(input('身高(cm):'))
weight = float(input('体重(kg):'))
bmi = weight / (height / 100) ** 2
print(f'{bmi = :.1f}')
if 18.5 <= bmi < 24:
    print('你的身材很棒!')

支持连续的比较运算符

格式化输出

一个带占位符的字符串,字符串前面的f表示这个字符串是需要格式化处理的,其中的{f:.1f}{c:.1f}可以先看成是{f}{c},表示输出时会用变量f和变量c的值替换掉这两个占位符,后面的:.1f表示这是一个浮点数,小数点后保留1位有效数字。

python
"""
将华氏温度转换为摄氏温度

Version: 1.1
Author: 骆昊
"""
f = float(input('请输入华氏温度: '))
c = (f - 32) / 1.8
print(f'{f:.1f}华氏度 = {c:.1f}摄氏度')

分支结构

在 Python 中,构造分支结构最常用的是ifelifelse三个关键字。

Python 3.10 中增加了一种新的构造分支结构的方式,通过使用matchcase 关键字,我们可以轻松的构造出多分支结构。

下面是使用match-case语法实现的代码,虽然作用完全相同,但是代码显得更加简单优雅。

python
status_code = int(input('响应状态码: '))
match status_code:
    case 400: description = 'Bad Request'
    case 401: description = 'Unauthorized'
    case 403: description = 'Forbidden'
    case 404: description = 'Not Found'
    case 405: description = 'Method Not Allowed'
    case 418: description = 'I am a teapot'
    case 429: description = 'Too many requests'
    case _: description = 'Unknown Status Code'
print('状态码描述:', description)

说明:带有_case语句在代码中起到通配符的作用,如果前面的分支都没有匹配上,代码就会来到case _case _的是可选的,并非每种分支结构都要给出通配符选项。如果分支中出现了case _,它只能放在分支结构的最后面,如果它的后面还有其他的分支,那么这些分支将是不可达的。

当然,match-case语法还有很多高级玩法,其中有一个合并模式可以先教给大家。例如,我们要将响应状态码401403404归入一个分支,400405归入到一个分支,其他保持不变,代码还可以这么写。

python
status_code = int(input('响应状态码: '))
match status_code:
    case 400 | 405: description = 'Invalid Request'
    case 401 | 403 | 404: description = 'Not Allowed'
    case 418: description = 'I am a teapot'
    case 429: description = 'Too many requests'
    case _: description = 'Unknown Status Code'
print('状态码描述:', description)

循环结构

for-in循环

如果明确知道循环执行的次数,我们推荐使用for-in循环,例如上面说的那个重复 3600 次的场景,我们可以用下面的代码来实现。 注意,被for-in循环控制的代码块也是通过缩进的方式来构造,这一点跟分支结构中构造代码块的做法是一样的。我们被for-in循环控制的代码块称为循环体,通常循环体中的语句会根据循环的设定被重复执行。

python
"""
每隔1秒输出一次“hello, world”,持续1小时

Author: 骆昊
Version: 1.1
"""
import time

for _ in range(3600):
    print('hello, world')
    time.sleep(1)

需要说明的是,上面代码中的range(3600)可以构造出一个从03599的范围,当我们把这样一个范围放到for-in循环中,就可以通过前面的循环变量i依次取出从03599的整数,这就会让for-in代码块中的语句重复 3600 次。当然,range的用法非常灵活,下面的清单给出了使用range函数的例子:

  • range(101):可以用来产生0100范围的整数,需要注意的是取不到101
  • range(1, 101):可以用来产生1100范围的整数,相当于是左闭右开的设定,即[1, 101)
  • range(1, 101, 2):可以用来产生1100的奇数,其中2是步长(跨度),即每次递增的值,101取不到。
  • range(100, 0, -2):可以用来产生1001的偶数,其中-2是步长(跨度),即每次递减的值,0取不到。

while循环

如果要构造循环结构但是又不能确定循环重复的次数,我们推荐使用while循环。while循环通过布尔值或能产生布尔值的表达式来控制循环,当布尔值或表达式的值为True时,循环体(while语句下方保持相同缩进的代码块)中的语句就会被重复执行,当表达式的值为False时,结束循环。

python
"""
从1到100的整数求和

Version: 1.1
Author: 骆昊
"""
total = 0
i = 1
while i <= 100:
    total += i
    i += 1
print(total)

break和continue

break关键字,它的作用是终止循环结构的执行

关键字continue,它可以用来放弃本次循环后续的代码直接让循环进入下一轮

列表

创建列表

在 Python 中,列表是由一系元素按特定顺序构成的数据序列,这就意味着如果我们定义一个列表类型的变量,可以用它来保存多个数据。在 Python 中,可以使用[]字面量语法来定义列表,列表中的多个元素用逗号进行分隔,代码如下所示。

python
items1 = [35, 12, 99, 68, 55, 35, 87]
items2 = ['Python', 'Java', 'Go', 'Kotlin']
items3 = [100, 12.3, 'Python', True]
print(items1)  # [35, 12, 99, 68, 55, 35, 87]
print(items2)  # ['Python', 'Java', 'Go', 'Kotlin']
print(items3)  # [100, 12.3, 'Python', True]

除此以外,还可以通过 Python 内置的list函数将其他序列变成列表。准确的说,list并不是一个普通的函数,它是创建列表对象的构造器

python
items4 = list(range(1, 10))
items5 = list('hello')
print(items4)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(items5)  # ['h', 'e', 'l', 'l', 'o']

列表的运算

我们可以使用+运算符实现两个列表的拼接,拼接运算会将两个列表中的元素连接起来放到一个列表中,代码如下所示。

python
items5 = [35, 12, 99, 45, 66]
items6 = [45, 58, 29]
items7 = ['Python', 'Java', 'JavaScript']
print(items5 + items6)  # [35, 12, 99, 45, 66, 45, 58, 29]
print(items6 + items7)  # [45, 58, 29, 'Python', 'Java', 'JavaScript']
items5 += items6
print(items5)  # [35, 12, 99, 45, 66, 45, 58, 29]

我们可以使用*运算符实现列表的重复运算,*运算符会将列表元素重复指定的次数,我们在上面的代码中增加两行,如下所示。

python
print(items6 * 3)  # [45, 58, 29, 45, 58, 29, 45, 58, 29]
print(items7 * 2)  # ['Python', 'Java', 'JavaScript', 'Python', 'Java', 'JavaScript']

我们可以使用innot in运算符判断一个元素在不在列表中,我们在上面的代码代码中再增加两行,如下所示。

python
print(29 in items6)  # True
print(99 in items6)  # False
print('C++' not in items7)     # True
print('Python' not in items7)  # False

如果希望一次性访问列表中的多个元素,我们可以使用切片运算。切片运算是形如[start:end:stride]的运算符,其中start代表访问列表元素的起始位置,end代表访问列表元素的终止位置(终止位置的元素无法访问),而stride则代表了跨度,简单的说就是位置的增量,比如我们访问的第一个元素在start位置,那么第二个元素就在start + stride位置,当然start + stride要小于end。我们给上面的代码增加下面的语句,来使用切片运算符访问列表元素。

python
print(items8[1:3:1])     # ['strawberry', 'durian']
print(items8[0:3:1])     # ['apple', 'strawberry', 'durian']
print(items8[0:5:2])     # ['apple', 'durian', 'watermelon']
print(items8[-4:-2:1])   # ['strawberry', 'durian']
print(items8[-2:-6:-1])  # ['peach', 'durian', 'strawberry', 'apple']

如果start值等于0,那么在使用切片运算符时可以将其省略;如果end值等于NN代表列表元素的个数,那么在使用切片运算符时可以将其省略;如果stride值等于1,那么在使用切片运算符时也可以将其省略。所以,下面的代码跟上面的代码作用完全相同。

python
print(items8[1:3])     # ['strawberry', 'durian']
print(items8[:3:1])    # ['apple', 'strawberry', 'durian']
print(items8[::2])     # ['apple', 'durian', 'watermelon']
print(items8[-4:-2])   # ['strawberry', 'durian']
print(items8[-2::-1])  # ['peach', 'durian', 'strawberry', 'apple']

事实上,我们还可以通过切片操作修改列表中的元素,例如我们给上面的代码再加上一行,大家可以看看这里的输出。

python
items8[1:3] = ['x', 'o']
print(items8)  # ['apple', 'x', 'o', 'peach', 'watermelon']

元素的遍历

如果想逐个取出列表中的元素,可以使用for-in循环的,有以下两种做法。

方法一:在循环结构中通过索引运算,遍历列表元素。

python
languages = ['Python', 'Java', 'C++', 'Kotlin']
for index in range(len(languages)):
    print(languages[index])

方法二:直接对列表做循环,循环变量就是列表元素的代表。

python
languages = ['Python', 'Java', 'C++', 'Kotlin']
for language in languages:
    print(language)

列表的方法

添加和删除元素

我们可以使用列表的append方法向列表中追加元素,使用insert方法向列表中插入元素。追加指的是将元素添加到列表的末尾,而插入则是在指定的位置添加新元素,大家可以看看下面的代码。

python
languages = ['Python', 'Java', 'C++']
languages.append('JavaScript')
print(languages)  # ['Python', 'Java', 'C++', 'JavaScript']
languages.insert(1, 'SQL')
print(languages)  # ['Python', 'SQL', 'Java', 'C++', 'JavaScript']

我们可以用列表的remove方法从列表中删除指定元素,需要注意的是,如果要删除的元素并不在列表中,会引发ValueError错误导致程序崩溃,所以建议大家在删除元素时,先用之前讲过的成员运算做一个判断。我们还可以使用pop方法从列表中删除元素,pop方法默认删除列表中的最后一个元素,当然也可以给一个位置,删除指定位置的元素。在使用pop方法删除元素时,如果索引的值超出了范围,会引发IndexError异常,导致程序崩溃。除此之外,列表还有一个clear方法,可以清空列表中的元素,代码如下所示。

python
languages = ['Python', 'SQL', 'Java', 'C++', 'JavaScript']
if 'Java' in languages:
    languages.remove('Java')
if 'Swift' in languages:
    languages.remove('Swift')
print(languages)  # ['Python', 'SQL', C++', 'JavaScript']
languages.pop()
temp = languages.pop(1)
print(temp)       # SQL
languages.append(temp)
print(languages)  # ['Python', C++', 'SQL']
languages.clear()
print(languages)  # []

元素位置和频次

列表的index方法可以查找某个元素在列表中的索引位置,如果找不到指定的元素,index方法会引发ValueError错误;列表的count方法可以统计一个元素在列表中出现的次数,代码如下所示。

python
items = ['Python', 'Java', 'Java', 'C++', 'Kotlin', 'Python']
print(items.index('Python'))     # 0
# 从索引位置1开始查找'Python'
print(items.index('Python', 1))  # 5
print(items.count('Python'))     # 2
print(items.count('Kotlin'))     # 1
print(items.count('Swfit'))      # 0
# 从索引位置3开始查找'Java'
print(items.index('Java', 3))    # ValueError: 'Java' is not in list

元素排序和反转

列表的sort操作可以实现列表元素的排序,而reverse操作可以实现元素的反转,代码如下所示。

python
items = ['Python', 'Java', 'C++', 'Kotlin', 'Swift']
items.sort()
print(items)  # ['C++', 'Java', 'Kotlin', 'Python', 'Swift']
items.reverse()
print(items)  # ['Swift', 'Python', 'Kotlin', 'Java', 'C++']

列表生成式

在 Python 中,列表还可以通过一种特殊的字面量语法来创建,这种语法叫做生成式。下面,我们通过例子来说明使用列表生成式创建列表到底有什么好处。

场景一:创建一个取值范围在199且能被3或者5整除的数字构成的列表。

python
items = []
for i in range(1, 100):
    if i % 3 == 0 or i % 5 == 0:
        items.append(i)
print(items)

使用列表生成式做同样的事情,代码如下所示。

python
items = [i for i in range(1, 100) if i % 3 == 0 or i % 5 == 0]
print(items)

场景二:有一个整数列表nums1,创建一个新的列表nums2nums2中的元素是nums1中对应元素的平方。

python
nums1 = [35, 12, 97, 64, 55]
nums2 = []
for num in nums1:
    nums2.append(num ** 2)
print(nums2)

使用列表生成式做同样的事情,代码如下所示。

python
nums1 = [35, 12, 97, 64, 55]
nums2 = [num ** 2 for num in nums1]
print(nums2)

元组

元组的定义和运算

元组和列表的不同之处在于,元组是不可变类型,这就意味着元组类型的变量一旦定义,其中的元素不能再添加或删除,而且元素的值也不能修改。如果试图修改元组中的元素,将引发TypeError错误,导致程序崩溃。定义元组通常使用形如(x, y, z)的字面量语法,元组类型支持的运算符跟列表是一样的,我们可以看看下面的代码。

python
# 定义一个三元组
t1 = (35, 12, 98)
# 定义一个四元组
t2 = ('骆昊', 45, True, '四川成都')

# 查看变量的类型
print(type(t1))  # <class 'tuple'>
print(type(t2))  # <class 'tuple'>

# 查看元组中元素的数量
print(len(t1))  # 3
print(len(t2))  # 4

# 索引运算
print(t1[0])    # 35
print(t1[2])    # 98
print(t2[-1])   # 四川成都

# 切片运算
print(t2[:2])   # ('骆昊', 43)
print(t2[::3])  # ('骆昊', '四川成都')

# 循环遍历元组中的元素
for elem in t1:
    print(elem)

# 成员运算
print(12 in t1)         # True
print(99 in t1)         # False
print('Hao' not in t2)  # True

# 拼接运算
t3 = t1 + t2
print(t3)  # (35, 12, 98, '骆昊', 43, True, '四川成都')

# 比较运算
print(t1 == t3)            # False
print(t1 >= t3)            # False
print(t1 <= (35, 11, 99))  # False

TIP

需要提醒大家注意的是,()表示空元组,但是如果元组中只有一个元素,需要加上一个逗号,否则()就不是代表元组的字面量语法,而是改变运算优先级的圆括号,所以('hello', )(100, )才是一元组,而('hello')(100)只是字符串和整数。

打包和解包操作

当我们把多个用逗号分隔的值赋给一个变量时,多个值会打包成一个元组类型;当我们把一个元组赋值给多个变量时,元组会解包成多个值然后分别赋给对应的变量,如下面的代码所示。

python
# 打包操作
a = 1, 10, 100
print(type(a))  # <class 'tuple'>
print(a)        # (1, 10, 100)
# 解包操作
i, j, k = a
print(i, j, k)  # 1 10 100

在解包时,如果解包出来的元素个数和变量个数不对应,会引发ValueError异常,错误信息为:too many values to unpack(解包的值太多)或not enough values to unpack(解包的值不足)。

python
a = 1, 10, 100, 1000
# i, j, k = a             # ValueError: too many values to unpack (expected 3)
# i, j, k, l, m, n = a    # ValueError: not enough values to unpack (expected 6, got 4)

有一种解决变量个数少于元素的个数方法,就是使用星号表达式。通过星号表达式,我们可以让一个变量接收多个值,代码如下所示。需要注意两点:首先,用星号表达式修饰的变量会变成一个列表,列表中有0个或多个元素;其次,在解包语法中,星号表达式只能出现一次。

python
a = 1, 10, 100, 1000
i, j, *k = a
print(i, j, k)        # 1 10 [100, 1000]
i, *j, k = a
print(i, j, k)        # 1 [10, 100] 1000
*i, j, k = a
print(i, j, k)        # [1, 10] 100 1000
*i, j = a
print(i, j)           # [1, 10, 100] 1000
i, *j = a
print(i, j)           # 1 [10, 100, 1000]
i, j, k, *l = a
print(i, j, k, l)     # 1 10 100 [1000]
i, j, k, l, *m = a
print(i, j, k, l, m)  # 1 10 100 1000 []

需要说明一点,解包语法对所有的序列都成立,这就意味着我们之前讲的列表、range函数构造的范围序列甚至字符串都可以使用解包语法。大家可以尝试运行下面的代码,看看会出现怎样的结果。

python
a, b, *c = range(1, 10)
print(a, b, c)
a, b, c = [1, 10, 100]
print(a, b, c)
a, *b, c = 'hello'
print(a, b, c)

交换变量的值

交换变量的值是写代码时经常用到的一个操作,但是在很多编程语言中,交换两个变量的值都需要借助一个中间变量才能做到,如果不用中间变量就需要使用比较晦涩的位运算来实现。在 Python 中,交换两个变量ab的值只需要使用如下所示的代码。

a, b = b, a

同理,如果要将三个变量abc的值互换,即b的值赋给ac的值赋给ba的值赋给c,也可以如法炮制。

a, b, c = b, c, a

需要说明的是,上面的操作并没有用到打包和解包语法,Python 的字节码指令中有ROT_TWOROT_THREE这样的指令可以直接实现这个操作,效率是非常高的。但是如果有多于三个变量的值要依次互换,这个时候是没有直接可用的字节码指令的,需要通过打包解包的方式来完成变量之间值的交换。

元组和列表的比较

这里还有一个非常值得探讨的问题,Python 中已经有了列表类型,为什么还需要元组这样的类型呢?

  1. 元组是不可变类型,不可变类型更适合多线程环境,因为它降低了并发访问变量的同步化开销。

  2. 元组是不可变类型,通常不可变类型在创建时间上优于对应的可变类型。我们可以使用timeit模块的timeit函数来看看创建保存相同元素的元组和列表各自花费的时间,timeit函数的number参数表示代码执行的次数。下面的代码中,我们分别创建了保存19的整数的列表和元组,每个操作执行10000000次,统计运行时间。

    import timeit
    
    print('%.3f 秒' % timeit.timeit('[1, 2, 3, 4, 5, 6, 7, 8, 9]', number=10000000))
    print('%.3f 秒' % timeit.timeit('(1, 2, 3, 4, 5, 6, 7, 8, 9)', number=10000000))

当然,Python 中的元组和列表类型是可以相互转换的,我们可以通过下面的代码来完成该操作。

python
infos = ('骆昊', 43, True, '四川成都')
# 将元组转换成列表
print(list(infos))  # ['骆昊', 43, True, '四川成都']

frts = ['apple', 'banana', 'orange']
# 将列表转换成元组
print(tuple(frts))  # ('apple', 'banana', 'orange')

字符串

原始字符串

Python 中有一种以rR开头的字符串,这种字符串被称为原始字符串,意思是字符串中的每个字符都是它本来的含义,没有所谓的转义字符。例如,在字符串'hello\n'中,\n表示换行;而在r'hello\n'中,\n不再表示换行,就是字符\和字符n。大家可以运行下面的代码,看看会输出什么。

python
s1 = '\it \is \time \to \read \now'
s2 = r'\it \is \time \to \read \now'
print(s1)
print(s2)

字符串的运算

Python 语言为字符串类型提供了非常丰富的运算符,有很多运算符跟列表类型的运算符作用类似。例如,我们可以使用+运算符来实现字符串的拼接,可以使用*运算符来重复一个字符串的内容,可以使用innot in来判断一个字符串是否包含另外一个字符串,我们也可以用[][:]运算符从字符串取出某个字符或某些字符。

字符的遍历

如果希望遍历字符串中的每个字符,可以使用for-in循环,有如下所示的两种方式。

方式一:

python
s = 'hello'
for i in range(len(s)):
    print(s[i])

方式二:

python
s = 'hello'
for elem in s:
    print(elem)

格式化

从 Python 3.6 开始,格式化字符串还有更为简洁的书写方式,就是在字符串前加上f来格式化字符串,在这种以f打头的字符串中,{变量名}是一个占位符,会被变量对应的值将其替换掉,代码如下所示。

python
a = 321
b = 123
print(f'{a} * {b} = {a * b}')

集合

创建集合

在 Python 中,创建集合可以使用{}字面量语法,当然,也可以使用 Python 内置函数set来创建一个集合,准确的说set并不是一个函数,而是创建集合对象的构造器,

python
set1 = {1, 2, 3, 3, 3, 2}
print(set1)

set2 = {'banana', 'pitaya', 'apple', 'apple', 'banana', 'grape'}
print(set2)

set3 = set('hello')
print(set3)

set4 = set([1, 2, 2, 3, 3, 3, 2, 1])
print(set4)

set5 = {num for num in range(1, 20) if num % 3 == 0 or num % 7 == 0}
print(set5)

元素的遍历

我们可以通过len函数来获得集合中有多少个元素,但是我们不能通过索引运算来遍历集合中的元素,因为集合元素并没有特定的顺序。当然,要实现对集合元素的遍历,我们仍然可以使用for-in循环,代码如下所示。

python
set1 = {'Python', 'C++', 'Java', 'Kotlin', 'Swift'}
for elem in set1:
    print(elem)

集合的方法

刚才我们说过,Python 中的集合是可变类型,我们可以通过集合的方法向集合添加元素或从集合中删除元素。

python
set1 = {1, 10, 100}

# 添加元素
set1.add(1000)
set1.add(10000)
print(set1)  # {1, 100, 1000, 10, 10000}

# 删除元素
set1.discard(10)
if 100 in set1:
    set1.remove(100)
print(set1)  # {1, 1000, 10000}

# 清空元素
set1.clear()
print(set1)  # set()

说明:删除元素的remove方法在元素不存在时会引发KeyError错误,所以上面的代码中我们先通过成员运算判断元素是否在集合中。集合类型还有一个pop方法可以从集合中随机删除一个元素,该方法在删除元素的同时会返回(获得)被删除的元素,而removediscard方法仅仅是删除元素,不会返回(获得)被删除的元素。

字典

创建和使用字典

Python 中创建字典可以使用{}字面量语法,这一点跟上一节课讲的集合是一样的。但是字典的{}中的元素是以键值对的形式存在的,每个元素由:分隔的两个值构成,:前面是键,:后面是值,代码如下所示。

python
xinhua = {
    '麓': '山脚下',
    '路': '道,往来通行的地方;方面,地区:南~货,外~货;种类:他俩是一~人',
    '蕗': '甘草的别名',
    '潞': '潞水,水名,即今山西省的浊漳河;潞江,水名,即云南省的怒江'
}
print(xinhua)
person = {
    'name': '王大锤',
    'age': 55,
    'height': 168,
    'weight': 60,
    'addr': '成都市武侯区科华北路62号1栋101', 
    'tel': '13122334455',
    'emergence contact': '13800998877'
}
print(person)

当然,如果愿意,我们也可以使用内置函数dict或者是字典的生成式语法来创建字典,代码如下所示。

python
# dict函数(构造器)中的每一组参数就是字典中的一组键值对
person = dict(name='王大锤', age=55, height=168, weight=60, addr='成都市武侯区科华北路62号1栋101')
print(person)  # {'name': '王大锤', 'age': 55, 'height': 168, 'weight': 60, 'addr': '成都市武侯区科华北路62号1栋101'}

# 可以通过Python内置函数zip压缩两个序列并创建字典
items1 = dict(zip('ABCDE', '12345'))
print(items1)  # {'A': '1', 'B': '2', 'C': '3', 'D': '4', 'E': '5'}
items2 = dict(zip('ABCDE', range(1, 10)))
print(items2)  # {'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5}

# 用字典生成式语法创建字典
items3 = {x: x ** 3 for x in range(1, 6)}
print(items3)  # {1: 1, 2: 8, 3: 27, 4: 64, 5: 125}

想知道字典中一共有多少组键值对,仍然是使用len函数;如果想对字典进行遍历,可以用for循环,但是需要注意,for循环只是对字典的键进行了遍历,不过没关系,在学习了字典的索引运算后,我们可以通过字典的键访问它对应的值。

python
person = {
    'name': '王大锤',
    'age': 55,
    'height': 168,
    'weight': 60,
    'addr': '成都市武侯区科华北路62号1栋101'
}
print(len(person))  # 5
for key in person:
    print(key)

字典的方法

字典类型的方法基本上都跟字典的键值对操作相关,其中get方法可以通过键来获取对应的值。跟索引运算不同的是,get方法在字典中没有指定的键时不会产生异常,而是返回None或指定的默认值,代码如下所示。

person = {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
print(person.get('name'))       # 王大锤
print(person.get('sex'))        # None
print(person.get('sex', True))  # True

如果需要获取字典中所有的键,可以使用keys方法;如果需要获取字典中所有的值,可以使用values方法。字典还有一个名为items的方法,它会将键和值组装成二元组,通过该方法来遍历字典中的元素也是非常方便的。

python
person = {'name': '王大锤', 'age': 25, 'height': 178}
print(person.keys())    # dict_keys(['name', 'age', 'height'])
print(person.values())  # dict_values(['王大锤', 25, 178])
print(person.items())   # dict_items([('name', '王大锤'), ('age', 25), ('height', 178)])
for key, value in person.items():
    print(f'{key}:\t{value}')

字典的update方法实现两个字典的合并操作。例如,有两个字典xy,当执行x.update(y)操作时,xy相同的键对应的值会被y中的值更新,而y中有但x中没有的键值对会直接添加到x中,代码如下所示。

python
person1 = {'name': '王大锤', 'age': 55, 'height': 178}
person2 = {'age': 25, 'addr': '成都市武侯区科华北路62号1栋101'}
person1.update(person2)
print(person1)  # {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}

如果使用 Python 3.9 及以上的版本,也可以使用|运算符来完成同样的操作,代码如下所示。

python
person1 = {'name': '王大锤', 'age': 55, 'height': 178}
person2 = {'age': 25, 'addr': '成都市武侯区科华北路62号1栋101'}
person1 |= person2
print(person1)  # {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}

可以通过poppopitem方法从字典中删除元素,前者会返回(获得)键对应的值,但是如果字典中不存在指定的键,会引发KeyError错误;后者在删除元素时,会返回(获得)键和值组成的二元组。字典的clear方法会清空字典中所有的键值对,代码如下所示。

python
person = {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
print(person.pop('age'))  # 25
print(person)             # {'name': '王大锤', 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
print(person.popitem())   # ('addr', '成都市武侯区科华北路62号1栋101')
print(person)             # {'name': '王大锤', 'height': 178}
person.clear()
print(person)             # {}

跟列表一样,从字典中删除元素也可以使用del关键字,在删除元素的时候如果指定的键索引不到对应的值,一样会引发KeyError错误,具体的做法如下所示。

python
person = {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
del person['age']
del person['addr']
print(person)  # {'name': '王大锤', 'height': 178}

函数和模块

我们再来写一个函数,根据给出的三条边的长度判断是否可以构成三角形,如果可以构成三角形则返回True,否则返回False,代码如下所示。

python
def make_judgement(a, b, c):
    """判断三条边的长度能否构成三角形"""
    return a + b > c and b + c > a and a + c > b

上面make_judgement函数有三个参数,这种参数叫做位置参数,在调用函数时通常按照从左到右的顺序依次传入,而且传入参数的数量必须和定义函数时参数的数量相同,如下所示。

python
print(make_judgement(1, 2, 3))  # False
print(make_judgement(4, 5, 6))  # True

如果不想按照从左到右的顺序依次给出abc 三个参数的值,也可以使用关键字参数,通过“参数名=参数值”的形式为函数传入参数,如下所示。

python
print(make_judgement(b=2, c=3, a=1))  # False
print(make_judgement(c=6, b=4, a=5))  # True

在定义函数时,我们可以在参数列表中用/设置强制位置参数positional-only arguments),用*设置命名关键字参数。所谓强制位置参数,就是调用函数时只能按照参数位置来接收参数值的参数;而命名关键字参数只能通过“参数名=参数值”的方式来传递和接收参数,大家可以看看下面的例子。

python
# /前面的参数是强制位置参数
def make_judgement(a, b, c, /):
    """判断三条边的长度能否构成三角形"""
    return a + b > c and b + c > a and a + c > b


# 下面的代码会产生TypeError错误,错误信息提示“强制位置参数是不允许给出参数名的”
# TypeError: make_judgement() got some positional-only arguments passed as keyword arguments
# print(make_judgement(b=2, c=3, a=1))

说明:强制位置参数是 Python 3.8 引入的新特性,在使用低版本的 Python 解释器时需要注意。

python
# *后面的参数是命名关键字参数
def make_judgement(*, a, b, c):
    """判断三条边的长度能否构成三角形"""
    return a + b > c and b + c > a and a + c > b


# 下面的代码会产生TypeError错误,错误信息提示“函数没有位置参数但却给了3个位置参数”
# TypeError: make_judgement() takes 0 positional arguments but 3 were given
# print(make_judgement(1, 2, 3))

可变参数

Python 语言中可以通过星号表达式语法让函数支持可变参数。所谓可变参数指的是在调用函数时,可以向函数传入0个或任意多个参数。将来我们以团队协作的方式开发商业项目时,很有可能要设计函数给其他人使用,但有的时候我们并不知道函数的调用者会向该函数传入多少个参数,这个时候可变参数就能派上用场。

下面的代码演示了如何使用可变位置参数实现对任意多个数求和的add函数,调用函数时传入的参数会保存到一个元组,通过对该元组的遍历,可以获取传入函数的参数。

python
# 用星号表达式来表示args可以接收0个或任意多个参数
# 调用函数时传入的n个参数会组装成一个n元组赋给args
# 如果一个参数都没有传入,那么args会是一个空元组
def add(*args):
    total = 0
    # 对保存可变参数的元组进行循环遍历
    for val in args:
        # 对参数进行了类型检查(数值型的才能求和)
        if type(val) in (int, float):
            total += val
    return total


# 在调用add函数时可以传入0个或任意多个参数
print(add())         # 0
print(add(1))        # 1
print(add(1, 2, 3))  # 6
print(add(1, 2, 'hello', 3.45, 6))  # 12.45

如果我们希望通过“参数名=参数值”的形式传入若干个参数,具体有多少个参数也是不确定的,我们还可以给函数添加可变关键字参数,把传入的关键字参数组装到一个字典中,代码如下所示。

python
# 参数列表中的**kwargs可以接收0个或任意多个关键字参数
# 调用函数时传入的关键字参数会组装成一个字典(参数名是字典中的键,参数值是字典中的值)
# 如果一个关键字参数都没有传入,那么kwargs会是一个空字典
def foo(*args, **kwargs):
    print(args)
    print(kwargs)


foo(3, 2.1, True, name='骆昊', age=43, gpa=4.95)

用模块管理函数

在导入模块时,还可以使用as关键字对模块进行别名,这样我们可以使用更为简短的完全限定名。

python
test.py
import module1 as m1
import module2 as m2

m1.foo()  # hello, world!
m2.foo()  # goodbye, world!

上面两段代码,我们导入的是定义函数的模块,我们也可以使用from...import...语法从模块中直接导入需要使用的函数,代码如下所示。

python
test.py
from module1 import foo

foo()  # hello, world!

from module2 import foo

foo()  # goodbye, world!

Lambda函数

在使用高阶函数的时候,如果作为参数或者返回值的函数本身非常简单,一行代码就能够完成,也不需要考虑对函数的复用,那么我们可以使用 lambda 函数。Python 中的 lambda 函数是没有的名字函数,所以很多人也把它叫做匿名函数,lambda 函数只能有一行代码,代码中的表达式产生的运算结果就是这个匿名函数的返回值。之前的代码中,我们写的is_evensquare函数都只有一行代码,我们可以考虑用 lambda 函数来替换掉它们,代码如下所示。

python
old_nums = [35, 12, 8, 99, 60, 52]
new_nums = list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, old_nums)))
print(new_nums)  # [144, 64, 3600, 2704]

通过上面的代码可以看出,定义 lambda 函数的关键字是lambda,后面跟函数的参数,如果有多个参数用逗号进行分隔;冒号后面的部分就是函数的执行体,通常是一个表达式,表达式的运算结果就是 lambda 函数的返回值,不需要写return 关键字。

装饰器

在 Python 中,使用装饰器很有更为便捷的语法糖(编程语言中添加的某种语法,这种语法对语言的功能没有影响,但是使用更加方法,代码的可读性也更强,我们将其称之为“语法糖”或“糖衣语法”),可以用@装饰器函数将装饰器函数直接放在被装饰的函数上,效果跟上面的代码相同。我们把完整的代码为大家罗列出来,大家可以再看看我们是如何定义和使用装饰器的。

python
import random
import time


def record_time(func):

    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f'{func.__name__}执行时间: {end - start:.2f}秒')
        return result

    return wrapper


@record_time
def download(filename):
    print(f'开始下载{filename}.')
    time.sleep(random.random() * 6)
    print(f'{filename}下载完成.')


@record_time
def upload(filename):
    print(f'开始上传{filename}.')
    time.sleep(random.random() * 8)
    print(f'{filename}上传完成.')


download('MySQL从删库到跑路.avi')
upload('Python从入门到住院.pdf')

面向对象编程

定义类

在 Python 语言中,我们可以使用class关键字加上类名来定义类,通过缩进我们可以确定类的代码块,就如同定义函数那样。在类的代码块中,我们需要写一些函数,我们说过类是一个抽象概念,那么这些函数就是我们对一类对象共同的动态特征的提取。写在类里面的函数我们通常称之为方法,方法就是对象的行为,也就是对象可以接收的消息。方法的第一个参数通常都是self,它代表了接收这个消息的对象本身。

python
class Student:

    def study(self, course_name):
        print(f'学生正在学习{course_name}.')

    def play(self):
        print(f'学生正在玩游戏.')

创建和使用对象

在我们定义好一个类之后,可以使用构造器语法来创建对象,代码如下所示。

python
stu1 = Student()
stu2 = Student()
print(stu1)    # <__main__.Student object at 0x10ad5ac50>
print(stu2)    # <__main__.Student object at 0x10ad5acd0> 
print(hex(id(stu1)), hex(id(stu2)))    # 0x10ad5ac50 0x10ad5acd0

初始化方法

大家可能已经注意到了,刚才我们创建的学生对象只有行为没有属性,如果要给学生对象定义属性,我们可以修改Student类,为其添加一个名为__init__的方法。在我们调用Student类的构造器创建对象时,首先会在内存中获得保存学生对象所需的内存空间,然后通过自动执行__init__方法,完成对内存的初始化操作,也就是把数据放到内存空间中。所以我们可以通过给Student类添加__init__方法的方式为学生对象指定属性,同时完成对属性赋初始值的操作,正因如此,__init__方法通常也被称为初始化方法。

我们对上面的Student类稍作修改,给学生对象添加name(姓名)和age(年龄)两个属性。

python
class Student:
    """学生"""

    def __init__(self, name, age):
        """初始化方法"""
        self.name = name
        self.age = age

    def study(self, course_name):
        """学习"""
        print(f'{self.name}正在学习{course_name}.')

    def play(self):
        """玩耍"""
        print(f'{self.name}正在玩游戏.')

可见性和属性装饰器

在很多面向对象编程语言中,对象的属性通常会被设置为私有(private)或受保护(protected)的成员,简单的说就是不允许直接访问这些属性;对象的方法通常都是公开的(public),因为公开的方法是对象能够接受的消息,也是对象暴露给外界的调用接口,这就是所谓的访问可见性。在 Python 中,可以通过给对象属性名添加前缀下划线的方式来说明属性的访问可见性,例如,可以用__name表示一个私有属性,_name表示一个受保护属性,代码如下所示。

python
class Student:

    def __init__(self, name, age):
        self.__name = name
        self.__age = age

    def study(self, course_name):
        print(f'{self.__name}正在学习{course_name}.')


stu = Student('王大锤', 20)
stu.study('Python程序设计')
print(stu.__name)  # AttributeError: 'Student' object has no attribute '__name'

上面代码的最后一行会引发AttributeError(属性错误)异常,异常消息为:'Student' object has no attribute '__name'。由此可见,以__开头的属性__name相当于是私有的,在类的外面无法直接访问,但是类里面的study方法中可以通过self.__name访问该属性。需要说明的是,大多数使用 Python 语言的人在定义类时,通常不会选择让对象的属性私有或受保护,正如有一句名言说的:“We are all consenting adults here”(大家都是成年人),成年人可以为自己的行为负责,而不需要通过 Python 语言本身来限制访问可见性。事实上,大多数的程序员都认为开放比封闭要好,把对象的属性私有化并非必不可少的东西,所以 Python 语言并没有从语义上做出最严格的限定,也就是说上面的代码如果你愿意,用stu._Student__name的方式仍然可以访问到私有属性__name,有兴趣的读者可以自己试一试

动态属性

Python 语言属于动态语言,维基百科对动态语言的解释是:“在运行时可以改变其结构的语言,例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化”。动态语言非常灵活,目前流行的 Python 和 JavaScript 都是动态语言,除此之外,诸如 PHP、Ruby 等也都属于动态语言,而 C、C++ 等语言则不属于动态语言。

在 Python 中,我们可以动态为对象添加属性,这是 Python 作为动态类型语言的一项特权,代码如下所示。需要提醒大家的是,对象的方法其实本质上也是对象的属性,如果给对象发送一个无法接收的消息,引发的异常仍然是AttributeError

python
class Student:

    def __init__(self, name, age):
        self.name = name
        self.age = age


stu = Student('王大锤', 20)
stu.sex = '男'  # 给学生对象动态添加sex属性

如果不希望在使用对象时动态的为对象添加属性,可以使用 Python 语言中的__slots__魔法。对于Student类来说,可以在类中指定__slots__ = ('name', 'age'),这样Student类的对象只能有nameage属性,如果想动态添加其他属性将会引发异常,代码如下所示。

python
class Student:
    __slots__ = ('name', 'age')

    def __init__(self, name, age):
        self.name = name
        self.age = age


stu = Student('王大锤', 20)
# AttributeError: 'Student' object has no attribute 'sex'
stu.sex = '男'

静态方法和类方法

之前我们在类中定义的方法都是对象方法,换句话说这些方法都是对象可以接收的消息。除了对象方法之外,类中还可以有静态方法和类方法,这两类方法是发给类的消息,二者并没有实质性的区别。在面向对象的世界里,一切皆为对象,我们定义的每一个类其实也是一个对象,而静态方法和类方法就是发送给类对象的消息。那么,什么样的消息会直接发送给类对象呢?

举一个例子,定义一个三角形类,通过传入三条边的长度来构造三角形,并提供计算周长和面积的方法。计算周长和面积肯定是三角形对象的方法,这一点毫无疑问。但是在创建三角形对象时,传入的三条边长未必能构造出三角形,为此我们可以先写一个方法来验证给定的三条边长是否可以构成三角形,这种方法很显然就不是对象方法,因为在调用这个方法时三角形对象还没有创建出来。我们可以把这类方法设计为静态方法或类方法,也就是说这类方法不是发送给三角形对象的消息,而是发送给三角形类的消息,代码如下所示。

python
class Triangle(object):
    """三角形"""

    def __init__(self, a, b, c):
        """初始化方法"""
        self.a = a
        self.b = b
        self.c = c

    @staticmethod
    def is_valid(a, b, c):
        """判断三条边长能否构成三角形(静态方法)"""
        return a + b > c and b + c > a and a + c > b

    # @classmethod
    # def is_valid(cls, a, b, c):
    #     """判断三条边长能否构成三角形(类方法)"""
    #     return a + b > c and b + c > a and a + c > b

    def perimeter(self):
        """计算周长"""
        return self.a + self.b + self.c

    def area(self):
        """计算面积"""
        p = self.perimeter() / 2
        return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5

上面的代码使用staticmethod装饰器声明了is_valid方法是Triangle类的静态方法,如果要声明类方法,可以使用classmethod装饰器(如上面的代码15~18行所示)。可以直接使用类名.方法名的方式来调用静态方法和类方法,二者的区别在于,类方法的第一个参数是类对象本身,而静态方法则没有这个参数。简单的总结一下,对象方法、类方法、静态方法都可以通过“类名.方法名”的方式来调用,区别在于方法的第一个参数到底是普通对象还是类对象,还是没有接受消息的对象。静态方法通常也可以直接写成一个独立的函数,因为它并没有跟特定的对象绑定。

这里做一个补充说明,我们可以给上面计算三角形周长和面积的方法添加一个property装饰器(Python 内置类型),这样三角形类的perimeterarea就变成了两个属性,不再通过调用方法的方式来访问,而是用对象访问属性的方式直接获得,修改后的代码如下所示。

python
class Triangle(object):
    """三角形"""

    def __init__(self, a, b, c):
        """初始化方法"""
        self.a = a
        self.b = b
        self.c = c

    @staticmethod
    def is_valid(a, b, c):
        """判断三条边长能否构成三角形(静态方法)"""
        return a + b > c and b + c > a and a + c > b

    @property
    def perimeter(self):
        """计算周长"""
        return self.a + self.b + self.c

    @property
    def area(self):
        """计算面积"""
        p = self.perimeter / 2
        return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5


t = Triangle(3, 4, 5)
print(f'周长: {t.perimeter}')
print(f'面积: {t.area}')

继承和多态

面向对象的编程语言支持在已有类的基础上创建新类,从而减少重复代码的编写。提供继承信息的类叫做父类(超类、基类),得到继承信息的类叫做子类(派生类、衍生类)。例如,我们定义一个学生类和一个老师类,我们会发现他们有大量的重复代码,而这些重复代码都是老师和学生作为人的公共属性和行为,所以在这种情况下,我们应该先定义人类,再通过继承,从人类派生出老师类和学生类,代码如下所示。

python
class Person:
    """人"""

    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def eat(self):
        print(f'{self.name}正在吃饭.')
    
    def sleep(self):
        print(f'{self.name}正在睡觉.')


class Student(Person):
    """学生"""
    
    def __init__(self, name, age):
        super().__init__(name, age)
    
    def study(self, course_name):
        print(f'{self.name}正在学习{course_name}.')


class Teacher(Person):
    """老师"""

    def __init__(self, name, age, title):
        super().__init__(name, age)
        self.title = title
    
    def teach(self, course_name):
        print(f'{self.name}{self.title}正在讲授{course_name}.')



stu1 = Student('白元芳', 21)
stu2 = Student('狄仁杰', 22)
tea1 = Teacher('武则天', 35, '副教授')
stu1.eat()
stu2.sleep()
tea1.eat()
stu1.study('Python程序设计')
tea1.teach('Python程序设计')
stu2.study('数据科学导论')

在子类的初始化方法中,我们可以通过super().__init__()来调用父类初始化方法,super函数是 Python 内置函数中专门为获取当前对象的父类对象而设计的。从上面的代码可以看出,子类除了可以通过继承得到父类提供的属性和方法外,还可以定义自己特有的属性和方法,所以子类比父类拥有的更多的能力。在实际开发中,我们经常会用子类对象去替换掉一个父类对象,这是面向对象编程中一个常见的行为,也叫做“里氏替换原则”(Liskov Substitution Principle)。

子类继承父类的方法后,还可以对方法进行重写(重新实现该方法),不同的子类可以对父类的同一个方法给出不同的实现版本,这样的方法在程序运行时就会表现出多态行为(调用相同的方法,做了不同的事情)。多态是面向对象编程中最精髓的部分

对象的序列化和反序列化

读写JSON格式的数据

在Python中,如果要将字典处理成JSON格式(以字符串形式存在),可以使用json模块的dumps函数,代码如下所示。

python
import json

my_dict = {
    'name': '骆昊',
    'age': 40,
    'friends': ['王大锤', '白元芳'],
    'cars': [
        {'brand': 'BMW', 'max_speed': 240},
        {'brand': 'Audi', 'max_speed': 280},
        {'brand': 'Benz', 'max_speed': 280}
    ]
}
print(json.dumps(my_dict))

运行上面的代码,输出如下所示,可以注意到中文字符都是用Unicode编码显示的。

python
{"name": "\u9a86\u660a", "age": 40, "friends": ["\u738b\u5927\u9524", "\u767d\u5143\u82b3"], "cars": [{"brand": "BMW", "max_speed": 240}, {"brand": "Audi", "max_speed": 280}, {"brand": "Benz", "max_speed": 280}]}

json模块有四个比较重要的函数,分别是:

  • dump - 将Python对象按照JSON格式序列化到文件中
  • dumps - 将Python对象处理成JSON格式的字符串
  • load - 将文件中的JSON数据反序列化成对象
  • loads - 将字符串的内容反序列化成Python对象

ujson

Python标准库中的json模块在数据序列化和反序列化时性能并不是非常理想,为了解决这个问题,可以使用三方库ujson来替换json

Python对正则表达式的支持

Python 提供了re模块来支持正则表达式相关操作,下面是re模块中的核心函数。

函数说明
compile(pattern, flags=0)编译正则表达式返回正则表达式对象
match(pattern, string, flags=0)用正则表达式匹配字符串 成功返回匹配对象 否则返回None
search(pattern, string, flags=0)搜索字符串中第一次出现正则表达式的模式 成功返回匹配对象 否则返回None
split(pattern, string, maxsplit=0, flags=0)用正则表达式指定的模式分隔符拆分字符串 返回列表
sub(pattern, repl, string, count=0, flags=0)用指定的字符串替换原字符串中与正则表达式匹配的模式 可以用count指定替换的次数
fullmatch(pattern, string, flags=0)match函数的完全匹配(从字符串开头到结尾)版本
findall(pattern, string, flags=0)查找字符串所有与正则表达式匹配的模式 返回字符串的列表
finditer(pattern, string, flags=0)查找字符串所有与正则表达式匹配的模式 返回一个迭代器
purge()清除隐式编译的正则表达式的缓存
re.I / re.IGNORECASE忽略大小写匹配标记
re.M / re.MULTILINE多行匹配标记
python
"""
要求:用户名必须由字母、数字或下划线构成且长度在6~20个字符之间,QQ号是5~12的数字且首位不能为0
"""
import re

username = input('请输入用户名: ')
qq = input('请输入QQ号: ')
# match函数的第一个参数是正则表达式字符串或正则表达式对象
# match函数的第二个参数是要跟正则表达式做匹配的字符串对象
m1 = re.match(r'^[0-9a-zA-Z_]{6,20}$', username)
if not m1:
    print('请输入有效的用户名.')
# fullmatch函数要求字符串和正则表达式完全匹配
# 所以正则表达式没有写起始符和结束符
m2 = re.fullmatch(r'[1-9]\d{4,11}', qq)
if not m2:
    print('请输入有效的QQ号.')
if m1 and m2:
    print('你输入的信息是有效的!')

提示: 上面在书写正则表达式时使用了“原始字符串”的写法(在字符串前面加上了r),所谓“原始字符串”就是字符串中的每个字符都是它原始的意义,说得更直接一点就是字符串中没有所谓的转义字符啦。因为正则表达式中有很多元字符和需要进行转义的地方,如果不使用原始字符串就需要将反斜杠写作\\,例如表示数字的\d得书写成\\d,这样不仅写起来不方便,阅读的时候也会很吃力。

例子2:从一段文字中提取出国内手机号码。

python
import re

# 创建正则表达式对象,使用了前瞻和回顾来保证手机号前后不应该再出现数字
pattern = re.compile(r'(?<=\D)1[34578]\d{9}(?=\D)')
sentence = '''重要的事情说8130123456789遍,我的手机号是13512346789这个靓号,
不是15600998765,也不是110或119,王大锤的手机号才是15600998765。'''
# 方法一:查找所有匹配并保存到一个列表中
tels_list = re.findall(pattern, sentence)
for tel in tels_list:
    print(tel)
print('--------华丽的分隔线--------')

# 方法二:通过迭代器取出匹配对象并获得匹配的内容
for temp in pattern.finditer(sentence):
    print(temp.group())
print('--------华丽的分隔线--------')

# 方法三:通过search函数指定搜索位置找出所有匹配
m = pattern.search(sentence)
while m:
    print(m.group())
    m = pattern.search(sentence, m.end())

例子3:替换字符串中的不良内容

python
import re

sentence = 'Oh, shit! 你是傻逼吗? Fuck you.'
purified = re.sub('fuck|shit|[傻煞沙][比笔逼叉缺吊碉雕]',
                  '*', sentence, flags=re.IGNORECASE)
print(purified)  # Oh, *! 你是*吗? * you.

例子4:拆分长字符串

python
import re

poem = '窗前明月光,疑是地上霜。举头望明月,低头思故乡。'
sentences_list = re.split(r'[,。]', poem)
sentences_list = [sentence for sentence in sentences_list if sentence]
for sentence in sentences_list:
    print(sentence)

上下文管理器

可以使用with上下文管理器语法在文件操作完成后自动执行文件对象的close方法,这样可以让代码变得更加简单优雅,因为不需要再写finally代码块来执行关闭文件释放资源的操作。需要提醒大家的是,并不是所有的对象都可以放在with上下文语法中,只有符合上下文管理器协议(有__enter____exit__魔术方法)的对象才能使用这种语法,

python
try:
    with open('致橡树.txt', 'r', encoding='utf-8') as file:
        print(file.read())
except FileNotFoundError:
    print('无法打开指定的文件!')
except LookupError:
    print('指定了未知的编码!')
except UnicodeDecodeError:
    print('读取文件时解码错误!')

Python中的并发编程

线程和进程

我们通过操作系统运行一个程序会创建出一个或多个进程,进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动。简单的说,进程是操作系统分配存储空间的基本单位,每个进程都有自己的地址空间、数据栈以及其他用于跟踪进程执行的辅助数据;操作系统管理所有进程的执行,为它们合理的分配资源。一个进程可以通过 fork 或 spawn 的方式创建新的进程来执行其他的任务,不过新的进程也有自己独立的内存空间,因此两个进程如果要共享数据,必须通过进程间通信机制来实现,具体的方式包括管道、信号、套接字等。

一个进程还可以拥有多个执行线索,简单的说就是拥有多个可以获得 CPU 调度的执行单元,这就是所谓的线程。由于线程在同一个进程下,它们可以共享相同的上下文,因此相对于进程而言,线程间的信息共享和通信更加容易。当然在单核 CPU 系统中,多个线程不可能同时执行,因为在某个时刻只有一个线程能够获得 CPU,多个线程通过共享 CPU 执行时间的方式来达到并发的效果。

这里,我们还需要跟大家再次强调两个概念:并发(concurrency)和并行(parallel)。并发通常是指同一时刻只能有一条指令执行,但是多个线程对应的指令被快速轮换地执行。比如一个处理器,它先执行线程 A 的指令一段时间,再执行线程 B 的指令一段时间,再切回到线程 A 执行一段时间。由于处理器执行指令的速度和切换的速度极快,人们完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这就使得宏观上看起来多个线程在同时运行,但微观上其实只有一个线程在执行。并行是指同一时刻,有多条指令在多个处理器上同时执行,并行必须要依赖于多个处理器,不论是从宏观上还是微观上,多个线程可以在同一时刻一起执行的。很多时候,我们并不用严格区分并发和并行两个词,所以我们有时候也把 Python 中的多线程、多进程以及异步 I/O 都视为实现并发编程的手段,但实际上前面两者也可以实现并行编程,当然这里还有一个全局解释器锁(GIL)的问题,

使用场景对比

CPU密集型任务(如数学计算、图像处理)

选择进程

由于Python的GIL(全局解释器锁),多线程无法真正并行执行CPU密集型任务。多进程可绕过GIL限制,利用多核CPU提升性能。

示例:视频编码、科学计算

I/O密集型任务(如网络请求、文件读写)

选择线程

I/O操作大部分时间在等待响应,线程切换开销小,可高效利用等待时间执行其他任务。

示例:批量下载文件、数据库查询

多线程编程

Python 标准库中threading模块的Thread类可以帮助我们非常轻松的实现多线程编程。我们用一个联网下载文件的例子来对比使用多线程和不使用多线程到底有什么区别,代码如下所示。

不使用多线程的下载。

python
import random
import time


def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.')
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.')


def main():
    start = time.time()
    download(filename='Python从入门到住院.pdf')
    download(filename='MySQL从删库到跑路.avi')
    download(filename='Linux从精通到放弃.mp4')
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')


if __name__ == '__main__':
    main()

运行上面的代码,可以得到如下所示的运行结果。可以看出,当我们的程序只有一个工作线程时,每个下载任务都需要等待上一个下载任务执行结束才能开始,所以程序执行的总耗时是三个下载任务各自执行时间的总和。

事实上,上面的三个下载任务之间并没有逻辑上的因果关系,三者是可以“并发”的,下一个下载任务没有必要等待上一个下载任务结束,为此,我们可以使用多线程编程来改写上面的代码。

python
import random
import time
from threading import Thread


def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.')
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.')


def main():
    threads = [
        Thread(target=download, kwargs={'filename': 'Python从入门到住院.pdf'}),
        Thread(target=download, kwargs={'filename': 'MySQL从删库到跑路.avi'}),
        Thread(target=download, kwargs={'filename': 'Linux从精通到放弃.mp4'})
    ]
    start = time.time()
    # 启动三个线程
    for thread in threads:
        thread.start()
    # 等待线程结束
    for thread in threads:
        thread.join()
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')


if __name__ == '__main__':
    main()

通过上面的运行结果可以发现,整个程序的执行时间几乎等于耗时最长的一个下载任务的执行时间,这也就意味着,三个下载任务是并发执行的,不存在一个等待另一个的情况,这样做很显然提高了程序的执行效率。简单的说,如果程序中有非常耗时的执行单元,而这些耗时的执行单元之间又没有逻辑上的因果关系,即 B 单元的执行不依赖于 A 单元的执行结果,那么 A 和 B 两个单元就可以放到两个不同的线程中,让他们并发的执行。这样做的好处除了减少程序执行的等待时间,还可以带来更好的用户体验,因为一个单元的阻塞不会造成程序的“假死”,因为程序中还有其他的单元是可以运转的。

使用 Thread 类创建线程对象

通过上面的代码可以看出,直接使用Thread类的构造器就可以创建线程对象,而线程对象的start()方法可以启动一个线程。线程启动后会执行target参数指定的函数,当然前提是获得 CPU 的调度;如果target指定的线程要执行的目标函数有参数,需要通过args参数为其进行指定,对于关键字参数,可以通过kwargs参数进行传入。Thread类的构造器还有很多其他的参数,我们遇到的时候再为大家进行讲解,目前需要大家掌握的,就是targetargskwargs

继承 Thread 类自定义线程

除了上面的代码展示的创建线程的方式外,还可以通过继承Thread类并重写run()方法的方式来自定义线程,具体的代码如下所示。

python
import random
import time
from threading import Thread


class DownloadThread(Thread):

    def __init__(self, filename):
        self.filename = filename
        super().__init__()

    def run(self):
        start = time.time()
        print(f'开始下载 {self.filename}.')
        time.sleep(random.randint(3, 6))
        print(f'{self.filename} 下载完成.')
        end = time.time()
        print(f'下载耗时: {end - start:.3f}秒.')


def main():
    threads = [
        DownloadThread('Python从入门到住院.pdf'),
        DownloadThread('MySQL从删库到跑路.avi'),
        DownloadThread('Linux从精通到放弃.mp4')
    ]
    start = time.time()
    # 启动三个线程
    for thread in threads:
        thread.start()
    # 等待线程结束
    for thread in threads:
        thread.join()
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')


if __name__ == '__main__':
    main()

使用线程池

我们还可以通过线程池的方式将任务放到多个线程中去执行,通过线程池来使用线程应该是多线程编程最理想的选择。事实上,线程的创建和释放都会带来较大的开销,频繁的创建和释放线程通常都不是很好的选择。利用线程池,可以提前准备好若干个线程,在使用的过程中不需要再通过自定义的代码创建和释放线程,而是直接复用线程池中的线程。Python 内置的concurrent.futures模块提供了对线程池的支持,代码如下所示。

python
import random
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Thread


def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.')
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.')


def main():
    with ThreadPoolExecutor(max_workers=4) as pool:
        filenames = ['Python从入门到住院.pdf', 'MySQL从删库到跑路.avi', 'Linux从精通到放弃.mp4']
        start = time.time()
        for filename in filenames:
            pool.submit(download, filename=filename)
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')


if __name__ == '__main__':
    main()

守护线程

所谓“守护线程”就是在主线程结束的时候,不值得再保留的执行线程。这里的不值得保留指的是守护线程会在其他非守护线程全部运行结束之后被销毁,它守护的是当前进程内所有的非守护线程。简单的说,守护线程会跟随主线程一起挂掉,而主线程的生命周期就是一个进程的生命周期。如果不理解,我们可以看一段简单的代码。

python
import time
from threading import Thread


def display(content):
    while True:
        print(content, end='', flush=True)
        time.sleep(0.1)


def main():
    Thread(target=display, args=('Ping', )).start()
    Thread(target=display, args=('Pong', )).start()


if __name__ == '__main__':
    main()

TIP

说明:上面的代码中,我们将print函数的参数flush设置为True,这是因为flush参数的值如果为False,而print又没有做换行处理,就会导致每次print输出的内容被放到操作系统的输出缓冲区,直到缓冲区被输出的内容塞满,才会清空缓冲区产生一次输出。上述现象是操作系统为了减少 I/O 中断,提升 CPU 利用率做出的设定,为了让代码产生直观交互,我们才将flush参数设置为True,强制每次输出都清空输出缓冲区。

上面的代码运行起来之后是不会停止的,因为两个子线程中都有死循环,除非你手动中断代码的执行。但是,如果在创建线程对象时,将名为daemon的参数设置为True,这两个线程就会变成守护线程,那么在其他线程结束时,即便有死循环,两个守护线程也会挂掉,不会再继续执行下去,代码如下所示。

python
import time
from threading import Thread


def display(content):
    while True:
        print(content, end='', flush=True)
        time.sleep(0.1)


def main():
    Thread(target=display, args=('Ping', ), daemon=True).start()
    Thread(target=display, args=('Pong', ), daemon=True).start()
    time.sleep(5)


if __name__ == '__main__':
    main()

资源竞争

在编写多线程代码时,不可避免的会遇到多个线程竞争同一个资源(对象)的情况。在这种情况下,如果没有合理的机制来保护被竞争的资源,那么就有可能出现非预期的状况。下面的代码创建了100个线程向同一个银行账户(初始余额为0元)转账,每个线程转账金额为1元。在正常的情况下,我们的银行账户最终的余额应该是100元,但是运行下面的代码我们并不能得到100元这个结果。

python
import time

from concurrent.futures import ThreadPoolExecutor


class Account(object):
    """银行账户"""

    def __init__(self):
        self.balance = 0.0

    def deposit(self, money):
        """存钱"""
        new_balance = self.balance + money
        time.sleep(0.01)
        self.balance = new_balance


def main():
    """主函数"""
    account = Account()
    with ThreadPoolExecutor(max_workers=16) as pool:
        for _ in range(100):
            pool.submit(account.deposit, 1)
    print(account.balance)


if __name__ == '__main__':
    main()

上面代码中的Account类代表了银行账户,它的deposit方法代表存款行为,参数money代表存入的金额,该方法通过time.sleep函数模拟受理存款需要一段时间。我们通过线程池的方式启动了100个线程向一个账户转账,但是上面的代码并不能运行出100这个我们期望的结果,这就是在多个线程竞争一个资源的时候,可能会遇到的数据不一致的问题。注意上面代码的第14行,当多个线程都执行到这行代码时,它们会在相同的余额上执行加上存入金额的操作,这就会造成“丢失更新”现象,即之前修改数据的成果被后续的修改给覆盖掉了,所以才得不到正确的结果。

要解决上面的问题,可以使用锁机制,通过锁对操作数据的关键代码加以保护。Python 标准库的threading模块提供了LockRLock类来支持锁机制,这里我们不去深究二者的区别,建议大家直接使用RLock。接下来,我们给银行账户添加一个锁对象,通过锁对象来解决刚才存款时发生“丢失更新”的问题,代码如下所示。

python
import time

from concurrent.futures import ThreadPoolExecutor
from threading import RLock


class Account(object):
    """银行账户"""

    def __init__(self):
        self.balance = 0.0
        self.lock = RLock()

    def deposit(self, money):
        # 获得锁
        self.lock.acquire()
        try:
            new_balance = self.balance + money
            time.sleep(0.01)
            self.balance = new_balance
        finally:
            # 释放锁
            self.lock.release()


def main():
    """主函数"""
    account = Account()
    with ThreadPoolExecutor(max_workers=16) as pool:
        for _ in range(100):
            pool.submit(account.deposit, 1)
    print(account.balance)


if __name__ == '__main__':
    main()

上面代码中,获得锁和释放锁的操作也可以通过上下文语法来实现,使用上下文语法会让代码更加简单优雅,这也是我们推荐大家使用的方式。

python
import time

from concurrent.futures import ThreadPoolExecutor
from threading import RLock


class Account(object):
    """银行账户"""

    def __init__(self):
        self.balance = 0.0
        self.lock = RLock()

    def deposit(self, money):
        # 通过上下文语法获得锁和释放锁
        with self.lock:
            new_balance = self.balance + money
            time.sleep(0.01)
            self.balance = new_balance


def main():
    """主函数"""
    account = Account()
    with ThreadPoolExecutor(max_workers=16) as pool:
        for _ in range(100):
            pool.submit(account.deposit, 1)
    print(account.balance)


if __name__ == '__main__':
    main()

GIL问题

如果使用官方的 Python 解释器(通常称之为 CPython)运行 Python 程序,我们并不能通过使用多线程的方式将 CPU 的利用率提升到逼近400%(对于4核 CPU)或逼近800%(对于8核 CPU)这样的水平,因为 CPython 在执行代码时,会受到 GIL(全局解释器锁)的限制。具体的说,CPython 在执行任何代码时,都需要对应的线程先获得 GIL,然后每执行100条(字节码)指令,CPython 就会让获得 GIL 的线程主动释放 GIL,这样别的线程才有机会执行。因为 GIL 的存在,无论你的 CPU 有多少个核,我们编写的 Python 代码也没有机会真正并行的执行。

GIL 是官方 Python 解释器在设计上的历史遗留问题,要解决这个问题,让多线程能够发挥 CPU 的多核优势,需要重新实现一个不带 GIL 的 Python 解释器。这个问题按照官方的说法,在 Python 发布4.0版本时会得到解决,就让我们拭目以待吧。当下,对于 CPython 而言,如果希望充分发挥 CPU 的多核优势,可以考虑使用多进程,因为每个进程都对应一个 Python 解释器,因此每个进程都有自己独立的 GIL,这样就可以突破 GIL 的限制。在下一个章节中,我们会为大家介绍关于多进程的相关知识,并对多线程和多进程的代码及其执行效果进行比较。

多进程

创建进程

在 Python 中可以基于Process类来创建进程,虽然进程和线程有着本质的差别,但是Process类和Thread类的用法却非常类似。在使用Process类的构造器创建对象时,也是通过target参数传入一个函数来指定进程要执行的代码,而argskwargs参数可以指定该函数使用的参数值。

python
from multiprocessing import Process, current_process
from time import sleep


def sub_task(content, nums):
    # 通过current_process函数获取当前进程对象
    # 通过进程对象的pid和name属性获取进程的ID号和名字
    print(f'PID: {current_process().pid}')
    print(f'Name: {current_process().name}')
    # 通过下面的输出不难发现,每个进程都有自己的nums列表,进程之间本就不共享内存
    # 在创建子进程时复制了父进程的数据结构,三个进程从列表中pop(0)得到的值都是20
    counter, total = 0, nums.pop(0)
    print(f'Loop count: {total}')
    sleep(0.5)
    while counter < total:
        counter += 1
        print(f'{counter}: {content}')
        sleep(0.01)


def main():
    nums = [20, 30, 40]
    # 创建并启动进程来执行指定的函数
    Process(target=sub_task, args=('Ping', nums)).start()
    Process(target=sub_task, args=('Pong', nums)).start()
    # 在主进程中执行sub_task函数
    sub_task('Good', nums)


if __name__ == '__main__':
    main()

多进程和多线程的比较

对于爬虫这类 I/O 密集型任务来说,使用多进程并没有什么优势;但是对于计算密集型任务来说,多进程相比多线程,在效率上会有显著的提升,我们可以通过下面的代码来加以证明。下面的代码会通过多线程和多进程两种方式来判断一组大整数是不是质数,很显然这是一个计算密集型任务,我们将任务分别放到多个线程和多个进程中来加速代码的执行,让我们看看多线程和多进程的代码具体表现有何不同。

我们先实现一个多线程的版本,代码如下所示。

python
import concurrent.futures

PRIMES = [
    1116281,
    1297337,
    104395303,
    472882027,
    533000389,
    817504243,
    982451653,
    112272535095293,
    112582705942171,
    112272535095293,
    115280095190773,
    115797848077099,
    1099726899285419
] * 5


def is_prime(n):
    """判断素数"""
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return n != 1


def main():
    """主函数"""
    with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
        for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)):
            print('%d is prime: %s' % (number, prime))


if __name__ == '__main__':
    main()

假设上面的代码保存在名为example.py的文件中,在 Linux 或 macOS 系统上,可以使用time python example.py命令执行程序并获得操作系统关于执行时间的统计,在我的 macOS 上,某次的运行结果的最后一行输出如下所示。

bash
python example09.py  38.69s user 1.01s system 101% cpu 39.213 total

从运行结果可以看出,多线程的代码只能让 CPU 利用率达到100%,这其实已经证明了多线程的代码无法利用 CPU 多核特性来加速代码的执行,我们再看看多进程的版本,我们将上面代码中的线程池(ThreadPoolExecutor)更换为进程池(ProcessPoolExecutor)。

多进程的版本。

python
import concurrent.futures

PRIMES = [
    1116281,
    1297337,
    104395303,
    472882027,
    533000389,
    817504243,
    982451653,
    112272535095293,
    112582705942171,
    112272535095293,
    115280095190773,
    115797848077099,
    1099726899285419
] * 5


def is_prime(n):
    """判断素数"""
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return n != 1


def main():
    """主函数"""
    with concurrent.futures.ProcessPoolExecutor(max_workers=16) as executor:
        for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)):
            print('%d is prime: %s' % (number, prime))


if __name__ == '__main__':
    main()

我们仍然通过time python example.py的方式来执行上述代码,运行结果的最后一行如下所示。

bash
python example09.py 106.63s user 0.57s system 389% cpu 27.497 total

可以看出,多进程的版本在我使用的这台电脑上,让 CPU 的利用率达到了将近400%,而运行代码时用户态耗费的 CPU 的时间(106.63秒)几乎是代码运行总时间(27.497秒)的4倍,从这两点都可以看出,我的电脑使用了一款4核的 CPU。当然,要知道自己的电脑有几个 CPU 或几个核,可以直接使用下面的代码。

python
import os

print(os.cpu_count())

综上所述,多进程可以突破 GIL 的限制,充分利用 CPU 多核特性,对于计算密集型任务,这一点是相当重要的。常见的计算密集型任务包括科学计算、图像处理、音视频编解码等,如果这些计算密集型任务本身是可以并行的,那么使用多进程应该是更好的选择。

进程间通信

在讲解进程间通信之前,先给大家一个任务:启动两个进程,一个输出“Ping”,一个输出“Pong”,两个进程输出的“Ping”和“Pong”加起来一共有50个时,就结束程序。听起来是不是非常简单,但是实际编写代码时,由于多个进程之间不能够像多个线程之间直接通过共享内存的方式交换数据,所以下面的代码是达不到我们想要的结果的。

python
from multiprocessing import Process
from time import sleep

counter = 0


def sub_task(string):
    global counter
    while counter < 50:
        print(string, end='', flush=True)
        counter += 1
        sleep(0.01)

        
def main():
    Process(target=sub_task, args=('Ping', )).start()
    Process(target=sub_task, args=('Pong', )).start()


if __name__ == '__main__':
    main()

上面的代码看起来没毛病,但是最后的结果是“Ping”和“Pong”各输出了50个。再次提醒大家,当我们在程序中创建进程的时候,子进程会复制父进程及其所有的数据结构,每个子进程有自己独立的内存空间,这也就意味着两个子进程中各有一个counter变量,它们都会从0加到50,所以结果就可想而知了。要解决这个问题比较简单的办法是使用multiprocessing模块中的Queue类,它是可以被多个进程共享的队列,底层是通过操作系统底层的管道和信号量(semaphore)机制来实现的,代码如下所示。

python
import time
from multiprocessing import Process, Queue


def sub_task(content, queue):
    counter = queue.get()
    while counter < 50:
        print(content, end='', flush=True)
        counter += 1
        queue.put(counter)
        time.sleep(0.01)
        counter = queue.get()


def main():
    queue = Queue()
    queue.put(0)
    p1 = Process(target=sub_task, args=('Ping', queue))
    p1.start()
    p2 = Process(target=sub_task, args=('Pong', queue))
    p2.start()
    while p1.is_alive() and p2.is_alive():
        pass
    queue.put(50)


if __name__ == '__main__':
    main()

总结

在 Python 中,我们还可以通过subprocess模块的call函数执行其他的命令来创建子进程,相当于就是在我们的程序中调用其他程序,这里我们暂不探讨这些知识,有兴趣的读者可以自行研究。

对于Python开发者来说,以下情况需要考虑使用多线程:

  1. 程序需要维护许多共享的状态(尤其是可变状态),Python 中的列表、字典、集合都是线程安全的(多个线程同时操作同一个列表、字典或集合,不会引发错误和数据问题),所以使用线程而不是进程维护共享状态的代价相对较小。
  2. 程序会花费大量时间在 I/O 操作上,没有太多并行计算的需求且不需占用太多的内存。

那么在遇到下列情况时,应该考虑使用多进程:

  1. 程序执行计算密集型任务(如:音视频编解码、数据压缩、科学计算等)。
  2. 程序的输入可以并行的分成块,并且可以将运算结果合并。
  3. 程序在内存使用方面没有任何限制且不强依赖于 I/O 操作(如读写文件、套接字等)。

协程

爬虫是典型的 I/O 密集型任务,I/O 密集型任务的特点就是程序会经常性的因为 I/O 操作而进入阻塞状态,比如我们之前使用requests获取页面代码或二进制内容,发出一个请求之后,程序必须要等待网站返回响应之后才能继续运行,如果目标网站不是很给力或者网络状况不是很理想,那么等待响应的时间可能会很久,而在这个过程中整个程序是一直阻塞在那里,没有做任何的事情。通过前面的课程,我们已经知道了可以通过多线程的方式为爬虫提速,使用多线程的本质就是,当一个线程阻塞的时候,程序还有其他的线程可以继续运转,因此整个程序就不会在阻塞和等待中浪费了大量的时间。

事实上,还有一种非常适合 I/O 密集型任务的并发编程方式,我们称之为异步编程,你也可以将它称为异步 I/O。这种方式并不需要启动多个线程或多个进程来实现并发,它是通过多个子程序相互协作的方式来提升 CPU 的利用率,解决了 I/O 密集型任务 CPU 利用率很低的问题,我一般将这种方式称为“协作式并发”。这里,我不打算探讨操作系统的各种 I/O 模式,因为这对很多读者来说都太过抽象;但是我们得先抛出两组概念给大家,一组叫做“阻塞”和“非阻塞”,一组叫做“同步”和“异步”。

基本概念

阻塞

阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的。阻塞随时都可能发生,最典型的就是 I/O 中断(包括网络 I/O 、磁盘 I/O 、用户输入等)、休眠操作、等待某个线程执行结束,甚至包括在 CPU 切换上下文时,程序都无法真正的执行,这就是所谓的阻塞。

非阻塞

程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。显然,某个操作的阻塞可能会导程序耗时以及效率低下,所以我们会希望把它变成非阻塞的。

同步

不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,我们称这些程序单元是同步执行的。例如前面讲过的给银行账户存钱的操作,我们在代码中使用了“锁”作为通信信号,让多个存钱操作强制排队顺序执行,这就是所谓的同步。

异步

不同程序单元在执行过程中无需通信协调,也能够完成一个任务,这种方式我们就称之为异步。例如,使用爬虫下载页面时,调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是不相关的,也无需相互通知协调。很显然,异步操作的完成时刻和先后顺序并不能确定。

很多人都不太能准确的把握这几个概念,这里我们简单的总结一下,同步与异步的关注点是消息通信机制,最终表现出来的是“有序”和“无序”的区别;阻塞和非阻塞的关注点是程序在等待消息时状态,最终表现出来的是程序在等待时能不能做点别的。如果想深入理解这些内容,推荐大家阅读经典著作《UNIX网络编程》,这本书非常的赞。

协程

Python 3.5版本中,引入了两个非常有意思的元素,一个叫async,一个叫await,它们在Python 3.7版本中成为了正式的关键字。通过这两个关键字,可以简化协程代码的编写,可以用更为简单的方式让多个子程序很好的协作起来。我们通过一个例子来加以说明,请大家先看看下面的代码。

python
import time


def display(num):
    time.sleep(1)
    print(num)


def main():
    start = time.time()
    for i in range(1, 10):
        display(i)
    end = time.time()
    print(f'{end - start:.3f}秒')


if __name__ == '__main__':
    main()

上面的代码每次执行都会依次输出19的数字,每个间隔1秒钟,整个代码需要执行大概需要9秒多的时间,这一点我相信大家都能看懂。不知道大家是否意识到,这段代码就是以同步和阻塞的方式执行的,同步可以从代码的输出看出来,而阻塞是指在调用display函数发生休眠时,整个代码的其他部分都不能继续执行,必须等待休眠结束。

接下来,我们尝试用异步的方式改写上面的代码,让display函数以异步的方式运转。

python
import asyncio
import time


async def display(num):
    await asyncio.sleep(1)
    print(num)


def main():
    start = time.time()
    objs = [display(i) for i in range(1, 10)]
    loop = asyncio.get_event_loop()
    loop.run_until_complete(asyncio.wait(objs))
    loop.close()
    end = time.time()
    print(f'{end - start:.3f}秒')


if __name__ == '__main__':
    main()

Python 中的asyncio模块提供了对异步 I/O 的支持。上面的代码中,我们首先在display函数前面加上了async关键字使其变成一个异步函数,调用异步函数不会执行函数体而是获得一个协程对象。我们将display函数中的time.sleep(1)修改为await asyncio.sleep(1),二者的区别在于,后者不会让整个代码陷入阻塞,因为await操作会让其他协作的子程序有获得 CPU 资源而得以运转的机会。为了让这些子程序可以协作起来,我们需要将他们放到一个事件循环(实现消息分派传递的系统)上,因为当协程遭遇 I/O 操作阻塞时,就会到事件循环中监听 I/O 操作是否完成,并注册自身的上下文以及自身的唤醒函数(以便恢复执行),之后该协程就变为阻塞状态。上面的第12行代码创建了9个协程对象并放到一个列表中,第13行代码通过asyncio模块的get_event_loop函数获得了系统的事件循环,第14行通过asyncio模块的run_until_complete函数将协程对象挂载到事件循环上。执行上面的代码会发现,9个分别会阻塞1秒钟的协程总共只阻塞了约1秒种的时间,因为阻塞的协程对象会放弃对 CPU 的占有而不是让 CPU 处于闲置状态,这种方式大大的提升了 CPU 的利用率。而且我们还会注意到,数字并不是按照从19的顺序打印输出的,这正是我们想要的结果,说明它们是异步执行的。对于爬虫这样的 I/O 密集型任务来说,这种协作式并发在很多场景下是比使用多线程更好的选择,因为这种做法减少了管理和维护多个线程以及多个线程切换所带来的开销。