Python - 100天从新手到大师

引言

这里主要是依托于 jackfrued 仓库 Python-100-Days 进行学习,记录自己的学习过程和心得体会。

Day1

1 Python 简介

Python(英式发音:/ˈpaɪθən/;美式发音:/ˈpaɪθɑːn/)是由荷兰人吉多·范罗苏姆(Guido von Rossum)发明的一种编程语言,是目前世界上最受欢迎和拥有最多用户的编程语言。Python 强调代码的可读性和语法的简洁性,相较于 C、C++、Java 这些同样影响深远的编程语言,Python 让使用者能够用更少的代码表达自己的意图。下面是几个权威的编程语言排行榜给出的 Python 语言的排名,其中第1张图由 TIOBE Index 提供,第3张图由 IEEE Spectrum 提供。值得一提的是第2张图,它展示了编程语言在全球最大代码托管平台 GitHub 上受欢迎的程度,最近的四年时间 Python 语言都占据了冠军的宝座。

alt text

alt text

1.1 Python 编年史

  1. 1989年

    • 荷兰程序员吉多·范罗苏姆在圣诞假期开始开发新编程语言
    • 名称源自喜剧《Monty Python’s Flying Circus》
  2. 1991年

    • 2月20日发布Python 0.9.0
    • 首次具备类继承、异常处理等现代特性
    • 确立"Batteries included"哲学
  3. 1994年

    • Python 1.0发布
    • 引入函数式编程工具lambda, map, filter
    • 形成开源社区雏形
  4. 2000年

    • Python 2.0发布
    • 新增垃圾回收机制
    • 标准库突破2万个函数
    • Zope成为首个企业级Python产品
  5. 2003年

    • NumPy库发布
    • 科学计算领域开始大规模采用
    • NASA、劳伦斯国家实验室成为早期用户
  6. 2008年

    • Python 3.0(代号Py3K)发布
    • 核心革新:统一Unicode编码,print改为函数
    • 向后不兼容引发社区长期讨论
  7. 2010年

    • Flask框架诞生
    • PyPI仓库突破1万个软件包
    • Instagram全面转向Python技术栈
  8. 2013年

    • Pandas 0.12重塑数据处理标准
    • Jupyter Notebook成为科研标配
    • Netflix推荐系统Python化
  9. 2016年

    • TensorFlow 1.0确立AI框架标准
    • Python占据91%机器学习框架市场
    • RedMonk语言排行榜首次登顶
  10. 2019年

    • Python 2.7正式退役
    • 结束长达10年的2.x/3.x并行期
  11. 2020年

    • Python 3.9发布
    • 引入模式匹配(match/case)
    • 官方支持Mypy静态类型检查
  12. 2021年

    • Python 3.10推出结构化模式匹配
    • 语言创建者吉多·范罗苏姆加入微软
    • 社区启动解释器性能优化计划
  13. 2022年

    • Python 3.11发布
    • 解释器速度平均提升25%
    • 引入Exception Groups语法
    • TIOBE年度语言冠军
  14. 2024年

    • Python 3.12引入JIT编译器雏形
    • 全球用户突破2500万
    • 教育领域采用率达87%
    • 持续主导AI大模型开发

1.2 Python优缺点

Python 语言的优点很多,简单为大家列出几点。

  1. 简单优雅,跟其他很多编程语言相比,Python 更容易上手。
  2. 能用更少的代码做更多的事情,提升开发效率。
  3. 开放源代码,拥有强大的社区和生态圈。
  4. 能够做的事情非常多,有极强的适应性。
  5. 胶水语言,能够黏合其他语言开发的东西。
  6. 解释型语言,更容易跨平台,能够在多种操作系统上运行。

Python 最主要的缺点是执行效率低(解释型语言的通病),如果更看重代码的执行效率,C、C++ 或 Go 可能是你更好的选择。

1.2 安装 Python 环境

建议直接使用 Anaconda 配置环境,Anaconda 是一个开源的 Python 发行版,它包含了大量的科学计算库,同时它还提供了一个包管理器和环境管理器,可以方便地安装和管理 Python 包和环境。

这里我给出一些参考链接:

2 Python 语言中的变量

对于想学习编程的新手来说,有两个问题可能是他们很想知道的,其一是"什么是(计算机)程序",其二是"写(计算机)程序能做什么"。先说说我对这两个问题的理解:程序是数据和指令的有序集合,写程序就是用数据和指令控制计算机做我们想让它做的事情。今时今日,为什么有那么多人选择用 Python 语言来写程序,因为 Python 语言足够简单和强大。相较于 C、C++、Java 这样的编程语言,Python 对初学者和非专业人士更加友好,很多问题在 Python 语言中都能找到简单优雅的解决方案。接下来,我们就从最基础的语言元素开始,带大家认识和使用 Python 语言。

2.1 一些常识

在开始系统的学习 Python 编程之前,我们先来科普一些计算机的基础知识。计算机的硬件系统通常由五大部件构成,包括:运算器、控制器、存储器、输入设备和输出设备。其中,运算器和控制器放在一起就是我们常说的中央处理器(CPU),它的功能是执行各种运算和控制指令。刚才我们提到过,程序是指令的集合,写程序就是将一系列的指令按照某种方式组织到一起,然后通过这些指令去控制计算机做我们想让它做的事情。存储器可以分为内部存储器和外部存储器,前者就是我们常说的内存,它是中央处理器可以直接寻址的存储空间,程序在执行的过程中,对应的数据和指令需要加载到内存中。输入设备和输出设备经常被统称为 I/O 设备,键盘、鼠标、麦克风、摄像头是典型的输入设备,而显示器、打印机、扬声器等则是典型的输出设备。目前,我们使用的计算机基本大多是遵循"冯·诺依曼体系结构"的计算机,这种计算机有两个关键点:一是将存储器与中央处理器分开;二是将数据以二进制方式编码

二进制是一种"逢二进一"的计数法,跟人类使用的"逢十进一"的计数法本质是一样的。人类因为有十根手指,所以使用了十进制计数法,在计数时十根手指用完之后,就只能用进位的方式来表示更大的数值。当然凡事都有例外,玛雅人可能是因为长年光着脚的原因,把脚趾头也都用上了,于是他们使用了二十进制的计数法。基于这样的计数方式,玛雅人使用的历法跟我们平常使用的历法就产生了差异。按照玛雅人的历法,2012 年是上一个所谓的"太阳纪"的最后一年,而 2013 年则是新的"太阳纪"的开始。后来这件事情还被以讹传讹的方式误传为"2012 年是玛雅人预言的世界末日"的荒诞说法。今天有很多人猜测,玛雅文明之所以发展缓慢跟使用了二十进制是有关系的。对于计算机来说,二进制在物理器件上最容易实现的,因为可以用高电压表示 1,用低电压表示 0。不是所有写程序的人都需要熟悉二进制,熟悉十进制与二进制、八进制、十六进制的转换,大多数时候我们即便不了解这些知识也能写程序。但是,我们必须知道,计算机是使用二进制计数的,不管什么样的数据,到了计算机内存中都是以二进制形态存在的。

说明:关于二进制计数法以及它与其他进制如何相互转换,大家可以翻翻名为《计算机导论》或《计算机文化》的书,都能找到相应的知识,此处就不再进行赘述了,不清楚的读者可以自行研究。

2.2 变量和类型

要想在计算机的内存中保存数据,首先得说一说变量这个概念。在编程语言中,变量是数据的载体,简单的说就是一块用来保存数据的内存空间,变量的值可以被读取和修改,这是所有运算和控制的基础。计算机能处理的数据有很多种类型,最常见的就是数值,除了数值之外还有文本、图像、音频、视频等各种各样的数据类型。虽然数据在计算机中都是以二进制形态存在的,但是我们可以用不同类型的变量来表示数据类型的差异。Python 语言中预设了多种数据类型,也允许我们自定义新的数据类型,这一点在后面会讲到。我们首先来了解几种 Python 中最为常用的数据类型。

  1. 整型(int):Python 中可以处理任意大小的整数,而且支持二进制(如0b100,换算成十进制是4)、八进制(如0o100,换算成十进制是64)、十进制(100)和十六进制(0x100,换算成十进制是256)的表示法。运行下面的代码,看看会输出什么。
1
2
3
4
print(0b100) # 二进制整数
print(0o100) # 八进制整数
print(100) # 十进制整数
print(0x100) # 十六进制整数

alt text

  1. 浮点型(float):浮点数也就是小数,之所以称为浮点数,是因为按照科学记数法表示时,一个浮点数的小数点位置是可变的,浮点数除了数学写法(如123.456)之外还支持科学计数法(如1.23456e2,表示1.23456×102\small{1.23456 \times 10^{2}})。运行下面的代码,看看会输出什么。
1
2
print(123.456)
print(1.23456e2)

alt text

  1. 字符串型(str):字符串是以单引号或双引号包裹起来的任意文本,比如’hello’和"hello"。
1
2
print('hello')
print("hello")

alt text

  1. 布尔型(bool):布尔型只有True、False两种值,要么是True,要么是False,可以用来表示现实世界中的"是"和"否",命题的"真"和"假",状况的"好"与"坏",水平的"高"与"低"等等。如果一个变量的值只有两种状态,我们就可以使用布尔型。
1
2
print(int(True))
print(int(False))

alt text

2.3 变量命名

对于每个变量,我们都需要给它取一个名字,就如同我们每个人都有自己的名字一样。在 Python 中,变量命名需要遵循以下的规则和惯例。

  • 规则部分:
    • 规则1:变量名由字母、数字和下划线构成,数字不能开头。需要说明的是,这里说的字母指的是 Unicode 字符,Unicode 称为万国码,囊括了世界上大部分的文字系统,这也就意味着中文、日文、希腊字母等都可以作为变量名中的字符,但是一些特殊字符(如:!、@、#等)是不能出现在变量名中的。我们强烈建议大家把这里说的字母理解为尽可能只使用英文字母
    • 规则2:Python 是大小写敏感的编程语言,简单的说就是大写的A和小写的a是两个不同的变量,这一条其实并不算规则,而是需要大家注意的地方。
    • 规则3:变量名不要跟 Python 的关键字重名,尽可能避开 Python 的保留字。这里的关键字是指在 Python 程序中有特殊含义的单词(如:isifelseforwhileTrueFalse等),保留字主要指 Python 语言内置函数、内置模块等的名字(如:intprintinputstrmathos等)。
  • 惯例部分:
    • 惯例1:变量名应该具有可读性,能够反映出变量的含义,比如一个表示年龄的变量,我们就可以命名为age,而不是a或者x
    • 惯例2:变量名应该避免使用缩写,除非缩写非常常见且易于理解,比如namenm要好,agea要好。
    • 惯例3:变量名通常使用小写英文字母,多个单词用下划线进行连接。
    • 惯例4:受保护的变量用单个下划线开头
    • 惯例5:私有的变量用两个下划线开头

2.4 变量的使用

下面通过例子来说明变量的类型和变量的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
"""
使用变量保存数据并进行加减乘除运算

Version: 1.0
Author: Penry
"""
a = 45 # 定义变量a,赋值45
b = 12 # 定义变量b,赋值12
print(a, b) # 45 12
print(a + b) # 57
print(a - b) # 33
print(a * b) # 540
print(a / b) # 3.75

alt text

在 Python 中可以使用type函数对变量的类型进行检查。程序设计中函数的概念跟数学上函数的概念非常类似,数学上的函数相信大家并不陌生,它包括了函数名、自变量和因变量。如果暂时不理解函数这个概念也不要紧,我们会在后续的内容中专门讲解函数的定义和使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
使用type函数检查变量的类型

Version: 1.0
Author: Penry
"""
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'>

alt text

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

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

下面的例子为大家演示了 Python 中类型转换的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"""
变量的类型转换操作

Version: 1.0
Author: Penry
"""
a = 100
b = 123.45
c = '123'
d = '100'
e = '123.45'
f = 'hello, world'
g = True
print(float(a)) # int类型的100转成float,输出100.0
print(int(b)) # float类型的123.45转成int,输出123
print(int(c)) # str类型的'123'转成int,输出123
print(int(c, base=16)) # str类型的'123'按十六进制转成int,输出291
print(int(d, base=2)) # str类型的'100'按二进制转成int,输出4
print(float(e)) # str类型的'123.45'转成float,输出123.45
print(bool(f)) # str类型的'hello, world'转成bool,输出True
print(int(g)) # bool类型的True转成int,输出1
print(chr(a)) # int类型的100转成str,输出'd'
print(ord('d')) # str类型的'd'转成int,输出100

alt text

说明:str类型转int类型时可以通过base参数来指定进制,可以将字符串视为对应进制的整数进行转换。str类型转成bool类型时,只要字符串有内容,不是’'或"",对应的布尔值都是Truebool类型转int类型时,True会变成1,False会变成0。在 ASCII 字符集和 Unicode 字符集中, 字符'd'对应的编码都是100

3 Python语言中的运算符

Python 语言支持很多种运算符,下面的表格按照运算符的优先级从高到低,对 Python 中的运算符进行了罗列。有了变量和运算符,我们就可以构造各种各样的表达式来解决实际问题。在计算机科学中,表达式是计算机程序中的句法实体,它由一个或多个常量、变量、函数和运算符组合而成,编程语言可以对其进行解释和计算以得到另一个值。不理解这句话没有关系,但是一定要知道,不管使用什么样的编程语言,构造表达式都是非常重要的。

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

说明: 所谓优先级就是在一个运算的表达式中,如果出现了多个运算符,应该先执行什么再执行什么的顺序。编写代码的时候,如果搞不清楚一个表达式中运算符的优先级,可以使用圆括号(小括号)来确保运算的执行顺序。

3.1 算术运算符

Python 中的算术运算符非常丰富,除了大家最为熟悉的加、减、乘、除之外,还有整除运算符、求模(求余数)运算符和求幂运算符。下面的例子为大家展示了算术运算符的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
"""
算术运算符

Version: 1.0
Author: Penry
"""
print(321 + 12) # 加法运算,输出333
print(321 - 12) # 减法运算,输出309
print(321 * 12) # 乘法运算,输出3852
print(321 / 12) # 除法运算,输出26.75
print(321 // 12) # 整除运算,输出26
print(321 % 12) # 求模运算,输出9
print(321 ** 12) # 求幂运算,输出1196906950228928915420617322241

alt text

算术运算需要先乘除后加减,这一点跟数学课本中讲的知识没有区别,也就是说乘除法的运算优先级是高于加减法的。如果还有求幂运算,求幂运算的优先级是高于乘除法的。如果想改变算术运算的执行顺序,可以使用英文输入法状态下的圆括号(小括号),写在圆括号中的表达式会被优先执行,如下面的例子所示。

1
2
3
4
5
6
7
8
9
10
"""
算术运算的优先级

Version: 1.0
Author: Penry
"""
print(2 + 3 * 5) # 17
print((2 + 3) * 5) # 25
print((2 + 3) * 5 ** 2) # 125
print(((2 + 3) * 5) ** 2) # 625

alt text

3.2 赋值运算符

赋值运算符应该是最为常见的运算符,它的作用是将右边的值赋给左边的变量。赋值运算符还可以跟上面的算术运算符放在一起,组合成复合赋值运算符,例如:a += b相当于a = a + ba *= a + 2相当于a = a * (a + 2)。下面的例子演示了赋值运算符和复合赋值运算符的使用。

1
2
3
4
5
6
7
8
9
10
11
"""
赋值运算符和复合赋值运算符

Version: 1.0
Author: Penry
"""
a = 10
b = 3
a += b # 相当于:a = a + b
a *= a + 2 # 相当于:a = a * (a + 2)
print(a) # 大家算一下这里会输出什么

alt text

赋值运算构成的表达式本身不产生任何值,也就是说,如果你把一个赋值表达式放到print函数中试图输出表达式的值,将会产生语法错误。为了解决这个问题,Python 3.8 中引入了一个新的赋值运算符:=,我们称之为海象运算符,大家可以猜一猜它为什么叫这个名字。海象运算符也是将运算符右侧的值赋值给左边的变量,与赋值运算符不同的是,运算符右侧的值也是整个表达式的值,看看下面的代码大家就明白了。

1
2
3
4
5
6
7
8
9
10
11
"""
海象运算符

Version: 1.0
Author: Penry
"""
# SyntaxError: invalid syntax
# print((a = 10))
# 海象运算符
print((a := 10)) # 10
print(a) # 10

alt text

提示:上面第 8 行代码如果不注释掉,运行代码会看到SyntaxError: invalid syntax错误信息,注意,这行代码中我们给a = 10加上了圆括号,如果不小心写成了print(a = 10),会看到TypeError: 'a' is an invalid keyword argument for print()错误信息,后面讲到函数的时候,大家就会明白这个错误提示是什么意思了。

这里给出一个普通赋值运算符和海象运算符实现密码输入检验程序的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
密码输入检验

Version: 1.0
Author: Penry
"""

while True:
psw = input('请输入密码: ')
if psw == '123456':
print('密码正确,进入程序...')
break
else:
print('密码错误,请重新输入...')

alt text

1
2
3
4
5
6
7
8
9
10
11
"""
海象运算符实现密码输入检验

Version: 1.0
Author: Penry
"""

while (psw := input("请输入密码:"))!= '123456':
print("密码错误,请重新输入...")

print("密码正确,进入程序...")

alt text

3.3 比较运算符和逻辑运算符

比较运算符也称为关系运算符,包括==!=<><=>=,我相信大家一看就能懂。需要提醒的是比较相等用的是==,请注意这里是两个等号,因为=是赋值运算符,我们在上面刚刚讲到过。比较不相等用的是!=,跟数学课本中使用的\small{\neq}并不相同,Python 2 中曾经使用过<>来表示不等于,在 Python 3 中使用<>会引发SyntaxError(语法错误)。比较运算符会产生布尔值,要么是True,要么是False

逻辑运算符有三个,分别是andornotand字面意思是"而且",所以and运算符会连接两个布尔值或者产生布尔值的表达式,如果两边的布尔值都是True,那么运算的结果就是True;左右两边的布尔值有一个是False,最终的运算结果就是False注意,如果and运算符左边的布尔值是False,不管右边的布尔值是什么,最终的结果都是False,这时运算符右边的布尔值会被跳过(专业的说法叫短路处理,如果and右边是一个表达式,那么这个表达式不会执行)。or字面意思是"或者",所以or运算符也会连接两个布尔值或产生布尔值的表达式,如果两边的布尔值有任意一个是True,那么最终的结果就是True注意or运算符也是有短路功能的,当它左边的布尔值为True的情况下,右边的布尔值会被短路(如果or右边是一个表达式,那么这个表达式不会执行)。not运算符的后面可以跟一个布尔值,如果not后面的布尔值或表达式是True,那么运算的结果就是False;如果not后面的布尔值或表达式是False,那么运算的结果就是True

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"""
比较运算符和逻辑运算符的使用

Version: 1.0
Author: Penry
"""
flag0 = 1 == 1
flag1 = 3 > 2
flag2 = 2 < 1
flag3 = flag1 and flag2
flag4 = flag1 or flag2
flag5 = not flag0
print('flag0 =', flag0) # flag0 = True
print('flag1 =', flag1) # flag1 = True
print('flag2 =', flag2) # flag2 = False
print('flag3 =', flag3) # flag3 = False
print('flag4 =', flag4) # flag4 = True
print('flag5 =', flag5) # flag5 = False
print(flag1 and not flag2) # True
print(1 > 2 or 2 == 3) # False

alt text

说明:比较运算符的优先级高于赋值运算符,所以上面的flag0 = 1 == 1先做1 == 1产生布尔值True,再将这个值赋值给变量flag0print函数可以输出多个值,多个值之间可以用,进行分隔,输出的内容默认以空格分开。

思考以下问题并与结果对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"""
基础短路验证

Version: 1.0
Author: Penry
"""

def foo():
print("foo 被调用")
return True

def bar():
print("bar 被调用")
return False

# 情况 A
result_A = bar() and foo() # 输出什么?为什么?

# 情况 B
result_B = foo() or bar() # 输出什么?为什么?

alt text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"""
嵌套短路分析

Version: 1.0
Author: Penry
"""

def func1():
print("func1 执行")
return False

def func2():
print("func2 执行")
return True

# 表达式分析
outcome = func1() and func2() or "短路生效"
print(outcome)

alt text

这里在测试过程中发现我们还需要详细学习一下 andor 返回值规则:

  1. and返回值规则​:
    • 如果 ​左操作数为假值​ → ​直接返回左操作数​
    • 如果 ​左操作数为真值​ → ​直接返回右操作数​(无论右操作数的真假)
  2. or返回值规则​:
    • 如果 ​左操作数为真值​ → ​直接返回左操作数​
    • 如果 ​左操作数为假值​ → ​直接返回右操作数​(无论右操作数的真假)
1
2
3
4
5
6
7
8
9
10
11
"""
and 和 or 返回值测试

Version: 1.0
Author: Penry
"""

print(n := ("1234" and "5678"))
print(m := (0 and "1234"))
print(x := ("2345" or "6789"))
print(y := (False or 0))

alt text

3.4 运算符和表达式应用

例子1:华氏温度转摄氏温度

要求:输入华氏温度将其转换为摄氏温度,华氏温度到摄氏温度的转换公式为: C=(F32)/1.8\small{C = (F - 32) / 1.8}

1
2
3
4
5
6
7
8
9
10
"""
华氏温度转摄氏温度

Version: 1.0
Author: Penry
"""

f = float(input("请输入华氏温度:"))
c = (f - 32) / 1.8
print("华氏温度为%.1f度,对应的摄氏温度为%.1f度" % (f, c))

alt text

说明:上面代码中的input函数用于从键盘接收用户输入,由于输入的都是字符串,如果想处理成浮点小数来做后续的运算,可以用我们上一课讲解的类型转换的方法,用float函数将str类型处理成float类型。

上面的代码中,我们对print函数输出的内容进行了格式化处理,print输出的字符串中有两个%.1f占位符,这两个占位符会被%之后的(f, c)中的两个float类型的变量值给替换掉,浮点数小数点后保留1位有效数字。如果字符串中有%d占位符,那么我们会用int类型的值替换掉它,如果字符串中有%s占位符,那么它会被str类型的值替换掉。

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

1
2
3
4
5
6
7
8
9
"""
将华氏温度转换为摄氏温度

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

alt text

例子2:计算圆的周长和面积

要求:输入一个圆的半径(r\small{r}),计算出它的周长( 2πr\small{2 \pi r} )和面积( πr2\small{\pi r^{2}} )。

1
2
3
4
5
6
7
8
9
10
11
12
"""
输入半径计算圆的周长和面积

Version: 1.0
Author: Penry
"""
radius = float(input('请输入圆的半径: '))
perimeter = 2 * 3.1416 * radius
area = 3.1416 * radius * radius
print('半径: %.2f' % radius)
print('周长: %.2f' % perimeter)
print('面积: %.2f' % area)

alt text

Python 中有一个名为math 的内置模块,该模块中定义了名为pi的变量,它的值就是圆周率。如果要使用 Python 内置的这个pi,我们可以对上面的代码稍作修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
输入半径计算圆的周长和面积

Version: 1.1
Author: Penry
"""
import math

radius = float(input('请输入圆的半径: '))
perimeter = 2 * math.pi * radius
area = math.pi * radius ** 2
print(f'半径: {radius:.2f}')
print(f'周长: {perimeter:.2f}')
print(f'面积: {area:.2f}')

alt text

说明:上面代码中的import math表示导入math模块,导入该模块以后,才能用math.pi得到圆周率的值。

这里其实还有一种格式化输出的方式,是 Python 3.8 中增加的新特性,大家直接看下面的代码就明白了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
输入半径计算圆的周长和面积

Version: 1.2
Author: Penry
"""
import math

radius = float(input('请输入圆的半径: '))
perimeter = 2 * math.pi * radius
area = math.pi * radius ** 2
print(f'{radius = :.2f}')
print(f'{perimeter = :.2f}')
print(f'{area = :.2f}')

alt text

说明:假如变量a的值是9.87,那么字符串f'{a = }'的值是a = 9.87;而字符串f'{a = :.1f}'的值是a = 9.9。这种格式化输出的方式会同时输出变量名和变量值。

例子3:判断闰年

要求:输入一个 1582 年以后的年份,判断该年份是不是闰年。

1
2
3
4
5
6
7
8
9
10
"""
闰年判断

Version: 1.0
Author: Penry
"""

year = int(input("请输入年份:"))
is_leap = (year % 4 == 0 and year % 100 != 0) or year % 400 == 0
print(f'{is_leap = }')

alt text

说明:对于格里历(Gregorian calendar),即今天我们使用的公历,判断闰年的规则是:1. 公元年份非 4 的倍数是平年;2. 公元年份为 4 的倍数但非 100 的倍数是闰年;3. 公元年份为 400 的倍数是闰年。格里历是由教皇格里高利十三世在 1582 年 10 月引入的,作为对儒略历(Julian calendar)的修改和替代,我们在输入年份时要注意这一点。上面的代码通过%来判断year是不是4的倍数、100的倍数、400的倍数,然后用andor运算符将三个条件组装在一起,前两个条件要同时满足,第三个条件跟前两个条件的组合只需满足其中之一。


Day2

1 分支结构

迄今为止,我们写的 Python 程序都是一条一条语句按顺序向下执行的,这种代码结构叫做顺序结构。然而仅有顺序结构并不能解决所有的问题,比如我们设计一个游戏,游戏第一关的过关条件是玩家获得 1000 分,那么在第一关完成后,我们要根据玩家得到的分数来决定是进入第二关,还是告诉玩家"Game Over"(游戏结束)。在这种场景下,我们的代码就会产生两个分支,而且只有一个会被执行。类似的场景还有很多,我们将这种结构称之为"分支结构"或"选择结构"。给大家一分钟的时间,你应该可以想到至少 5 个以上类似的例子,赶紧试一试吧!

1.1 使用 ifelse 构造分支结构

在 Python 中,构造分支结构最常用的是ifelifelse三个关键字。所谓关键字就是编程语言中有特殊含义的单词,很显然你不能够使用它作为变量名。当然,我们并不是每次构造分支结构都会把三个关键字全部用上,我们通过例子加以说明。例如我们要写一个身体质量指数(BMI)的计算器。身体质量质数也叫体质指数,是国际上常用的衡量人体胖瘦程度以及是否健康的一个指标,计算公式如下所示。通常认为 BMI 值低于 18.5 为偏瘦,18.5~24.9 为正常,25~29.9 为偏胖,30 以上为肥胖。

BMI=体重2BMI = \frac{体重}{身高^{2}}

说明:上面公式中的体重以千克(kg)为单位,身高以米(m)为单位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"""
BMI计算器

Version: 1.0
Author: Penry
"""
height = float(input("请输入身高(m):"))
weight = float(input("请输入体重(kg):"))
bmi = weight / (height ** 2)
print(f"您的BMI指数为:{bmi:.2f}")
if bmi < 18.5:
print("您的体重过轻!")
elif bmi < 24.9:
print("您的体重正常!")
elif bmi < 29.9:
print("您的体重过重!")
else:
print("您的体重肥胖!")

alt text

提示:if语句的最后面有一个:,它是用英文输入法输入的冒号;程序中输入的'"=()等特殊字符,都是在英文输入法状态下输入的,这一点之前已经提醒过大家了。很多初学者经常会忽略这一点,等到执行代码时,就会看到一大堆错误提示。当然,认真读一下错误提示还是很容易发现哪里出了问题,但是强烈建议大家在写代码的时候切换到英文输入法,这样可以避免很多不必要的麻烦。

上面的代码中,if后面的条件判断语句就是bmi < 18.5,如果条件成立,那么就执行ifelse之间的代码,否则就执行elseif之间的代码。ifelse之间的代码又称为分支体,它也是由一条或多条语句组成的代码块。ifelse之间的代码块需要用缩进的方式表示出来,缩进方式有两种,一种是空格,一种是Tab。空格和 Tab 之间是有区别的,空格是等宽字符,Tab 是等高字符,所以它们之间不能混用。在 Python 中,缩进是语法的一部分,如果缩进不正确,那么程序就会报错。

1.2 使用 matchcase 构造分支结构

Python 3.10 中增加了一种新的构造分支结构的方式,通过使用matchcase关键字,我们可以轻松的构造出多分支结构。Python 的官方文档在介绍这个新语法时,举了一个 HTTP 响应状态码识别的例子(根据 HTTP 响应状态输出对应的描述),非常有意思。如果不知道什么是 HTTP 响应状态吗,可以看看 MDN 上面的下面我们对官方文档上的示例稍作修改,为大家讲解这个语法,先看看下面用if-else结构实现的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"""
HTTP响应状态码识别

Version: 1.0
Author: Penry
"""

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

alt text

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"""
HTTP响应状态码识别

Version: 1.1
Author: Penry
"""

"""
HTTP响应状态码识别

Version: 1.1
Author: Penry
"""

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)

alt text

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

当然,match-case语法还有很多高级玩法,比如可以匹配多个值,或者匹配一个范围,或者匹配一个模式,或者匹配一个类型,或者匹配一个函数,或者匹配一个对象,或者匹配一个表达式,等等。下面是一个匹配多个值的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"""
HTTP响应状态码识别

Version: 1.2
Author: Penry
"""
HTTP响应状态码识别

Version: 1.2
Author: Penry
"""

status_code = int(input("响应状态码: "))
match status_code:
case 400 | 405:
description = "Invalid Request"
case 401 | 403 | 404:
description = "Not Allowed"
case 418:
description = "I'm a teapot"
case 429:
description = "Too Many Requests"
case _:
description = "Unknown Status Code"
print(f"状态码描述:{description =}")

alt text

1.3 分支结构的应用

1.3.1 示例1:分段函数求值

要求:编写一个程序,输入一个数,输出这个数在分段函数中的值。分段函数如下:

y={3x5if x>1x+2if 1x15x+3if x<1y = \begin{cases} 3x-5 & \text{if } x > 1 \\ x+2 & \text{if } -1 \leq x \leq 1 \\ 5x+3 & \text{if } x < -1 \end{cases}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"""
分段函数求值

Version: 1.0
Author: Penry
"""

x = float(input("x = "))
match x:
case x if x > 1:
y = 3 * x - 5
case x if x >= -1:
y = x + 2
case _:
y = 5 * x + 3
print(f"y = {y}")

alt text

根据实际开发的需要,分支结构是可以嵌套的,也就是说在分支结构的ifelifelse代码块中还可以再次引入分支结构。例如if条件成立表示玩家过关,但过关以后还要根据你获得宝物或者道具的数量对你的表现给出评价(比如点亮一颗、两颗或三颗星星),那么我们就需要在if的内部再构造一个新的分支结构。同理,我们在elifelse中也可以构造新的分支,我们称之为嵌套的分支结构。按照这样的思路,上面的分段函数求值也可以用下面的代码来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"""
分段函数求值

Version: 1.1
Author: Penry
"""

"""
分段函数求值

Version: 1.1
Author: Penry
"""
x = float(input('x = '))
if x > 1:
y = 3 * x - 5
else:
if x >= -1:
y = x + 2
else:
y = 5 * x+3
print('f(%.2f) = %.2f' % (x, y))

alt text

1.3.2 示例2:百分制成绩转等级制成绩

要求:编写一个程序,根据输入的百分制成绩输出对应的等级。等级划分标准如下:

成绩 等级
90-100 A
80-89 B
70-79 C
60-69 D
0-59 E
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"""
百分制成绩转等级制成绩

Version: 1.0
Author: Penry
"""

score = int(input("该学生的成绩是:"))
match score:
case score if score > 100:
print("成绩有误!")
grade = 'Error'
case score if score >= 90:
grade = 'A'
case score if score >= 80:
grade = 'B'
case score if score >= 70:
grade = 'C'
case score if score >= 60:
grade = 'D'
case _:
grade = 'E'
print("该学生的成绩等级是:", grade)

alt text

1.3.3 示例3:输入三条边长,如果能构成三角形就计算周长和面积

要求:输入三条边长,如果能构成三角形就计算周长和面积。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"""
三角形周长和面积计算

Version: 1.0
Author: Penry
"""

a = int(input("a = "))
b = int(input("b = "))
c = int(input("c = "))
if a + b > c and a + c > b and b + c > a:
print("周长: %d" % (a + b + c))
p = (a + b + c) / 2
area = (p * (p - a) * (p - b) * (p - c)) ** 0.5
print("面积: %f" % area)
else:
print("不能构成三角形")

alt text

2 循环结构

我们在写程序的时候,极有可能遇到需要重复执行某条或某些指令的场景,例如我们需要每隔1秒钟在屏幕上输出一次"hello, world"并持续输出一个小时。如下所示的代码可以完成一次这样的操作,如果要持续输出一个小时,我们就需要把这段代码写3600遍,你愿意这么做吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
每隔1秒输出一次"hello, world"

Version: 1.0
Author: Penry
"""

import time

time.sleep(1)
print("Hello World")
time.sleep(1)
print("Hello World")
time.sleep(1)
print("Hello World")

说明:上面的代码中,我们使用了time模块的sleep函数,它可以让程序暂停1秒。

为了应对上述场景中的问题,我们可以在 Python 程序中使用循环结构。所谓循环结构,就是程序中控制某条或某些指令重复执行的结构。有了这样的结构,刚才的代码就不需要写 3600 遍,而是写一遍然后放到循环结构中重复 3600 次。在 Python 语言中构造循环结构有两种做法,一种是for-in循环,另一种是while循环。

2.1 使用 for-in 循环

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

1
2
3
4
5
6
7
8
9
10
11
12
"""
每隔1秒输出一次"Hello,world",持续1小时

Version: 1.1
Author: Penry
"""

import time

for i in range(3600):
time.sleep(1)
print("Hello,world")

alt text

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

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

大家可能已经注意到了,上面代码的输出操作和休眠操作都没有用到循环变量i,对于不需要用到循环变量的for-in循环结构,按照 Python 的编程惯例,我们通常把循环变量命名为_,修改后的代码如下所示。虽然结果没什么变化,但是这样写显得你更加专业,逼格瞬间拉满。

1
2
3
4
5
6
7
8
9
10
11
12
"""
每隔1秒输出一次"Hello,world",持续1小时

Version: 1.2
Author: Penry
"""

import time

for _ in range(3600):
time.sleep(1)
print("Hello,world")

下面,我们用for-in循环实现从 1 到 100 的整数求和,即i=1100i\sum_{i=1}^{100}i

1
2
3
4
5
6
7
8
9
10
"""
求从 1 到 100 的所有整数之和。

Version: 1.0
Author: Penry
"""
sum_num = 0
for i in range(101):
sum_num += i
print(sum_num)

alt text

我们再来写一个求取从 1 到 100 的偶数之和的程序。

1
2
3
4
5
6
7
8
9
10
11
"""
求从 1 到 100 的所有偶数之和。

Version: 1.0
Author: Penry
"""

sum_even = 0
for i in range(0,101,2):
sum_even += i
print(sum_even)

alt text

当然, 更为简单的办法是使用 Python 内置的sum函数求和,这样我们连循环结构都省掉了。

1
2
3
4
5
6
7
8
"""
求从 1 到 100 的所有偶数之和。

Version: 1.1
Author: Penry
"""

print(f"1-100偶数求和为:{sum(range(2, 101, 2))}")

alt text

2.2 使用 while 循环

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

下面我们用while循环来实现从 1 到 100 的偶数求和,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"""
求从 1 到 100 的所有偶数之和。

Version: 1.0
Author: Penry
"""

sum_even = 0
i = 0
num_lst = []
while i <= 100:
num_lst.append(i)
sum_even += i
i += 2
print(num_lst)
print(sum_even)

alt text

这里需要注意,我们根据 num_lst 列表来观察中间变量 i 的值,它从 0 开始,每次循环都增加 2,当 i 的值大于 100 时,循环结束。这里也能发现,在循环外围我们设置 i 的初始值为 0,而在 num_lst 列表中,第一个元素也为0,说明 while 循环是先将起始值赋值给中间变量,再进行条件判断。

相较于for-in循环,上面的代码我们在循环开始前增加了一个变量i,我们使用这个变量来控制循环,所以while后面给出了i <= 100的条件。在while的循环体中,我们除了做累加,还需要让变量i的值递增,所以我们添加了i += 2这条语句,这样i的值就会依次取到0、2、4、……,直到 102。当i变成 102 时,while循环的条件不再成立,代码会离开while循环,此时我们输出变量sum_even的值,它就是从 1 到 100 偶数求和的结果 2550。

2.3 breakcontinue 语句

在循环结构中,我们还可以使用breakcontinue两个关键字来控制循环的流程。其中,break用于终止循环,而continue用于跳过当前循环的剩余语句,直接进入下一次循环。

如果把while循环的条件设置为True,即让条件恒成立会怎么样呢?我们看看下面的代码,还是使用while构造循环结构,计算 1 到 100 的偶数和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"""
求从 1 到 100 的所有偶数之和。

Version: 1.1
Author: Penry
"""

sum_even = 0
num_lst = []
i = 0
while True:
i += 2
if i > 100:
break
else:
num_lst.append(i)
sum_even += i
print(num_lst)
print(sum_even)

alt text

上面的代码中使用while True构造了一个条件恒成立的循环,也就意味着如果不做特殊处理,循环是不会结束的,这就是我们常说的"死循环"。为了在i的值超过 100 后让循环停下来,我们使用了break关键字,它的作用是终止循环结构的执行。需要注意的是,break只能终止它所在的那个循环,这一点在使用嵌套循环结构时需要引起注意,后面我们会讲到什么是嵌套的循环结构。除了break之外,还有另一个在循环结构中可以使用的关键字continue,它可以用来放弃本次循环后续的代码直接让循环进入下一轮,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"""
求从 1 到 100 的所有偶数之和。

Version: 1.2
Author: Penry
"""

"""
求从 1 到 100 的所有偶数之和。

Version: 1.2
Author: Penry
"""

sum_even = 0
sum_lst = []
for i in range(1,101):
if i % 2 == 0:
continue
else:
sum_lst.append(i)
sum_even += i
print(sum_lst)
print(sum_even)

alt text

说明:上面的代码中,我们使用continue关键字来跳过奇数的累加。

2.4 嵌套的循环结构

和分支结构一样,循环结构也是可以嵌套的,也就是说在循环结构中还可以构造循环结构。下面的例子演示了如何通过嵌套的循环来输出一个乘法口诀表(九九表)。

1
2
3
4
5
6
7
8
9
10
11
"""
打印九九乘法表

Version: 1.0
Author: Penry
"""

for i in range(1, 10):
for j in range(1, i + 1):
print(f"{i} × {j} = {i * j}", end="\t")
print()

alt text

上面的代码中,for-in循环的循环体中又用到了for-in循环,外面的循环用来控制产生i行的输出,而里面的循环则用来控制在一行中输出j列。显然,里面的for-in循环的输出就是乘法口诀表中的一整行。所以在里面的循环完成时,我们用了一个print()来实现换行的效果,让下面的输出重新另起一行。

2.5 循环结构的应用

2.5.1 练习题1:判断素数

要求:输入一个正整数判断是不是素数。
提示:素数指的是只能被 1 和自身整除的大于 1 的整数。例如对于正整数 n,我们可以通过在 2n-1 之间寻找有没有 n 的因子,来判断它到底是不是一个素数。当然,循环不用从 2 开始到 n-1 结束,因为对于大于 1 的正整数,因子应该都是成对出现的,所以循环到 n\sqrt{n} 就可以结束了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"""
判断一个数是不是素数

Version: 1.0
Author: Penry
"""

num = int(input("请输入你要判断的整数:"))
end = int(num ** 0.5) + 1
is_prime = True
for i in range(2, end):
if num % i == 0:
is_prime = False
break
if is_prime and num != 1:
print("%d是素数" % num)
else:
print("%d不是素数" % num)

alt text

说明:上面的代码中我们用了布尔型的变量is_prime,我们先将它赋值为True,假设num是一个素数;接下来,我们在 2num ** 0.5 的范围寻找num的因子,如果找到了num的因子,那么它一定不是素数,此时我们将is_prime赋值为False,同时使用break关键字终止循环结构;最后,我们根据is_prime的值是True还是False来给出不同的输出。

2.5.2 练习题2:最大公约数

要求:输入两个正整数,计算它们的最大公约数。
提示:两个数的最大公约数是两个数的公共因子中最大的那个。例如,1218 的最大公约数是 6,因为 1218 的公共因子有 1236,其中最大的因子是 6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"""
输入两个正整数,计算它们的最大公约数。

Version: 1.0
Author: Penry
"""

a = int(input())
b = int(input())
print(f"{a=}{b=}")
if a > b:
a, b = b, a
for factor in range(a, 0, -1):
if a % factor == 0 and b % factor == 0:
print(f"最大公约数为{factor}")
break

alt text

用上面代码的找最大公约数在执行效率是有问题的。假如a的值是999999999998b的值是999999999999,很显然两个数是互质的,最大公约数为 1。但是我们使用上面的代码,循环会重复999999999998次,这通常是难以接受的。我们可以使用欧几里得算法来找最大公约数,它能帮我们更快的得到想要的结果,整体数学原理见补充材料。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
输入两个正整数,计算它们的最大公约数。

Version: 1.1
Author: Penry
"""

a = int(input())
b = int(input())
print(f"{a=}{b=}")
if a > b:
a, b = b, a
while b % a != 0:
b,a = a, b % a
print(f"最大公约数是{a}")

alt text

2.5.3 补充材料:欧几里得算法最大公约数原理的严格证明

欧几里得算法(也称辗转相除法)用于求解两个正整数 aabb 的最大公约数(gcd(a,b)\gcd(a, b)),其核心原理基于以下恒等式:

gcd(a,b)=gcd(b,amodb)\gcd(a, b) = \gcd(b, a \mod b)

其中 amodba \mod b 表示 aa 除以 bb 的余数(记作 rr),满足 0r<b0 \leq r < ba=bq+ra = bq + rqq 为商)。下面提供一个完整的数学证明,分为两部分:正确性证明(即该恒等式成立)和终止性证明(即算法在有限步内结束)。

正确性证明

aabb 是正整数,且 aba \geq b(如果 b>ab > a,交换两者不影响结果)。定义 r=amodbr = a \mod b,则有:

a=bq+r,0r<ba = bq + r, \quad 0 \leq r < b

需要证明:

gcd(a,b)=gcd(b,r)\gcd(a, b) = \gcd(b, r)

证明:

  1. d=gcd(a,b)d = \gcd(a, b)
    dad \mid add 整除 aa)) 且 dbd \mid b
    r=abqr = a - bq(因为 a=bq+ra = bq + r),可得:

    d(abq)    drd \mid (a - bq) \implies d \mid r

    (因为如果 dad \mid adbd \mid b,则 dd 整除它们的线性组合 abqa - bq)。
    因此,dbd \mid bdrd \mid r,所以 ddbbrr 的一个公约数。

  2. e=gcd(b,r)e = \gcd(b, r)
    ebe \mid bere \mid r
    a=bq+ra = bq + r,可得:

    e(bq+r)    eae \mid (bq + r) \implies e \mid a

    (因为如果 ebe \mid bere \mid r,则 ee 整除它们的线性组合 bq+rbq + r)。
    因此,eae \mid aebe \mid b,所以 eeaabb 的一个公约数。

  3. 比较 ddee

    • 因为 d=gcd(a,b)d = \gcd(a, b)aabb 的最大公约数,且 eeaabb 的公约数,故 ede \leq d
    • 因为 e=gcd(b,r)e = \gcd(b, r)bbrr 的最大公约数,且 ddbbrr 的公约数(由步骤 1),故 ded \leq e
      结合两者,有 d=ed = e,即:

    gcd(a,b)=gcd(b,r)\gcd(a, b) = \gcd(b, r)

结论: 对任意正整数 aabb,$$\gcd(a, b) = \gcd(b, a \mod b)$$ 成立。

终止性证明

欧几里得算法通过反复应用恒等式 $$\gcd(a, b) = \gcd(b, r)$$ 逐步减小问题规模,直到余数为 0。算法过程如下:

  • 初始化:设 r2=ar_{-2} = a, r1=br_{-1} = b(假设 aba \geq b)。
  • 迭代:对于第 kk 步(k0k \geq 0),计算:

    rk=rk2modrk1,0rk<rk1r_k = r_{k-2} \mod r_{k-1}, \quad 0 \leq r_k < r_{k-1}

  • 当某步余数 rn=0r_n = 0 时停止,此时 $$\gcd(a, b) = r_{n-1}$$。

证明算法在有限步内终止:

  • 在每一步迭代中,定义余数序列 r0,r1,r2,r_0, r_1, r_2, \ldots,其中:
    • r0=amodbr_0 = a \mod b(初始余数),
    • 后续 rk=rk2modrk1r_k = r_{k-2} \mod r_{k-1}
  • 由余数定义,满足 0rk<rk10 \leq r_k < r_{k-1}(当 rk1>0r_{k-1} > 0 时)。
  • 因此,序列 r0,r1,r2,r_0, r_1, r_2, \ldots 是非负整数序列,并严格递减:

    b>r0>r1>r2>0b > r_0 > r_1 > r_2 > \cdots \geq 0

  • 因为序列递减且非负(所有 rk0r_k \geq 0)),所以余数每次至少减少 1,序列必定在有限步内达到 0。
  • 设迭代次数为 nn,则当 rn=0r_n = 0 时,有:

    gcd(a,b)=gcd(rn1,rn)=gcd(rn1,0)=rn1\gcd(a, b) = \gcd(r_{n-1}, r_n) = \gcd(r_{n-1}, 0) = r_{n-1}

    (因为 gcd(m,0)=m\gcd(m, 0) = m 对于正整数 mm 成立)。

说明: 最坏情况下,算法步数由斐波那契数列控制,但余数序列长度上限为 O(log(min(a,b)))O(\log(\min(a, b))),确保效率。

2.5.3 练习题3:猜数字游戏

要求:计算机出一个1到100之间的随机数,玩家输入自己猜的数字,计算机给出对应的提示信息(大一点、小一点或猜对了),如果玩家猜中了数字,计算机提示"恭喜你,猜对了!"并结束游戏;如果玩家没有猜中数字,计算机提示"游戏结束,游戏结束!"并结束游戏。
提示:计算机随机生成一个1到100之间的整数,玩家输入自己猜的数字,计算机给出对应的提示信息(大一点、小一点或猜对了),如果玩家猜中了数字,计算机提示"恭喜你,猜对了!"并结束游戏;如果玩家没有猜中数字,计算机提示"游戏结束,游戏结束!"并结束游戏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"""
猜数字游戏

Version: 1.0
Author: Penry
"""

import random

answer = random.randint(1,101)
counter = 0
while True:
counter += 1
number = int(input('请输入: '))
if number < answer:
print('请大一些')
elif number > answer:
print('请小一些')
else:
print('恭喜你猜对了!')
break
if counter == 7:
print('你的智商余额明显不足')
break
print('你总共猜了%d次' % counter)

alt text

3 分支和循环结构实战

3.1 实战1:100以内的素数

要求:输出100以内的素数。
说明:素数指的是只能被 1 和自身整除的正整数(不包括 1),之前我们写过判断素数的代码,这里相当于是一个升级版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"""
输出100以内的素数

Version: 1.0
Author: Penry
"""

prime_lst = []

for num in range(2,100):
is_prime = True
for factor in range(2,int(num ** 0.5 +1)):
if num % factor == 0:
is_prime = False
break
if is_prime:
prime_lst.append(num)

print(prime_lst)

alt text

3.2 实战2:斐波那契数列

要求:输出斐波那契数列中的前 20 个数。
说明:斐波那契数列(Fibonacci sequence),通常也被称作黄金分割数列,是意大利数学家莱昂纳多·斐波那契(Leonardoda Fibonacci)在《计算之书》中研究理想假设条件下兔子成长率问题而引入的数列,因此这个数列也常被戏称为“兔子数列”。斐波那契数列的特点是数列的前两个数都是 1,从第三个数开始,每个数都是它前面两个数的和。按照这个规律,斐波那契数列的前 10 个数是:1, 1, 2, 3, 5, 8, 13, 21, 34, 55。斐波那契数列在现代物理、准晶体结构、化学等领域都有直接的应用。

1
2
3
4
5
6
7
8
9
10
11
12
"""
输出斐波那契数列中的前 20 个数

Version: 1.0
Author: Penry
"""

a,b = 0,1

for _ in range(20):
a,b = b, a+b
print(a, end=' ')

alt text

3.3 实战3:寻找水仙花数

要求:找出 100 到 999 范围内的所有水仙花数。
提示:在数论中,水仙花数(narcissistic number)也被称为超完全数字不变数、自恋数、自幂数、阿姆斯特朗数,它是一个 NN 位非负整数,其各位数字的 NN 次方和刚好等于该数本身,例如:153=13+53+33153 = 1^3 + 5^3 + 3^3,所以 153 是一个水仙花数;1634=14+64+34+441634 = 1^4 + 6^4 + 3^4 + 4^4,所以 1634 也是一个水仙花数。对于三位数,解题的关键是将它拆分为个位、十位、百位,再判断是否满足水仙花数的要求,这一点利用 Python 中的//%运算符其实很容易做到。

1
2
3
4
5
6
7
8
9
10
11
12
13
"""
找出100到999范围内的所有水仙花数

Version: 1.0
Author: Penry
"""

for num in range(100, 1000):
a = num // 100
b = num // 10 % 10
c = num % 10
if num == a ** 3 + b ** 3 + c ** 3:
print(num)

alt text

3.4 实战4:不知位数的正整数反转

要求:将一个正整数反转。
提示:将一个正整数反转,例如:123456变成654321。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
将一个正整数反转

Version: 1.0
Author: Penry
"""

num = int(input("请输入要反转的整数: "))
print(num)
reversed_num = 0
while num > 0:
reversed_num = reversed_num * 10 + num % 10
num //= 10
print(reversed_num)

alt text

3.5 实战5:百钱百鸡问题

要求:百钱百鸡是我国古代数学家张丘建在《算经》一书中提出的数学问题:鸡翁一值钱五,鸡母一值钱三,鸡雏三值钱一。百钱买百鸡,问鸡翁、鸡母、鸡雏各几何?
提示:设鸡翁、鸡母、鸡雏的个数分别为 xxyyzz,则 xxyyzz 满足以下条件:

  • x+y+z=100x + y + z = 100
  • 5x+3y+z/3=1005x + 3y + z/3 = 100
  • xxyyzz 都是正整数
1
2
3
4
5
6
7
8
9
10
11
12
"""
百钱白鸡问题

Version: 1.0
Author: Penry
"""

for x in range(0,21):
for y in range(0,34):
for z in range(0,101,3):
if x*5 + y*3 + z//3 == 100 and x + y + z == 100:
print('公鸡:%d只,母鸡:%d只,小鸡:%d只' % (x,y,z))

alt text

上面使用的方法叫做穷举法,也称为暴力搜索法,这种方法通过一项一项的列举备选解决方案中所有可能的候选项,并检查每个候选项是否符合问题的描述,最终得到问题的解。上面的代码中,我们使用了嵌套的循环结构,假设公鸡有x只,显然x的取值范围是 0 到 20,假设母鸡有y只,它的取值范围是 0 到 33,假设小鸡有z只,它的取值范围是 0 到 99 且取值是 3 的倍数。这样,我们设置好 100 只鸡的条件x + y + z == 100,设置好 100 块钱的条件5 * x + 3 * y + z // 3 == 100,当两个条件同时满足时,就是问题的正确答案,我们用print函数输出它。这种方法看起来比较笨拙,但对于运算能力非常强大的计算机来说,通常都是一个可行的甚至是不错的选择,只要问题的解存在就能够找到它。

事实上,上面的代码还有更好的写法,既然我们已经假设公鸡有x只,母鸡有y只,那么小鸡的数量就应该是100 - x - y,这样减少一个条件,我们就可以把上面三层嵌套的for-in循环改写为两层嵌套的for-in循环。循环次数减少了,代码的执行效率就有了显著的提升,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
"""
百钱白鸡问题

Version: 1.1
Author: Penry
"""

for x in range(0, 21):
for y in range(0, 34):
z = 100 - x - y
if z % 3 == 0 and 5 * x + 3 * y + z / 3 == 100:
print("公鸡: %d只, 母鸡: %d只, 小鸡: %d只" % (x, y, z))

alt text

3.6 实战6:CRAPS赌博游戏

说明:CRAPS又称花旗骰,是美国拉斯维加斯非常受欢迎的一种的桌上赌博游戏。该游戏使用两粒骰子,玩家通过摇两粒骰子获得点数进行游戏。简化后的规则是:玩家第一次摇骰子如果摇出了 7 点或 11 点,玩家胜;玩家第一次如果摇出 2 点、3 点或 12 点,庄家胜;玩家如果摇出其他点数则游戏继续,玩家重新摇骰子,如果玩家摇出了 7 点,庄家胜;如果玩家摇出了第一次摇的点数,玩家胜;其他点数玩家继续摇骰子,直到分出胜负。为了增加代码的趣味性,我们设定游戏开始时玩家有 1000 元的赌注,每局游戏开始之前,玩家先下注,如果玩家获胜就可以获得对应下注金额的奖励,如果庄家获胜,玩家就会输掉自己下注的金额。游戏结束的条件是玩家破产(输光所有的赌注)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
"""
CRAPS赌博游戏

Version: 1.0
Author: Penry
"""

import random

property = 1000 # 起始资金
flag_participate = 'y' # 判断是否继续游戏

while property > 0 and flag_participate == 'y':
print(f"您目前的总资产为:{property}$")

# 下注
while True:
bet = int(input("请下注:"))
if 0 < bet <= property:
break
else:
print("下注金额无效,请重新输入!")

# 开始游戏,用两个1到6均匀分布的随机数相加模拟摇两颗色子得到的点数
first_point = random.randint(1,6) + random.randint(1,6)
print(f"玩家摇出了{first_point}点")

if first_point == 7 or first_point == 11:
print("玩家胜!")
property += bet
elif first_point == 2 or first_point == 3 or first_point == 12:
print("庄家胜!")
property -= bet
else:
# 玩家继续摇色子,如果玩家摇出了 7 点,庄家胜;如果玩家摇出了第一次摇的点数,玩家胜;其他点数玩家继续摇骰子,直到分出胜负。
while True:
current_point = random.randint(1,6) + random.randint(1,6)
print(f"玩家摇出了{current_point}点")
if current_point == 7:
print("庄家胜!")
property -= bet
break
elif current_point == first_point:
print("玩家胜!")
property += bet
break
# 是否继续游戏
while property > 0:
flag_participate = input("是否继续游戏?(y/n)")
if flag_participate == "y" or flag_participate == "n":
break
else:
print("输入无效,请重新输入!")

print(f"您最终的资产为:{property}$")

alt text

3.7 实战7:完美数

要求:找出 10000 以内的完美数。
说明:完美数(Perfect number),又称完全数或完备数,是一些特殊的自然数。它所有的真因子(即除了自身以外的因子)的和(即因子函数),恰好等于它本身。例如:6(6=1+2+36 = 1 + 2 + 3)和 28(28=1+2+4+7+1428 = 1 + 2 + 4 + 7 + 14)就是完美数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
找出10000以内的完美数

Version: 1.0
Author: Penry
"""

for num in range(1, 10000):
sum = 0
for factor in range(1, num):
if num % factor == 0:
sum += factor
if sum == num:
print(num)

alt text


Day3

0 常用数据结构之列表-1

在开始本节课的内容之前,我们先给大家一个编程任务,将一颗色子掷 6000 次,统计每种点数出现的次数。这个任务对大家来说应该是非常简单的,我们可以用 1 到 6 均匀分布的随机数来模拟掷色子,然后用 6 个变量分别记录每个点数出现的次数,相信通过前面的学习,大家都能比较顺利的写出下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"""
掷骰子

Version: 1.0
Author: Penry
"""

import random

i = 6000
p1,p2,p3,p4,p5,p6 = 0,0,0,0,0,0

while i > 0:
point = random.randint(1, 6)
match point:
case 1: p1 += 1
case 2: p2 += 1
case 3: p3 += 1
case 4: p4 += 1
case 5: p5 += 1
case 6: p6 += 1
i -= 1

for i in range(1, 7):
print(f"点数{i}出现的次数为{eval(f'p{i}')}")

说明:eval() 是 Python 的一个内置函数,作用是将字符串解析为 Python 代码并执行

alt text

上面的代码非常有多么“丑陋”相信就不用我多说了。当然,更为可怕的是,如果我们要掷两颗或者掷更多的色子,然后统计每种点数出现的次数,那就需要定义更多的变量,写更多的分支结构,大家想想都会感到恶心。讲到这里,相信大家心中已经有一个疑问了:有没有办法用一个变量来保存多个数据,有没有办法用统一的代码对多个数据进行操作?答案是肯定的,在 Python 语言中我们可以通过容器型变量来保存和操作多个数据,我们首先为大家介绍列表(list)这种新的数据类型。

0.1 创建列表

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

1
2
3
4
5
6
items1 = [35, 12, 99, 68, 55, 35, 87]
items2 = ['Python', 'Java', 'Go', 'Kotlin']
items3 = [100, 12.3, 'Python', True]
print(items1)
print(items2)
print(items3)

alt text

说明:列表中可以有重复元素,例如items1中的35;列表中可以有不同类型的元素,例如items3中有int类型、float类型、str类型和bool类型的元素,但是我们通常并不建议将不同类型的元素放在同一个列表中,主要是操作起来极为不便。

我们可以使用type函数来查看变量的类型,代码如下所示。

1
2
3
4
5
6
items1 = [35, 12, 99, 68, 55, 35, 87]
items2 = ['Python', 'Java', 'Go', 'Kotlin']
items3 = [100, 12.3, 'Python', True]
print(type(items1))
print(type(items2))
print(type(items3))

alt text

因为列表可以保存多个元素,它是一种容器型的数据类型,所以我们在给列表类型的变量起名字时,变量名通常用复数形式的单词。

除此以外,还可以通过 Python 内置的list函数将其他序列变成列表。准确的说,list并不是一个普通的函数,它是创建列表对象的构造器,后面的课程会为大家介绍对象和构造器这些概念。

1
2
3
4
items1 = list(range(1, 10))
items2 = list('hello')
print(items1)
print(items2)

alt text

说明:range(1, 10)会产生1到9的整数序列,给到list构造器中,会创建出由1到9的整数构成的列表。字符串是字符构成的序列,上面的list('hello')用字符串hello的字符作为列表元素,创建了列表对象。

0.2 列表的运算

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

1
2
3
4
5
6
7
items5 = [35, 12, 99, 45, 66]
items6 = [45, 58, 29]
items7 = ['Python', 'Java', 'JavaScript']
print(items5 + items6)
print(items6 + items7)
items5 += items6
print(items5)

alt text

我们可以使用*运算符实现列表的重复运算,*运算符会将列表元素重复指定的次数,如下所示。

1
2
items8 = ['hello'] * 3
print(items8)

alt text

我们可以使用innot in运算符判断一个元素在不在列表中,如下所示。

1
2
3
items9 = ['Python', 'Java', 'Go', 'Kotlin']
print('Python' in items9)
print('Ruby' not in items9)

alt text

由于列表中有多个元素,而且元素是按照特定顺序放在列表中的,所以当我们想操作列表中的某个元素时,可以使用[]运算符,通过在[]中指定元素的位置来访问该元素,这种运算称为索引运算。需要说明的是,[]的元素位置可以是0到N - 1的整数,也可以是-1到-N的整数,分别称为正向索引和反向索引,其中N代表列表元素的个数。对于正向索引,[0]可以访问列表中的第一个元素,[N - 1]可以访问最后一个元素;对于反向索引,[-1]可以访问列表中的最后一个元素,[-N]可以访问第一个元素,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
items8 = ['apple', 'waxberry', 'pitaya', 'peach', 'watermelon']
print(items8[0])
print(items8[2])
print(items8[4])
items8[2] = 'durian'
print(items8)
print(items8[-5])
print(items8[-4])
print(items8[-1])
items8[-4] = 'strawberry'
print(items8)

alt text

在使用索引运算的时候要避免出现索引越界的情况,对于上面的items8,如果我们访问items8[5]items8[-6],就会引发IndexError错误,导致程序崩溃,对应的错误信息是:list index out of range,翻译成中文就是“数组索引超出范围”。因为对于只有五个元素的列表items8,有效的正向索引是0到4,有效的反向索引是-1到-5。

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

1
2
3
4
5
6
7
items8 = ['apple', 'waxberry', 'pitaya', 'peach', 'watermelon']
print(items8[1:3])
print(items8[1:3:1])
print(items8[1:-2])
print(items8[1:])
print(items8[-2:-6:-1])
print(items8[:])

alt text

如果start值等于0,那么在使用切片运算符时可以将其省略;如果end值等于NN代表列表元素的个数,那么在使用切片运算符时可以将其省略;如果stride值等于1,那么在使用切片运算符时也可以将其省略。

事实上,我们还可以通过切片操作修改列表中的元素,例如如下代码:

1
2
3
4
5
items8 = ['apple', 'waxberry', 'pitaya', 'peach', 'watermelon']
items8[1:3] = ['banana', 'orange']
print(items8)
items8[1:3] = ['banana', 'orange', 'pear', 'strawberry', 'pitaya']
print(items8)

alt text

两个列表还可以做关系运算,我们可以比较两个列表是否相等,也可以给两个列表比大小,代码如下所示。

1
2
3
4
5
6
7
nums1 = [1, 2, 3, 4]
nums2 = list(range(1, 5))
nums3 = [3, 2, 1]
print(nums1 == nums2)
print(nums1 != nums2)
print(nums1 <= nums3)
print(nums2 >= nums3)

alt text

说明:上面的nums1nums2对应元素完全相同,所以==运算的结果是Truenums2nums3的比较,由于nums2的第一个元素1小于nums3的第一个元素3,所以nums2 >= nums3比较的结果是False。两个列表的关系运算在实际工作并不那么常用,如果实在不理解就跳过吧,不用纠结。

0.3 列表的遍历

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

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

1
2
3
languages = ['Python', 'Java', 'C++', 'Kotlin']
for index in range(len(languages)):
print(f"{index} - {languages[index]}")

alt text

方法二:直接遍历列表元素。

1
2
3
languages = ['Python', 'Java', 'C++', 'Kotlin']
for item in languages:
print(item)

alt text

0.4 总结

讲到这里,我们可以用列表的知识来重构上面“掷色子统计每种点数出现次数”的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"""
将一颗色子掷6000次,统计每种点数出现的次数

Version: 1.0
Author: Penry
"""

import random

counters = [0] * 6

for _ in range(6000):
point = random.randint(1, 6)
counters[point - 1] += 1

for point in range(1, 7):
print(f"{point}点出现了{counters[point - 1]}次")

alt text

1 常用数据结构之列表-2

1.1 列表的方法

列表类型的变量拥有很多方法可以帮助我们操作一个列表,假设我们有名为foos的列表,列表有名为bar的方法,那么使用列表方法的语法是:foos.bar(),这是一种通过对象引用调用对象方法的语法。后面我们讲面向对象编程的时候,还会对这种语法进行详细的讲解,这种语法也称为给对象发消息。

列表的常用方法有:

  • append:在列表的末尾添加一个元素。
  • insert:在列表的指定位置插入一个元素。
  • remove:从列表中删除一个元素。
  • clear:清空列表。
  • reverse:反转列表。
  • sort:对列表进行排序。

1.1.1 添加和删除元素

列表是一种可变容器,可变容器指的是我们可以向容器中添加元素、可以从容器移除元素,也可以修改现有容器中的元素。我们可以使用列表的append方法向列表中追加元素,使用insert方法向列表中插入元素。追加指的是将元素添加到列表的末尾,而插入则是在指定的位置添加新元素,大家可以看看下面的代码。

1
2
3
4
5
languages = ['Python', 'C++', 'Ruby', 'Go']
languages.append('Perl')
print(languages)
languages.insert(1, 'Javascript')
print(languages)

alt text

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
languages = ["Python", "Java", "C++", "C#", "JavaScript", "PHP", "Go", "Rust"]
print(f'原始列表:{languages}')
if 'Java' in languages:
languages.remove('Java')
if 'Swift' in languages:
languages.remove('Swift')
print(f'判断并删除Java和Swift后:{languages}')

languages.pop()
temp = languages.pop(1)
print(f'删除尾部元素和索引为1的元素后:{languages}')

languages.append(temp)
print(f'将删除的元素添加到尾部后:{languages}')

languages.clear()
print(f'清空列表后:{languages}')

alt text

说明:remove方法在删除元素时,如果列表中存在多个相同的元素,那么只会删除第一个元素pop方法在删除元素时,如果给定了索引,那么会删除指定位置的元素,并且返回被删除的元素,如果给定的索引不存在,会引发IndexError异常;clear方法会清空列表中的元素,但是不会删除列表对象,所以列表对象仍然存在。

alt text

1.1.2 元素位置和频次

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

1
2
3
4
5
languages = ['Python', 'Java', 'C++', 'Kotlin', 'Python', 'Go']
print(languages.index('Python'))
print(languages.count('Python'))
print(languages.index('Kotlin'))
print(languages.count('Kotlin'))

alt text

1.1.3 元素排序和反转

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

1
2
3
4
5
languages = ['Python', 'Java', 'C++', 'Kotlin', 'Python', 'Go']
languages.sort()
print(languages)
languages.reverse()
print(languages)

alt text

说明:sort方法会直接修改列表中的元素,而reverse方法会反转列表中的元素,这两个方法都是原地操作,不会返回新的列表,即列表的地址不会发生变化。
强调:sort方法的排序规则是按照元素的大小进行排序,如果元素是字符串,那么会按照字符串的ASCII码进行排序。如果是数字,那么会按照数字的大小进行排序。如果列表中既有字符串又有数字,那么会引发TypeError异常。

alt text

1.2 列表生成式

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

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

1
2
3
4
5
6
7
8
9
10
11
12
"""
创建一个取值范围在 1 到 99 且能被 3 或者 5 整除的数字构成的列表

Version: 1.0
Author: Penry
"""

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

alt text

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

1
2
3
4
5
6
7
8
9
"""
创建一个取值范围在 1 到 99 且能被 3 或者 5 整除的数字构成的列表

Version: 1.1
Author: Penry
"""

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

alt text

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

1
2
3
4
5
6
7
8
9
10
11
12
"""
创建一个新的列表nums2,nums2中的元素是nums1中对应元素的平方

Version: 1.0
Author: Penry
"""

nums1 = [1, 2, 3, 4, 5]
nums2 = []
for num in nums1:
nums2.append(num ** 2)
print(nums2)

alt text

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

1
2
3
4
5
6
7
8
9
10
"""
创建一个新的列表nums2,nums2中的元素是nums1中对应元素的平方

Version: 1.1
Author: Penry
"""

nums1 = [1, 2, 3, 4, 5]
nums2 = [num**2 for num in nums1]
print(nums2)

alt text

场景三: 有一个整数列表nums1,创建一个新的列表nums2,将nums1中大于50的元素放到nums2中。

1
2
3
4
5
6
7
8
9
10
11
12
13
"""
创建一个新的列表nums2,将nums1中大于50的元素放到nums2中

Version: 1.0
Author: Penry
"""

nums1 = [35, 12, 97, 64, 55]
nums2 = []
for num in nums1:
if num > 50:
nums2.append(num)
print(nums2)

alt text

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

1
2
3
4
5
6
7
8
9
10
"""
创建一个新的列表nums2,将nums1中大于50的元素放到nums2中

Version: 1.1
Author: Penry
"""

nums1 = [35, 12, 97, 64, 55]
nums2 = [num for num in nums1 if num > 50]
print(nums2)

alt text

使用列表生成式创建列表不仅代码简单优雅,而且性能上也优于使用for-in循环和append方法向空列表中追加元素的方式。为什么说生成式有更好的性能呢,那是因为 Python 解释器的字节码指令中有专门针对生成式的指令(LIST_APPEND指令);而for循环是通过方法调用(LOAD_METHODCALL_METHOD指令)的方式为列表添加元素,方法调用本身就是一个相对比较耗时的操作。对这一点不理解也没有关系,记住“强烈建议用生成式语法来创建列表”这个结论就可以了。

1.3 嵌套列表

Python 语言没有限定列表中的元素必须是相同的数据类型,也就是说一个列表中的元素可以任意的数据类型,当然也包括列表本身。如果列表中的元素也是列表,那么我们可以称之为嵌套的列表。嵌套的列表可以用来表示表格或数学上的矩阵,例如:我们想保存5个学生3门课程的成绩,可以用如下所示的列表。

1
2
3
scores = [[95, 83, 92], [80, 75, 82], [92, 97, 90], [80, 78, 69], [65, 66, 89]]
print(scores[0])
print(scores[0][1])

alt text

对于上面的嵌套列表,每个元素相当于就是一个学生3门课程的成绩,例如[95, 83, 92],而这个列表中的83代表了这个学生某一门课的成绩,如果想访问这个值,可以使用两次索引运算scores[0][1],其中scores[0]可以得到[95, 83, 92]这个列表,再次使用索引运算[1]就可以获得该列表中的第二个元素。

如果想通过键盘输入的方式来录入5个学生3门课程的成绩并保存在列表中,可以使用如下所示的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
录入5个学生3门课程的成绩并保存在列表中

Version: 1.0
Author: Penry
"""

scores = []
for i in range(5):
temp = []
for j in range(3):
score = int(input(f'请输入第{i + 1}个学生第{j + 1}门课成绩: '))
temp.append(score)
scores.append(temp)
print(scores)

alt text

如果想通过产生随机数的方式来生成5个学生3门课程的成绩并保存在列表中,我们可以使用列表生成式,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
"""
通过产生随机数的方式来生成5个学生3门课程的成绩并保存在列表中

Version: 1.0
Author: Penry
"""

import random

scores = [[random.randint(60,100) for _ in range(3)] for _ in range(5)]
print(scores)

alt text

说明:上面的代码[random.randrange(60, 101) for _ in range(3)]可以产生由3个随机整数构成的列表,我们把这段代码又放在了另一个列表生成式中作为列表的元素,这样的元素一共生成5个,最终得到了一个嵌套列表。

1.4 列表的应用

下面我们通过一个双色球随机选号的例子为大家讲解列表的应用。双色球是由中国福利彩票发行管理中心发售的乐透型彩票,每注投注号码由6个红色球和1个蓝色球组成。红色球号码从1到33中选择,蓝色球号码从1到16中选择。每注需要选择6个红色球号码和1个蓝色球号码,如下所示。

alt text

提示:知乎上有一段对国内各种形式的彩票本质的论述相当精彩,这里分享给大家:“虚构一个不劳而获的人,去忽悠一群想不劳而获的人,最终养活一批真正不劳而获的人”。很多对概率没有概念的人,甚至认为彩票中与不中的概率都是 50%;还有很多人认为如果中奖的概率是 1%,那么买 100 次就一定可以中奖,这些都是非常荒唐的想法。所以,珍爱生命,远离赌博,尤其是你对概率一无所知的情况下。

下面,我们用 Python 来模拟双色球随机选号的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
"""
双色球随机选号

Version: 1.0
Author: Penry
"""

import random

# 生成红球
red_balls = list(range(1, 34))
selected_red_balls = []

# 生成蓝球
blue_balls = list(range(1, 17))
selected_blue_balls = []

# 添加六个红球到选中列表中
for _ in range(6):
index = random.randint(0, len(red_balls)-1)
# 将选中的球放入选中列表,并从原始列表中移除
selected_red_balls.append(red_balls.pop(index))
# 将红球排序
selected_red_balls.sort()
# 输出选中的红球
for red_ball in selected_red_balls:
print(f"\033[031m{red_ball:0>2d}\033[0m", end=" ")

# 添加一个蓝球到选中列表中
index = random.randint(0, len(blue_balls)-1)
selected_blue_balls.append(blue_balls.pop(index))
# 输出选中的蓝球
print(f"\033[034m{selected_blue_balls[0]:0>2d}\033[0m")

alt text

说明:上面代码中print(f'\033[0m...\033[0m')是为了控制输出内容的颜色,红色球输出成红色,蓝色球输出成蓝色。其中省略号代表我们要输出的内容,\033[0m是一个控制码,表示关闭所有属性,也就是说之前的控制码将会失效,你也可以将其简单的理解为一个定界符,m前面的0表示控制台的显示方式为默认值,0可以省略,1表示高亮,5表示闪烁,7表示反显等。在0m的中间,我们可以写上代表颜色的数字,比如30代表黑色,31代表红色,32代表绿色,33代表黄色,34代表蓝色等。

我们还可以利用random模块提供的samplechoice函数来简化上面的代码,前者可以实现无放回随机抽样,后者可以实现随机抽取一个元素,修改后的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
"""
双色球随机选号

Version: 1.1
Author: Penry
"""

import random
# 生成红球
red_balls = list(range(1, 34))
selected_red_balls = []

# 生成蓝球
blue_balls = list(range(1, 17))

# 从红色球列表中随机抽出6个红色球(无放回抽样)
selected_red_balls = random.sample(red_balls, 6)
# 对生成的红色球进行排序
selected_red_balls.sort()
# 输出选中的红球
for red_ball in selected_red_balls:
print(f'\033[031m{red_ball:0>2d}\033[0m', end=' ')

# 从蓝色球列表中随机抽出1个蓝色球
selected_blue_ball = random.choice(blue_balls)
# 输出选中的蓝球
print(f'\033[034m{selected_blue_ball:0>2d}\033[0m')

alt text

如果要实现随机生成N注号码,我们只需要将上面的代码放到一个N次的循环中,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
"""
双色球随机选号

Version: 1.2
Author: Penry
"""

import random

n = int(input("生成几注号码:"))

# 生成红球
red_balls = list(range(1, 34))
selected_red_balls = []

# 生成蓝球
blue_balls = list(range(1, 17))

for _ in range(n):

# 从红色球列表中随机抽出6个红色球(无放回抽样)
selected_red_balls = random.sample(red_balls, 6)
# 对生成的红色球进行排序
selected_red_balls.sort()
# 输出选中的红球
for red_ball in selected_red_balls:
print(f'\033[031m{red_ball:0>2d}\033[0m', end=' ')

# 从蓝色球列表中随机抽出1个蓝色球
selected_blue_ball = random.choice(blue_balls)
# 输出选中的蓝球
print(f'\033[034m{selected_blue_ball:0>2d}\033[0m')

alt text

这里顺便给大家介绍一个名为 rich 的 Python 三方库,它可以帮助我们用最简单的方式产生最漂亮的输出,你可以在终端中使用 Python 包管理工具 pip 来安装这个三方库。

1
pip install rich

安装好 rich 库之后,我们就可以使用它来美化我们的输出,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
"""
双色球随机选号

Version: 1.2
Author: Penry
"""

import random

from rich.console import Console
from rich.table import Table

# 创建控制台
console = Console()

n = int(input("生成几注号码:"))

# 生成红球
red_balls = list(range(1, 34))
selected_red_balls = []

# 生成蓝球
blue_balls = list(range(1, 17))

# 创建表格并添加表头
table = Table(show_header=True)
for col_name in ('序号', '红球', '蓝球'):
table.add_column(col_name, justify='center')

for i in range(n):

# 从红色球列表中随机抽出6个红色球(无放回抽样)
selected_red_balls = random.sample(red_balls, 6)
# 对生成的红色球进行排序
selected_red_balls.sort()

# 从蓝色球列表中随机抽出1个蓝色球
selected_blue_ball = random.choice(blue_balls)

# 向表格中添加行(序号,红色球,蓝色球)
table.add_row(
str(i + 1),
f'[red]{" ".join([f"{ball:0>2d}" for ball in selected_red_balls])}[/red]',
f'[blue]{selected_blue_ball:0>2d}[/blue]'
)

# 通过控制台输出表格
console.print(table)

alt text

说明:上面代码中[red]...[/red][blue]...[/blue]是用来控制输出内容的颜色,rich库中还提供了其他很多控制输出内容样式的功能,大家可以参考官方文档

1.5 总结

Python 中的列表底层是一个可以动态扩容数组,列表元素在计算机内存中是连续存储的,所以可以实现随机访问(通过一个有效的索引获取对应的元素且操作时间与列表元素个数无关)。我们可以暂时不去触碰这些底层的存储细节,也不需要大家理解列表每个方法的渐近时间复杂度(执行方法耗费的时间跟列表元素个数之间的关系),大家先学会用列表解决工作中的问题,我想这一点更为重要。

2 常用数据结构之元组

2.1 元组的定义和运算

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 定义一个三元组
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

alt text

一个元组中如果有两个元素,我们就称之为二元组;一个元组中如果五个元素,我们就称之为五元组。需要提醒大家注意的是,()表示空元组,但是如果元组中只有一个元素,需要加上一个逗号,否则()就不是代表元组的字面量语法,而是改变运算优先级的圆括号,所以('hello', )(100, )才是一元组,而('hello')(100)只是字符串和整数。我们可以通过下面的代码来加以验证。

1
2
3
4
5
6
7
8
9
10
a = ()
print(type(a)) # <class 'tuple'>
b = ('hello')
print(type(b)) # <class 'str'>
c = (100)
print(type(c)) # <class 'int'>
d = ('hello', )
print(type(d)) # <class 'tuple'>
e = (100, )
print(type(e)) # <class 'tuple'>

alt text

2.2 打包和解包操作

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

1
2
3
4
5
6
7
# 打包操作
a = 1, 2, 3, 4
print(type(a))

# 解包操作
a, b, c, d = 1, 2, 3, 4
print(a, b, c, d)

alt text

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

1
2
3
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)

alt text

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
a = 1, 10, 100, 1000
i, j, *k = a
print(i, j, k)

i, *j, k = a
print(i, j, k)

*i, j, k = a
print(i, j, k)

*i, j = a
print(i, j)

i, j, k, *l = a
print(i, j, k, l)

i, j, k, l, *m = a
print(i, j, k, l, m)****

alt text

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

1
2
3
4
5
6
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)

alt text

2.3 交换变量的值

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

1
a, b = b, a

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

1
a, b, c = b, c, a

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

2.4 元组和列表的比较

这里还有一个非常值得探讨的问题,Python 中已经有了列表类型,为什么还需要元组这样的类型呢?这个问题对于初学者来说似乎有点困难,不过没有关系,我们先抛出观点,大家可以一边学习一边慢慢体会。

  1. 元组是不可变类型,不可变类型更适合多线程环境,因为它降低了并发访问变量的同步化开销。关于这一点,我们会在后面讲解并发编程的时候跟大家一起探讨。
  2. 元组是不可变类型,通常不可变类型在创建时间上优于对应的可变类型。我们可以使用timeit模块的timeit函数来看看创建保存相同元素的元组和列表各自花费的时间,timeit函数的number参数表示代码执行的次数。下面的代码中,我们分别创建了保存1到9的整数的列表和元组,每个操作执行10000000次,统计运行时间。
1
2
3
4
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))

alt text

说明:上面代码的执行结果因软硬件系统而异,在我目前使用的电脑上,执行10000000次创建列表的操作时间是0.436秒,而执行10000000次创建元组的操作时间是0.089秒,显然创建元组更快且二者时间上有数量级的差别。

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

1
2
3
4
5
6
7
infos = ('骆昊', 43, True, '四川成都')
# 将元组转换成列表
print(list(infos)) # ['骆昊', 43, True, '四川成都']

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

2.5 总结

列表和元组都是容器型的数据类型,即一个变量可以保存多个数据,而且它们都是按一定顺序组织元素的有序容器。列表是可变数据类型,元组是不可变数据类型,所以列表可以添加元素、删除元素、清空元素、排序反转,但这些操作对元组来说是不成立的。列表和元组都可以支持拼接运算、成员运算、索引运算、切片运算等操作,后面我们要讲到的字符串类型也支持这些运算,因为字符串就是字符按一定顺序构成的序列,在这一点上三者并没有什么区别。我们推荐大家使用列表的生成式语法来创建列表,它不仅好用而且效率很高,是 Python 语言中非常有特色的语法。

3 常用数据结构之字符串

第二次世界大战促使了现代电子计算机的诞生,世界上的第一台通用电子计算机名叫 ENIAC(电子数值积分计算机),诞生于美国的宾夕法尼亚大学,占地167平米,重量约27吨,每秒钟大约能够完成约5000次浮点运算,如下图所示。ENIAC 诞生之后被应用于导弹弹道的计算,而数值计算也是现代电子计算机最为重要的一项功能。

随着时间的推移,虽然数值运算仍然是计算机日常工作中最为重要的组成部分,但是今天的计算机还要处理大量的以文本形式存在的信息。如果我们希望通过 Python 程序来操作本这些文本信息,就必须要先了解字符串这种数据类型以及与它相关的运算和方法。

3.1 字符串的定义

所谓字符串,就是由零个或多个字符组成的有限序列,一般记为:

s=a1a2a3...an,(0n)s = a_1a_2a_3...a_n,\cdots (0 \leq n \leq \infty)

在 Python 程序中,我们把单个或多个字符用单引号或者双引号包围起来,就可以表示一个字符串。字符串中的字符可以是特殊符号、英文字母、中文字符、日文的平假名或片假名、希腊字母、Emoji 字符(如:💩、🐷、🀄️)等。

1
2
3
4
5
6
7
8
s1 = 'hello, world!'
s2 = "你好,世界!❤️"
s3 = '''hello,
wonderful
world!'''
print(s1)
print(s2)
print(s3)

alt text

3.1.1 转义字符

我们可以在字符串中使用\(反斜杠)来表示转义,也就是说\后面的字符不再是它原来的意义,例如:\n不是代表字符\和字符n,而是表示换行;\t也不是代表字符\和字符t,而是表示制表符。所以如果字符串本身又包含了'"\这些特殊的字符,必须要通过\进行转义处理。例如要输出一个带单引号或反斜杠的字符串,需要用如下所示的方法。

1
2
3
s1 = '\'hello, world!\''
s2 = '\\hello, world!\\'
print(s1, s2, sep='\n')

alt text

3.1.2 原始字符串

原始字符串是字符串类型前加r,它不会对字符串中的转义符号进行转义,例如:\n不会被转义成换行符,而是保留原来的样子。

1
2
3
s1 = '\it \is \time \to \read \now'
s2 = r'\it \is \time \to \read \now'
print(s1, s2, sep='\n')

alt text

说明:上面的变量s1中,\t\r\n都是转义字符。\t是制表符(table),\n是换行符(new line),\r是回车符(carriage return)相当于让输出回到了行首。对比一下两个print函数的输出,看看到底有什么区别!

注意:这里由于s1中的\i\t\r\n等都被解释为转义序列,但这些转义字符大部分是非法或不可见字符,其中的\it 由于 \i 是非法的转义序列,所以\it 字符被吞掉。

3.1.3 字符的特殊表示

Python 中还允许在\后面还可以跟一个八进制或者十六进制数来表示字符,例如\141\x61都代表小写字母a,前者是八进制的表示法,后者是十六进制的表示法。另外一种表示字符的方式是在\u后面跟 Unicode 字符编码,例如\u9e4f\u8fdc代表的是中文“鹏远”。运行下面的代码,看看输出了什么。

1
2
3
print('\141')
print('\x61')
print('\u9e4f\u8fdc')

alt text

这里给出一个在线的在线 Unicode 编码转换,大家可以看看一些常见的中文、日文、韩文、拉丁字母等字符的 Unicode 编码。

3.2 字符串的运算

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

3.2.1 拼接和重复

下面的例子演示了使用+*运算符来实现字符串的拼接和重复操作。

1
2
3
4
s1 = 'hello' * 3
print(s1)
s2 = 'world'
print(s1 + s2)

alt text

*实现字符串的重复是非常有意思的一个运算符,在很多编程语言中,要表示一个有10个a的字符串,你只能写成aaaaaaaaaa,但是在 Python 中,你可以写成'a' * 10。你可能觉得aaaaaaaaaa这种写法也没有什么不方便的,但是请想一想,如果字符a要重复100次或者1000次又会如何呢?

3.2.2 比较运算

对于两个字符串类型的变量,可以直接使用比较运算符来判断两个字符串的相等性或比较大小。需要说明的是,因为字符串在计算机内存中也是以二进制形式存在的,那么字符串的大小比较比的是每个字符对应的编码的大小。例如A的编码是65, 而a的编码是97,所以'A' < 'a'的结果相当于就是65 < 97的结果,这里很显然是True;而'boy' < 'bad',因为第一个字符都是'b'比不出大小,所以实际比较的是第二个字符的大小,显然'o' < 'a'的结果是False,所以'boy' < 'bad'的结果是False。如果不清楚两个字符对应的编码到底是多少,可以使用ord函数来获得,之前我们有提到过这个函数。例如ord('A')的值是65,而ord('鹏')的值是40527。下面的代码展示了字符串的比较运算,请大家仔细看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
s1 = 'a whole new world'
s2 = 'hello world'
print(s1 == s2)
print(s1 < s2)
print(s1 == 'hello world')
print(s2 == 'hello world')
print(s2 != 'Hello world')

s1 = 'a whole new world'
s2 = 'hello world'
print(s1 == s2)
print(s1 < s2)
print(s1 == 'hello world')
print(s2 == 'hello world')
print(s2 != 'Hello world')

print('-----------------------')

s3 = '鹏远'
print(ord(s3[0]))
print(ord(s3[1]))
s4 = '北理工'
print(ord(s4[0]))
print(ord(s4[1]))
print(ord(s4[2]))
print(s3 >= s4)
print(s3 != s4)

alt text

3.2.3 成员运算

Python 中可以用innot in判断一个字符串中是否包含另外一个字符或字符串,跟列表类型一样,innot in称为成员运算符,会产生布尔值TrueFalse,代码如下所示。

1
2
3
s1 = 'hello, world!'
print('wo' in s1)
print('hello' not in s1)

alt text

3.2.4 获取字符串长度

获取字符串长度跟获取列表元素个数一样,使用内置函数len,代码如下所示。

1
2
s1 = 'hello, world!'
print(len(s1))

alt text

说明:这里逗号和空格也是字符,所以字符串的长度是13。

3.2.5 索引和切片

字符串的索引和切片操作跟列表、元组几乎没有区别,因为字符串也是一种有序序列,可以通过正向或反向的整数索引访问其中的元素。但是有一点需要注意,因为字符串是不可变类型,所以不能通过索引运算修改字符串中的字符。

1
2
3
4
5
6
7
8
9
10
11
12
s = 'abc123456'
n = len(s)
print(s[0], s[-n])
print(s[n-1], s[-1])
print(s[2], s[-7])
print(s[5], s[-n+5])
print(s[2:5])
print(s[-7:-4])
print(s[2:])
print(s[:2])
print(s[::2])
print(s[::-1])

alt text

注意:需要再次提醒大家注意的是,在进行索引运算时,如果索引越界,会引发IndexError异常,错误提示信息为:string index out of range(字符串索引超出范围)。

3.3 字符的遍历

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

方式一

1
2
3
s = 'hello'
for i in range(len(s)):
print(s[i])

alt text

1
2
3
s = 'hello'
for elem in s:
print(elem)

alt text

3.4 字符串的常用方法

在 Python 中,我们可以通过字符串类型自带的方法对字符串进行操作和处理,假设我们有名为foo的字符串,字符串有名为bar的方法,那么使用字符串方法的语法是:foo.bar(),这是一种通过对象引用调用对象方法的语法,跟前面使用列表方法的语法是一样的。

3.4.1 大小写相关操作

下面的代码演示了和字符串大小写变换相关的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
s1 = 'hello, world!'
# 首字母大写
print(s1.capitalize())
# 每个单词首字母大写
print(s1.title())
# 字符串变大写
print(s1.upper())

s2 = 'GOOD MORNING!'
# 字符串变小写
print(s2.lower())

# 检查s1 和 s2 的值
print(s1)
print(s2)

alt text

说明:由于字符串是不可变类型,使用字符串的方法对字符串进行操作会产生新的字符串,但是原来变量的值并没有发生变化。所以上面的代码中,当我们最后检查s1s2两个变量的值时,s1s2 的值并没有发生变化。

3.4.2 查找操作

如果想在一个字符串中从前向后查找有没有另外一个字符串,可以使用字符串的findindex方法。在使用findindex方法时还可以通过方法的参数来指定查找的范围,也就是查找不必从索引为0的位置开始。

1
2
3
4
5
6
7
s = 'hello, world!9'
print(s.find('or'))
print(s.find('or',8))
print(s.find('or',9))
print(s.index('or'))
print(s.index('or',8))
print(s.index('or',9))

alt text

说明:find返回值是字符串中要查找的字符串的第一个字符的索引,如果字符串中不存在要查找的字符串,则返回-1index方法在字符串中找不到要查找的字符串时会引发ValueError异常。
注意:find(sub, start) 中的 start 表示从哪个索引位置开始查找(包括这个位置)。如果从这个位置之后找不到 sub,就会返回 -1,而 index(sub, start) 中的 start 表示从哪个索引位置开始查找(包括这个位置)。如果从这个位置之后找不到 sub,就会引发 ValueError 异常。

这里给出两张图,帮助大家理解findindex方法的参数和返回值。

alt text

alt text

findindex方法还有逆向查找(从后向前查找)的版本,分别是rfindrindex,代码如下所示。

1
2
3
4
5
s = 'hello, world!'
print(s.rfind('or'))
print(s.rfind('or',0,9))
print(s.rindex('or'))
print(s.rindex('or',0,10))

alt text

说明:rfindrindex 都支持三个参数 (sub, start, end),表示在 s[start:end] 中从右向左查找,但结果仍然是原始字符串中的索引位置。
注意:这里比如 s.rfind('or',0,9),表示在s[0:9]中从右向左查找or,这里的索引范围是08, 对应范围是开区间。

这里给出两张图,帮助大家理解rfindrindex方法的参数和返回值。

alt text

alt text

3.4.3 性质判断

可以通过字符串的startswithendswith来判断字符串是否以某个字符串开头和结尾;还可以用is开头的方法判断字符串的特征,这些方法都返回布尔值,代码如下所示。

1
2
3
4
5
6
7
8
9
s1 = 'hello, world!'
print(s1.startswith('hel'))
print(s1.endswith('ld'))
print(s1.endswith('ld!'))

s2 = 'abc123456'
print(s2.isalpha())
print(s2.isdigit())
print(s2. isalnum())

alt text

说明:上面的isdigit用来判断字符串是不是完全由数字构成的,isalpha用来判断字符串是不是完全由字母构成的,这里的字母指的是 Unicode 字符但不包含 Emoji 字符,isalnum用来判断字符串是不是由字母和数字构成的。

3.4.4 格式化

在 Python 中,字符串类型可以通过centerljustrjust方法做居中、左对齐和右对齐的处理。如果要在字符串的左侧补零,也可以使用zfill方法。

1
2
3
4
5
6
7
8
s1 = 'hello, world!'
print(s1.center(20, '*'))
print(s1.ljust(20))
print(s1.rjust(20, '~'))

s2 = '33'
print(s2.zfill(5))
print(s2.zfill(3))

alt text

我们之前讲过,在用print函数输出字符串时,可以用下面的方式对字符串进行格式化。

1
2
3
a = 321
b = 123
print('%d × %d = %d' % (a, b, a * b))

alt text

当然,我们也可以用字符串的format方法来完成字符串的格式化,代码如下所示。

1
2
3
4
5
a = 321
b = 123
print('{1} × {0} = {2}'.format(a, b, a * b))
print('{0} × {1} = {2}'.format(a, b, a * b))
print('{first} × {second} = {third}'.format(first=a, second=b, third=a*b))

alt text

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

1
2
3
a = 321
b = 123
print(f'{a} × {b} = {a * b}')

alt text

如果需要进一步控制格式化语法中变量值的形式,可以参照下面的表格来进行字符串格式化操作。

变量值 占位符 格式化结果 说明
3.1415926 {:.2f} '3.14' 保留小数点后两位
3.1415926 {:+.2f} '+3.14' 带符号保留小数点后两位
-1 {:+.2f} '-1.00' 带符号保留小数点后两位
3.1415926 {:.0f} '3' 不带小数
123 {:0>10d} '0000000123' 左边补0,补够10位
123 {:x<10d} '123xxxxxxx' 右边补x ,补够10位
123 {:>10d} ' 123' 左边补空格,补够10位
123 {:<10d} '123 ' 右边补空格,补够10位
123456789 {:,} '123,456,789' 逗号分隔格式
0.123 {:.2%} '12.30%' 百分比格式
123456789 {:.2e} '1.23e+08' 科学计数法格式

3.4.5 修剪操作

字符串的strip方法可以帮我们获得将原字符串修剪掉左右两端指定字符之后的字符串,默认是修剪空格字符。这个方法非常有实用价值,可以用来将用户输入时不小心键入的头尾空格等去掉,strip方法还有lstriprstrip两个版本,相信从名字大家已经猜出来这两个方法是做什么用的。

1
2
3
4
5
6
7
8
9
10
s1 = '  hello, world!  '
s2 = ' \tmpy\[email protected]\n\n\n'
print(s1.strip())
print(s1.lstrip())
print(s1.rstrip())
print(s2.strip())
print(s2.lstrip())
print(s2.rstrip())

print(id(s1),id(s1.strip()),sep='\n')

alt text

注意:这里面id函数的作用是获取对象的内存地址(十六进制表示),输出不同也表明strip方法不会修改字符串本身,而是返回一个新的字符串。

3.4.6 替换操作

如果希望用新的内容替换字符串中指定的内容,可以使用replace方法,代码如下所示。replace方法的第一个参数是被替换的内容,第二个参数是替换后的内容,还可以通过第三个参数指定替换的次数。

1
2
3
4
5
6
7
s = 'hello, world!'
print(s.replace('o', '*'))
print(s.replace('o', '*', 1))
print(s.replace('x', 'y'))

print(id(s))
print(id(s.replace('o', '*')))

alt text

注意:replace方法不会修改字符串本身,而是返回一个新的字符串,所以需要用变量接收返回值。

3.4.7 拆分与合并

可以使用字符串的split方法将一个字符串拆分为多个字符串(放在一个列表中),也可以使用字符串的join方法将列表中的多个字符串连接成一个字符串,代码如下所示。

1
2
3
4
5
s = 'I#love#you#so#much'
words = s.split('#')
print(words)
words = s.split('#', 2)
print(words)

alt text

注意:split方法的第一个参数是分隔符,第二个参数是最大拆分次数,如果第二个参数为2,则表示最多拆分为两个子字符串,第三个子字符串会作为剩余部分。

3.4.8 编码和解码

Python 中除了字符串str类型外,还有一种表示二进制数据的字节串类型(bytes)。所谓字节串,就是由零个或多个字节组成的有限序列。通过字符串的encode方法,我们可以按照某种编码方式将字符串编码为字节串,我们也可以使用字节串的decode方法,将字节串解码为字符串,代码如下所示。

1
2
3
4
5
6
7
a = '鹏远'
b = a.encode('utf-8')
c = a.encode('gbk')
print(b)
print(c)
print(b.decode('utf-8'))
print(c.decode('gbk'))

alt text

注意,如果编码和解码的方式不一致,会导致乱码问题(无法再现原始的内容)或引发UnicodeDecodeError错误,导致程序崩溃。

这里给出常见编码方式的对应关系。

编码方式 描述
utf-8 万国码,支持所有语言
gbk 国标码,支持中文
gb2312 国标码,支持中文

3.4.9 其他方法

对于字符串类型来说,还有一个常用的操作是对字符串进行匹配检查,即检查字符串是否满足某种特定的模式。例如,一个网站对用户注册信息中用户名和邮箱的检查,就属于模式匹配检查。实现模式匹配检查的工具叫做正则表达式,Python 语言通过标准库中的re模块提供了对正则表达式的支持。

4 常用数据结构之集合

在学习了列表和元组之后,我们再来学习一种容器型的数据类型,它的名字叫集合(set)。说到集合这个词大家一定不会陌生,在数学课本上就有这个概念。如果我们把一定范围的、确定的、可以区别的事物当作一个整体来看待,那么这个整体就是集合,集合中的各个事物称为集合的元素。通常,集合需要满足以下要求:

  1. 无序性:一个集合中,每个元素的地位都是相同的,元素之间是无序的。
  2. 互异性:一个集合中,任何两个元素都是不相同的,即元素在集合中只能出现一次。
  3. 确定性:给定一个集合和一个任意元素,该元素要么属这个集合,要么不属于这个集合,二者必居其一,不允许有模棱两可的情况出现。

Python 程序中的集合跟数学上的集合没有什么本质区别,需要强调的是上面所说的无序性和互异性。无序性说明集合中的元素并不像列中的元素那样存在某种次序,可以通过索引运算就能访问任意元素,集合并不支持索引运算。另外,集合的互异性决定了集合中不能有重复元素,这一点也是集合区别于列表的地方,我们无法将重复的元素添加到一个集合中。集合类型必然是支持innot in成员运算的,这样就可以确定一个元素是否属于集合,也就是上面所说的集合的确定性。集合的成员运算在性能上要优于列表的成员运算,这是集合的底层存储特性决定的,此处我们暂时不做讨论,大家记住这个结论即可。

说明:集合底层使用了哈希存储(散列存储),对哈希存储不了解的读者可以先看看“Hello 算法”网站对哈希表的讲解,感谢作者的开源精神。

4.1 创建集合

在 Python 中,创建集合可以使用{}字面量语法,{}中需要至少有一个元素,因为没有元素的{}并不是空集合而是一个空字典,字典类型我们会在下一节课中为大家介绍。当然,也可以使用 Python 内置函数set来创建一个集合,准确的说set并不是一个函数,而是创建集合对象的构造器,这个知识点会在后面讲解面向对象编程的地方为大家介绍。我们可以使用set函数创建一个空集合,也可以用它将其他序列转换成集合,例如:set('hello')会得到一个包含了4个字符的集合(重复的字符l只会在集合中出现一次)。除了这两种方式,还可以使用生成式语法来创建集合,就像我们之前用生成式语法创建列表那样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
set1 = {1, 2, 3, 4, 5}
print(set1)

set2 = {'banana', 'apple', 'orange'}
print(set2)

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

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

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

alt text

需要提醒大家,集合中的元素必须是hashable类型,所谓hashable类型指的是能够计算出哈希码的数据类型,通常不可变类型都是hashable类型,如整数(int)、浮点小数(float)、布尔值(bool)、字符串(str)、元组(tuple)等。可变类型都不是hashable类型,因为可变类型无法计算出确定的哈希码,所以它们不能放到集合中。例如:我们不能将列表作为集合中的元素;同理,由于集合本身也是可变类型,所以集合也不能作为集合中的元素。我们可以创建出嵌套列表(列表的元素也是列表),但是我们不能创建出嵌套的集合,这一点在使用集合的时候一定要引起注意。

4.2 集合的遍历

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

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

alt text

提示:大家看看上面代码的运行结果,通过单词输出的顺序体会一下集合的无序性。

4.3 集合的运算

Python 为集合类型提供了非常丰富的运算,主要包括:成员运算、交集运算、并集运算、差集运算、比较运算(相等性、子集、超集)等。

4.3.1 成员运算

可以通过成员运算innot in检查元素是否在集合中,代码如下所示。

1
2
3
4
5
6
set1 = {11, 12, 13, 14, 15}
print(10 not in set1)
print(15 in set1)
set2 = {'Python', 'Java', 'C++', 'Swift'}
print('Ruby' in set2)
print('Java' in set2)

alt text

4.3.2 二元运算

集合的二元运算主要指集合的交集、并集、差集、对称差等运算,这些运算可以通过运算符来实现,也可以通过集合类型的方法来实现,代码如下所示。

alt text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
set1 = {1, 2, 3, 4, 5, 6, 7}
set2 = {2, 4, 6, 8, 10}

# 交集
print(set1 & set2)
print(set1.intersection(set2))

# 并集
print(set1 | set2)
print(set1.union(set2))

# 差集
print(set1 - set2)
print(set1.difference(set2))

# 对称差
print(set1 ^ set2)
print(set1.symmetric_difference(set2))

alt text

通过上面的代码可以看出,对两个集合求交集,&运算符和intersection方法的作用是完全相同的,使用运算符的方式显然更直观且代码也更简短。需要说明的是,集合的二元运算还可以跟赋值运算一起构成复合赋值运算,例如:set1 |= set2相当于set1 = set1 | set2,跟|=作用相同的方法是updateset1 &= set2相当于set1 = set1 & set2,跟&=作用相同的方法是intersection_update,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
set1 = {1, 3, 5, 7}
set2 = {2, 4, 6}
# set1 |= set2
set1.update(set2)
print(set1)
set3 = {3, 6, 9}
# set1 &= set3
set1.intersection_update(set3)
print(set1)
set2 -= set1
# set2.difference_update(set1)
print(set2)

alt text

4.3.3 比较运算

两个集合可以用==!=进行相等性判断,如果两个集合中的元素完全相同,那么==比较的结果就是True,否则就是False。如果集合A的任意一个元素都是集合B的元素,那么集合A称为集合B子集,即对于∀a∈A,均有a∈B,则A⊆BAB的子集,反过来也可以称BA超集。如果AB的子集且A不等于B,那么A就是B真子集。Python 为集合类型提供了判断子集和超集的运算符,其实就是我们非常熟悉的<<=>>=这些运算符。当然,我们也可以通过集合类型的方法issubsetissuperset来判断集合之间的关系,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
set1 = {1, 3, 5}
set2 = {1, 2, 3, 4, 5}
set3 = {5, 4, 3, 2, 1}
set4 = {1, 2, 4, 5}

print(set1 <= set2)
print(set1 < set2)
print(set1 <= set3)
print(set1 < set3)
print(set1 < set4)
print(set2 == set3)

print(set1.issubset(set2))
print(set2.issuperset(set1))

alt text

说明:上面的代码中,set1 < set2判断set1是不是set2真子集set1 <= set2判断set1是不是set2子集set2 > set1判断set2是不是set1超集。当然,我们也可以通过set1.issubset(set2)判断set1是不是set2的子集;通过set2.issuperset(set1)判断set2是不是set1的超集。

4.4 集合的常用方法

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
set1 = {1, 10, 100}

# 添加元素
set1.add(1000)
set1.add(10000)
print(set1)

# 删除元素
set1.discard(10)
if 100 in set1:
set1.remove(100)
print(set1)

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

alt text

这里我们用到了discard方法,这个方法跟remove方法的区别在于,如果删除的元素不存在,discard方法不会引发KeyError错误,而remove方法会引发KeyError错误。

集合中还有一个pop方法,这个方法会随机删除集合中的一个元素并返回该元素,如果集合为空,则引发KeyError错误。

1
2
3
set1 = {1, 10, 100}
print(set1.pop())
print(set1)

alt text

集合类型还有一个名为isdisjoint的方法可以判断两个集合有没有相同的元素,如果没有相同元素,该方法返回True,否则该方法返回False,代码如下所示。

1
2
3
set1 = {1, 3, 5}
set2 = {2, 4, 6}
print(set1.isdisjoint(set2))

alt text

4.5 不可变集合

Python 中还有一种不可变类型的集合,名字叫frozensetsetfrozenset的区别就如同listtuple的区别,frozenset由于是不可变类型,能够计算出哈希码,因此它可以作为set中的元素。除了不能添加和删除元素,frozenset在其他方面跟set是一样的,下面的代码简单的展示了frozenset的用法。

1
2
3
4
5
6
7
8
fset1 = frozenset({1, 3, 5, 7})
fset2 = frozenset(range(1, 6))
print(fset1) # frozenset({1, 3, 5, 7})
print(fset2) # frozenset({1, 2, 3, 4, 5})
print(fset1 & fset2) # frozenset({1, 3, 5})
print(fset1 | fset2) # frozenset({1, 2, 3, 4, 5, 7})
print(fset1 - fset2) # frozenset({7})
print(fset1 < fset2) # False

alt text

4.6 总结

Python 中的集合类型是一种无序容器,不允许有重复运算,由于底层使用了哈希存储,集合中的元素必须是hashable类型。集合与列表最大的区别在于集合中的元素没有顺序、所以不能够通过索引运算访问元素、但是集合可以执行交集、并集、差集等二元运算,也可以通过关系运算符检查两个集合是否存在超集、子集等关系。

5 常用数据结构之字典

迄今为止,我们已经为大家介绍了 Python 中的三种容器型数据类型(列表、元组、集合),但是这些数据类型仍然不足以帮助我们解决所有的问题。例如,我们需要一个变量来保存一个人的多项信息,包括:姓名、年龄、身高、体重、家庭住址、本人手机号、紧急联系人手机号,此时你会发现,我们之前学过的列表、元组和集合类型都不够好使。

集合肯定是最不合适的,因为集合中不能有重复元素,如果一个人的年龄和体重刚好相同,那么集合中就会少一项信息;同理,如果这个人的手机号和紧急联系人手机号是相同的,那么集合中又会少一项信息。另一方面,虽然列表和元组可以把一个人的所有信息都保存下来,但是当你想要获取这个人的手机号或家庭住址时,你得先知道他的手机号是列表或元组中的第几个元素。总之,在遇到上述的场景时,列表、元组、集合都不是最合适的选择,此时我们需要字典(dictionary)类型,这种数据类型最适合把相关联的信息组装到一起,可以帮助我们解决 Python 程序中为真实事物建模的问题。

Python 程序中的字典跟现实生活中的字典很像,它以键值对(键和值的组合)的方式把数据组织到一起,我们可以通过键找到与之对应的值并进行操作。就像《新华字典》中,每个字(键)都有与它对应的解释(值)一样,每个字和它的解释合在一起就是字典中的一个条目,而字典中通常包含了很多个这样的条目。

5.1 创建字典

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
xinhua = {
'麓': '山脚下',
'路': '道,往来通行的地方;方面,地区:南~货,外~货;种类:他俩是一~人',
'蕗': '甘草的别名',
'潞': '潞水,水名,即今山西省的浊漳河;潞江,水名,即云南省的怒江'
}
print(xinhua)

person = {
'name': 'BIT第一深情',
'age': 21,
'height': 171,
'weight': 59,
'addr': 'XXXXXX',
'tel': '150XXXXXXXX'
}
print(person)

alt text

通过上面的代码,相信大家已经看出来了,用字典来保存一个人的信息远远优于使用列表或元组,因为我们可以用:前面的键来表示条目的含义,而:后面就是这个条目所对应的值。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
# 用 dict 函数(构造器)生成,其每一组参数就是字典中的一组键值对
dict1 = dict(name='BIT第一深情', age=21, height=171, weight=59, addr='XXXXXX', tel='150XXXXXXXX')
print(dict1)

# 用字典生成式语法来创建字典
dict2 = {key: value for key, value in [('name', 'BIT第一深情'), ('age', 21), ('height', 171), ('weight', 59), ('addr', 'XXXXXX'), ('tel', '150XXXXXXXX')]}
print(dict2)

# 通过 Python 内置函数 zip 压缩两个序列并创建字典
items1 = dict(zip('ABCDEF', range(6)))
print(items1)
items2 = dict(zip('ABCDEF',range(0,12)))
print(items2)

alt text

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

1
2
3
4
5
6
7
8
9
10
11
12
person = {
'name': 'BIT第一深情',
'age': 21,
'height': 171,
'weight': 59,
'addr': 'XXXXXX',
'tel': '150XXXXXXXX'
}
print(len(person))

for key in person:
print(key)

alt text

5.2 字典的运算

对于字典类型来说,成员运算和索引运算肯定是很重要的,前者可以判定指定的键在不在字典中,后者可以通过键访问对应的值或者向字典中添加新的键值对。值得注意的是,字典的索引不同于列表的索引,列表中的元素因为有属于自己有序号,所以列表的索引是一个整数;字典中因为保存的是键值对,所以字典需要用键去索引对应的值。需要特别提醒大家注意的是,字典中的键必须是不可变类型,例如整数(int)、浮点数(float)、字符串(str)、元组(tuple)等类型,这一点跟集合类型对元素的要求是一样的;很显然,之前我们讲的列表(list)和集合(set)不能作为字典中的键,字典类型本身也不能再作为字典中的键,因为字典也是可变类型,但是列表、集合、字典都可以作为字典中的值,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
person = {
'name': '王大锤',
'age': 55,
'height': 168,
'weight': 60,
'addr': ['成都市武侯区科华北路62号1栋101', '北京市西城区百万庄大街1号'],
'car': {
'brand': 'BMW X7',
'maxSpeed': '250',
'length': 5170,
'width': 2000,
'height': 1835,
'displacement': 3.0
}
}
print(person)

alt text

大家可以看看下面的代码,了解一下字典的成员运算和索引运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
person = {
'name': 'BIT第一深情',
'age': 21,
'height': 171,
'weight': 59,
'addr': 'XXXXXX'
}

# 成员运算
print('name' in person)
print('car' in person)

# 索引运算
print(person['name'])
print(person['addr'])
person['age'] = 22
person['height'] = 172
person['tel'] = '150XXXXXXXX'
person['habits'] = ['篮球', '足球', '羽毛球']
print(person)

# 循环遍历
for key in person:
print(f'{key}\t{person[key]}')

alt text

说明:字典的索引运算跟列表的索引运算一样,如果键不存在,会引发KeyError错误。

5.3 字典的方法

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

1
2
3
4
5
6
7
8
9
10
11
person = {
'name': 'BIT第一深情',
'age': 21,
'height': 171,
'weight': 59,
'addr': 'XXXXXX'
}
print(person.get('name'))
print(person.get('addr'))
print(person.get('sex'))
print(person.get('sex', '男'))

alt text

get方法的第一个参数是键,第二个参数是默认值,如果字典中存在指定的键,则返回对应的值,否则返回第二个参数对应的值。

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

1
2
3
4
x = {'a': 1, 'b': 2}
y = {'b': 3, 'c': 4}
x.update(y)
print(x)

alt text

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

1
2
3
4
x = {'a': 1, 'b': 2}
y = {'b': 3, 'c': 4}
x |= y
print(x)

alt text

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

1
2
3
4
5
6
7
8
9
10
11
person = {
'name': 'BIT第一深情',
'age': 21,
'height': 171,
'weight': 59,
'addr': 'XXXXXX'
}
print(person.pop('name'))
print(person)
print(person.popitem())
print(person)

alt text

5.4 字典的应用

我们通过几个简单的例子来看看如何使用字典类型解决一些实际的问题。

例子1:输入一段话,统计每个英文字母出现的次数,按出现次数从高到低输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"""
输入一段话,统计每个英文字母出现的次数,按出现次数从高到低输出。

Version: 1.0
Author: Penry
"""

sentence = input("请输入一段话:")
counter = {}
for ch in sentence:
if 'A' <= ch <= 'Z' or 'a' <= ch <= 'z':
counter[ch] = counter.get(ch, 0) + 1

sorted_keys = sorted(counter, key=lambda x: counter[x], reverse=True)
for key in sorted_keys:
print(f"{key}: {counter[key]}")

alt text

例子2:在一个字典中保存了股票的代码和价格,找出股价大于100元的股票并创建一个新的字典。

说明:可以用字典的生成式语法来创建这个新字典。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"""
在一个字典中保存了股票的代码和价格,找出股价大于100元的股票并创建一个新的字典。

Verison: 1.0
Author: Penry
"""

stocks = {
'AAPL': 191.88,
'GOOG': 1186.96,
'IBM': 149.24,
'ORCL': 48.44,
'ACN': 166.89,
'FB': 208.09,
'SYMC': 21.29
}

stocks_selected_over_100 = {key: value for key, value in stocks.items() if value > 100}
print(stocks_selected_over_100)
for key, value in stocks_selected_over_100.items():
print(f"{key}: {value}")

alt text

5.5 总结

Python 程序中的字典跟现实生活中字典非常像,允许我们以键值对的形式保存数据,再通过键访问对应的值。字典是一种非常有利于数据检索的数据类型,但是需要再次提醒大家,字典中的键必须是不可变类型,列表、集合、字典等类型的数据都不能作为字典的键。

6 常用数据结构总结

数据结构 是否可变 是否有序 是否允许重复 索引支持 典型符号 适用场景举例
列表 list ✅ 可变 ✅ 有序 ✅ 允许 ✅ 支持 [1, 2, 3] 多元素集合,可增删改查
字符串 str ❌ 不可变 ✅ 有序 ✅ 允许 ✅ 支持 'abc'"abc" 文本处理、编码转换
元组 tuple ❌ 不可变 ✅ 有序 ✅ 允许 ✅ 支持 (1, 2, 3) 不可变序列、函数多返回值
集合 set ✅ 可变 ❌ 无序 ❌ 不允许重复 ❌ 不支持 {1, 2, 3} 去重、集合运算(交并差)
字典 dict ✅ 可变 ✅ 有序* ❌ 键不重复 ✅ 支持键索引 {'a': 1} 键值映射、快速查找

✅ 有序:指插入元素的顺序是否保留;Python 3.7+ 中 dict 默认有序
✅ 可变:是否支持增删改;不可变结构不能原地修改
✅ 支持索引:是否可以使用 结构[i]结构[key] 访问元素

Day4

1 函数和模块

1.1 函数引入

在讲解本节课的内容之前,我们先来研究一道数学题,请说出下面的方程有多少组正整数解

x1+x2+x3+x4=8x_1 + x_2 + x_3 + x_4 = 8

你可能已经想到了,这个问题其实等同于将 8 个苹果分成四组且每组至少一个苹果有多少种方案,也等价于在 8 个苹果之间的 7 个间隙中放入 3 个隔板,将苹果分成四组。因此,答案为:

C73=7!3!4!=35C_7^3 = \frac{7!}{3!\,4!} = 35

其中,C73C_7^3 表示从 7 个间隙中选出 3 个放隔板的组合数,其计算公式如下:

Cnk=n!k!(nk)!C_n^k = \frac{n!}{k!(n-k)!}

根据之前我们所学的知识,我们可以用循环做累乘的方式分别计算出 7!7!3!3!4!4!,最后将它们相除即可得到 C73C_7^3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
"""
计算 C_7^3

Version: 1.0
Author: Penry
"""

m = int(input('m = '))
n = int(input('n = '))

# 计算 m 的阶乘
fm = 1
for num in range(1, m+1):
fm *= num

# 计算 n 的阶乘
fn = 1
for num in range(1, n+1):
fn *= num

# 计算 m-n 的阶乘
fnm = 1
for num in range(1, m-n+1):
fnm *= num

# 计算 C_m^n
fmn = int(fm / (fn * fnm))
print('C_{0}^{1} = {2}'.format(m, n, fmn))

alt text

不知大家是否注意到,上面的代码中我们做了三次求阶乘的操作,虽然 mmnnmnm - n 的值各不相同,但是三段代码并没有实质性的区别,属于重复代码。世界级的编程大师Martin Fowler曾经说过:“代码有很多种坏味道,重复是最坏的一种!”。要写出高质量的代码,首先就要解决重复代码的问题。对于上面的代码来说,我们可以将求阶乘的功能封装到一个称为“函数”的代码块中,在需要计算阶乘的地方,我们只需“调用函数”即可实现对求阶乘功能的复用。

1.2 定义函数

数学上的函数通常形如 y=f(x)y=f(x) 或者 z=g(x,y)z=g(x,y) 这样的形式,在 y=f(x)y=f(x) 中,ff 是函数的名字,xx 是函数的自变量,yy 是函数的因变量;而在 z=g(x,y)z=g(x,y) 中,gg 是函数名,xxyy 是函数的自变量,zz 是函数的因变量。Python 中的函数跟这个结构是一致的,每个函数都有自己的名字、自变量和因变量。我们通常把 Python 函数的自变量称为函数的参数,而因变量称为函数的返回值。

Python 中可以使用 def 关键字来定义函数,和变量一样每个函数也应该有一个漂亮的名字,命名规则跟变量的命名规则是一样的(大家赶紧想想我们之前讲过的变量的命名规则)。在函数名后面的圆括号中可以设置函数的参数,也就是我们刚才说的函数的自变量,而函数执行完成后,我们会通过 return 关键字来返回函数的执行结果,这就是我们刚才说的函数的因变量。如果函数中没有 return 语句,那么函数会返回代表空值的 None。另外,函数也可以没有自变量(参数),但是函数名后面的圆括号是必须有的。一个函数要做的事情(要执行的代码),是通过代码缩进的方式放到函数定义行之后,跟之前分支和循环结构的代码块类似,如下图所示。

alt text

下面,我们将之前代码中求阶乘的操作放到一个函数中,通过这种方式来重构上面的代码。所谓重构,是在不影响代码执行结果的前提下对代码的结构进行调整,重构之后的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
"""
计算 C_m^n

Version: 1.1
Author: Penry
"""

# 定义计算阶乘的函数
"""
function: 计算阶乘
parameter: n
return: n的阶乘结果
"""
def fact(n):
result = 1
match n:
case 0, 1:
return 1
case _:
for num in range(2, n+1):
result *= num
return result

m = int(input('m = '))
n = int(input('n = '))
# 计算阶乘的时候不需要写重复的代码而是直接调用函数
# 调用函数的语法是在函数名后面跟上圆括号并传入参数
print('C_{0}^{1} = {2}'.format(m, n, fact(m)//fact(n)//fact(m-n)))

alt text

大家可以感受下,上面的代码是不是比之前的版本更加简单优雅。更为重要的是,我们定义的求阶乘函数fac还可以在其他需要求阶乘的代码中重复使用。所以,使用函数可以帮助我们将功能上相对独立且会被重复使用的代码封装起来,当我们需要这些的代码,不是把重复的代码再编写一遍,而是通过调用函数实现对既有代码的复用。事实上,Python 标准库的math模块中,已经有一个名为factorial的函数实现了求阶乘的功能,我们可以直接用import math导入math模块,然后使用math.factorial来调用求阶乘的函数;我们也可以通过from math import factorial直接导入factorial函数来使用它,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
"""
计算 C_m^n

Version: 1.2
Author: Penry
"""

import math

m = int(input('m = '))
n = int(input('n = '))
print('C_{0}^{1} = {2}'.format(m, n, math.factorial(m) // math.factorial(n) // math.factorial(m - n)))

alt text

将来我们使用的函数,要么是自定义的函数,要么是 Python 标准库或者三方库中提供的函数,如果已经有现成的可用的函数,我们就没有必要自己去定义,“重复发明轮子”是一件非常糟糕的事情。对于上面的代码,如果你觉得factorial这个名字太长,书写代码的时候不是特别方便,我们在导入函数的时候还可以通过as关键字为其别名。在调用函数的时候,我们可以用函数的别名,而不再使用它之前的名字,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
"""
计算 C_m^n

Version: 1.3
Author: Penry
"""

from math import factorial as fac

m = int(input('m = '))
n = int(input('n = '))
print('C_{0}_{1} = {2}'.format(m, n, fac(m) // fac(n) // fac(m - n)))

alt text

1.3 函数的参数

1.3.1 位置参数和关键字参数

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"""
判断三角形

Version: 1.0
Author: Penry
"""

def is_triangle(a, b, c):
"""
function: 判断是否为三角形
parameter: a, b, c 三条边
return: True or False
"""
if a + b > c and a + c > b and b + c > a:
return True
else:
return False

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

1
2
print(is_triangle(1, 2, 3))
print(is_triangle(3, 4, 5))

alt text

如果不想按照从左到右的顺序依次给出 a, b, c ,可以在传入参数的时候指定参数名,这样传入参数的顺序就可以和定义函数时的参数顺序不同,代码如下所示:

1
2
print(is_triangle(a=3, b=4, c=5))
print(is_triangle(c=5, b=4, a=3))

alt text

在定义函数的时候,可以在参数列表中用 / 设置强制位置参数(positional-only arguments),用 \* 设置可变参数(*args)和关键字参数(**kwargs)。

  • 强制位置参数:就是调用函数时只能按照参数位置来接收参数值的参数,/ 前面的参数是强制位置参数;
  • 命名关键字参数:只能通过“参数名=参数值”的方式来传递和接收参数,* 后面的参数是命名关键字参数。

可以看下面这个例子:

1
2
3
4
5
6
7
def func(a, /, b, *, c):
print(a, b, c)

func(1, 2, c = 3) # 1 2 3
func(1, b = 2, c = 3)
func(a = 1, b = 2, c = 3) # 报错
# 报错内容为:TypeError: func() got some positional-only arguments passed as keyword arguments: 'a'

在上面的代码中,a 是一个位置参数,b 是一个强制位置参数,c 是一个命名关键字参数。在调用函数的时候,a 只能按照位置参数的方式来传入,而 bc 必须通过“参数名=参数值”的方式来传入。

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

1.3.2 默认参数

函数的参数可以设置默认值,这样在调用函数的时候,如果没有传入对应的参数值,就会使用默认值。我们可以把之前讲过的一个例子“CRAPS赌博游戏”中摇色子获得点数的功能封装到函数中,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"""
摇骰子

Author: Penry
Version: 1.0
"""
from random import randint

def roll_dice(n = 2):
"""
function: 摇色子
parameter: n 个色子的个数
return: 摇子点数的总和
"""
total = 0
for _ in range(n):
total += randint(1, 6)
return total

# 如果没有指定参数,那么使用默认值摇两颗色子
print(roll_dice())
# 如果传入参数3,那么摇三颗色子
print(roll_dice(3))

在上面的代码中,我们给 n 设置了默认值为 2,也就是说,如果不传入 n 的值,那么默认摇两颗色子。

我们再来看一个更为简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"""
三数相加求和

Author: Penry
Version: 1.0
"""


def add(a = 0, b = 0, c = 0):
return a + b + c


# 调用add函数,没有传入参数,那么a、b、c都使用默认值0
print(add())
# 调用add函数,传入一个参数,该参数赋值给变量a, 变量b和c使用默认值0
print(add(1))
# 调用add函数,传入两个参数,分别赋值给变量a和b,变量c使用默认值0
print(add(1, 2))
# 调用add函数,传入三个参数,分别赋值给变量a、b和c
print(add(1, 2, 3))
# 调用add函数,传入两个参数,运用关键字参数赋值给变量b和c,变量a使用默认值0
print(add(b=1, c=2))

alt text

需要注意的是,带默认值的参数必须放在不带默认值的参数之后,否则将产生SyntaxError错误,错误消息是:non-default argument follows default argument,翻译成中文的意思是“没有默认值的参数放在了带默认值的参数后面”。

1
2
3
4
5
6
7
8
9
10
"""
三数相加求和

Author: Penry
Version: 1.1
"""


def add(a = 0, b = 0, c):
return a + b + c

报错如下:

alt text

1.3.3 可变参数

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"""
三数相加求和

Author: Penry
Version: 1.2
"""

# 用星号表达式来表示args可以接收0个或任意多个参数
# 调用函数时传入的n个参数会组装成一个n元组赋给args
# 如果一个参数都没有传入,那么args会是一个空元组

def add(*args):
total = 0
for val in args:
# 对参数进行了类型检查(数值型的才能求和)
if type(val) in (int, float):
total += val
print(val)
return total, type(args)

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

alt text

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"""
三数相加求和

Author: Penry
Version: 1.3
"""

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

def foo(*agrs, **kwagrs):
print(agrs)
print(kwagrs)

foo(1, 2, 3, True, name='Penry', age=21, gpa=3.87)

alt text

1.4 用模块管理函数

不管用什么样的编程语言来写代码,给变量、函数起名字都是一个让人头疼的问题,因为我们会遇到命名冲突这种尴尬的情况。最简单的场景就是在同一个 .py 文件中定义了两个同名的函数,如下所示:

1
2
3
4
5
6
7
8
9
def foo():
print('hello, world!')


def foo():
print('goodbye, world!')


foo()

alt text

当然上面的这种情况我们很容易就能避免,但是如果项目是团队协作多人开发的时候,团队中可能有多个程序员都定义了名为 foo 的函数,这种情况下怎么解决命名冲突呢?答案其实很简单,Python 中每个文件就代表了一个模块(module),我们在不同的模块中可以有同名的函数,在使用函数的时候,我们通过 import 关键字导入指定的模块再使用完全限定名(模块名.函数名)的调用方式,就可以区分到底要使用的是哪个模块中的foo函数,代码如下所示:

model1.py

1
2
def foo():
print('hello, world!')

model2.py

1
2
def foo():
print('goodbye, world!')

test.py

1
2
3
4
5
import model1
import model2

model1.foo()
model2.foo()

输出:

Text
1
2
hello, world!
goodbye, world!

需要说明的是,如果我们导入的模块除了定义函数之外还有其他代码,那么 Python 解释器在导入这个模块时就会执行这些代码。实际上,我们编写的每个 .py 文件都是一个模块,模块的名字就是文件名去掉 .py 后缀。

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

1
2
3
4
5
from model1 import foo
foo()

from model2 import foo
foo()

输出:

Text
1
2
hello, world!
goodbye, world!

但是,如果我们如果从两个不同的模块中导入了同名的函数,后面导入的函数会替换掉之前的导入,就像下面的代码,调用 foo 会输出 goodbye, world!,因为我们先导入了module1foo,后导入了module2foo。如果两个from...import...反过来写,那就是另外一种情况了,调用foo会输出hello, world!,因为我们先导入了module2foo,后导入了module1foo

1
2
3
4
from model2 import foo
from model1 import foo

foo()

输出:

Text
1
goodbye, world!

1.5 标准库中的模块和函数

Python 标准库中提供了大量的模块和函数来简化我们的开发工作,我们之前用过的random模块就为我们提供了生成随机数和进行随机抽样的函数;而time模块则提供了和时间操作相关的函数;我们之前用到过的math模块中还包括了计算正弦、余弦、指数、对数等一系列的数学函数。随着我们深入学习 Python 语言,我们还会用到更多的模块和函数。

Python 标准库中还有一类函数是不需要import就能够直接使用的,我们将其称之为内置函数,这些内置函数不仅有用而且还很常用,下面的表格列出了一部分的内置函数:

函数 说明
abs 返回一个数的绝对值,例如:abs(-1.3)会返回1.3
bin 把一个整数转换成以'0b'开头的二进制字符串,例如:bin(123)会返回'0b1111011'
chr 将Unicode编码转换成对应的字符,例如:chr(8364)会返回'€'
hex 将一个整数转换成以'0x'开头的十六进制字符串,例如:hex(123)会返回'0x7b'
input 从输入中读取一行,返回读到的字符串。
len 获取字符串、列表等的长度。
max 返回多个参数或一个可迭代对象中的最大值,例如:max(12, 95, 37)会返回95
min 返回多个参数或一个可迭代对象中的最小值,例如:min(12, 95, 37)会返回12
oct 把一个整数转换成以'0o'开头的八进制字符串,例如:oct(123)会返回'0o173'
open 打开一个文件并返回文件对象。
ord 将字符转换成对应的Unicode编码,例如:ord('€')会返回8364
pow 求幂运算,例如:pow(2, 3)会返回8pow(2, 0.5)会返回1.4142135623730951
print 打印输出。
range 构造一个范围序列,例如:range(100)会产生099的整数序列。
round 按照指定的精度对数值进行四舍五入,例如:round(1.23456, 4)会返回1.2346
sum 对一个序列中的项从左到右进行求和运算,例如:sum(range(1, 101))会返回5050
type 返回对象的类型,例如:type(10)会返回int;而 type('hello')会返回str

1.6 总结

函数是对功能相对独立且会重复使用的代码的封装。学会使用定义和使用函数,就能够写出更为优质的代码。当然,Python 语言的标准库中已经为我们提供了大量的模块和常用的函数,用好这些模块和函数就能够用更少的代码做更多的事情;如果这些模块和函数不能满足我们的要求,可能就需要自定义函数,然后再通过模块的概念来管理这些自定义函数。

2 函数应用实战

2.1 随机验证码

设计一个生成随机验证码的函数,验证码由数字和英文大小写字母构成,长度可以通过参数设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"""
生成指定长度的随机验证码

Author: Penry
Version: 1.0
"""

import random
import string

ALL_CHARS = string.digits + string.ascii_letters

def generate_code(*, code_len=4):
"""
生成指定长度的随机验证码

:param code_len: 验证码的长度(默认值为4)
:return: 指定长度的随机验证码
"""
result_code = ''.join(random.choices(ALL_CHARS, k = code_len))
return result_code

# 随机生成五组验证码
for _ in range(5):
print(generate_code())

alt text

说明1:

代码 ALL_CHARS = string.digits + string.ascii_letters 的作用是:

  • string.digits 表示所有的数字字符,即字符串 “0123456789”。
  • string.ascii_letters 表示所有的英文字母,包括大写和小写,即字符串 “abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ”。
  • 两者相加,得到一个包含所有数字和英文字母(共62个字符)的字符串。

最终,ALL_CHARS 变量就包含了所有数字和英文字母,常用于生成验证码、随机密码等需要用到这些字符的场景。

示例:

1
2
3
4
import string
ALL_CHARS = string.digits + string.ascii_letters
print(ALL_CHARS)
# 输出:0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ

说明2:

random模块的samplechoices函数都可以实现随机抽样,sample实现无放回抽样,这意味着抽样取出的元素是不重复的;choices实现有放回抽样,这意味着可能会重复选中某些元素。这两个函数的第一个参数代表抽样的总体,而参数k代表样本容量,需要说明的是choices函数的参数k是一个命名关键字参数,在传参时必须指定参数名。

说明3:

我们设计的generate_code函数的参数code_len命名关键字参数,由于它有默认值,可以不给它传值,使用默认值4。如果需要给函数传入参数,必须指定参数名code_len

2.2 判断素数

设计一个判断给定的大于1的正整数是不是质数的函数。质数是只能被1和自身整除的正整数(大于1)。如果一个大于1的正整数N是质数,那就意味着在2到N-1之间都没有它的因子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"""
判断给定的正整数是不是质数

Author: Penry
Version: 1.0
"""

def is_prime(num: int) -> bool:
"""
判断给定的正整数是不是质数

:param num: 要判断的正整数
:return: 如果是质数返回True,否则返回False
"""
if num <= 1:
return False
else:
for i in range(2, int(num ** 0.5) + 1):
if num % i ==0:
return False
return True

num = int(input('请输入一个正整数:'))
result = is_prime(num)

print('{0} 是质数么:{1}'.format(num, result))

alt text

说明1:上面is_prime函数的参数num后面的: int用来标注参数的类型,虽然它对代码的执行结果不产生任何影响,但是很好的增强了代码的可读性。同理,参数列表后面的-> bool用来标注函数返回值的类型,它也不会对代码的执行结果产生影响,但是却让我们清楚的知道,调用函数会得到一个布尔值,要么是True,要么是False
说明2:上面的循环并不需要从 2 循环到 N1N-1,因为如果循环到 N\sqrt{N} 时,还没有找到 NN 的因子,那么之后肯定也不会出现了。

2.3 最大公约数和最小公倍数

题目描述:输入两个正整数,计算它们的最大公约数和最小公倍数。

两个数的最大公约数是能够同时整除这两个数的最大正整数。例如,12和18的最大公约数是6。

两个数的最小公倍数是能够被这两个数同时整除的最小正整数。例如,12和18的最小公倍数是36。

这个问题可以通过多种算法来解决:

  1. 欧几里得算法(辗转相除法)求最大公约数
  2. 根据最大公约数计算最小公倍数
  3. 穷举法(不推荐,效率较低)

2.3.1 欧几里得算法

欧几里得算法的基本思想是:用较大的数去除以较小的数,再用得到的余数去除以除数,如此反复,直到余数为0时,被除数就是这两个数的最大公约数。

例如,计算12和18的最大公约数的过程如下:

  1. 12 ÷ 18 = 0(余12)
  2. 18 ÷ 12 = 1(余6)
  3. 12 ÷ 6 = 2(余0)

所以,12和18的最大公约数是6。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
"""
欧几里得算法求最大公约数,并利用最大公约数求取最小公倍数

Author: Penry
Version: 1.0
"""

def gcd(a: int, b: int) -> int:
"""
欧几里得算法求最大公约数
:param a: 整数a
:param b: 整数b
:return: 最大公约数
"""
while b % a != 0:
a, b = b % a, a
return a

def lcm(a: int, b: int) -> int:
"""
利用最大公约数求取最小公倍数
:param a: 整数a
:param b: 整数b
:return: 最小公倍数
"""
return a * b // gcd(a, b)

if __name__ == '__main__':
a = int(input('请输入整数a:'))
b = int(input('请输入整数b:'))
print('输入的整数a为:', a)
print('输入的整数b为:', b)
print('最大公约数为:', gcd(a, b))
print('最小公倍数为:', lcm(a, b))

alt text

2.3.2 穷举法

穷举法的基本思想是:从1开始逐个尝试,直到找到同时能整除这两个数的最小正整数,这个数就是它们的最大公约数。

例如,计算12和18的最大公约数的过程如下:

  1. 从1开始逐个尝试,直到找到同时能整除12和18的最小正整数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
"""
穷举法求最大公约数,并利用最大公约数求取最小公倍数

Author: Penry
Version: 1.0
"""

def gcd(a: int, b: int) -> int:
"""
穷举法求最大公约数
:param a: 整数a
:param b: 整数b
:return: 最大公约数
"""
# 1. 确定较小的数
if a > b:
smaller = b
else:
smaller = a
# 2. 从较小的数开始递减,直到找到最大公约数
for i in range(smaller, 0, -1):
if a % i == 0 and b % i == 0:
return i

def lcm(a: int, b: int) -> int:
"""
利用最大公约数求取最小公倍数
:param a: 整数a
:param b: 整数b
:return: 最小公倍数
"""
# 1. 求最大公约数
gcd_ab = gcd(a, b)
# 2. 利用公式求取最小公倍数
lcm_ab = a * b // gcd_ab
return lcm_ab

if __name__ == '__main__':
a = int(input('请输入整数a:'))
b = int(input('请输入整数b:'))
print('输入的整数a为:', a)
print('输入的整数b为:', b)
print('最大公约数为:', gcd(a, b))
print('最小公倍数为:', lcm(a, b))

alt text

2.3.3 数据统计

假设样本数据保存一个列表中,设计计算样本数据描述性统计信息的函数。描述性统计信息通常包括:算术平均值、中位数、极差(最大值和最小值的差)、方差、标准差、变异系数等,计算公式如下所示:

  • 算术平均值:$$\bar{x} = \frac{1}{n}\sum_{i=1}^{n}x_i$$
  • 中位数:$$\text{中位数} = \begin{cases} x_{\frac{n+1}{2}} & \text{如果 } n \text{ 是奇数} \ \frac{1}{2}(x_{\frac{n}{2}} + x_{\frac{n}{2}+1}) & \text{如果 } n \text{ 是偶数} \end{cases}$$
  • 极差:$$\text{极差} = x_{\text{最大值}} - x_{\text{最小值}}$$
  • 方差:$$s^2 = \frac{1}{n-1}\sum_{i=1}^{n}(x_i - \bar{x})^2$$
  • 标准差:$$s = \sqrt{s^2}$$
  • 变异系数:$$CV = \frac{s}{\bar{x}}$$
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
"""
数据统计函数

Author: Penry
Version: 1.0
"""

def ptp(data):
"""极差(全距)"""
return max(data) - min(data)


def mean(data):
"""算术平均"""
return sum(data) / len(data)


def median(data):
"""中位数"""
temp, size = sorted(data), len(data)
if size % 2 != 0:
return temp[size // 2]
else:
return mean(temp[size // 2 - 1:size // 2 + 1])


def var(data, ddof=1):
"""方差"""
x_bar = mean(data)
temp = [(num - x_bar) ** 2 for num in data]
return sum(temp) / (len(temp) - ddof)


def std(data, ddof=1):
"""标准差"""
return var(data, ddof) ** 0.5


def cv(data, ddof=1):
"""变异系数"""
return std(data, ddof) / mean(data)


def describe(data):
"""输出描述性统计信息"""
print(f'均值: {mean(data)}')
print(f'中位数: {median(data)}')
print(f'极差: {ptp(data)}')
print(f'方差: {var(data)}')
print(f'标准差: {std(data)}')
print(f'变异系数: {cv(data)}')

说明:describe函数将上面封装好的统计函数组装到一起,用于输出数据的描述性统计信息。事实上,Python 标准库中有一个名为statistics的模块,它已经把获取描述性统计信息的函数封装好了,有兴趣的读者可以自行了解。

2.3.4 双色球随机选号

我们用函数重构之前讲过的双色球随机选号的例子,将生成随机号码和输出一组号码的功能分别封装到两个函数中,然后通过调用函数实现机选N注号码的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
"""
双色球随机选号

Author: Penry
Version: 1.0
"""

import random

RED_BALLS = [i for i in range(1,34)]
BLUE_BALLS = [i for i in range(1,17)]

def choose_balls():
"""
生成一组随机号码
:return: 一组随机号码,包括6个红球号码和1个蓝球号码
"""
# 1. 从红球池中随机抽取6个号码
red_balls = random.sample(RED_BALLS, 6)
# 2. 从蓝球池中随机抽取1个号码
blue_balls = random.sample(BLUE_BALLS, 1)
# 3. 返回随机号码
return red_balls + blue_balls

def display_balls(balls):
"""
显示一组号码
:param balls: 一组号码,包括6个红球号码和1个蓝球号码
:return: None
"""
# 1. 从号码中取出红球号码和蓝球号码
red_balls = balls[:6]
blue_balls = balls[-1]
# 2. 对红球号码进行排序
red_balls.sort()
# 3. 打印红球号码和蓝球号码
print('红球号码:', red_balls)
print('蓝球号码:', blue_balls)

n = int(input('生成几注号码: '))
for _ in range(n):
display_balls(choose_balls())

alt text

2.4 总结

在写代码尤其是开发商业项目的时候,一定要有意识的将相对独立且重复使用的功能封装成函数,这样不管是自己还是团队的其他成员都可以通过调用函数的方式来使用这些功能,减少工作中那些重复且乏味的劳动。

3 函数使用进阶

我们继续探索定义和使用函数的相关知识。通过前面的学习,我们知道了函数有自变量(参数)和因变量(返回值),自变量可以是任意的数据类型,因变量也可以是任意的数据类型,那么这里就有一个小问题,我们能不能用函数作为函数的参数,用函数作为函数的返回值?这里我们先说结论:Python 中的函数是一等函数,所谓“一等函数”指的就是函数可以赋值给变量,函数可以作为函数的参数,函数也可以作为函数的返回值。把一个函数作为其他函数的参数或返回值的用法,我们通常称之为高阶函数

3.1 高阶函数

我们回到之前讲过的一个例子,设计一个函数,传入任意多个参数,对其中int类型或float类型的元素实现求和操作。我们对之前的代码稍作调整,让整个代码更加紧凑一些,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"""
高阶函数示例

Author: Penry
Version: 1.0
"""

def calc(*args, **kwargs):
"""
计算函数
:param args: 可变参数,接收任意多个数字
:param kwargs: 关键字参数,接收运算符和数字
:return: 求和计算结果
"""
items = list(*args) + list(kwargs.values())
result = 0
for item in items:
if type(item) in (int, float):
result += item
return result

如果我们希望上面的calc函数不仅仅可以做多个参数的求和,还可以实现更多的甚至是自定义的二元运算,我们该怎么做呢?上面的代码只能求和是因为函数中使用了+=运算符,这使得函数跟加法运算形成了耦合关系,如果能解除这种耦合关系,函数的通用性和灵活性就会更好。解除耦合的办法就是将+运算符变成函数调用,并将其设计为函数的参数,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
"""
高阶函数示例

Author: Penry
Version: 1.1
"""

def calc(init_value, op_func, *args, **kwargs):
"""
计算函数
:param init_value: 初始值
:param op_func: 操作函数
:param args: 可变参数,接收任意多个数字
:param kwargs: 关键字参数,接收运算符和数字
:return: 计算结果
"""
items = list(args) + list(kwargs.values())
result = init_value
for item in items:
if type(item) in (int, float):
result = op_func(result, item)
return result

def add(x, y):
return x + y

def mul(x, y):
return x * y

def sub(x, y):
return x - y

# 如果要进行加法运算,可以按照下方示例调用calc函数
print(calc(0, add, 1, 2, 3, 4, 5))
# 如果要进行乘法运算,可以按照下方示例调用calc函数
print(calc(1, mul, 1, 2, 3, 4, 5))
# 如果要进行减法运算,可以按照下方示例调用calc函数
print(calc(100, sub, 1, 2, 3, 4, 5))

alt text

上面的calc函数通过将运算符变成函数的参数,实现了跟加法运算去耦合,这是一种非常高明和实用的编程技巧,但对于最初学者来说可能会觉得难以理解,建议大家细品一下。需要注意上面的代码中,将函数作为参数传入其他函数和直接调用函数是有显著的区别的,调用函数需要在函数名后面跟上圆括号,而把函数作为参数时只需要函数名即可

如果我们没有提前定义好addmul函数,也可以使用 Python 标准库中的operator模块提供的addmul函数,它们分别代表了做加法和做乘法的二元运算,我们拿过来直接使用即可。

Python 内置函数中有不少高阶函数,我们前面提到过的filtermap函数就是高阶函数,前者可以实现对序列中元素的过滤,后者可以实现对序列中元素的映射,例如我们要去掉一个整数列表中的奇数,并对所有的偶数求平方得到一个新的列表,就可以直接使用这两个函数来做到,具体的做法是如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"""
高阶函数示例,演示filter和map函数的使用

Author: Penry
Version: 1.2
"""

def is_even(num: int) -> bool:
"""
判断num是否为偶数
"""
return num % 2 == 0

def square(num: int) -> int:
"""
返回num的平方
"""
return num ** 2

old_nums = [35, 12, 8, 99, 60, 52]
new_nums = list(map(square, filter(is_even, old_nums)))
print(new_nums)

alt text

我们再来讨论一个内置函数sorted,它可以实现对容器型数据类型(如:列表、字典等)元素的排序。我们之前讲过list类型的sort方法,它实现了对列表元素的排序,sorted函数从功能上来讲跟列表的sort方法没有区别,但它会返回排序后的列表对象,而不是直接修改原来的列表,这一点我们称为函数的无副作用设计,也就是说调用函数除了产生返回值以外,不会对程序的状态或外部环境产生任何其他的影响。使用sorted函数排序时,可以通过高阶函数的形式自定义排序的规则,我们通过下面的例子加以说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"""
高阶函数示例,演示sorted函数的使用

Author: Penry
Version: 1.3
"""

old_strings = ['in', 'apple', 'zoo', 'waxberry', 'pear']

# 1. 默认排序方式,升序
new_strings = sorted(old_strings)
print(new_strings)

# 2. 按字符串长度排序,升序
new_strings = sorted(old_strings, key=len)
print(new_strings)

# 3. 按字符串长度排序,并逆序
new_strings = sorted(old_strings, key=len, reverse=True)
print(new_strings)

# 4. 按字符串首字母排序,升序
new_strings = sorted(old_strings, key=lambda x: x[0])
print(new_strings)

alt text

3.2 Lambda 函数

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

1
2
3
4
5
6
7
8
9
10
"""
高阶函数示例,演示lambda函数的使用

Author: Penry
Version: 1.4
"""

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)

alt text

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

前面我们说过,Python 中的函数是一等函数,函数是可以直接赋值给变量的。在学习了 lambda 函数之后,前面我们写过的一些函数就可以用一行代码来实现它们了,大家可以看看能否理解下面的求阶乘和判断素数的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"""
高阶函数示例,演示lambda函数的使用来求取阶乘和判断素数

Author: Penry
Version: 1.5
"""

import functools
import operator

# 用一行代码实现计算阶乘的函数
factorial = lambda n: functools.reduce(operator.mul, range(2, n+1), 1)
print(factorial(5)) # 输出 120

# 用一行代码实现判断素数的函数
is_prime = lambda n: n > 1 and all(n % i != 0 for i in range(2, int(n**0.5)+1))
print(is_prime(7)) # 输出 True
print(is_prime(4)) # 输出 False

alt text

解读1:上面使用的reduce函数是 Python 标准库functools模块中的函数,它可以实现对一组数据的归约操作,类似于我们之前定义的calc函数,第一个参数是代表运算的函数,第二个参数是运算的数据,第三个参数是运算的初始值。很显然,reduce函数也是高阶函数,它和filter函数、map函数一起构成了处理数据中非常关键的三个动作:过滤、映射和归约。
解读2:上面判断素数的 lambda 函数通过 range 函数构造了从 2 到 x\sqrt{x} 的范围,检查这个范围有没有 xx 的因子。all函数也是 Python 内置函数,如果传入的序列中所有的布尔值都是Trueall函数返回True,否则all函数返回False。

3.3 偏函数

偏函数(Partial Function)是 Python 中一个非常有用的工具,它允许我们创建一个新的函数,这个新函数的某些参数已经被预先设置好。偏函数通常用于简化函数调用,减少重复代码,或者在需要固定某些参数的情况下使用。

在 Python 中,偏函数是通过 functools.partial 实现的。

3.3.1 偏函数的概念

偏函数是一个函数的“部分应用”,即我们预先设置好某些参数的值,生成一个新的函数。当我们调用这个新函数时,只需要传入剩余的参数即可。

例如,我们有一个函数 power(base, exponent),它返回 baseexponent 次幂。如果我们经常需要计算平方或立方,可以创建两个偏函数,分别对应 exponent=2exponent=3

3.3.2 偏函数的语法

偏函数通过 functools.partial 创建,其语法如下:

1
2
3
from functools import partial

new_func = partial(func, *args, **keywords)
  • func:原始函数。
  • *args:要预先设置的位置参数。
  • **keywords:要预先设置的关键字参数。

返回的 new_func 是一个新函数,它在调用时会将预先设置的参数传递给原始函数。

3.3.3 示例 1:基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
"""
偏函数示例,演示如何使用functools模块中的partial函数来创建偏函数
偏函数是指预先设置好部分参数的函数,使用时只需传入剩余的参数即可

Author: Penry
Version: 1.0
"""

from functools import partial

def power(base, exponent):
"""
计算乘方的函数
:param base: 底数
:param exponent: 指数
:return: 乘方的结果
"""
return base ** exponent

# 创建偏函数,用于计算平方
square = partial(power, exponent=2)

# 创建偏函数,用于计算立方
cube = partial(power, exponent=3)

print(square(3)) # 输出:9
print(cube(3)) # 输出:27

3.3.4 示例 2:在排序中的应用

偏函数经常用于排序操作中,特别是当我们需要自定义排序键时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
"""
偏函数示例:排序

Author: Penry
Version: 1.0
"""
from functools import partial

# 定义一个学生类
class Student:
def __init__(self, name, age):
self.name = name
self.age = age

def __repr__(self):
return f"Student(name='{self.name}', age={self.age})"

students = [
Student("Alice", 20),
Student("Bob", 19),
Student("Charlie", 21)
]

# 使用偏函数自定义排序键
sorted_by_age = sorted(students, key=partial(lambda s, attr: getattr(s, attr), attr='age'))
sorted_by_name = sorted(students, key=partial(lambda s, attr: getattr(s, attr), attr='name'))

print(sorted_by_age) # 按年龄排序
print(sorted_by_name) # 按姓名排序

alt text

3.3.5 示例 3:与 map 函数结合

偏函数可以与 map 函数结合使用,简化代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"""
偏函数示例:与map函数结合

Author: Penry
Version: 1.0
"""
from functools import partial

def multiply(a, b):
return a * b

# 创建偏函数,固定第二个参数为 2
double = partial(multiply, b=2)

numbers = [1, 2, 3, 4, 5]
result = list(map(double, numbers))
print(result) # 输出:[2, 4, 6, 8, 10]

3.3.6 示例 4:条件判断中的应用

偏函数也可以用于条件判断,简化判断逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"""
偏函数示例:条件判断中的应用

Author: Penry
Version: 1.0
"""
from functools import partial

def is_greater_than(value, threshold):
return value > threshold

# 创建偏函数,判断是否大于 10
is_greater_than_10 = partial(is_greater_than, threshold=10)

values = [5, 10, 15, 20]
result = list(filter(is_greater_than_10, values))
print(result) # 输出:[15, 20]

3.3.7 偏函数的优势

  1. 减少重复代码:通过固定某些参数,避免重复编写类似的函数调用。
  2. 提高代码可读性:偏函数可以清晰地表达意图,使代码更易读。
  3. 灵活性:偏函数可以在不同场景下复用,适应不同的需求。

3.3.8 偏函数与 lambda 表达式的对比

特性/维度 偏函数(functools.partial lambda 表达式
定义方式 partial(函数, 固定参数=值) lambda 参数: 表达式
是否返回函数对象 ✅ 是 ✅ 是
是否固定参数 ✅ 是(提前绑定部分参数) ✅ 可以(通过表达式绑定)
可读性 ✅ 更清晰(尤其用于已有函数封装) ⚠️ 复杂时较难读
是否支持复杂逻辑 ❌ 否(只传参,不可写逻辑) ✅ 是(可写任意表达式)
调用开销 较低(C实现) 较低
使用场景 回调函数、简化函数调用、API适配等 简单匿名函数、排序键、map/filter等
是否依赖模块 ✅ 是(需 from functools import partial ❌ 否(内建支持)
  • 使用 partial:当你已有一个函数,只想固定部分参数,生成更简洁的调用方式。
  • 使用 lambda:当你需要动态生成匿名函数,或者函数逻辑不能直接用原函数表示时。

3.4 总结

Python 中的函数是一等函数,可以赋值给变量,也可以作为函数的参数和返回值,这也就意味着我们可以在 Python 中使用高阶函数。高阶函数的概念对新手并不友好,但它却带来了函数设计上的灵活性。如果我们要定义的函数非常简单,只有一行代码,而且不需要函数名来复用它,我们可以使用 lambda 函数。

4 函数高级应用

在上一个章节中,我们探索了 Python 中的高阶函数,相信大家对函数的定义和应用有了更深刻的认知。本章我们继续为大家讲解函数相关的知识,一个是 Python 中的特色语法装饰器,一个是函数的递归调用

4.1 装饰器

4.1.1 装饰器引入

Python 语言中,装饰器就是“用一个函数装饰另外一个函数,并为其提供额外的能力”的语法现象。装饰器本身就是一个函数,它的参数是另一个函数,返回值是一个带有装饰功能的函数,装饰器的作用是在不改变原函数的代码的前提下,给原函数添加新的功能。装饰器的语法是 @装饰器名,它放在函数定义的上方,装饰器的定义和使用都需要遵循一定的规则,装饰器的使用场景有很多,比如:日志记录、性能分析、权限验证、缓存等。

假设有名为downlaodupload的两个函数,分别用于文件的上传和下载,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import random
import time


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


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


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

说明:上面的代码用休眠一段随机时间的方式模拟了下载和上传文件需要花费一定的时间,并没有真正的联网上传下载文件。用 Python 语言实现联网上传下载文件也非常简单,后面我们会讲到相关的知识。

现在有一个新的需求,我们希望知道调用downloadupload函数上传下载文件到底用了多少时间,这应该如何实现呢?相信很多小伙伴已经想到了,我们可以在函数开始执行的时候记录一个时间,在函数调用结束后记录一个时间,两个时间相减就可以计算出下载或上传的时间,代码如下所示:

1
2
3
4
5
6
7
8
start = time.time()
download('MySQL从删库到跑路.avi')
end = time.time()
print(f'花费时间: {end - start:.2f}秒')
start = time.time()
upload('Python从入门到住院.pdf')
end = time.time()
print(f'花费时间: {end - start:.2f}秒')

alt text

通过上面的代码,我们可以在下载和上传文件的时候记录下耗费的时间,但不知道大家是否注意到,上面记录时间、计算和显示执行时间的代码都是重复代码。有编程经验的人都知道,重复的代码是万恶之源,我们应该避免编写重复的代码。那么有没有办法在不写重复代码的前提下,用一种简单优雅的方式记录下函数的执行时间呢?在 Python 语言中,装饰器就是解决这类问题的最佳选择。通过装饰器语法,我们可以把跟原来的业务(上传和下载)没有关系计时功能的代码封装到一个函数中,如果 uploaddownload 函数需要记录时间,我们直接把装饰器作用到这两个函数上即可。既然上面提到了,装饰器就是一个高阶函数,它的参数和返回值都是函数,我们将记录时间的装饰器命名为 record_time,那么它的整体结构应该如下所示:

1
2
3
4
5
6
7
8
9
def record_time(func):

def wrapper(*args, **kwargs):

result = func(*args, **kwargs)

return result

return wrapper

相信大家注意到了,record_time函数的参数func代表了一个被装饰的函数,函数里面定义的wrapper函数是带有装饰功能的函数,它会执行被装饰的函数func,它还需要返回在最后产生函数执行的返回值。不知大家是否留意到,上面的代码我在第4行第6行留下了两个空行,这意味着我们可以这些地方添加代码来实现额外的功能。record_time函数最终会返回这个带有装饰功能的函数wrapper并通过它替代原函数func,当原函数funcrecord_time函数装饰后,我们调用它时其实调用的是wrapper函数,所以才获得了额外的能力。wrapper函数的参数比较特殊,由于我们要用wrapper替代原函数func,但是我们又不清楚原函数func会接受哪些参数,所以我们就通过可变参数和关键字参数照单全收,然后在调用func的时候,原封不动的全部给它。这里还要强调一下,Python 语言支持函数的嵌套定义,就像上面,我们可以在record_time函数中定义wrapper函数,这个操作在很多编程语言中并不被支持。

看懂这个结构后,我们就可以把记录时间的功能写到这个装饰器中,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def record_time(func):
"""
记录函数的运行时间的装饰器

:param func: 要记录运行时间的函数
:return: 记录了运行时间的函数
"""
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

写装饰器虽然颇费周折,但是这是个一劳永逸的骚操作,将来再有记录函数执行时间的需求时,我们只需要添加上面的装饰器即可。使用上面的装饰器函数有两种方式

4.1.2 装饰器使用方法一

第一种方式就是直接调用装饰器函数,传入被装饰的函数并获得返回值,我们可以用这个返回值直接替代原来的函数,那么在调用时就已经获得了装饰器提供的额外的能力(记录执行时间),大家试试下面的代码就明白了。

1
2
3
4
download = record_time(download)
upload = record_time(upload)
download('MySQL从删库到跑路.avi')
upload('Python从入门到住院.pdf')

alt text

4.1.3 装饰器使用方法2

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
"""
装饰器函数演示

Author: Penry
Version: 1.0
"""

import time
import random

def record_time(func):
"""
记录函数的运行时间的装饰器
:param func: 要记录运行时间的函数
:return: 记录了运行时间的函数
"""
def wrapper(*args, **kwargs):
"""
记录函数运行时间的包装函数
:param args: 位置参数
:param kwargs: 关键字参数
:return: 被装饰函数的返回值
"""
# 在执行被装饰的函数之前记录开始时间
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):
"""
模拟下载文件的函数
:param filename: 要下载的文件名
"""
print(f'{filename}开始下载...')
time.sleep(random.random() * 6)
print(f'{filename}下载完成')

@record_time
def upload(filename):
"""
模拟上传文件的函数
:param filename: 要上传的文件名
"""
print(f'{filename}开始上传...')
time.sleep(random.random() * 6)
print(f'{filename}上传完成')

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

alt text

上面的代码,我们通过装饰器语法糖downloadupload函数添加了装饰器,被装饰后的downloadupload函数其实就是我们在装饰器中返回的wrapper函数,调用它们其实就是在调用wrapper函数,所以才有了记录函数执行时间的功能。

如果在代码的某些地方,我们想去掉装饰器的作用执行原函数,那么在定义装饰器函数的时候,需要做一点点额外的工作,常见有两种方法:

4.1.4 ✅ 方法 1:保留原函数引用,手动调用

1
2
3
4
5
6
7
8
9
10
11
12
13
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Wrapped")
return func(*args, **kwargs)
wrapper._original = func # 添加一个属性,保存原函数引用
return wrapper

@my_decorator
def foo(x):
print(f"foo({x})")

foo(42) # 调用装饰后的
foo._original(42) # 直接调用原函数,绕过装饰器

alt text

4.1.5 ✅ 方法 2:functools.wraps 保留原函数引用

通过 functools.wraps 保留原函数引用,然后通过 .__wrapped__ 属性调用原函数,从而绕过装饰器的执行逻辑。

这属于**装饰器解包(unwrapping)**的一种方式,常用于:

  1. 调试时跳过装饰逻辑;
  2. 保留原函数以便测试;
  3. 在某些特殊场合(比如单元测试、文档生成)只希望使用未被包装的函数。

Python 标准库functools模块的wraps函数也是一个装饰器,我们将它放在wrapper函数上,这个装饰器可以帮我们保留被装饰之前的函数,这样在需要取消装饰器时,可以通过被装饰函数的__wrapped__属性获得被装饰之前的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import random
import time

from functools import wraps


def record_time(func):

@wraps(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')
# 取消装饰器的作用不记录执行时间
download.__wrapped__('MySQL必知必会.pdf')
upload.__wrapped__('Python从新手到大师.pdf')

alt text

装饰器函数本身也可以参数化,简单的说就是装饰器也是可以通过调用者传入的参数来进行定制的,这个知识点我们在后面用到的时候再为大家讲解。

4.2 递归调用

Python 中允许函数嵌套定义,也允许函数之间相互调用,而且一个函数还可以直接或间接的调用自身。函数自己调用自己称为递归调用,那么递归调用有什么用处呢?现实中,有很多问题的定义本身就是一个递归定义,例如我们之前讲到的阶乘,非负整数N的阶乘是N乘以N-1的阶乘,即 N!=N×(N1)!N! = N \times (N-1)! ,定义的左边和右边都出现了阶乘的概念,所以这是一个递归定义。既然如此,我们可以使用递归调用的方式来写一个求阶乘的函数,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
"""
递归调用求阶乘


Author: Penry
Version: 1.0
"""
def fac(num):
if num in (0, 1):
return 1
return num * fac(num - 1)

fac(5)

上面的代码中,fac函数中又调用了fac函数,这就是所谓的递归调用。代码第2行的if条件叫做递归的收敛条件,简单的说就是什么时候要结束函数的递归调用,在计算阶乘时,如果计算到01的阶乘,就停止递归调用,直接返回1;代码第4行的num * fac(num - 1)是递归公式,也就是阶乘的递归定义。下面,我们简单的分析下,如果用fac(5)计算5的阶乘,整个过程会是怎样的。

1
2
3
4
5
6
7
8
9
10
11
12
# 递归调用函数入栈
# 5 * fac(4)
# 5 * (4 * fac(3))
# 5 * (4 * (3 * fac(2)))
# 5 * (4 * (3 * (2 * fac(1))))
# 停止递归函数出栈
# 5 * (4 * (3 * (2 * 1)))
# 5 * (4 * (3 * 2))
# 5 * (4 * 6)
# 5 * 24
# 120
print(fac(5)) # 120

注意,函数调用会通过内存中称为(stack)的数据结构来保存当前代码的执行现场,函数调用结束后会通过这个栈结构恢复之前的执行现场。栈是一种先进后出的数据结构,这也就意味着最早入栈的函数最后才会返回,而最后入栈的函数会最先返回。例如调用一个名为a的函数,函数a的执行体中又调用了函数b,函数b的执行体中又调用了函数c,那么最先入栈的函数是a,最先出栈的函数是c。每进入一个函数调用,栈就会增加一层栈帧(stack frame),栈帧就是我们刚才提到的保存当前代码执行现场的结构;每当函数调用结束后,栈就会减少一层栈帧。通常,内存中的栈空间很小,因此递归调用的次数如果太多,会导致栈溢出(stack overflow),所以递归调用一定要确保能够快速收敛。我们可以尝试执行fac(5000),看看是不是会提示RecursionError错误,错误消息为:maximum recursion depth exceeded in comparison(超出最大递归深度),其实就是发生了栈溢出。

如果我们使用官方的 Python 解释器(CPython),默认将函数调用的栈结构最大深度设置为1000层。如果超出这个深度,就会发生上面说的RecursionError。当然,我们可以使用sys模块的setrecursionlimit函数来改变递归调用的最大深度,但是我们不建议这样做,因为让递归快速收敛才是我们应该做的事情,否则就应该考虑使用循环递推而不是递归。

再举一个之前讲过的生成斐波那契数列的例子,因为斐波那契数列前两个数都是1,从第三个数开始,每个数是前两个数相加的和,可以记为f(n) = f(n - 1) + f(n - 2),很显然这又是一个递归的定义,所以我们可以用下面的递归调用函数来计算第​n个斐波那契数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
递归调用求斐波那契数列


Author: Penry
Version: 1.0
"""
def fib1(n):
if n in (1, 2):
return 1
return fib1(n - 1) + fib1(n - 2)

for i in range(1, 21):
print(f'斐波那契数列第{i}个数是{fib1(i)}')

alt text

需要提醒大家,上面计算斐波那契数的代码虽然看起来非常简单明了,但执行性能是比较糟糕的。大家可以试一试,把上面代码for循环中range函数的第二个参数修改为51,即输出前50个斐波那契数,看看需要多长时间。至于为什么这么慢,大家可以自己思考一下原因。很显然,直接使用循环递推的方式获得斐波那契数列是更好的选择,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
循环求斐波那契数列


Author: Penry
Version: 1.0
"""
def fib2(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a

for i in range(1, 21):
print(f'斐波那契数列第{i}个数是{fib2(i)}')

除此以外,我们还可以使用 Python 标准库中functools模块的lru_cache函数来优化上面的递归代码。lru_cache函数是一个装饰器函数,我们将其置于上面的函数fib1之上,它可以缓存该函数的执行结果从而避免在递归调用的过程中产生大量的重复运算,这样代码的执行性能就有“飞一般”的提升。大家可以尝试输出前50个斐波那契数,看看加上装饰器以后代码需要执行多长时间?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"""
使用 lru_cache 提高递归速度

Author: Penry
Version: 1.0
"""

import time
from functools import lru_cache

@lru_cache()
def fib1(num):
if num in (1, 2):
return 1
return fib1(num - 1) + fib1(num - 2)

start = time.time()
for i in range(1, 51):
print(f'斐波那契数列第{i}个数是{fib1(i)}')
end = time.time()
print(f'递归计算斐波那契数列第50个数的时间为{end - start}秒')

alt text

lru_cache 是 Python 内置模块 functools 提供的一个装饰器,用于给函数添加缓存机制(memoization),即:自动记住函数以前的调用结果,如果下次用到相同的参数,直接返回结果,不再重复计算。

提示lru_cache函数是一个带参数的装饰器,所以上面第4行代码使用装饰器语法糖时,lru_cache后面要跟上圆括号。lru_cache函数有一个非常重要的参数叫maxsize,它可以用来定义缓存空间的大小,默认值是128。

而不使用 lru_cache 修饰器的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"""
递归调用求斐波那契数列


Author: Penry
Version: 1.0
"""

import time

def fib1(n):
if n in (1, 2):
return 1
return fib1(n - 1) + fib1(n - 2)

start = time.time()
for i in range(1, 51):
print(f'斐波那契数列第{i}个数是{fib1(i)}')
end = time.time()
print(f'递归计算斐波那契数列第50个数的时间为{end - start}秒')

alt text

上述图片可以看出,运行了5min才仅仅运算到第43个数,因此非常耗时,使用functools中的lru_cache修饰器以后,运算第50个数只用了0.001s,这是一个数量级的提升,这就是装饰器的强大之处。

4.3 总结

装饰器是 Python 语言中的特色语法,可以通过装饰器来增强现有的函数,这是一种非常有用的编程技巧。另一方面,通过函数递归调用,可以在代码层面将一些复杂的问题简单化,但是递归调用一定要注意收敛条件和递归公式,找到递归公式才有机会使用递归调用,而收敛条件则确保了递归调用能停下来。函数调用通过内存中的栈空间来保存现场和恢复现场,栈空间通常都很小,所以递归如果不能迅速收敛,很可能会引发栈溢出错误,从而导致程序的崩溃


Day5

1 面向对象编程入门

面向对象编程是一种非常流行的编程范式(programming paradigm),所谓编程范式就是程序设计的方法论,简单的说就是程序员对程序的认知和理解以及他们编写代码的方式。

在前面的课程中,我们说过“程序是指令的集合”,运行程序时,程序中的语句会变成一条或多条指令,然后由CPU(中央处理器)去执行。为了简化程序的设计,我们又讲到了函数,把相对独立且经常重复使用的代码放置到函数中,在需要使用这些代码的时候调用函数即可。如果一个函数的功能过于复杂和臃肿,我们又可以进一步将函数进一步拆分为多个子函数来降低系统的复杂性。

不知大家是否发现,编程其实是写程序的人按照计算机的工作方式通过代码控制机器完成任务。但是,计算机的工作方式与人类正常的思维模式是不同的,如果编程就必须抛弃人类正常的思维方式去迎合计算机,编程的乐趣就少了很多。这里,我想说的并不是我们不能按照计算机的工作方式去编写代码,但是当我们需要开发一个复杂的系统时,这种方式会让代码过于复杂,从而导致开发和维护工作都变得举步维艰。

随着软件复杂性的增加,编写正确可靠的代码会变成了一项极为艰巨的任务,这也是很多人都坚信“软件开发是人类改造世界所有活动中最为复杂的活动”的原因。如何用程序描述复杂系统和解决复杂问题,就成为了所有程序员必须要思考和直面的问题。诞生于上世纪70年代的 Smalltalk 语言让软件开发者看到了希望,因为它引入了一种新的编程范式叫面向对象编程。在面向对象编程的世界里,程序中的数据和操作数据的函数是一个逻辑上的整体,我们称之为对象对象可以接收消息,解决问题的方法就是创建对象并向对象发出各种各样的消息;通过消息传递,程序中的多个对象可以协同工作,这样就能构造出复杂的系统并解决现实中的问题。当然,面向对象编程的雏形还可以向前追溯到更早期的 Simula 语言,但这不是我们要讨论的重点。

说明: 今天我们使用的很多高级程序设计语言都支持面向对象编程,但是面向对象编程也不是解决软件开发中所有问题的“银弹”,或者说在软件开发这个行业目前还没有所谓的“银弹”。关于这个问题,大家可以参考 IBM360 系统之父弗雷德里克·布鲁克斯所发表的论文《没有银弹:软件工程的本质性与附属性工作》或软件工程的经典著作《人月神话》一书。

1.1 类和对象

如果要用一句话来概括面向对象编程,我认为下面的说法是相当精辟和准确的。

面向对象编程:把一组数据和处理数据的方法组成对象,把行为相同的对象归纳为,通过封装隐藏对象的内部细节,通过继承实现类的特化和泛化,通过多态实现基于对象类型的动态分派。

这句话对初学者来说可能不那么容易理解,但是我可以先为大家圈出几个关键词:对象(object)、(class)、封装(encapsulation)、继承(inheritance)、多态(polymorphism)。

我们先说说类和对象这两个词。在面向对象编程中,类是一个抽象的概念,对象是一个具体的概念。我们把同一类对象的共同特征抽取出来就是一个类,比如我们经常说的人类,这是一个抽象概念,而我们每个人就是人类的这个抽象概念下的实实在在的存在,也就是一个对象。简而言之,类是对象的蓝图和模板,对象是类的实例,是可以接受消息的实体

在面向对象编程的世界中,一切皆为对象对象都有属性和行为每个对象都是独一无二的,而且对象一定属于某个类。对象的属性是对象的静态特征,对象的行为是对象的动态特征。按照上面的说法,如果我们把拥有共同特征的对象的属性和行为都抽取出来,就可以定义出一个类。

1.2 定义类

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

1
2
3
4
5
6
7
class Student:

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

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

1.3 创建和使用对象

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

1
2
3
4
5
6
7
stu1 = Student()
stu2 = Student()

print(stu1)
print(stu2)
print(hex(id(stu1)))
print(hex(id(stu2)))

在类的名字后跟上圆括号就是所谓的构造器语法,上面的代码创建了两个学生对象,一个赋值给变量stu1,一个赋值给变量stu2。当我们用print函数打印stu1stu2两个变量时,我们会看到输出了对象在内存中的地址(十六进制形式),跟我们用id函数查看对象标识获得的值是相同的。现在我们可以告诉大家,我们定义的变量其实保存的是一个对象在内存中的逻辑地址(位置),通过这个逻辑地址,我们就可以在内存中找到这个对象。所以stu3 = stu2这样的赋值语句并没有创建新的对象,只是用一个新的变量保存了已有对象的地址。

接下来,我们尝试给对象发消息,即调用对象的方法。刚才的Student类中我们定义了studyplay两个方法,两个方法的第一个参数self代表了接收消息的学生对象,study方法的第二个参数是学习的课程名称。Python中,给对象发消息有两种方式,请看下面的代码。

1
2
3
4
5
6
7
8
9
10
11
# 通过“类.方法”调用方法
# 第一个参数是接收消息的对象
# 第二个参数是学习的课程名称
Student.study(stu1, 'Python程序设计')
Student.play(stu2)

# 通过“对象.方法”调用方法
# 点前面的对象就是接收消息的对象
# 只需要传入第二个参数课程名称
stu1.study('Python程序设计')
stu2.play()

1.4 初始化方法

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Student:
"""学生"""

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

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

def play(self):
"""玩耍方法"""
print(f"{self.name}正在玩耍")

修改刚才创建对象和给对象发消息的代码,重新执行一次,看看程序的执行结果有什么变化。

1
2
3
4
5
# 调用Student类的构造器创建对象并传入初始化参数
stu1 = Student('Penry', 21)
stu2 = Student('Doudou', 20)
stu1.study('Python程序设计')
stu2.play()

1.5 面向对象的支柱

面向对象编程有三大支柱,就是我们之前给大家划重点的时候圈出的三个词:封装继承多态。后面两个概念在下一节课中会详细说明,这里我们先说一下什么是封装。我自己对封装的理解是:隐藏一切可以隐藏的实现细节,只向外界暴露简单的调用接口。我们在类中定义的对象方法其实就是一种封装,这种封装可以让我们在创建对象之后,只需要给对象发送一个消息就可以执行方法中的代码,也就是说我们在只知道方法的名字和参数(方法的外部视图),不知道方法内部实现细节(方法的内部视图)的情况下就完成了对方法的使用。

举一个例子,假如要控制一个机器人帮我倒杯水,如果不使用面向对象编程,不做任何的封装,那么就需要向这个机器人发出一系列的指令,如站起来、向左转、向前走5步、拿起面前的水杯、向后转、向前走10步、弯腰、放下水杯、按下出水按钮、等待10秒、松开出水按钮、拿起水杯、向右转、向前走5步、放下水杯等,才能完成这个简单的操作,想想都觉得麻烦。按照面向对象编程的思想,我们可以将倒水的操作封装到机器人的一个方法中,当需要机器人帮我们倒水的时候,只需要向机器人对象发出倒水的消息就可以了,这样做不是更好吗?

在很多场景下,面向对象编程其实就是一个三步走的问题。第一步定义类,第二步创建对象,第三步给对象发消息。当然,有的时候我们是不需要第一步的,因为我们想用的类可能已经存在了。之前我们说过,Python内置的listsetdict其实都是类,如果要创建列表、集合、字典对象,我们就不用自定义类了。当然,有的类并不是 Python 标准库中直接提供的,它可能来自于第三方的代码,如何安装和使用三方代码在后续课程中会进行讨论。在某些特殊的场景中,我们会用到名为“内置对象”的对象,所谓“内置对象”就是说上面三步走的第一步和第二步都不需要了,因为类已经存在而且对象已然创建过了,直接向对象发消息就可以了,这也就是我们常说的“开箱即用”。

1.6 面向对象案例

例子1:时钟

要求:定义一个类描述数字时钟,提供走字和显示时间的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
"""
时钟:定义一个类描述数字时钟,提供走字和显示时间的功能。

Author: Penry
Version: 1.0
"""

import time

# 定义时钟类
class Clock:
"""数字时钟"""

def __init__(self, hour=0, minute=0, second=0):
"""初始化方法
:param hour: 时
:param minute: 分
:param second: 秒
"""
self.hour = hour
self.minute = minute
self.second = second

def run(self):
"""走字"""
self.second += 1
if self.second == 60:
self.second = 0
self.minute += 1
if self.minute == 60:
self.minute = 0
self.hour += 1
if self.hour == 24:
self.hour = 0

def show(self):
"""显示时间"""
return f'{self.hour:0>2d}:{self.minute:0>2d}:{self.second:0>2d}'

# 创建时钟对象
clock = Clock(23, 59, 59)
while True:
print(clock.show())
time.sleep(1)
clock.run()

例子2:平面上的点

要求:定义一个类描述平面上的点,提供计算到另一个点距离的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Point:
"""平面上的点"""

def __init__(self, x=0, y=0):
"""初始化方法
:param x: 横坐标
:param y: 纵坐标
"""
self.x = x
self.y = y

def distance_to(self, other):
"""计算与另一个点的距离
:param other: 另一个点
:return: 距离
"""
dx = self.x - other.x
dy = self.y - other.y
return (dx ** 2 + dy ** 2) ** 0.5

def __str__(self):
"""返回字符串表示
:return: 字符串表示
"""
return f"Point({self.x}, {self.y})"

p1 = Point(3, 5)
p2 = Point(6, 9)
print(p1)
print(p2)
print(p1.distance_to(p2))

1.7 总结

面向对象编程是一种非常流行的编程范式,除此之外还有指令式编程函数式编程等编程范式。由于现实世界是由对象构成的,而对象是可以接收消息的实体,所以面向对象编程更符合人类正常的思维习惯。类是抽象的,对象是具体的,有了类就能创建对象,有了对象就可以接收消息,这就是面向对象编程的基础。定义类的过程是一个抽象的过程,找到对象公共的属性属于数据抽象,找到对象公共的方法属于行为抽象。抽象的过程是一个仁者见仁智者见智的过程,对同一类对象进行抽象可能会得到不同的结果,如下图所示。

说明: 本节课的插图来自于 Grady Booc 等撰写的《面向对象分析与设计》一书,该书是讲解面向对象编程的经典著作,有兴趣的读者可以购买和阅读这本书来了解更多的面向对象的相关知识。

2 面向对象编程进阶

2.1 可见性和属性装饰器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Student:
"""学生类"""

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

def study(self, course):
"""学习方法"""
print(f"{self.__name}正在学习{course}")

stu = Student("Penry", 21)
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,有兴趣的读者可以自己试一试。

2.2 动态属性

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

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

1
2
3
4
5
6
7
8
9
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属性,如果想动态添加其他属性将会引发异常,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
class Student:
"""学生类"""

__slots__ = ('name', 'age')

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

stu = Student("Penry", 21)
stu.sex = "male" # 为学生对象动态添加sex属性

解释__slots__ 是一个特殊的类属性,用于限制类实例可以动态添加的属性(即限制实例的属性列表)。其英语全称是 “slots”,意为 “插槽” 或 “槽位”,表示类实例的属性被固定在预先定义的 “槽” 中,不能随意添加额外属性。
原理dict 字典会为每个实例额外分配内存,而 slots 通过预分配固定内存空间存储属性,避免了字典的开销。
适用场景:创建大量实例时(如数据结构、游戏对象),内存节省效果显著。

2.3 静态方法和类方法

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 a + c > b and b + c > a

# @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

if Triangle.is_valid(1, 2, 3):
triangle1 = Triangle(1, 2, 3)
print(f"周长: {triangle1.perimeter()}")
print(f"面积: {triangle1.area()}")
else:
print("无效的三角形")

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 a + c > b and b + c > a

@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

if Triangle.is_valid(2, 3, 4):
triangle1 = Triangle(2, 3, 4)
print(f"周长: {triangle1.perimeter}")
print(f"面积: {triangle1.area}")
else:
print("无效的三角形")

2.4 继承和多态

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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, score):
"""初始化方法"""
super().__init__(name, age)
self.score = score

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}在教学{course_name}")

stu1 = Student("Penry", 21, 100)
stu2 = Student("Taco", 22, 100)
tea1 = Teacher("Gang", 37, "教授")

stu1.eat()
stu2.sleep()

stu1.study("Python程序设计")
stu2.study("Python程序设计")

tea1.teach("智能控制基础")

继承的语法是在定义类的时候,在类名后的圆括号中指定当前类的父类。如果定义一个类的时候没有指定它的父类是谁,那么默认的父类是object类。object类是 Python 中的顶级类,这也就意味着所有的类都是它的子类,要么直接继承它,要么间接继承它。Python 语言允许多重继承,也就是说一个类可以有一个或多个父类,关于多重继承的问题我们在后面会有更为详细的讨论。在子类的初始化方法中,我们可以通过super().__init__()来调用父类初始化方法,super函数是 Python 内置函数中专门为获取当前对象的父类对象而设计的。从上面的代码可以看出,子类除了可以通过继承得到父类提供的属性和方法外,还可以定义自己特有的属性和方法,所以子类比父类拥有的更多的能力。在实际开发中,我们经常会用子类对象去替换掉一个父类对象,这是面向对象编程中一个常见的行为,也叫做“里氏替换原则”(Liskov Substitution Principle)。

子类继承父类的方法后,还可以对方法进行重写(重新实现该方法),不同的子类可以对父类的同一个方法给出不同的实现版本,这样的方法在程序运行时就会表现出多态行为(调用相同的方法,做了不同的事情)。多态是面向对象编程中最精髓的部分,当然也是对初学者来说最难以理解和灵活运用的部分,我们会在下一个章节用专门的例子来讲解这个知识点。

2.5 总结

Python 是动态类型语言,Python 中的对象可以动态的添加属性,对象的方法其实也是属性,只不过和该属性对应的是一个可以调用的函数。在面向对象的世界中,一切皆为对象,我们定义的类也是对象,所以类也可以接收消息,对应的方法是类方法或静态方法。通过继承,我们可以从已有的类创建新类,实现对已有类代码的复用。

3 面向对象编程应用

面向对象编程对初学者来说不难理解但很难应用,虽然我们为大家总结过面向对象的三步走方法(定义类、创建对象、给对象发消息),但是说起来容易做起来难。大量的编程练习阅读优质的代码可能是这个阶段最能够帮助到大家的两件事情。接下来我们还是通过经典的案例来剖析面向对象编程的知识,同时也通过这些案例把我们之前学过的 Python 知识都串联起来。

例子1:扑克游戏。

说明:简单起见,我们的扑克只有52张牌(没有大小王),游戏需要将 52 张牌发到 4 个玩家的手上,每个玩家手上有 13 张牌,按照黑桃、红心、草花、方块的顺序和点数从小到大排列,暂时不实现其他的功能。

使用面向对象编程方法,首先需要从问题的需求中找到对象并抽象出对应的类,此外还要找到对象的属性和行为。当然,这件事情并不是特别困难,我们可以从需求的描述中找出名词和动词,名词通常就是对象或者是对象的属性,而动词通常是对象的行为。扑克游戏中至少应该有三类对象,分别是牌、扑克和玩家,牌、扑克、玩家三个类也并不是孤立的。类和类之间的关系可以粗略的分为 is-a关系(继承)、**has-a关系(关联)**和 use-a关系(依赖)。很显然扑克和牌是 has-a 关系,因为一副扑克有(has-a)52 张牌;玩家和牌之间不仅有关联关系还有依赖关系,因为玩家手上有(has-a)牌而且玩家使用了(use-a)牌。

牌的属性显而易见,有花色和点数。我们可以用 0 到 3 的四个数字来代表四种不同的花色,但是这样的代码可读性会非常糟糕,因为我们并不知道黑桃、红心、草花、方块跟 0 到 3 的数字的对应关系。如果一个变量的取值只有有限多个选项,我们可以使用枚举。与 C、Java 等语言不同的是,Python 中没有声明枚举类型的关键字,但是可以通过继承enum模块的Enum类来创建枚举类型,代码如下所示。

1
2
3
4
5
from enum import Enum

class Suite(Enum):
"""花色(枚举)"""
SPADE, HEART, CLUB, DIAMOND = range(4)

通过上面的代码可以看出,定义枚举类型其实就是定义符号常量,如SPADEHEART等。每个符号常量都有与之对应的值,这样表示黑桃就可以不用数字 0,而是用Suite.SPADE;同理,表示方块可以不用数字 3, 而是用Suite.DIAMOND。注意,使用符号常量肯定是优于使用字面常量的,因为能够读懂英文就能理解符号常量的含义,代码的可读性会提升很多。Python 中的枚举类型是可迭代类型,简单的说就是可以将枚举类型放到for-in循环中,依次取出每一个符号常量及其对应的值,如下所示。

1
2
for suite in Suite:
print(f'{suite}: {suite.value}')

接下来我们可以定义牌类。

1
2
3
4
5
6
7
8
9
10
11
class Card:
"""牌"""

def __init__(self, suite, face):
self.suite = suite
self.face = face

def __repr__(self):
suites = '♠♥♣♦'
faces = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
return f'{suites[self.suite.value]}{faces[self.face]}' # 返回牌的花色和点数

可以通过下面的代码来测试下Card类。

1
2
3
4
card1 = Card(Suite.HEART, 13)
card2 = Card(Suite.CLUB, 1)
print(card1)
print(card2)

接下来我们定义扑克类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import random


class Poker:
"""扑克"""

def __init__(self):
self.cards = [Card(suite, face)
for suite in Suite
for face in range(1, 14)] # 52张牌构成的列表
self.current = 0 # 记录发牌位置的属性

def shuffle(self):
"""洗牌"""
self.current = 0
random.shuffle(self.cards) # 通过random模块的shuffle函数实现随机乱序

def deal(self):
"""发牌"""
card = self.cards[self.current]
self.current += 1
return card

@property
def has_next(self):
"""还有没有牌可以发"""
return self.current < len(self.cards)

可以通过下面的代码来测试下Poker类。

1
2
3
4
poker = Poker()
print(poker.cards) # 洗牌前的牌
poker.shuffle()
print(poker.cards) # 洗牌后的牌
1
2
[♠A, ♠2, ♠3, ♠4, ♠5, ♠6, ♠7, ♠8, ♠9, ♠10, ♠J, ♠Q, ♠K, ♥A, ♥2, ♥3, ♥4, ♥5, ♥6, ♥7, ♥8, ♥9, ♥10, ♥J, ♥Q, ♥K, ♣A, ♣2, ♣3, ♣4, ♣5, ♣6, ♣7, ♣8, ♣9, ♣10, ♣J, ♣Q, ♣K, ♦A, ♦2, ♦3, ♦4, ♦5, ♦6, ♦7, ♦8, ♦9, ♦10, ♦J, ♦Q, ♦K]
[♣J, ♥J, ♥8, ♦A, ♠3, ♣Q, ♠4, ♦6, ♣9, ♣3, ♣4, ♣10, ♦8, ♦5, ♠8, ♥2, ♦K, ♥4, ♣7, ♠J, ♥6, ♠Q, ♣2, ♦Q, ♦10, ♦2, ♦9, ♣K, ♠6, ♠9, ♣6, ♥9, ♠A, ♥K, ♣A, ♥Q, ♠7, ♠5, ♦7, ♥3, ♠2, ♣8, ♦3, ♦4, ♥5, ♦J, ♠K, ♥A, ♣5, ♠10, ♥7, ♥10]

定义玩家类。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Player:
"""玩家"""
def __init__(self, name):
self.name = name
self.cards = []

def get_one(self, card):
"""摸牌"""
self.cards.append(card)

def arrange(self):
"""整理牌"""
self.cards.sort(key=lambda card: (card.suite.value, card.face))

创建四个玩家并将牌发到玩家的手上。

1
2
3
4
5
6
7
8
9
10
11
12
13
poker = Poker()
poker.shuffle()
players = [Player("Penry"), Player("Taco"), Player("Cruise"), Player("Ma")]
# 将牌轮流发到每个玩家手上每人13张牌
for _ in range(13):
for player in players:
player.get_one(poker.deal())

# 玩家整理牌
for player in players:
player.arrange()
print(f"{player.name}: ", end='')
print(player.cards)

执行上面的代码会在player.arrange()那里出现异常,因为Playerarrange方法使用了列表的sort对玩家手上的牌进行排序,排序需要比较两个Card对象的大小,而<运算符又不能直接作用于Card类型,所以就出现了TypeError异常,异常消息为:'<' not supported between instances of 'Card' and 'Card'

为了解决这个问题,我们可以对Card类的代码稍作修改,使得两个Card对象可以直接用<进行大小的比较。这里用到技术叫运算符重载,Python 中要实现对<运算符的重载,需要在类中添加一个名为__lt__的魔术方法。很显然,魔术方法__lt__中的lt是英文单词“less than”的缩写,以此类推,魔术方法__gt__对应>运算符,魔术方法__le__对应<=运算符,__ge__对应>=运算符,__eq__对应==运算符,__ne__对应!=运算符。

修改后的Card类代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Card:
"""牌"""

def __init__(self, suite, face):
self.suite = suite
self.face = face

def __repr__(self):
suites = '♠♥♣♦'
faces = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
return f'{suites[self.suite.value]}{faces[self.face]}'

def __lt__(self, other):
if self.suite == other.suite:
return self.face < other.face # 花色相同比较点数的大小
return self.suite.value < other.suite.value # 花色不同比较花色对应的值

说明: 大家可以尝试在上面代码的基础上写一个简单的扑克游戏,如 21 点游戏(Black Jack),游戏的规则可以自己在网上找一找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
"""
题目描述:简单起见,我们的扑克只有52张牌(没有大小王),游戏需要将 52 张牌发到 4 个玩家的手上,每个玩家手上有 13 张牌,按照黑桃、红心、草花、方块的顺序和点数从小到大排列,暂时不实现其他的功能。

Author: Penry
Version: 1.0
"""

from enum import Enum
import random

class Suite(Enum):
"""花色(枚举)"""
SPADE, HEART, CLUB, DIAMOND = range(4)

class Card:
"""牌"""
def __init__(self, suite, face):
"""初始化牌"""
self.suite = suite
self.face = face

def __repr__(self):
suites = "♠♥♣♦"
faces = ['','A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
return f'{suites[self.suite.value]}{faces[self.face]}'

class Poker:
"""扑克"""
def __init__(self):
self.cards = [Card(suite, face)
for suite in Suite
for face in range(1, 14)] # 52张牌构成的列表
self.current = 0 # 记录发牌位置的属性

def shuffle(self):
"""洗牌"""
self.current = 0
random.shuffle(self.cards) # 通过random模块的shuffle函数实现随机乱序

def deal(self):
"""发牌"""
card = self.cards[self.current]
self.current += 1
return card

@property
def has_next(self):
"""判断是否还有牌"""
return self.current < len(self.cards)

class Player:
"""玩家"""
def __init__(self, name):
self.name = name
self.cards = []

def get_one(self, card):
"""摸牌"""
self.cards.append(card)

def arrange(self):
"""整理牌"""
self.cards.sort(key=lambda card: (card.suite.value, card.face))

for suite in Suite:
print(f'{suite}: {suite.value}')

card1 = Card(Suite.HEART, 13)
card2 = Card(Suite.CLUB, 1)
print(card1)
print(card2)

poker = Poker()
print(poker.cards) # 洗牌前的牌
poker.shuffle()
print(poker.cards) # 洗牌后的牌

players = [Player("Penry"), Player("Taco"), Player("Cruise"), Player("Ma")]
# 将牌轮流发到每个玩家手上每人13张牌
for _ in range(13):
for player in players:
player.get_one(poker.deal())

# 玩家整理牌
for player in players:
player.arrange()
print(f"{player.name}: ", end='')
print(player.cards)

例子2:工资结算系统。

要求:某公司有三种类型的员工,分别是部门经理、程序员和销售员。需要设计一个工资结算系统,根据提供的员工信息来计算员工的月薪。其中,部门经理的月薪是固定 15000 元;程序员按工作时间(以小时为单位)支付月薪,每小时 200 元;销售员的月薪由 1800 元底薪加上销售额 5% 的提成两部分构成。

通过对上述需求的分析,可以看出部门经理、程序员、销售员都是员工,有相同的属性和行为,那么我们可以先设计一个名为Employee的父类,再通过继承的方式从这个父类派生出部门经理、程序员和销售员三个子类。很显然,后续的代码不会创建Employee 类的对象,因为我们需要的是具体的员工对象,所以这个类可以设计成专门用于继承的抽象类。Python 语言中没有定义抽象类的关键字,但是可以通过abc模块中名为ABCMeta 的元类来定义抽象类。关于元类的概念此处不展开讲解,当然大家不用纠结,照做即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
from abc import ABCMeta, abstractmethod


class Employee(metaclass=ABCMeta):
"""员工"""

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

@abstractmethod
def get_salary(self):
"""结算月薪"""
pass

在上面的员工类中,有一个名为get_salary的方法用于结算月薪,但是由于还没有确定是哪一类员工,所以结算月薪虽然是员工的公共行为但这里却没有办法实现。对于暂时无法实现的方法,我们可以使用abstractmethod装饰器将其声明为抽象方法,所谓抽象方法就是只有声明没有实现的方法声明这个方法是为了让子类去重写这个方法。接下来的代码展示了如何从员工类派生出部门经理、程序员、销售员这三个子类以及子类如何重写父类的抽象方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Manager(Employee):
"""部门经理"""

def get_salary(self):
return 15000.0


class Programmer(Employee):
"""程序员"""

def __init__(self, name, working_hour=0):
super().__init__(name)
self.working_hour = working_hour

def get_salary(self):
return 200 * self.working_hour


class Salesman(Employee):
"""销售员"""

def __init__(self, name, sales=0):
super().__init__(name)
self.sales = sales

def get_salary(self):
return 1800 + self.sales * 0.05

上面的ManagerProgrammerSalesman三个类都继承自Employee,三个类都分别重写了get_salary方法。重写就是子类对父类已有的方法重新做出实现。相信大家已经注意到了,三个子类中的get_salary各不相同,所以这个方法在程序运行时会产生多态行为,多态简单的说就是调用相同的方法不同的子类对象做不同的事情

我们通过下面的代码来完成这个工资结算系统,由于程序员和销售员需要分别录入本月的工作时间和销售额,所以在下面的代码中我们使用了 Python 内置的isinstance函数来判断员工对象的类型。我们之前讲过的type函数也能识别对象的类型,但是isinstance函数更加强大,因为它可以判断出一个对象是不是某个继承结构下的子类型,你可以简单的理解为type函数是对对象类型的精准匹配,而isinstance函数是对对象类型的模糊匹配。

1
2
3
4
5
6
7
emps = [Manager('刘备'), Programmer('诸葛亮'), Manager('曹操'), Programmer('荀彧'), Salesman('张辽')]
for emp in emps:
if isinstance(emp, Programmer):
emp.working_hour = int(input(f'请输入{emp.name}本月工作时间: '))
elif isinstance(emp, Salesman):
emp.sales = float(input(f'请输入{emp.name}本月销售额: '))
print(f'{emp.name}本月工资为: ¥{emp.get_salary():.2f}元')

总结

面向对象的编程思想非常的好,也符合人类的正常思维习惯,但是要想灵活运用面向对象编程中的抽象、封装、继承、多态需要长时间的积累和沉淀,这件事情无法一蹴而就,因为知识的积累本就是涓滴成河的过程。


Day6

1 文件读写和异常处理

实际开发中常常会遇到对数据进行持久化的场景,所谓持久化是指将数据从无法长久保存数据的存储介质(通常是内存)转移到可以长久保存数据的存储介质(通常是硬盘)中。实现数据持久化最直接简单的方式就是通过文件系统将数据保存到文件中。

计算机的文件系统是一种存储和组织计算机数据的方法,它使得对数据的访问和查找变得容易,文件系统使用文件树形目录的抽象逻辑概念代替了硬盘、光盘、闪存等物理设备的数据块概念,用户使用文件系统来保存数据时,不必关心数据实际保存在硬盘的哪个数据块上,只需要记住这个文件的路径和文件名。在写入新数据之前,用户不必关心硬盘上的哪个数据块没有被使用,硬盘上的存储空间管理(分配和释放)功能由文件系统自动完成,用户只需要记住数据被写入到了哪个文件中。

1.1 打开和关闭文件

有了文件系统,我们可以非常方便的通过文件来读写数据;在Python中要实现文件操作是非常简单的。我们可以使用Python内置的open函数来打开文件,在使用open函数时,我们可以通过函数的参数指定文件名操作模式字符编码等信息,接下来就可以对文件进行读写操作了。这里所说的操作模式是指要打开什么样的文件(字符文件或二进制文件)以及做什么样的操作(读、写或追加),具体如下表所示。

操作模式 具体含义
'r' 读取 (默认)
'w' 写入(会先截断之前的内容)
'x' 写入,如果文件已经存在会产生异常
'a' 追加,将内容写入到已有文件的末尾
'b' 二进制模式
't' 文本模式(默认)
'+' 更新(既可以读又可以写)

下图展示了如何根据程序的需要来设置open函数的操作模式。

在使用open函数时,如果打开的文件是字符文件(文本文件),可以通过encoding参数来指定读写文件使用的字符编码。如果对字符编码和字符集这些概念不了解,可以看看《字符集和字符编码》一文,此处不再进行赘述。

使用open函数打开文件成功后会返回一个文件对象,通过这个对象,我们就可以实现对文件的读写操作;如果打开文件失败,open函数会引发异常,稍后会对此加以说明。如果要关闭打开的文件,可以使用文件对象的close方法,这样可以在结束文件操作时释放掉这个文件。

1.2 读写文本文件

open函数打开文本文件时,需要指定文件名并将文件的操作模式设置为'r',如果不指定,默认值也是'r';如果需要指定字符编码,可以传入encoding参数,如果不指定,默认值是None,那么在读取文件时使用的是操作系统默认的编码。需要提醒大家,如果不能保证保存文件时使用的编码方式与encoding参数指定的编码方式是一致的,那么就可能因无法解码字符而导致读取文件失败。

下面的例子演示了如何读取一个纯文本文件(一般指只有字符原生编码构成的文件,与富文本相比,纯文本不包含字符样式的控制元素,能够被最简单的文本编辑器直接读取)。

1
2
3
file = open('致橡树.txt', 'r', encoding='utf-8')
print(file.read())
file.close()

说明《致橡树》是舒婷老师在1977年3月创建的爱情诗,也是我最喜欢的现代诗之一。

除了使用文件对象的read方法读取文件之外,还可以使用for-in循环逐行读取或者用readlines方法将文件按行读取到一个列表容器中,代码如下所示。

1
2
3
4
5
6
7
8
9
10
file = open('致橡树.txt', 'r', encoding='utf-8')
for line in file:
print(line, end='')
file.close()

file = open('致橡树.txt', 'r', encoding='utf-8')
lines = file.readlines()
for line in lines:
print(line, end='')
file.close()

如果要向文件中写入内容,可以在打开文件时使用w或者a作为操作模式,前者会截断之前的文本内容写入新的内容,后者是在原来内容的尾部追加新的内容。

1
2
3
4
5
6
7
8
9
10
11
12
file = open("致橡树.txt", 'a+', encoding="utf-8")
file.write('\n\n标题:《致橡树》')
file.write('\n作者:舒婷')
file.write('\n时间:1977年3月')

# 关键:将文件指针移动到开头
file.seek(0)

lines = file.readlines()
for line in lines:
print(line, end='')
file.close()

1.3 异常处理机制

请注意上面的代码,如果open函数指定的文件并不存在或者无法打开,那么将引发异常状况导致程序崩溃。为了让代码具有健壮性和容错性,我们可以使用Python的异常机制对可能在运行时发生状况的代码进行适当的处理。Python中和异常相关的关键字有五个,分别是tryexceptelsefinallyraise,我们先看看下面的代码,再来为大家介绍这些关键字的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
file = None
try:
file = open('致橡树.txt', 'r', encoding='utf-8')
print(file.read())
except FileNotFoundError:
print('无法打开指定的文件!')
except LookupError:
print('指定了未知的编码!')
except UnicodeDecodeError:
print('读取文件时解码错误!')
finally:
if file:
file.close()

在Python中,我们可以将运行时会出现状况的代码放在try代码块中,在try后面可以跟上一个或多个except块来捕获异常并进行相应的处理。例如,在上面的代码中,文件找不到会引发FileNotFoundError,指定了未知的编码会引发LookupError,而如果读取文件时无法按指定编码方式解码文件会引发UnicodeDecodeError,所以我们在try后面跟上了三个except分别处理这三种不同的异常状况。在except后面,我们还可以加上else代码块,这是try 中的代码没有出现异常时会执行的代码,而且else中的代码不会再进行异常捕获,也就是说如果遇到异常状况,程序会因异常而终止并报告异常信息。最后我们使用finally代码块来关闭打开的文件,释放掉程序中获取的外部资源。由于finally块的代码不论程序正常还是异常都会执行,甚至是调用了sys模块的exit函数终止Python程序,finally块中的代码仍然会被执行(因为exit函数的本质是引发了SystemExit异常),因此我们把finally代码块称为“总是执行代码块”,它最适合用来做释放外部资源的操作。

Python中内置了大量的异常类型,除了上面代码中用到的异常类型以及之前的课程中遇到过的异常类型外,还有许多的异常类型,其继承结构如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
BaseException
+-- SystemExit
+-- KeyboardInterrupt
+-- GeneratorExit
+-- Exception
+-- StopIteration
+-- StopAsyncIteration
+-- ArithmeticError
| +-- FloatingPointError
| +-- OverflowError
| +-- ZeroDivisionError
+-- AssertionError
+-- AttributeError
+-- BufferError
+-- EOFError
+-- ImportError
| +-- ModuleNotFoundError
+-- LookupError
| +-- IndexError
| +-- KeyError
+-- MemoryError
+-- NameError
| +-- UnboundLocalError
+-- OSError
| +-- BlockingIOError
| +-- ChildProcessError
| +-- ConnectionError
| | +-- BrokenPipeError
| | +-- ConnectionAbortedError
| | +-- ConnectionRefusedError
| | +-- ConnectionResetError
| +-- FileExistsError
| +-- FileNotFoundError
| +-- InterruptedError
| +-- IsADirectoryError
| +-- NotADirectoryError
| +-- PermissionError
| +-- ProcessLookupError
| +-- TimeoutError
+-- ReferenceError
+-- RuntimeError
| +-- NotImplementedError
| +-- RecursionError
+-- SyntaxError
| +-- IndentationError
| +-- TabError
+-- SystemError
+-- TypeError
+-- ValueError
| +-- UnicodeError
| +-- UnicodeDecodeError
| +-- UnicodeEncodeError
| +-- UnicodeTranslateError
+-- Warning
+-- DeprecationWarning
+-- PendingDeprecationWarning
+-- RuntimeWarning
+-- SyntaxWarning
+-- UserWarning
+-- FutureWarning
+-- ImportWarning
+-- UnicodeWarning
+-- BytesWarning
+-- ResourceWarning

从上面的继承结构可以看出,Python中所有的异常都是BaseException的子类型,它有四个直接的子类,分别是:SystemExitKeyboardInterruptGeneratorExitException。其中:

  • SystemExit表示解释器请求退出;
  • KeyboardInterrupt是用户中断程序执行(按下Ctrl+c);
  • GeneratorExit表示生成器发生异常通知退出,不理解这些异常没有关系,继续学习就好了。
  • 值得一提的是Exception类,它是常规异常类型的父类型,很多的异常都是直接或间接的继承自Exception类。

如果Python内置的异常类型不能满足应用程序的需要,我们可以自定义异常类型,而自定义的异常类型也应该直接或间接继承自Exception类,当然还可以根据需要重写或添加方法。

在Python中,可以使用raise关键字来引发异常(抛出异常对象),而调用者可以通过try...except...结构来捕获并处理异常。例如在函数中,当函数的执行条件不满足时,可以使用抛出异常的方式来告知调用者问题的所在,而调用者可以通过捕获处理异常来使得代码从异常中恢复,定义异常和抛出异常的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class InputError(ValueError):
"""自定义异常类型"""
pass


def fac(num):
"""求阶乘"""
if num < 0:
raise InputError('只能计算非负整数的阶乘')
if num in (0, 1):
return 1
return num * fac(num - 1)

fac(-1)

调用求阶乘的函数fac,通过try...except...结构捕获输入错误的异常并打印异常对象(显示异常信息),如果输入正确就计算阶乘并结束程序。

1
2
3
4
5
6
7
8
flag = True
while flag:
num = int(input('n = '))
try:
print(f'{num}! = {fac(num)}')
flag = False
except InputError as err:
print(err)

1.4 上下文管理器语法

对于open函数返回的文件对象,还可以使用with上下文管理器语法在文件操作完成后自动执行文件对象的close方法,这样可以让代码变得更加简单优雅,因为不需要再写finally代码块来执行关闭文件释放资源的操作。需要提醒大家的是,并不是所有的对象都可以放在with上下文语法中,只有符合上下文管理器协议(有__enter____exit__魔术方法)的对象才能使用这种语法,Python标准库中的contextlib模块也提供了对with上下文语法的支持,后面再为大家进行讲解。

with上下文语法改写后的代码如下所示。

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

1.5 读写二进制文件

读写二进制文件跟读写文本文件的操作类似,但是需要注意,在使用open函数打开文件时,如果要进行读操作,操作模式是'rb',如果要进行写操作,操作模式是'wb'。还有一点,读写文本文件时,read方法的返回值以及write方法的参数是str对象(字符串),而读写二进制文件时,read方法的返回值以及write方法的参数是bytes-like对象(字节串)。下面的代码实现了将当前路径下名为Penry.jpg的图片文件复制到BIT.jpg文件中的操作。

1
2
3
4
5
6
7
8
9
10
try:
with open('Penry.jpg', 'rb') as file1:
data = file1.read()
with open('BIT.jpg', 'wb') as file2:
file2.write(data)
except FileNotFoundError:
print('指定的文件无法打开.')
except IOError:
print('读写文件时出现错误.')
print('程序执行结束.')

如果要复制的图片文件很大,一次将文件内容直接读入内存中可能会造成非常大的内存开销,为了减少对内存的占用,可以为read方法传入size参数来指定每次读取的字节数,通过循环读取和写入的方式来完成上面的操作,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
try:
with open('guido.jpg', 'rb') as file1, open('吉多.jpg', 'wb') as file2:
data = file1.read(512)
while data:
file2.write(data)
data = file1.read()
except FileNotFoundError:
print('指定的文件无法打开.')
except IOError:
print('读写文件时出现错误.')
print('程序执行结束.')

1.6 总结

通过读写文件的操作,我们可以实现数据持久化。在Python中可以通过open函数来获得文件对象,可以通过文件对象的readwrite方法实现文件读写操作。程序在运行时可能遭遇无法预料的异常状况,可以使用Python的异常机制来处理这些状况。Python的异常机制主要包括tryexceptelsefinallyraise这五个核心关键字。try后面的except语句不是必须的,finally语句也不是必须的,但是二者必须要有一个;except语句可以有一个或多个,多个except会按照书写的顺序依次匹配指定的异常,如果异常已经处理就不会再进入后续的except语句;except语句中还可以通过元组同时指定多个异常类型进行捕获;except语句后面如果不指定异常类型,则默认捕获所有异常;捕获异常后可以使用raise要再次抛出,但是不建议捕获并抛出同一个异常;不建议在不清楚逻辑的情况下捕获所有异常,这可能会掩盖程序中严重的问题。最后强调一点,不要使用异常机制来处理正常业务逻辑或控制程序流程,简单的说就是不要滥用异常机制,这是初学者常犯的错误。

2 对象的序列化和反序列化

2.1 JSON概述

通过上面的讲解,我们已经知道如何将文本数据和二进制数据保存到文件中,那么这里还有一个问题,如果希望把一个列表或者一个字典中的数据保存到文件中又该怎么做呢?在Python中,我们可以将程序中的数据以JSON格式进行保存。JSON是“JavaScript Object Notation”的缩写,它本来是JavaScript语言中创建对象的一种字面量语法,现在已经被广泛的应用于跨语言跨平台的数据交换。使用JSON的原因非常简单,因为它结构紧凑而且是纯文本,任何操作系统和编程语言都能处理纯文本,这就是实现跨语言跨平台数据交换的前提条件。目前JSON基本上已经取代了XML(可扩展标记语言)作为异构系统间交换数据的事实标准。可以在JSON的官方网站找到更多关于JSON的知识,这个网站还提供了每种语言处理JSON数据格式可以使用的工具或三方库。

1
2
3
4
5
6
7
8
9
10
{
name: "Penry",
age: 21,
friends: ["Taco", "Cruise"],
cars: [
{"brand": "BMW", "max_speed": 240},
{"brand": "Benz", "max_speed": 280},
{"brand": "Audi", "max_speed": 280}
]
}

上面是JSON的一个简单例子,大家可能已经注意到了,它跟Python中的字典非常类似而且支持嵌套结构,就好比Python字典中的值可以是另一个字典。我们可以尝试把下面的代码输入浏览器的控制台(对于Chrome浏览器,可以通过“更多工具”菜单找到“开发者工具”子菜单,就可以打开浏览器的控制台),浏览器的控制台提供了一个运行JavaScript代码的交互式环境(类似于Python的交互式环境),下面的代码会帮我们创建出一个JavaScript的对象,我们将其赋值给名为obj的变量。

1
2
3
4
5
6
7
8
9
10
let obj = {
name: "Penry",
age: 21,
friends: ["Taco", "Cruise"],
cars: [
{"brand": "BMW", "max_speed": 240},
{"brand": "Benz", "max_speed": 280},
{"brand": "Audi", "max_speed": 280}
]
}

上面的obj就是JavaScript中的一个对象,我们可以通过obj.nameobj["name"]两种方式获取到name对应的值,如下图所示。可以注意到,obj["name"]这种获取数据的方式跟Python字典通过键获取值的索引操作是完全一致的,而Python中也通过名为json的模块提供了字典与JSON双向转换的支持。

我们在JSON中使用的数据类型(JavaScript数据类型)和Python中的数据类型也是很容易找到对应关系的,大家可以看看下面的两张表。

表1:JavaScript数据类型(值)对应的Python数据类型(值)

JSON Python
object dict
array list
string str
number int / float
number (real) float
boolean (true / false) bool (True / False)
null None

表2:Python数据类型(值)对应的JavaScript数据类型(值)

Python JSON
dict object
list / tuple array
str string
int / float number
boolTrue / False boolean (true / false)
None null

2.2 读写JSON格式的数据

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

1
2
3
4
5
6
7
8
9
10
11
12
13
import json

my_dict = {
'name': 'Penry',
'age': 21,
'friends': ['Taco', 'Cruise'],
'cars': [
{'brand': 'BMW', 'max_speed': 240},
{'brand': 'Audi', 'max_speed': 280},
{'brand': 'Benz', 'max_speed': 280}
]
}
print(json.dumps(my_dict))

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

1
{"name": "Penry", "age": 21, "friends": ["Taco", "Cruise"], "cars": [{"brand": "BMW", "max_speed": 240}, {"brand": "Audi", "max_speed": 280}, {"brand": "Benz", "max_speed": 280}]}

如果要将字典处理成JSON格式并写入文本文件,只需要将dumps函数换成dump函数并传入文件对象即可,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import json

my_dict = {
'name': 'Penry',
'age': 21,
'friends': ['Taco', 'Cruise'],
'cars': [
{'brand': 'BMW', 'max_speed': 240},
{'brand': 'Audi', 'max_speed': 280},
{'brand': 'Benz', 'max_speed': 280}
]
}

try:
with open("data.json", "w") as file:
json.dump(my_dict, file)
except Exception as e:
print(e)

# 验证json文件
try:
with open("data.json", "r") as file:
data = json.load(file)
print(data)
except Exception as e:
print(e)

执行上面的代码,会创建data.json文件,文件的内容跟上面代码的输出是一样的。

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

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

这里出现了两个概念,一个叫序列化,一个叫反序列化,维基百科上的解释是:“序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换为可以存储或传输的形式,这样在需要的时候能够恢复到原先的状态,而且通过序列化的数据重新获取字节时,可以利用这些字节来产生原始对象的副本(拷贝)。与这个过程相反的动作,即从一系列字节中提取数据结构的操作,就是反序列化(deserialization)”。

我们可以通过下面的代码,读取上面创建的data.json文件,将JSON格式的数据还原成Python中的字典。

1
2
3
4
5
6
import json

with open('data.json', 'r') as file:
my_dict = json.load(file)
print(type(my_dict))
print(my_dict)

2.3 包管理工具pip

Python标准库中的json模块在数据序列化和反序列化时性能并不是非常理想,为了解决这个问题,可以使用三方库ujson来替换json。所谓三方库,是指非公司内部开发和使用的,也不是来自于官方标准库的Python模块,这些模块通常由其他公司、组织或个人开发,所以被称为三方库。虽然Python语言的标准库虽然已经提供了诸多模块来方便我们的开发,但是对于一个强大的语言来说,它的生态圈一定也是非常繁荣的。

之前安装Python解释器时,默认情况下已经勾选了安装pip,大家可以在命令提示符或终端中通过pip --version来确定是否已经拥有了pip。pip是Python的包管理工具,通过pip可以查找、安装、卸载、更新Python的三方库或工具,macOS和Linux系统应该使用pip3。例如要安装替代json模块的ujson,可以使用下面的命令。

1
pip install ujson

在默认情况下,pip会访问https://pypi.org/simple/来获得三方库相关的数据,但是国内访问这个网站的速度并不是十分理想,因此国内用户可以使用豆瓣网提供的镜像来替代这个默认的下载源,操作如下所示。

1
pip install ujson

可以通过pip search命令根据名字查找需要的三方库,可以通过pip list命令来查看已经安装过的三方库。如果想更新某个三方库,可以使用pip install -Upip install --upgrade;如果要删除某个三方库,可以使用pip uninstall命令。

搜索ujson三方库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pip search ujson

micropython-cpython-ujson (0.2) - MicroPython module ujson ported to CPython
pycopy-cpython-ujson (0.2) - Pycopy module ujson ported to CPython
ujson (3.0.0) - Ultra fast JSON encoder and decoder for Python
ujson-bedframe (1.33.0) - Ultra fast JSON encoder and decoder for Python
ujson-segfault (2.1.57) - Ultra fast JSON encoder and decoder for Python. Continuing
development.
ujson-ia (2.1.1) - Ultra fast JSON encoder and decoder for Python (Internet
Archive fork)
ujson-x (1.37) - Ultra fast JSON encoder and decoder for Python
ujson-x-legacy (1.35.1) - Ultra fast JSON encoder and decoder for Python
drf_ujson (1.2) - Django Rest Framework UJSON Renderer
drf-ujson2 (1.6.1) - Django Rest Framework UJSON Renderer
ujsonDB (0.1.0) - A lightweight and simple database using ujson.
fast-json (0.3.2) - Combines best parts of json and ujson for fast serialization
decimal-monkeypatch (0.4.3) - Python 2 performance patches: decimal to cdecimal, json to
ujson for psycopg2

查看已经安装的三方库。

1
2
3
4
5
6
7
8
9
pip list

Package Version
----------------------------- ----------
aiohttp 3.5.4
alipay 0.7.4
altgraph 0.16.1
amqp 2.4.2
... ...

更新ujson三方库。

1
pip install -U ujson

删除ujson三方库。

1
pip uninstall -y ujson

提示:如果要更新pip自身,对于macOS系统来说,可以使用命令pip install -U pip。在Windows系统上,可以将命令替换为python -m pip install -U --user pip

2.4 使用网络API获取数据

如果想在我们自己的程序中显示天气、路况、航班等信息,这些信息我们自己没有能力提供,所以必须使用网络数据服务。目前绝大多数的网络数据服务(或称之为网络API)都是基于HTTP或HTTPS提供JSON格式的数据,我们可以通过Python程序发送HTTP请求给指定的URL(统一资源定位符),这个URL就是所谓的网络API,如果请求成功,它会返回HTTP响应,而HTTP响应的消息体中就有我们需要的JSON格式的数据。关于HTTP的相关知识,可以看看阮一峰的《HTTP协议入门》一文。

国内有很多提供网络API接口的网站,例如聚合数据阿凡达数据等,这些网站上有免费的和付费的数据接口,国外的{API}Search网站也提供了类似的功能,有兴趣的可以自行研究。下面的例子演示了如何使用requests库(基于HTTP进行网络资源访问的三方库)访问网络API获取国内新闻并显示新闻标题和链接。在这个例子中,我们使用了名为天行数据的网站提供的国内新闻数据接口,其中的APIKey需要自己到网站上注册申请。在天行数据网站注册账号后会自动分配APIKey,但是要访问接口获取数据,需要绑定验证邮箱或手机,然后还要申请需要使用的接口,如下图所示。

Python通过URL接入网络,我们推荐大家使用requests三方库,它简单且强大,但需要自行安装。

1
pip install requests

获取国内新闻并显示新闻标题和链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import requests
import json

def fetch_and_save_news():
"""
获取新闻数据并保存到data_news.json文件
"""
print("🚀 开始获取新闻数据...")

try:
# 发送API请求
# 注意这里参数更换为自己的APIKEY
resp = requests.get("https://apis.tianapi.com/guonei/index?key=APIKEY&num=10")

if resp.status_code == 200:
data_model = resp.json()
print("✅ API请求成功!")

# 检查返回的数据结构
if 'result' in data_model and 'newslist' in data_model['result']:
news_count = len(data_model['result']['newslist'])
print(f"📊 获取到 {news_count} 条新闻")

# 保存完整数据到 data_news.json
try:
with open("data_news.json", "w", encoding="utf-8") as file:
json.dump(data_model, file, ensure_ascii=False, indent=4)
print("💾 新闻数据已成功保存到 data_news.json")
except Exception as e:
print(f"❌ 保存文件时出错: {e}")
return

# 显示新闻标题预览
print("\n📰 新闻列表预览:")
print("=" * 80)
for i, news in enumerate(data_model['result']['newslist'], 1):
print(f"{i:2d}. 📝 {news['title']}")
print(f" ⏰ 时间: {news['ctime']}")
print(f" 📺 来源: {news['source']}")
if news.get('picUrl'):
print(f" 🖼️ 图片: 有")
print("-" * 80)

# 验证保存的文件
print("\n🔍 验证保存的文件...")
try:
with open("data_news.json", "r", encoding="utf-8") as file:
saved_data = json.load(file)
saved_count = len(saved_data['result']['newslist'])
print(f"✅ 文件验证成功!保存了 {saved_count} 条新闻")

# 显示文件大小
import os
file_size = os.path.getsize("data_news.json")
print(f"📁 文件大小: {file_size} 字节 ({file_size/1024:.2f} KB)")

except Exception as e:
print(f"❌ 验证文件时出错: {e}")

else:
print("❌ API返回数据格式异常")
print("返回的数据:", data_model)

else:
print(f"❌ 请求失败,状态码: {resp.status_code}")
print(f"响应内容: {resp.text}")

except requests.exceptions.RequestException as e:
print(f"❌ 网络请求异常: {e}")
except json.JSONDecodeError as e:
print(f"❌ JSON解析错误: {e}")
except Exception as e:
print(f"❌ 发生未知错误: {e}")

def read_saved_news():
"""
读取并显示保存的新闻数据
"""
print("\n" + "="*80)
print("📖 读取保存的新闻数据...")
print("="*80)

try:
with open("data_news.json", "r", encoding="utf-8") as file:
data = json.load(file)

if 'result' in data and 'newslist' in data['result']:
newslist = data['result']['newslist']
print(f"📊 文件中共有 {len(newslist)} 条新闻")
print(f"📅 数据获取时间: {data.get('msg', '未知')}")

# 显示详细新闻信息
for i, news in enumerate(newslist, 1):
print(f"\n📰 新闻 {i}:")
print(f"📝 标题: {news['title']}")
print(f"⏰ 时间: {news['ctime']}")
print(f"📺 来源: {news['source']}")
print(f"🔗 链接: {news['url']}")
if news.get('description'):
print(f"📄 描述: {news['description']}")
if news.get('picUrl'):
print(f"🖼️ 图片: {news['picUrl']}")
print("-" * 60)
else:
print("❌ 文件格式异常")

except FileNotFoundError:
print("❌ 文件 data_news.json 不存在,请先运行获取新闻的功能")
except json.JSONDecodeError:
print("❌ 文件格式错误,不是有效的JSON文件")
except Exception as e:
print(f"❌ 读取文件时出错: {e}")

if __name__ == "__main__":
# 获取并保存新闻
fetch_and_save_news()

# 读取并显示保存的新闻
read_saved_news()

print("\n" + "="*80)
print("✨ 程序执行完成!")
print("📁 新闻数据已保存在 data_news.json 文件中")
print("="*80)

上面的代码通过requests模块的get函数向天行数据的国内新闻接口发起了一次请求,如果请求过程没有出现问题,get函数会返回一个Response对象,通过该对象的status_code属性表示HTTP响应状态码,如果不理解没关系,你只需要关注它的值,如果值等于200或者其他2字头的值,那么我们的请求是成功的。通过Response对象的json()方法可以将返回的JSON格式的数据直接处理成Python字典,非常方便。天行数据国内新闻接口返回的JSON格式的数据(部分)如下图所示。

提示:上面代码中的APIKey需要换成自己在天行数据网站申请的APIKey。天行数据网站上还有提供了很多非常有意思的API接口,例如:垃圾分类、周公解梦等,大家可以仿照上面的代码来调用这些接口。每个接口都有对应的接口文档,文档中有关于如何使用接口的详细说明。

2.5 总结

Python中实现序列化和反序列化除了使用json模块之外,还可以使用pickleshelve模块,但是这两个模块是使用特有的序列化协议来序列化数据,因此序列化后的数据只能被Python识别,关于这两个模块的相关知识,有兴趣的读者可以自己查找网络上的资料。处理JSON格式的数据很显然是程序员必须掌握的一项技能,因为不管是访问网络API接口还是提供网络API接口给他人使用,都需要具备处理JSON格式数据的相关知识。

3 Python读写CSV文件

3.1 CSV文件介绍

CSV(Comma Separated Values)全称逗号分隔值文件是一种简单、通用的文件格式,被广泛的应用于应用程序(数据库、电子表格等)数据的导入和导出以及异构系统之间的数据交换。因为CSV是纯文本文件,不管是什么操作系统和编程语言都是可以处理纯文本的,而且很多编程语言中都提供了对读写CSV文件的支持,因此CSV格式在数据处理和数据科学中被广泛应用。

CSV文件有以下特点:

  1. 纯文本,使用某种字符集(如ASCIIUnicodeGB2312)等);
  2. 由一条条的记录组成(典型的是每行一条记录);
  3. 每条记录被分隔符(如逗号、分号、制表符等)分隔为字段(列);
  4. 每条记录都有同样的字段序列。

CSV文件可以使用文本编辑器或类似于Excel电子表格这类工具打开和编辑,当使用Excel这类电子表格打开CSV文件时,你甚至感觉不到CSV和Excel文件的区别。很多数据库系统都支持将数据导出到CSV文件中,当然也支持从CSV文件中读入数据保存到数据库中,这些内容并不是现在要讨论的重点。

3.2 将数据写入CSV文件

现有五个学生三门课程的考试成绩需要保存到一个CSV文件中,要达成这个目标,可以使用Python标准库中的csv模块,该模块的writer函数会返回一个csvwriter对象,通过该对象的writerowwriterows方法就可以将数据写入到CSV文件中,具体的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
import csv
import random

with open("scores.csv", "w") as f:
writer = csv.writer(f)
writer.writerow(["姓名", "语文", "数学", "英语"])
names = ["张三", "李四", "王五", "赵六"]
for name in names:
scores = [random.randrange(50, 101) for _ in range(3)]
scores.insert(0, name)
writer.writerow(scores)

生成的CSV文件的内容。

需要说明的是上面的writer函数,除了传入要写入数据的文件对象外,还可以dialect参数,它表示CSV文件的方言,默认值是excel。除此之外,还可以通过delimiterquotecharquoting参数来指定分隔符(默认是逗号)、包围值的字符(默认是双引号)以及包围的方式。其中,包围值的字符主要用于当字段中有特殊符号时,通过添加包围值的字符可以避免二义性。大家可以尝试下面的代码,然后查看生成的CSV文件。

1
2
3
4
5
6
7
8
9
10
11
12
import csv
import random

with open("scores_1.csv", "w") as f:
writer = csv.writer(f, delimiter='|', quoting=csv.QUOTE_ALL)
writer.writerow(["姓名", "语文", "数学", "英语"])
names = ["张三", "李四", "王五", "赵六"]
for name in names:
scores = [random.randrange(50, 101) for _ in range(3)]
scores.insert(0, name)
writer.writerow(scores)

生成的CSV文件的内容。

3.3 从CSV文件读取数据

如果要读取刚才创建的CSV文件,可以使用下面的代码,通过csv模块的reader函数可以创建出csvreader对象,该对象是一个迭代器,可以通过next函数或for-in循环读取到文件中的数据。

1
2
3
4
5
6
7
8
9
import csv

with open('scores_1.csv', 'r') as f:
reader = csv.reader(f, delimiter='|')
for data_list in reader:
print(reader.line_num, end = '\t')
for elem in data_list:
print(elem, end = '\t')
print()

注意:上面的代码对csvreader对象做for循环时,每次会取出一个列表对象,该列表对象包含了一行中所有的字段。

3.4 不同引用模式对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import csv

# 示例数据
data = [
["姓名", "语文", "数学", "英语"],
["张三", 85, 92, 78],
["李四", 90, 88, 95],
["王五", 78, 85, 82]
]

print("=== CSV写入器参数对比示例 ===\n")

# 1. 默认设置(逗号分隔,智能引用)
print("1. 默认设置 - csv.writer(f)")
with open("example_default.csv", "w", encoding="utf-8") as f:
writer = csv.writer(f)
for row in data:
writer.writerow(row)

with open("example_default.csv", "r", encoding="utf-8") as f:
content = f.read()
print("输出内容:")
print(content)

# 2. 管道分隔符,默认引用
print("\n2. 管道分隔符 - csv.writer(f, delimiter='|')")
with open("example_pipe.csv", "w", encoding="utf-8") as f:
writer = csv.writer(f, delimiter='|')
for row in data:
writer.writerow(row)

with open("example_pipe.csv", "r", encoding="utf-8") as f:
content = f.read()
print("输出内容:")
print(content)

# 3. 管道分隔符 + 全部引用(你的代码)
print("\n3. 你的代码设置 - csv.writer(f, delimiter='|', quoting=csv.QUOTE_ALL)")
with open("example_pipe_quote_all.csv", "w", encoding="utf-8") as f:
writer = csv.writer(f, delimiter='|', quoting=csv.QUOTE_ALL)
for row in data:
writer.writerow(row)

with open("example_pipe_quote_all.csv", "r", encoding="utf-8") as f:
content = f.read()
print("输出内容:")
print(content)

# 4. 不同引用模式对比
print("\n=== 引用模式对比 ===")

quote_modes = [
(csv.QUOTE_ALL, "QUOTE_ALL - 所有字段都加引号"),
(csv.QUOTE_MINIMAL, "QUOTE_MINIMAL - 只在必要时加引号"),
(csv.QUOTE_NONNUMERIC, "QUOTE_NONNUMERIC - 非数字字段加引号"),
(csv.QUOTE_NONE, "QUOTE_NONE - 不加引号")
]

for mode, description in quote_modes:
print(f"\n{description}:")
try:
with open(f"example_{mode}.csv", "w", encoding="utf-8") as f:
writer = csv.writer(f, delimiter='|', quoting=mode)
writer.writerow(["姓名", "分数", "备注"])
writer.writerow(["张三", 85, "优秀学生"])

with open(f"example_{mode}.csv", "r", encoding="utf-8") as f:
content = f.read()
print(content.strip())
except Exception as e:
print(f"错误: {e}")

print("\n=== 总结 ===")
print("你的代码 writer = csv.writer(f, dialect='|', quoting=csv.QUOTE_ALL) 的作用:")
print("1. 使用管道符(|)作为字段分隔符")
print("2. 对所有字段都添加双引号")
print("3. 这样可以确保数据的完整性,特别是当字段中包含特殊字符时")

3.5 总结

将来如果大家使用Python做数据分析,很有可能会用到名为pandas的三方库,它是Python数据分析的神器之一。pandas中封装了名为read_csvto_csv的函数用来读写CSV文件,其中read_CSV会将读取到的数据变成一个DataFrame对象,而DataFrame就是pandas库中最重要的类型,它封装了一系列用于数据处理的方法(清洗、转换、聚合等);而to_csv会将DataFrame对象中的数据写入CSV文件,完成数据的持久化。read_csv函数和to_csv函数远远比原生的csvreadercsvwriter强大。

4 Python读写Excel文件-1

4.1 Excel简介

Excel 是 Microsoft(微软)为使用 Windows 和 macOS 操作系统开发的一款电子表格软件。Excel 凭借其直观的界面、出色的计算功能和图表工具,再加上成功的市场营销,一直以来都是最为流行的个人计算机数据处理软件。当然,Excel 也有很多竞品,例如 Google Sheets、LibreOffice Calc、Numbers 等,这些竞品基本上也能够兼容 Excel,至少能够读写较新版本的 Excel 文件,当然这些不是我们讨论的重点。掌握用 Python 程序操作 Excel 文件,可以让日常办公自动化的工作更加轻松愉快,而且在很多商业项目中,导入导出 Excel 文件都是特别常见的功能。

Python 操作 Excel 需要三方库的支持,如果要兼容 Excel 2007 以前的版本,也就是xls格式的 Excel 文件,可以使用三方库xlrdxlwt,前者用于读 Excel 文件,后者用于写 Excel 文件。如果使用较新版本的 Excel,即xlsx格式的 Excel 文件,可以使用openpyxl库,当然这个库不仅仅可以操作Excel,还可以操作其他基于 Office Open XML 的电子表格文件。

本章我们先讲解基于xlwtxlrd操作 Excel 文件,大家可以先使用下面的命令安装这两个三方库以及配合使用的工具模块xlutils

1
pip install xlwt xlrd xlutils

4.2 读Excel文件

例如在当前文件夹下有一个名为“阿里巴巴2020年股票数据.xls”的 Excel 文件,如果想读取并显示该文件的内容,可以通过如下所示的代码来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import xlrd

# 使用xlrd模块的open_workbook函数打开指定Excel文件并获得Book对象(工作簿)
book = xlrd.open_workbook('data/阿里巴巴2020年股票数据.xls')
# 通过Book对象的sheet_names方法可以获取所有表单名称
sheet_names = book.sheet_names()
print(sheet_names)
# 通过指定的表单名称获取Sheet对象(工作表)
sheet = book.sheet_by_name(sheet_names[0])
# 通过Sheet对象的nrows和ncols属性获取表单的行数和列数
print(sheet.nrows,sheet.ncols)

for row in range(sheet.nrows):
for col in range(sheet.ncols):
# 通过Sheet对象的cell方法获取指定Cell对象(单元格)
# 通过Cell对象的value属性获取单元格中的值
value = sheet.cell(row, col).value
# 对除首行外的其他行进行数据格式化处理
if row > 0:
# 第1列的xldate类型先转成元组再格式化为“年月日”的格式
if col == 0:
# xldate_as_tuple函数的第二个参数只有0和1两个取值
# 其中0代表以1900-01-01为基准的日期,1代表以1904-01-01为基准的日期
value = xlrd.xldate_as_tuple(value, 0)
value = f'{value[0]}{value[1]:>02d}{value[2]:>02d}日'
# 其他列的number类型处理成小数点后保留两位有效数字的浮点数
elif isinstance(value,(int, float)):
value = f'{value:.2f}'
print(value, end='\t')
print()

提示:上面代码中使用的Excel文件“阿里巴巴2020年股票数据.xls”可以通过后面的百度云盘地址进行获取。链接:https://pan.quark.cn/s/dfbd00457072?pwd=n3uY。

相信通过上面的代码,大家已经了解到了如何读取一个 Excel 文件,如果想知道更多关于xlrd模块的知识,可以阅读它的官方文档

4.3 写Excel文件

写入 Excel 文件可以通过xlwt 模块的Workbook类创建工作簿对象,通过工作簿对象的add_sheet方法可以添加工作表,通过工作表对象的write方法可以向指定单元格中写入数据,最后通过工作簿对象的save方法将工作簿写入到指定的文件或内存中。下面的代码实现了将5 个学生 3 门课程的考试成绩写入 Excel 文件的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import random
import xlwt

student_names = ['Penry', 'Cruise', 'Taco', 'Ma']
scores = [[random.randrange(60, 101) for _ in range(3)] for _ in range(4)]
# 创建工作簿对象
workbook = xlwt.Workbook()
# 创建工作表对象
worksheet = workbook.add_sheet("239宿舍")
# 添加表头
titles = ('姓名', '卫生', '健康', '体育')
for index, title in enumerate(titles):
worksheet.write(0, index, title)
# 将学生姓名和得分写入单元格
for row in range(len(scores)):
worksheet.write(row + 1, 0, student_names[row])
for col in range(len(scores[row])):
worksheet.write(row + 1, col + 1, scores[row][col])
# 保存Excel工作簿
workbook.save('data/成绩表.xls')

4.4 调整单元格样式

在写Excel文件时,我们还可以为单元格设置样式,主要包括字体(Font)、对齐方式(Alignment)、边框(Border)和背景(Background)的设置,xlwt对这几项设置都封装了对应的类来支持。要设置单元格样式需要首先创建一个XFStyle对象,再通过该对象的属性对字体、对齐方式、边框等进行设定,例如在上面的例子中,如果希望将表头单元格的背景色修改为黄色,可以按照如下的方式进行操作。

1
2
3
4
5
6
7
8
9
header_style = xlwt.XFStyle()
pattern = xlwt.Pattern()
pattern.pattern = xlwt.Pattern.SOLID_PATTERN
# 0 - 黑色、1 - 白色、2 - 红色、3 - 绿色、4 - 蓝色、5 - 黄色、6 - 粉色、7 - 青色
pattern.pattern_fore_colour = 5
header_style.pattern = pattern
titles = ('姓名', '语文', '数学', '英语')
for index, title in enumerate(titles):
sheet.write(0, index, title, header_style)

如果希望为表头设置指定的字体,可以使用Font类并添加如下所示的代码。

1
2
3
4
5
6
7
8
9
10
11
12
font = xlwt.Font()
# 字体名称
font.name = '华文楷体'
# 字体大小(20是基准单位,18表示18px)
font.height = 20 * 18
# 是否使用粗体
font.bold = True
# 是否使用斜体
font.italic = False
# 字体颜色
font.colour_index = 1
header_style.font = font

注意:上面代码中指定的字体名(font.name)应当是本地系统有的字体,例如在我的电脑上有名为“华文楷体”的字体。

如果希望表头垂直居中对齐,可以使用下面的代码进行设置。

1
2
3
4
5
6
align = xlwt.Alignment()
# 垂直方向的对齐方式
align.vert = xlwt.Alignment.VERT_CENTER
# 水平方向的对齐方式
align.horz = xlwt.Alignment.HORZ_CENTER
header_style.alignment = align

如果希望给表头加上黄色的虚线边框,可以使用下面的代码来设置。

1
2
3
4
5
6
7
8
9
10
11
borders = xlwt.Borders()
props = (
('top', 'top_colour'), ('right', 'right_colour'),
('bottom', 'bottom_colour'), ('left', 'left_colour')
)
# 通过循环对四个方向的边框样式及颜色进行设定
for position, color in props:
# 使用setattr内置函数动态给对象指定的属性赋值
setattr(borders, position, xlwt.Borders.DASHED)
setattr(borders, color, 5)
header_style.borders = borders

如果要调整单元格的宽度(列宽)和表头的高度(行高),可以按照下面的代码进行操作。

1
2
3
4
5
6
7
8
# 设置行高为40px
sheet.row(0).set_style(xlwt.easyxf(f'font:height {20 * 40}'))
titles = ('姓名', '语文', '数学', '英语')
for index, title in enumerate(titles):
# 设置列宽为200px
sheet.col(index).width = 20 * 200
# 设置单元格的数据和样式
sheet.write(0, index, title, header_style)

4.5 公式计算

对于前面打开的“阿里巴巴2020年股票数据.xls”文件,如果要统计售价平均值以及销售数量的总和,可以使用Excel的公式计算即可。我们可以先使用xlrd读取Excel文件夹,然后通过xlutils三方库提供的copy函数将读取到的Excel文件转成Workbook对象进行写操作,在调用write方法时,可以将一个Formula对象写入单元格。

实现公式计算的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
import xlrd
import xlwt
from xlutils.copy import copy

wb_for_read = xlrd.open_workbook('阿里巴巴2020年股票数据.xls')
sheet1 = wb_for_read.sheet_by_index(0)
nrows, ncols = sheet1.nrows, sheet1.ncols
wb_for_write = copy(wb_for_read)
sheet2 = wb_for_write.get_sheet(0)
sheet2.write(nrows, 4, xlwt.Formula(f'average(E2:E{nrows})'))
sheet2.write(nrows, 6, xlwt.Formula(f'sum(G2:G{nrows})'))
wb_for_write.save('阿里巴巴2020年股票数据汇总.xls')

说明:上面的代码有一些小瑕疵,有兴趣的读者可以自行探索并思考如何解决。

4.6 总结

掌握了 Python 程序操作 Excel 的方法,可以解决日常办公中很多繁琐的处理 Excel 电子表格工作,最常见就是将多个数据格式相同的 Excel 文件合并到一个文件以及从多个 Excel 文件或表单中提取指定的数据。当然,如果要对表格数据进行处理,使用 Python 数据分析神器之一的 pandas 库可能更为方便。

5 Python读写Excel文件-2

5.1 Excel简介

Excel 是 Microsoft(微软)为使用 Windows 和 macOS 操作系统开发的一款电子表格软件。Excel 凭借其直观的界面、出色的计算功能和图表工具,再加上成功的市场营销,一直以来都是最为流行的个人计算机数据处理软件。当然,Excel 也有很多竞品,例如 Google Sheets、LibreOffice Calc、Numbers 等,这些竞品基本上也能够兼容 Excel,至少能够读写较新版本的 Excel 文件,当然这些不是我们讨论的重点。掌握用 Python 程序操作 Excel 文件,可以让日常办公自动化的工作更加轻松愉快,而且在很多商业项目中,导入导出 Excel 文件都是特别常见的功能。

本章我们继续讲解基于另一个三方库openpyxl如何进行 Excel 文件操作,首先需要先安装它。

1
pip install openpyxl

openpyxl的优点在于,当我们打开一个 Excel 文件后,既可以对它进行读操作,又可以对它进行写操作,而且在操作的便捷性上是优于xlwtxlrd的。此外,如果要进行样式编辑和公式计算,使用openpyxl也远比上一个章节我们讲解的方式更为简单,而且openpyxl还支持数据透视和插入图表等操作,功能非常强大。有一点需要再次强调,openpyxl并不支持操作 Office 2007 以前版本的 Excel 文件。

5.2 读取Excel文件

例如在data文件夹下有一个名为“阿里巴巴2020年股票数据.xlsx”的 Excel 文件,如果想读取并显示该文件的内容,可以通过如下所示的代码来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import datetime
import openpyxl

# 加载一个工作簿 ---> Workbook
wb = openpyxl.load_workbook('data/阿里巴巴2020年股票数据.xlsx')
# 获取工作表名称
print(wb.sheetnames)
# 获取工作表 ---> Worksheet
sheet = wb.worksheets[0]
# 获取单元格范围
print(sheet.dimensions)
# 获取行数和列数
print(sheet.max_row, sheet.max_column)

# 获取指定单元格的值
print(sheet.cell(3, 3).value)
print(sheet['C3'].value)
print(sheet['G255'].value)

# 获取多个单元格(嵌套元组)
print(sheet['A2:C5'])

# 读取所有单元格的数据
for row_ch in range(2, sheet.max_row + 1):
for col_ch in 'ABCDEFG':
value = sheet[f'{col_ch}{row_ch}'].value
if type(value) == datetime.datetime:
print(value.strftime('%Y年%m月%d日'), end='\t')
elif type(value) == int:
print(f'{value:<10d}', end='\t')
elif type(value) == float:
print(f'{value:.4f}', end='\t')
else:
print(value, end='\t')
print()

需要提醒大家一点,openpyxl获取指定的单元格有两种方式,一种是通过cell方法,需要注意,该方法的行索引和列索引都是从1开始的,这是为了照顾用惯了 Excel 的人的习惯;另一种是通过索引运算,通过指定单元格的坐标,例如C3G255,也可以取得对应的单元格,再通过单元格对象的value属性,就可以获取到单元格的值。通过上面的代码,相信大家还注意到了,可以通过类似sheet['A2:C5']sheet['A2':'C5']这样的切片操作获取多个单元格,该操作将返回嵌套的元组,相当于获取到了多行多列。

5.3 写Excel文件

下面我们使用openpyxl来进行写 Excel 操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import openpyxl
import random

# 第一步:创建工作簿(Workbook)
wb = openpyxl.Workbook()

# 第二步:添加工作表(Worksheet)
sheet = wb.active
sheet.title = '期末成绩'

# 第三步:写入数据

titles = ('姓名', '语文', '数学', '英语')
for col_index, title in enumerate(titles):
sheet.cell(1, col_index+1, title)

names = ('关羽', '张飞', '赵云', '马超', '黄忠')
for row_index, name in enumerate(names):
sheet.cell(row_index+2, 1, name)
for col_index in range(2, 5):
sheet.cell(row_index+2, col_index, random.randrange(60, 101))

# 第四步:保存工作簿
wb.save('data/期末成绩.xlsx')

5.4 调整样式和公式计算

在使用openpyxl操作 Excel 时,如果要调整单元格的样式,可以直接通过单元格对象(Cell对象)的属性进行操作。单元格对象的属性包括字体(font)、对齐(alignment)、边框(border)等,具体的可以参考openpyxl官方文档。在使用openpyxl时,如果需要做公式计算,可以完全按照 Excel 中的操作方式来进行,具体的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import openpyxl
from openpyxl.styles import Font, Alignment, Border, Side

# 对齐方式
alignment = Alignment(horizontal='center', vertical='center')
# 边框线条
side = Side(style='mediumDashed', color='ff7f50')

wb = openpyxl.load_workbook('data/期末成绩.xlsx')
sheet = wb.worksheets[0]

# 调整行高和行宽
sheet.row_dimensions[1].height = 30
sheet.column_dimensions['E'].width = 120

sheet['E1'] = '平均分'

# 设置字体
sheet.cell(1, 5).font = Font(name='微软雅黑', size=18, bold=True, color='ff1493')
# 设置对齐方式
sheet.cell(1, 5).alignment = alignment
# 设置单元格边框
sheet.cell(1, 5).border = Border(left=side, top=side, right=side, bottom=side)
for i in range(2, 7):
# 公式计算每个学生的平均分
sheet[f'E{i}'] = f'=average(B{i}:D{i})'
sheet.cell(i, 5).font = Font(size=12, color='4169e1', italic=True)
sheet.cell(i, 5).alignment = alignment

wb.save('data/考试成绩表.xlsx')

5.5 生成统计图表

通过openpyxl库,可以直接向 Excel 中插入统计图表,具体的做法跟在 Excel 中插入图表大体一致。我们可以创建指定类型的图表对象,然后通过该对象的属性对图表进行设置。当然,最为重要的是为图表绑定数据,即横轴代表什么,纵轴代表什么,具体的数值是多少。最后,可以将图表对象添加到表单中,具体的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference

wb = Workbook(write_only=True)
sheet = wb.create_sheet()

rows = [
('类别', '销售A组', '销售B组'),
('手机', 40, 30),
('平板', 50, 60),
('笔记本', 80, 70),
('外围设备', 20, 10),
]

# 向表单中添加行
for row in rows:
sheet.append(row)

# 创建图表对象
chart = BarChart()
chart.type = 'col'
chart.style = 10
# 设置图表的标题
chart.title = '销售统计图'
# 设置图表纵轴的标题
chart.y_axis.title = '销量'
# 设置图表横轴的标题
chart.x_axis.title = '商品类别'
# 设置数据的范围
data = Reference(sheet, min_col=2, min_row=1, max_row=5, max_col=3)
# 设置分类的范围
cats = Reference(sheet, min_col=1, min_row=2, max_row=5)
# 给图表添加数据
chart.add_data(data, titles_from_data=True)
# 给图表设置分类
chart.set_categories(cats)
chart.shape = 4
# 将图表添加到表单指定的单元格中
sheet.add_chart(chart, 'A10')

wb.save('demo.xlsx')

运行上面的代码,打开生成的 Excel 文件,效果如下图所示。

5.6 总结

掌握了 Python 程序操作 Excel 的方法,可以解决日常办公中很多繁琐的处理 Excel 电子表格工作,最常见就是将多个数据格式相同的Excel 文件合并到一个文件以及从多个 Excel 文件或表单中提取指定的数据。如果数据体量较大或者处理数据的方式比较复杂,我们还是推荐大家使用 Python 数据分析神器之一的 pandas 库。

6 Python操作Word和PowerPoint文件

在日常工作中,有很多简单重复的劳动其实完全可以交给 Python 程序,比如根据样板文件(模板文件)批量的生成很多个 Word 文件或 PowerPoint 文件。Word 是微软公司开发的文字处理程序,相信大家都不陌生,日常办公中很多正式的文档都是用 Word 进行撰写和编辑的,目前使用的 Word 文件后缀名一般为.docx。PowerPoint 是微软公司开发的演示文稿程序,是微软的 Office 系列软件中的一员,被商业人士、教师、学生等群体广泛使用,通常也将其称之为“幻灯片”。在 Python 中,可以使用名为python-docx 的三方库来操作 Word,可以使用名为python-pptx的三方库来生成 PowerPoint。

6.1 操作Word文档

我们可以先通过下面的命令来安装python-docx三方库。

1
pip install python-docx

按照官方文档的介绍,我们可以使用如下所示的代码来生成一个简单的 Word 文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
from docx import Document
from docx.shared import Cm, Pt

from docx.document import Document as Doc

# 创建代表Word文档的Doc对象
document = Document() # type: Doc
# 添加大标题
document.add_heading('快快乐乐学Python', 0)
# 添加段落
p = document.add_paragraph('Python是一门非常流行的编程语言,它')
run = p.add_run('简单')
run.bold = True
run.font.size = Pt(18)
p.add_run('而且')
run = p.add_run('优雅')
run.font.size = Pt(18)
run.underline = True
p.add_run('。')

# 添加一级标题
document.add_heading('Heading, level 1', level=1)
# 添加带样式的段落
document.add_paragraph('Intense quote', style='Intense Quote')
# 添加无序列表
document.add_paragraph(
'first item in unordered list', style='List Bullet'
)
document.add_paragraph(
'second item in ordered list', style='List Bullet'
)
# 添加有序列表
document.add_paragraph(
'first item in ordered list', style='List Number'
)
document.add_paragraph(
'second item in ordered list', style='List Number'
)

# 添加图片(注意路径和图片必须要存在)
document.add_picture('resources/dm10.jpg', width=Cm(5.2))

# 添加分节符
document.add_section()

records = (
('Penry', '男', '2004-05'),
('Doudou', '女', '2005-05')
)
# 添加表格
table = document.add_table(rows=1, cols=3)
table.style = 'Dark List'
hdr_cells = table.rows[0].cells
hdr_cells[0].text = '姓名'
hdr_cells[1].text = '性别'
hdr_cells[2].text = '出生日期'
# 为表格添加行
for name, sex, birthday in records:
row_cells = table.add_row().cells
row_cells[0].text = name
row_cells[1].text = sex
row_cells[2].text = birthday

# 添加分页符
document.add_page_break()

# 保存文档
document.save('Intermediate file/demo.docx')

提示:上面代码第7行中的注释# type: Doc是为了在PyCharm中获得代码补全提示,因为如果不清楚对象具体的数据类型,PyCharm 无法在后续代码中给出Doc对象的代码补全提示。

执行上面的代码,打开生成的 Word 文档,效果如下图所示。

对于一个已经存在的 Word 文件,我们可以通过下面的代码去遍历它所有的段落并获取对应的内容。

1
2
3
4
5
6
from docx import Document
from docx.document import Document as Doc

doc = Document('resources/离职证明.docx') # type: Doc
for no, p in enumerate(doc.paragraphs):
print(no, p.text)

提示:如果需要上面代码中的 Word 文件,可以通过下面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。

读取到的内容如下所示。

1
2
3
4
5
6
7
8
9
10
11
0 快快乐乐学Python
1 Python是一门非常流行的编程语言,它简单而且优雅。
2 Heading, level 1
3 Intense quote
4 first item in unordered list
5 second item in ordered list
6 first item in ordered list
7 second item in ordered list
8
9
10

讲到这里,相信很多读者已经想到了,我们可以把离职证明制作成一个模板文件,把姓名、身份证号、入职和离职日期等信息用占位符代替,这样通过对占位符的替换,就可以根据实际需要写入对应的信息,这样就可以批量的生成 Word 文档。

按照上面的思路,我们首先编辑一个离职证明的模板文件,如下图所示。

接下来我们读取该文件,将占位符替换为真实信息,就可以生成一个新的 Word 文档,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from docx import Document
from docx.document import Document as Doc

# 将真实信息用字典的方式保存在列表中
employees = [
{
'name': '赵云',
'id': '100200198011280001',
'sdate': '2008年3月1日',
'edate': '2012年2月29日',
'department': '产品研发',
'position': '架构师',
'company': '成都华为技术有限公司'
},
{
'name': '王大锤',
'id': '510210199012125566',
'sdate': '2019年1月1日',
'edate': '2021年4月30日',
'department': '产品研发',
'position': 'Python开发工程师',
'company': '成都谷道科技有限公司'
},
{
'name': '李元芳',
'id': '2102101995103221599',
'sdate': '2020年5月10日',
'edate': '2021年3月5日',
'department': '产品研发',
'position': 'Java开发工程师',
'company': '同城企业管理集团有限公司'
},
]
# 对列表进行循环遍历,批量生成Word文档
for emp_dict in employees:
# 读取离职证明模板文件
doc = Document('resources/离职证明模板.docx') # type: Doc
# 循环遍历所有段落寻找占位符
for p in doc.paragraphs:
if '{' not in p.text:
continue
# 不能直接修改段落内容,否则会丢失样式
# 所以需要对段落中的元素进行遍历并进行查找替换
for run in p.runs:
if '{' not in run.text:
continue
# 将占位符换成实际内容
start, end = run.text.find('{'), run.text.find('}')
key, place_holder = run.text[start + 1:end], run.text[start:end + 1]
run.text = run.text.replace(place_holder, emp_dict[key])
# 每个人对应保存一个Word文档
doc.save(f'{emp_dict["name"]}离职证明.docx')

执行上面的代码,会在当前路径下生成三个 Word 文档。

6.2 生成PowerPoint

首先我们需要安装名为python-pptx的三方库,命令如下所示。

1
pip install python-pptx

用 Python 操作 PowerPoint 的内容,因为实际应用场景不算很多,我不打算在这里进行赘述,有兴趣的读者可以自行阅读python-pptx官方文档,下面仅展示一段来自于官方文档的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from pptx import Presentation

# 创建幻灯片对象
pres = Presentation()

# 选择母版添加一页
title_slide_layout = pres.slide_layouts[0]
slide = pres.slides.add_slide(title_slide_layout)
# 获取标题栏和副标题栏
title = slide.shapes.title
subtitle = slide.placeholders[1]
# 编辑标题和副标题
title.text = "Welcome to Python"
subtitle.text = "Life is short, I use Python"

# 选择母版添加一页
bullet_slide_layout = pres.slide_layouts[1]
slide = pres.slides.add_slide(bullet_slide_layout)
# 获取页面上所有形状
shapes = slide.shapes
# 获取标题和主体
title_shape = shapes.title
body_shape = shapes.placeholders[1]
# 编辑标题
title_shape.text = 'Introduction'
# 编辑主体内容
tf = body_shape.text_frame
tf.text = 'History of Python'
# 添加一个一级段落
p = tf.add_paragraph()
p.text = 'X\'max 1989'
p.level = 1
# 添加一个二级段落
p = tf.add_paragraph()
p.text = 'Guido began to write interpreter for Python.'
p.level = 2

# 保存幻灯片
pres.save('test.pptx')

运行上面的代码,生成的 PowerPoint 文件如下图所示。

6.3 总结

用 Python 程序解决办公自动化的问题真的非常酷,它可以将我们从繁琐乏味的劳动中解放出来。写这类代码就是去做一件一劳永逸的事情,写代码的过程即便不怎么愉快,使用这些代码的时候应该是非常开心的。

7 正则表达式的应用

7.1 正则表达式相关知识

在编写处理字符串的程时,经常会遇到在一段文本中查找符合某些规则的字符串的需求,正则表达式就是用于描述这些规则的工具,换句话说,我们可以使用正则表达式来定义字符串的匹配模式,即如何检查一个字符串是否有跟某种模式匹配的部分或者从一个字符串中将与模式匹配的部分提取出来或者替换掉。

举一个简单的例子,如果你在 Windows 操作系统中使用过文件查找并且在指定文件名时使用过通配符(*?),那么正则表达式也是与之类似的用 来进行文本匹配的工具,只不过比起通配符正则表达式更强大,它能更精确地描述你的需求,当然你付出的代价是书写一个正则表达式比使用通配符要复杂得多,因为任何给你带来好处的东西都需要你付出对应的代价。

再举一个例子,我们从某个地方(可能是一个文本文件,也可能是网络上的一则新闻)获得了一个字符串,希望在字符串中找出手机号和座机号。当然我们可以设定手机号是 11 位的数字(注意并不是随机的 11 位数字,因为你没有见过“25012345678”这样的手机号),而座机号则是类似于“区号-号码”这样的模式,如果不使用正则表达式要完成这个任务就会比较麻烦。最初计算机是为了做数学运算而诞生的,处理的信息基本上都是数值,而今天我们在日常工作中处理的信息很多都是文本数据,我们希望计算机能够识别和处理符合某些模式的文本,正则表达式就显得非常重要了。今天几乎所有的编程语言都提供了对正则表达式操作的支持,Python 通过标准库中的re模块来支持正则表达式操作。

关于正则表达式的相关知识,大家可以阅读一篇非常有名的博文叫《正则表达式30分钟入门教程》,读完这篇文章后你就可以看懂下面的表格,这是我们对正则表达式中的一些基本符号进行的扼要总结。

符号 解释 示例 说明
. 匹配任意字符 b.t 可以匹配bat / but / b#t / b1t等
\w 匹配字母/数字/下划线 b\wt 可以匹配bat / b1t / b_t等
但不能匹配b#t
\s 匹配空白字符(包括\r、\n、\t等) love\syou 可以匹配love you
\d 匹配数字 \d\d 可以匹配01 / 23 / 99等
\b 匹配单词的边界 \bThe\b
^ 匹配字符串的开始 ^The 可以匹配The开头的字符串
$ 匹配字符串的结束 .exe$ 可以匹配.exe结尾的字符串
\W 匹配非字母/数字/下划线 b\Wt 可以匹配b#t / b@t等
但不能匹配but / b1t / b_t等
\S 匹配非空白字符 love\Syou 可以匹配love#you等
但不能匹配love you
\D 匹配非数字 \d\D 可以匹配9a / 3# / 0F等
\B 匹配非单词边界 \Bio\B
[] 匹配来自字符集的任意单一字符 [aeiou] 可以匹配任一元音字母字符
[^] 匹配不在字符集中的任意单一字符 [^aeiou] 可以匹配任一非元音字母字符
* 匹配0次或多次 \w*
+ 匹配1次或多次 \w+
? 匹配0次或1次 \w?
{N} 匹配N次 \w{3}
{M,} 匹配至少M次 \w{3,}
{M,N} 匹配至少M次至多N次 \w{3,6}
| 分支 foo|bar 可以匹配foo或者bar
(?#) 注释
(exp) 匹配exp并捕获到自动命名的组中
(?<name>exp) 匹配exp并捕获到名为name的组中
(?:exp) 匹配exp但是不捕获匹配的文本
(?=exp) 匹配exp前面的位置 \b\w+(?=ing) 可以匹配I’m dancing中的danc
(?<=exp) 匹配exp后面的位置 (?<=\bdanc)\w+\b 可以匹配I love dancing and reading中的第一个ing
(?!exp) 匹配后面不是exp的位置
(?<!exp) 匹配前面不是exp的位置
*? 重复任意次,但尽可能少重复 a.*b
a.*?b
将正则表达式应用于aabab,前者会匹配整个字符串aabab,后者会匹配aab和ab两个字符串
+? 重复1次或多次,但尽可能少重复
?? 重复0次或1次,但尽可能少重复
{M,N}? 重复M到N次,但尽可能少重复
{M,}? 重复M次以上,但尽可能少重复

说明: 如果需要匹配的字符是正则表达式中的特殊字符,那么可以使用\进行转义处理,例如想匹配小数点可以写成\.就可以了,因为直接写.会匹配任意字符;同理,想匹配圆括号必须写成\(\),否则圆括号被视为正则表达式中的分组。

7.2 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 多行匹配标记

说明: 上面提到的re模块中的这些函数,实际开发中也可以用正则表达式对象(Pattern对象)的方法替代对这些函数的使用,如果一个正则表达式需要重复的使用,那么先通过compile函数编译正则表达式并创建出正则表达式对象无疑是更为明智的选择。

下面我们通过一系列的例子来告诉大家在Python中如何使用正则表达式。

例子1:验证输入用户名和QQ号是否有效并给出对应的提示信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"""
要求:用户名必须由字母、数字或下划线构成且长度在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:从一段文字中提取出国内手机号码。

下面这张图是截止到 2017 年底,国内三家运营商推出的手机号段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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())

说明: 上面匹配国内手机号的正则表达式并不够好,因为像 14 开头的号码只有 145 或 147,而上面的正则表达式并没有考虑这种情况,要匹配国内手机号,更好的正则表达式的写法是:(?<=\D)(1[38]\d{9}|14[57]\d{8}|15[0-35-9]\d{8}|17[678]\d{8})(?=\D),国内好像已经有 19 和 16 开头的手机号了,但是这个暂时不在我们考虑之列。

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

1
2
3
4
5
6
import re

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

说明: re模块的正则表达式相关函数中都有一个flags参数,它代表了正则表达式的匹配标记,可以通过该标记来指定匹配时是否忽略大小写、是否进行多行匹配、是否显示调试信息等。如果需要为flags参数指定多个值,可以使用按位或运算符进行叠加,如flags=re.I | re.M

例子4:拆分长字符串

1
2
3
4
5
6
7
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)

7.3 总结

正则表达式在字符串的处理和匹配上真的非常强大,通过上面的例子相信大家已经感受到了正则表达式的魅力,当然写一个正则表达式对新手来说并不是那么容易,但是很多事情都是熟能生巧,大胆的去尝试就行了,有一个在线的正则表达式测试工具相信能够在一定程度上帮到大家。


Day8

1 Python语言进阶

1.1 重要知识点

  • 生成式(推导式)的用法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    prices = {
    'AAPL': 191.88,
    'GOOG': 1186.96,
    'IBM': 149.24,
    'ORCL': 48.44,
    'ACN': 166.89,
    'FB': 208.09,
    'SYMC': 21.29
    }
    # 用股票价格大于100元的股票构造一个新的字典
    prices2 = {key: value for key, value in prices.items() if value > 100}
    print(prices2)

    说明:生成式(推导式)可以用来生成列表、集合和字典。

  • 嵌套的列表的坑

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    names = ['关羽', '张飞', '赵云', '马超', '黄忠']
    courses = ['语文', '数学', '英语']
    # 录入五个学生三门课程的成绩
    # 错误 - 参考http://pythontutor.com/visualize.html#mode=edit
    # scores = [[None] * len(courses)] * len(names)
    scores = [[None] * len(courses) for _ in range(len(names))]
    for row, name in enumerate(names):
    for col, course in enumerate(courses):
    scores[row][col] = float(input(f'请输入{name}{course}成绩: '))
    print(scores)

    Python Tutor - VISUALIZE CODE AND GET LIVE HELP

  • heapq模块(堆排序)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    """
    从列表中找出最大的或最小的N个元素
    堆结构(大根堆/小根堆)
    """
    import heapq

    list1 = [34, 25, 12, 99, 87, 63, 58, 78, 88, 92]
    list2 = [
    {'name': 'IBM', 'shares': 100, 'price': 91.1},
    {'name': 'AAPL', 'shares': 50, 'price': 543.22},
    {'name': 'FB', 'shares': 200, 'price': 21.09},
    {'name': 'HPQ', 'shares': 35, 'price': 31.75},
    {'name': 'YHOO', 'shares': 45, 'price': 16.35},
    {'name': 'ACME', 'shares': 75, 'price': 115.65}
    ]
    print(heapq.nlargest(3, list1))
    print(heapq.nsmallest(3, list1))
    print(heapq.nlargest(2, list2, key=lambda x: x['price']))
    print(heapq.nlargest(2, list2, key=lambda x: x['shares']))
  • itertools模块

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    """
    迭代工具模块
    """
    import itertools

    # 产生ABCD的全排列
    itertools.permutations('ABCD')
    # 产生ABCDE的五选三组合
    itertools.combinations('ABCDE', 3)
    # 产生ABCD和123的笛卡尔积
    itertools.product('ABCD', '123')
    # 产生ABC的无限循环序列
    itertools.cycle(('A', 'B', 'C'))
  • collections模块

    常用的工具类:

    • namedtuple:命令元组,它是一个类工厂,接受类型的名称和属性列表来创建一个类。
    • deque:双端队列,是列表的替代实现。Python中的列表底层是基于数组来实现的,而deque底层是双向链表,因此当你需要在头尾添加和删除元素时,deque会表现出更好的性能,渐近时间复杂度为O(1)O(1)
    • Counterdict的子类,键是元素,值是元素的计数,它的most_common()方法可以帮助我们获取出现频率最高的元素。Counterdict的继承关系我认为是值得商榷的,按照CARP原则,Counterdict的关系应该设计为关联关系更为合理。
    • OrderedDictdict的子类,它记录了键值对插入的顺序,看起来既有字典的行为,也有链表的行为。
    • defaultdict:类似于字典类型,但是可以通过默认的工厂函数来获得键对应的默认值,相比字典中的setdefault()方法,这种做法更加高效。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    """
    找出序列中出现次数最多的元素
    """
    from collections import Counter

    words = [
    'look', 'into', 'my', 'eyes', 'look', 'into', 'my', 'eyes',
    'the', 'eyes', 'the', 'eyes', 'the', 'eyes', 'not', 'around',
    'the', 'eyes', "don't", 'look', 'around', 'the', 'eyes',
    'look', 'into', 'my', 'eyes', "you're", 'under'
    ]
    counter = Counter(words)
    print(counter.most_common(3))

1.2 数据结构和算法

  • 算法:解决问题的方法和步骤

  • 评价算法的好坏:渐近时间复杂度和渐近空间复杂度。

  • 渐近时间复杂度的大O标记:

    • - 常量时间复杂度 - 布隆过滤器 / 哈希存储
    • - 对数时间复杂度 - 折半查找(二分查找)
    • - 线性时间复杂度 - 顺序查找 / 计数排序
    • - 对数线性时间复杂度 - 高级排序算法(归并排序、快速排序)
    • - 平方时间复杂度 - 简单排序算法(选择排序、插入排序、冒泡排序)
    • - 立方时间复杂度 - Floyd算法 / 矩阵乘法运算
    • - 几何级数时间复杂度 - 汉诺塔
    • - 阶乘时间复杂度 - 旅行经销商问题 - NPC

  • 排序算法(选择、冒泡和归并)和查找算法(顺序和折半)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    def select_sort(items, comp=lambda x, y: x < y):
    """简单选择排序"""
    items = items[:]
    for i in range(len(items) - 1):
    min_index = i
    for j in range(i + 1, len(items)):
    if comp(items[j], items[min_index]):
    min_index = j
    items[i], items[min_index] = items[min_index], items[i]
    return items
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    def bubble_sort(items, comp=lambda x, y: x > y):
    """冒泡排序"""
    items = items[:]
    for i in range(len(items) - 1):
    swapped = False
    for j in range(len(items) - 1 - i):
    if comp(items[j], items[j + 1]):
    items[j], items[j + 1] = items[j + 1], items[j]
    swapped = True
    if not swapped:
    break
    return items
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    def bubble_sort(items, comp=lambda x, y: x > y):
    """搅拌排序(冒泡排序升级版)"""
    items = items[:]
    for i in range(len(items) - 1):
    swapped = False
    for j in range(len(items) - 1 - i):
    if comp(items[j], items[j + 1]):
    items[j], items[j + 1] = items[j + 1], items[j]
    swapped = True
    if swapped:
    swapped = False
    for j in range(len(items) - 2 - i, i, -1):
    if comp(items[j - 1], items[j]):
    items[j], items[j - 1] = items[j - 1], items[j]
    swapped = True
    if not swapped:
    break
    return items
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    def merge(items1, items2, comp=lambda x, y: x < y):
    """合并(将两个有序的列表合并成一个有序的列表)"""
    items = []
    index1, index2 = 0, 0
    while index1 < len(items1) and index2 < len(items2):
    if comp(items1[index1], items2[index2]):
    items.append(items1[index1])
    index1 += 1
    else:
    items.append(items2[index2])
    index2 += 1
    items += items1[index1:]
    items += items2[index2:]
    return items


    def merge_sort(items, comp=lambda x, y: x < y):
    return _merge_sort(list(items), comp)


    def _merge_sort(items, comp):
    """归并排序"""
    if len(items) < 2:
    return items
    mid = len(items) // 2
    left = _merge_sort(items[:mid], comp)
    right = _merge_sort(items[mid:], comp)
    return merge(left, right, comp)
    1
    2
    3
    4
    5
    6
    def seq_search(items, key):
    """顺序查找"""
    for index, item in enumerate(items):
    if item == key:
    return index
    return -1
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    def bin_search(items, key):
    """折半查找"""
    start, end = 0, len(items) - 1
    while start <= end:
    mid = (start + end) // 2
    if key > items[mid]:
    start = mid + 1
    elif key < items[mid]:
    end = mid - 1
    else:
    return mid
    return -1
  • 常用算法:

    • 穷举法 - 又称为暴力破解法,对所有的可能性进行验证,直到找到正确答案。
    • 贪婪法 - 在对问题求解时,总是做出在当前看来
    • 最好的选择,不追求最优解,快速找到满意解。
    • 分治法 - 把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题,直到可以直接求解的程度,最后将子问题的解进行合并得到原问题的解。
    • 回溯法 - 回溯法又称为试探法,按选优条件向前搜索,当搜索到某一步发现原先选择并不优或达不到目标时,就退回一步重新选择。
    • 动态规划 - 基本思想也是将待求解问题分解成若干个子问题,先求解并保存这些子问题的解,避免产生大量的重复运算。

    穷举法例子:百钱百鸡和五人分鱼。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    # 公鸡5元一只 母鸡3元一只 小鸡1元三只
    # 用100元买100只鸡 问公鸡/母鸡/小鸡各多少只
    for x in range(20):
    for y in range(33):
    z = 100 - x - y
    if 5 * x + 3 * y + z // 3 == 100 and z % 3 == 0:
    print(x, y, z)

    # A、B、C、D、E五人在某天夜里合伙捕鱼 最后疲惫不堪各自睡觉
    # 第二天A第一个醒来 他将鱼分为5份 扔掉多余的1条 拿走自己的一份
    # B第二个醒来 也将鱼分为5份 扔掉多余的1条 拿走自己的一份
    # 然后C、D、E依次醒来也按同样的方式分鱼 问他们至少捕了多少条鱼
    fish = 6
    while True:
    total = fish
    enough = True
    for _ in range(5):
    if (total - 1) % 5 == 0:
    total = (total - 1) // 5 * 4
    else:
    enough = False
    break
    if enough:
    print(fish)
    break
    fish += 5

    贪婪法例子:假设小偷有一个背包,最多能装20公斤赃物,他闯入一户人家,发现如下表所示的物品。很显然,他不能把所有物品都装进背包,所以必须确定拿走哪些物品,留下哪些物品。

    名称 价格(美元) 重量(kg)
    电脑 200 20
    收音机 20 4
    175 10
    花瓶 50 2
    10 1
    油画 90 9
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    """
    贪婪法:在对问题求解时,总是做出在当前看来是最好的选择,不追求最优解,快速找到满意解。
    输入:
    20 6
    电脑 200 20
    收音机 20 4
    钟 175 10
    花瓶 50 2
    书 10 1
    油画 90 9
    """
    class Thing(object):
    """物品"""

    def __init__(self, name, price, weight):
    self.name = name
    self.price = price
    self.weight = weight

    @property
    def value(self):
    """价格重量比"""
    return self.price / self.weight


    def input_thing():
    """输入物品信息"""
    name_str, price_str, weight_str = input().split()
    return name_str, int(price_str), int(weight_str)


    def main():
    """主函数"""
    max_weight, num_of_things = map(int, input().split())
    all_things = []
    for _ in range(num_of_things):
    all_things.append(Thing(*input_thing()))
    all_things.sort(key=lambda x: x.value, reverse=True)
    total_weight = 0
    total_price = 0
    for thing in all_things:
    if total_weight + thing.weight <= max_weight:
    print(f'小偷拿走了{thing.name}')
    total_weight += thing.weight
    total_price += thing.price
    print(f'总价值: {total_price}美元')


    if __name__ == '__main__':
    main()

    分治法例子:快速排序

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    """
    快速排序 - 选择枢轴对元素进行划分,左边都比枢轴小右边都比枢轴大
    """
    def quick_sort(items, comp=lambda x, y: x <= y):
    items = list(items)[:]
    _quick_sort(items, 0, len(items) - 1, comp)
    return items


    def _quick_sort(items, start, end, comp):
    if start < end:
    pos = _partition(items, start, end, comp)
    _quick_sort(items, start, pos - 1, comp)
    _quick_sort(items, pos + 1, end, comp)


    def _partition(items, start, end, comp):
    pivot = items[end]
    i = start - 1
    for j in range(start, end):
    if comp(items[j], pivot):
    i += 1
    items[i], items[j] = items[j], items[i]
    items[i + 1], items[end] = items[end], items[i + 1]
    return i + 1

    回溯法例子:骑士巡逻

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    """
    递归回溯法:叫称为试探法,按选优条件向前搜索,当搜索到某一步,发现原先选择并不优或达不到目标时,就退回一步重新选择,比较经典的问题包括骑士巡逻、八皇后和迷宫寻路等。
    """
    import sys
    import time

    SIZE = 5
    total = 0


    def print_board(board):
    for row in board:
    for col in row:
    print(str(col).center(4), end='')
    print()


    def patrol(board, row, col, step=1):
    if row >= 0 and row < SIZE and \
    col >= 0 and col < SIZE and \
    board[row][col] == 0:
    board[row][col] = step
    if step == SIZE * SIZE:
    global total
    total += 1
    print(f'第{total}种走法: ')
    print_board(board)
    patrol(board, row - 2, col - 1, step + 1)
    patrol(board, row - 1, col - 2, step + 1)
    patrol(board, row + 1, col - 2, step + 1)
    patrol(board, row + 2, col - 1, step + 1)
    patrol(board, row + 2, col + 1, step + 1)
    patrol(board, row + 1, col + 2, step + 1)
    patrol(board, row - 1, col + 2, step + 1)
    patrol(board, row - 2, col + 1, step + 1)
    board[row][col] = 0


    def main():
    board = [[0] * SIZE for _ in range(SIZE)]
    patrol(board, SIZE - 1, SIZE - 1)


    if __name__ == '__main__':
    main()

    动态规划例子:子列表元素之和的最大值。

    说明:子列表指的是列表中索引(下标)连续的元素构成的列表;列表中的元素是int类型,可能包含正整数、0、负整数;程序输入列表中的元素,输出子列表元素求和的最大值,例如:

    输入:1 -2 3 5 -3 2

    输出:8

    输入:0 -2 3 5 -1 2

    输出:9

    输入:-9 -2 -3 -5 -3

    输出:-2

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    def main():
    items = list(map(int, input().split()))
    overall = partial = items[0]
    for i in range(1, len(items)):
    partial = max(items[i], partial + items[i])
    overall = max(partial, overall)
    print(overall)


    if __name__ == '__main__':
    main()

    说明:这个题目最容易想到的解法是使用二重循环,但是代码的时间性能将会变得非常的糟糕。使用动态规划的思想,仅仅是多用了两个变量,就将原来O(N2)O(N^2)复杂度的问题变成了O(N)O(N)

1.3 函数的使用方式

  • 将函数视为“一等公民”

    • 函数可以赋值给变量
    • 函数可以作为函数的参数
    • 函数可以作为函数的返回值
  • 高阶函数的用法(filtermap以及它们的替代品)

    1
    2
    items1 = list(map(lambda x: x ** 2, filter(lambda x: x % 2, range(1, 10))))
    items2 = [x ** 2 for x in range(1, 10) if x % 2]
  • 位置参数、可变参数、关键字参数、命名关键字参数

  • 参数的元信息(代码可读性问题)

  • 匿名函数和内联函数的用法(lambda函数)

  • 闭包和作用域问题

    • Python搜索变量的LEGB顺序(Local >>> Embedded >>> Global >>> Built-in)

    • globalnonlocal关键字的作用

      global:声明或定义全局变量(要么直接使用现有的全局作用域的变量,要么定义一个变量放到全局作用域)。

      nonlocal:声明使用嵌套作用域的变量(嵌套作用域必须存在该变量,否则报错)。

  • 装饰器函数(使用装饰器和取消装饰器)

    例子:输出函数执行时间的装饰器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    def record_time(func):
    """自定义装饰函数的装饰器"""

    @wraps(func)
    def wrapper(*args, **kwargs):
    start = time()
    result = func(*args, **kwargs)
    print(f'{func.__name__}: {time() - start}秒')
    return result

    return wrapper

    如果装饰器不希望跟print函数耦合,可以编写可以参数化的装饰器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    from functools import wraps
    from time import time


    def record(output):
    """可以参数化的装饰器"""

    def decorate(func):

    @wraps(func)
    def wrapper(*args, **kwargs):
    start = time()
    result = func(*args, **kwargs)
    output(func.__name__, time() - start)
    return result

    return wrapper

    return decorate
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    from functools import wraps
    from time import time


    class Record():
    """通过定义类的方式定义装饰器"""

    def __init__(self, output):
    self.output = output

    def __call__(self, func):

    @wraps(func)
    def wrapper(*args, **kwargs):
    start = time()
    result = func(*args, **kwargs)
    self.output(func.__name__, time() - start)
    return result

    return wrapper

    说明:由于对带装饰功能的函数添加了@wraps装饰器,可以通过func.__wrapped__方式获得被装饰之前的函数或类来取消装饰器的作用。

    例子:用装饰器来实现单例模式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    from functools import wraps


    def singleton(cls):
    """装饰类的装饰器"""
    instances = {}

    @wraps(cls)
    def wrapper(*args, **kwargs):
    if cls not in instances:
    instances[cls] = cls(*args, **kwargs)
    return instances[cls]

    return wrapper


    @singleton
    class President:
    """总统(单例类)"""
    pass

    提示:上面的代码中用到了闭包(closure),不知道你是否已经意识到了。还没有一个小问题就是,上面的代码并没有实现线程安全的单例,如果要实现线程安全的单例应该怎么做呢?

    线程安全的单例装饰器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    from functools import wraps
    from threading import RLock


    def singleton(cls):
    """线程安全的单例装饰器"""
    instances = {}
    locker = RLock()

    @wraps(cls)
    def wrapper(*args, **kwargs):
    if cls not in instances:
    with locker:
    if cls not in instances:
    instances[cls] = cls(*args, **kwargs)
    return instances[cls]

    return wrapper

    提示:上面的代码用到了with上下文语法来进行锁操作,因为锁对象本身就是上下文管理器对象(支持__enter____exit__魔术方法)。在wrapper函数中,我们先做了一次不带锁的检查,然后再做带锁的检查,这样做比直接加锁检查性能要更好,如果对象已经创建就没有必须再去加锁而是直接返回该对象就可以了。

1.4 面向对象相关知识

  • 三大支柱:封装、继承、多态

    例子:工资结算系统。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    """
    月薪结算系统 - 部门经理每月15000 程序员每小时200 销售员1800底薪加销售额5%提成
    """
    from abc import ABCMeta, abstractmethod


    class Employee(metaclass=ABCMeta):
    """员工(抽象类)"""

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

    @abstractmethod
    def get_salary(self):
    """结算月薪(抽象方法)"""
    pass


    class Manager(Employee):
    """部门经理"""

    def get_salary(self):
    return 15000.0


    class Programmer(Employee):
    """程序员"""

    def __init__(self, name, working_hour=0):
    self.working_hour = working_hour
    super().__init__(name)

    def get_salary(self):
    return 200.0 * self.working_hour


    class Salesman(Employee):
    """销售员"""

    def __init__(self, name, sales=0.0):
    self.sales = sales
    super().__init__(name)

    def get_salary(self):
    return 1800.0 + self.sales * 0.05


    class EmployeeFactory:
    """创建员工的工厂(工厂模式 - 通过工厂实现对象使用者和对象之间的解耦合)"""

    @staticmethod
    def create(emp_type, *args, **kwargs):
    """创建员工"""
    all_emp_types = {'M': Manager, 'P': Programmer, 'S': Salesman}
    cls = all_emp_types[emp_type.upper()]
    return cls(*args, **kwargs) if cls else None


    def main():
    """主函数"""
    emps = [
    EmployeeFactory.create('M', '曹操'),
    EmployeeFactory.create('P', '荀彧', 120),
    EmployeeFactory.create('P', '郭嘉', 85),
    EmployeeFactory.create('S', '典韦', 123000),
    ]
    for emp in emps:
    print(f'{emp.name}: {emp.get_salary():.2f}元')


    if __name__ == '__main__':
    main()
  • 类与类之间的关系

    • is-a关系:继承
    • has-a关系:关联 / 聚合 / 合成
    • use-a关系:依赖

    例子:扑克游戏。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    """
    经验:符号常量总是优于字面常量,枚举类型是定义符号常量的最佳选择
    """
    from enum import Enum, unique

    import random


    @unique
    class Suite(Enum):
    """花色"""

    SPADE, HEART, CLUB, DIAMOND = range(4)

    def __lt__(self, other):
    return self.value < other.value


    class Card:
    """牌"""

    def __init__(self, suite, face):
    """初始化方法"""
    self.suite = suite
    self.face = face

    def show(self):
    """显示牌面"""
    suites = ['♠︎', '♥︎', '♣︎', '♦︎']
    faces = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
    return f'{suites[self.suite.value]}{faces[self.face]}'

    def __repr__(self):
    return self.show()


    class Poker:
    """扑克"""

    def __init__(self):
    self.index = 0
    self.cards = [Card(suite, face)
    for suite in Suite
    for face in range(1, 14)]

    def shuffle(self):
    """洗牌(随机乱序)"""
    random.shuffle(self.cards)
    self.index = 0

    def deal(self):
    """发牌"""
    card = self.cards[self.index]
    self.index += 1
    return card

    @property
    def has_more(self):
    return self.index < len(self.cards)


    class Player:
    """玩家"""

    def __init__(self, name):
    self.name = name
    self.cards = []

    def get_one(self, card):
    """摸一张牌"""
    self.cards.append(card)

    def sort(self, comp=lambda card: (card.suite, card.face)):
    """整理手上的牌"""
    self.cards.sort(key=comp)


    def main():
    """主函数"""
    poker = Poker()
    poker.shuffle()
    players = [Player('东邪'), Player('西毒'), Player('南帝'), Player('北丐')]
    while poker.has_more:
    for player in players:
    player.get_one(poker.deal())
    for player in players:
    player.sort()
    print(player.name, end=': ')
    print(player.cards)


    if __name__ == '__main__':
    main()

    说明:上面的代码中使用了Emoji字符来表示扑克牌的四种花色,在某些不支持Emoji字符的系统上可能无法显示。

  • 对象的复制(深复制/深拷贝/深度克隆和浅复制/浅拷贝/影子克隆)

  • 垃圾回收、循环引用和弱引用

    Python使用了自动化内存管理,这种管理机制以引用计数为基础,同时也引入了标记-清除分代收集两种机制为辅的策略。

    1
    2
    3
    4
    5
    6
    typedef struct _object {
    /* 引用计数 */
    int ob_refcnt;
    /* 对象指针 */
    struct _typeobject *ob_type;
    } PyObject;
    1
    2
    3
    4
    5
    6
    7
    8
    /* 增加引用计数的宏定义 */
    #define Py_INCREF(op) ((op)->ob_refcnt++)
    /* 减少引用计数的宏定义 */
    #define Py_DECREF(op) \ //减少计数
    if (--(op)->ob_refcnt != 0) \
    ; \
    else \
    __Py_Dealloc((PyObject *)(op))

    导致引用计数+1的情况:

    • 对象被创建,例如a = 23
    • 对象被引用,例如b = a
    • 对象被作为参数,传入到一个函数中,例如f(a)
    • 对象作为一个元素,存储在容器中,例如list1 = [a, a]

    导致引用计数-1的情况:

    • 对象的别名被显式销毁,例如del a
    • 对象的别名被赋予新的对象,例如a = 24
    • 一个对象离开它的作用域,例如f函数执行完毕时,f函数中的局部变量(全局变量不会)
    • 对象所在的容器被销毁,或从容器中删除对象

    引用计数可能会导致循环引用问题,而循环引用会导致内存泄露,如下面的代码所示。为了解决这个问题,Python中引入了“标记-清除”和“分代收集”。在创建一个对象的时候,对象被放在第一代中,如果在第一代的垃圾检查中对象存活了下来,该对象就会被放到第二代中,同理在第二代的垃圾检查中对象存活下来,该对象就会被放到第三代中。

    1
    2
    3
    4
    5
    6
    7
    # 循环引用会导致内存泄露 - Python除了引用技术还引入了标记清理和分代回收
    # 在Python 3.6以前如果重写__del__魔术方法会导致循环引用处理失效
    # 如果不想造成循环引用可以使用弱引用
    list1 = []
    list2 = []
    list1.append(list2)
    list2.append(list1)

    以下情况会导致垃圾回收:

    • 调用gc.collect()
    • gc模块的计数器达到阀值
    • 程序退出

    如果循环引用中两个对象都定义了__del__方法,gc模块不会销毁这些不可达对象,因为gc模块不知道应该先调用哪个对象的__del__方法,这个问题在Python 3.6中得到了解决。

    也可以通过weakref模块构造弱引用的方式来解决循环引用的问题。

  • 魔法属性和方法(请参考《Python魔法方法指南》)

    有几个小问题请大家思考:

    • 自定义的对象能不能使用运算符做运算?
    • 自定义的对象能不能放到set中?能去重吗?
    • 自定义的对象能不能作为dict的键?
    • 自定义的对象能不能使用上下文语法?
  • 混入(Mixin)

    例子:自定义字典限制只有在指定的key不存在时才能在字典中设置键值对。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    class SetOnceMappingMixin:
    """自定义混入类"""
    __slots__ = ()

    def __setitem__(self, key, value):
    if key in self:
    raise KeyError(str(key) + ' already set')
    return super().__setitem__(key, value)


    class SetOnceDict(SetOnceMappingMixin, dict):
    """自定义字典"""
    pass


    my_dict= SetOnceDict()
    try:
    my_dict['username'] = 'jackfrued'
    my_dict['username'] = 'hellokitty'
    except KeyError:
    pass
    print(my_dict)
  • 元编程和元类

    对象是通过类创建的,类是通过元类创建的,元类提供了创建类的元信息。所有的类都直接或间接的继承自object,所有的元类都直接或间接的继承自type

    例子:用元类实现单例模式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import threading


    class SingletonMeta(type):
    """自定义元类"""

    def __init__(cls, *args, **kwargs):
    cls.__instance = None
    cls.__lock = threading.RLock()
    super().__init__(*args, **kwargs)

    def __call__(cls, *args, **kwargs):
    if cls.__instance is None:
    with cls.__lock:
    if cls.__instance is None:
    cls.__instance = super().__call__(*args, **kwargs)
    return cls.__instance


    class President(metaclass=SingletonMeta):
    """总统(单例类)"""

    pass
  • 面向对象设计原则

    • 单一职责原则 (SRP)- 一个类只做该做的事情(类的设计要高内聚)
    • 开闭原则 (OCP)- 软件实体应该对扩展开发对修改关闭
    • 依赖倒转原则(DIP)- 面向抽象编程(在弱类型语言中已经被弱化)
    • 里氏替换原则(LSP) - 任何时候可以用子类对象替换掉父类对象
    • 接口隔离原则(ISP)- 接口要小而专不要大而全(Python中没有接口的概念)
    • 合成聚合复用原则(CARP) - 优先使用强关联关系而不是继承关系复用代码
    • 最少知识原则(迪米特法则,LoD)- 不要给没有必然联系的对象发消息

    说明:上面加粗的字母放在一起称为面向对象的SOLID原则。

  • GoF设计模式

    • 创建型模式:单例、工厂、建造者、原型
    • 结构型模式:适配器、门面(外观)、代理
    • 行为型模式:迭代器、观察者、状态、策略

    例子:可插拔的哈希算法(策略模式)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    class StreamHasher:
    """哈希摘要生成器"""

    def __init__(self, alg='md5', size=4096):
    self.size = size
    alg = alg.lower()
    self.hasher = getattr(__import__('hashlib'), alg.lower())()

    def __call__(self, stream):
    return self.to_digest(stream)

    def to_digest(self, stream):
    """生成十六进制形式的摘要"""
    for buf in iter(lambda: stream.read(self.size), b''):
    self.hasher.update(buf)
    return self.hasher.hexdigest()

    def main():
    """主函数"""
    hasher1 = StreamHasher()
    with open('Python-3.7.6.tgz', 'rb') as stream:
    print(hasher1.to_digest(stream))
    hasher2 = StreamHasher('sha1')
    with open('Python-3.7.6.tgz', 'rb') as stream:
    print(hasher2(stream))


    if __name__ == '__main__':
    main()

1.5 迭代器和生成器

  • 迭代器是实现了迭代器协议的对象。

    • Python中没有像protocolinterface这样的定义协议的关键字。
    • Python中用魔术方法表示协议。
    • __iter____next__魔术方法就是迭代器协议。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class Fib(object):
    """迭代器"""

    def __init__(self, num):
    self.num = num
    self.a, self.b = 0, 1
    self.idx = 0

    def __iter__(self):
    return self

    def __next__(self):
    if self.idx < self.num:
    self.a, self.b = self.b, self.a + self.b
    self.idx += 1
    return self.a
    raise StopIteration()
  • 生成器是语法简化版的迭代器。

    1
    2
    3
    4
    5
    6
    def fib(num):
    """生成器"""
    a, b = 0, 1
    for _ in range(num):
    a, b = b, a + b
    yield a
  • 生成器进化为协程。

    生成器对象可以使用send()方法发送数据,发送的数据会成为生成器函数中通过yield表达式获得的值。这样,生成器就可以作为协程使用,协程简单的说就是可以相互协作的子程序。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    def calc_avg():
    """流式计算平均值"""
    total, counter = 0, 0
    avg_value = None
    while True:
    value = yield avg_value
    total, counter = total + value, counter + 1
    avg_value = total / counter


    gen = calc_avg()
    next(gen)
    print(gen.send(10))
    print(gen.send(20))
    print(gen.send(30))

1.6 并发编程

Python中实现并发编程的三种方案:多线程、多进程和异步I/O。并发编程的好处在于可以提升程序的执行效率以及改善用户体验;坏处在于并发的程序不容易开发和调试,同时对其他程序来说它并不友好。

  • 多线程:Python中提供了Thread类并辅以LockConditionEventSemaphoreBarrier。Python中有GIL来防止多个线程同时执行本地字节码,这个锁对于CPython是必须的,因为CPython的内存管理并不是线程安全的,因为GIL的存在多线程并不能发挥CPU的多核特性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    """
    面试题:进程和线程的区别和联系?
    进程 - 操作系统分配内存的基本单位 - 一个进程可以包含一个或多个线程
    线程 - 操作系统分配CPU的基本单位
    并发编程(concurrent programming)
    1. 提升执行性能 - 让程序中没有因果关系的部分可以并发的执行
    2. 改善用户体验 - 让耗时间的操作不会造成程序的假死
    """
    import glob
    import os
    import threading

    from PIL import Image

    PREFIX = 'thumbnails'


    def generate_thumbnail(infile, size, format='PNG'):
    """生成指定图片文件的缩略图"""
    file, ext = os.path.splitext(infile)
    file = file[file.rfind('/') + 1:]
    outfile = f'{PREFIX}/{file}_{size[0]}_{size[1]}.{ext}'
    img = Image.open(infile)
    img.thumbnail(size, Image.ANTIALIAS)
    img.save(outfile, format)


    def main():
    """主函数"""
    if not os.path.exists(PREFIX):
    os.mkdir(PREFIX)
    for infile in glob.glob('images/*.png'):
    for size in (32, 64, 128):
    # 创建并启动线程
    threading.Thread(
    target=generate_thumbnail,
    args=(infile, (size, size))
    ).start()


    if __name__ == '__main__':
    main()

    多个线程竞争资源的情况。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    """
    多线程程序如果没有竞争资源处理起来通常也比较简单
    当多个线程竞争临界资源的时候如果缺乏必要的保护措施就会导致数据错乱
    说明:临界资源就是被多个线程竞争的资源
    """
    import time
    import threading

    from concurrent.futures import ThreadPoolExecutor


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

    def __init__(self):
    self.balance = 0.0
    self.lock = threading.Lock()

    def deposit(self, money):
    # 通过锁保护临界资源
    with self.lock:
    new_balance = self.balance + money
    time.sleep(0.001)
    self.balance = new_balance


    def main():
    """主函数"""
    account = Account()
    # 创建线程池
    pool = ThreadPoolExecutor(max_workers=10)
    futures = []
    for _ in range(100):
    future = pool.submit(account.deposit, 1)
    futures.append(future)
    # 关闭线程池
    pool.shutdown()
    for future in futures:
    future.result()
    print(account.balance)


    if __name__ == '__main__':
    main()

    修改上面的程序,启动5个线程向账户中存钱,5个线程从账户中取钱,取钱时如果余额不足就暂停线程进行等待。为了达到上述目标,需要对存钱和取钱的线程进行调度,在余额不足时取钱的线程暂停并释放锁,而存钱的线程将钱存入后要通知取钱的线程,使其从暂停状态被唤醒。可以使用threading模块的Condition来实现线程调度,该对象也是基于锁来创建的,代码如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    """
    多个线程竞争一个资源 - 保护临界资源 - 锁(Lock/RLock)
    多个线程竞争多个资源(线程数>资源数) - 信号量(Semaphore)
    多个线程的调度 - 暂停线程执行/唤醒等待中的线程 - Condition
    """
    from concurrent.futures import ThreadPoolExecutor
    from random import randint
    from time import sleep

    import threading


    class Account:
    """银行账户"""

    def __init__(self, balance=0):
    self.balance = balance
    lock = threading.RLock()
    self.condition = threading.Condition(lock)

    def withdraw(self, money):
    """取钱"""
    with self.condition:
    while money > self.balance:
    self.condition.wait()
    new_balance = self.balance - money
    sleep(0.001)
    self.balance = new_balance

    def deposit(self, money):
    """存钱"""
    with self.condition:
    new_balance = self.balance + money
    sleep(0.001)
    self.balance = new_balance
    self.condition.notify_all()


    def add_money(account):
    while True:
    money = randint(5, 10)
    account.deposit(money)
    print(threading.current_thread().name,
    ':', money, '====>', account.balance)
    sleep(0.5)


    def sub_money(account):
    while True:
    money = randint(10, 30)
    account.withdraw(money)
    print(threading.current_thread().name,
    ':', money, '<====', account.balance)
    sleep(1)


    def main():
    account = Account()
    with ThreadPoolExecutor(max_workers=15) as pool:
    for _ in range(5):
    pool.submit(add_money, account)
    for _ in range(10):
    pool.submit(sub_money, account)


    if __name__ == '__main__':
    main()
  • 多进程:多进程可以有效的解决GIL的问题,实现多进程主要的类是Process,其他辅助的类跟threading模块中的类似,进程间共享数据可以使用管道、套接字等,在multiprocessing模块中有一个Queue类,它基于管道和锁机制提供了多个进程共享的队列。下面是官方文档上关于多进程和进程池的一个示例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    """
    多进程和进程池的使用
    多线程因为GIL的存在不能够发挥CPU的多核特性
    对于计算密集型任务应该考虑使用多进程
    time python3 example22.py
    real 0m11.512s
    user 0m39.319s
    sys 0m0.169s
    使用多进程后实际执行时间为11.512秒,而用户时间39.319秒约为实际执行时间的4倍
    这就证明我们的程序通过多进程使用了CPU的多核特性,而且这台计算机配置了4核的CPU
    """
    import concurrent.futures
    import math

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


    def is_prime(n):
    """判断素数"""
    if n % 2 == 0:
    return False

    sqrt_n = int(math.floor(math.sqrt(n)))
    for i in range(3, sqrt_n + 1, 2):
    if n % i == 0:
    return False
    return True


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


    if __name__ == '__main__':
    main()

    重点多线程和多进程的比较

    以下情况需要使用多线程:

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

    以下情况需要使用多进程:

    1. 程序执行计算密集型任务(如:字节码操作、数据处理、科学计算)。
    2. 程序的输入可以并行的分成块,并且可以将运算结果合并。
    3. 程序在内存使用方面没有任何限制且不强依赖于I/O操作(如:读写文件、套接字等)。
  • 异步处理:从调度程序的任务队列中挑选任务,该调度程序以交叉的形式执行这些任务,我们并不能保证任务将以某种顺序去执行,因为执行顺序取决于队列中的一项任务是否愿意将 CPU 处理时间让位给另一项任务。异步任务通常通过多任务协作处理的方式来实现,由于执行时间和顺序的不确定,因此需要通过回调式编程或者future对象来获取任务执行的结果。Python 3 通过asyncio模块和awaitasync关键字(在 Python 3.7 中正式被列为关键字)来支持异步处理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    """
    异步I/O - async / await
    """
    import asyncio


    def num_generator(m, n):
    """指定范围的数字生成器"""
    yield from range(m, n + 1)


    async def prime_filter(m, n):
    """素数过滤器"""
    primes = []
    for i in num_generator(m, n):
    flag = True
    for j in range(2, int(i ** 0.5 + 1)):
    if i % j == 0:
    flag = False
    break
    if flag:
    print('Prime =>', i)
    primes.append(i)

    await asyncio.sleep(0.001)
    return tuple(primes)


    async def square_mapper(m, n):
    """平方映射器"""
    squares = []
    for i in num_generator(m, n):
    print('Square =>', i * i)
    squares.append(i * i)

    await asyncio.sleep(0.001)
    return squares


    def main():
    """主函数"""
    loop = asyncio.get_event_loop()
    future = asyncio.gather(prime_filter(2, 100), square_mapper(1, 100))
    future.add_done_callback(lambda x: print(x.result()))
    loop.run_until_complete(future)
    loop.close()


    if __name__ == '__main__':
    main()

    说明:上面的代码使用get_event_loop函数获得系统默认的事件循环,通过gather函数可以获得一个future对象,future对象的add_done_callback可以添加执行完成时的回调函数,loop对象的run_until_complete方法可以等待通过future对象获得协程执行结果。

    Python 中有一个名为aiohttp的三方库,它提供了异步的 HTTP 客户端和服务器,这个三方库可以跟asyncio模块一起工作,并提供了对Future对象的支持。Python 3.6中引入了asyncawait来定义异步执行的函数以及创建异步上下文,在 Python 3.7 中它们正式成为了关键字。下面的代码异步的从5个URL中获取页面并通过正则表达式的命名捕获组提取了网站的标题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    import asyncio
    import re

    import aiohttp

    PATTERN = re.compile(r'\<title\>(?P<title>.*)\<\/title\>')


    async def fetch_page(session, url):
    async with session.get(url, ssl=False) as resp:
    return await resp.text()


    async def show_title(url):
    async with aiohttp.ClientSession() as session:
    html = await fetch_page(session, url)
    print(PATTERN.search(html).group('title'))


    def main():
    urls = ('https://www.python.org/',
    'https://git-scm.com/',
    'https://www.jd.com/',
    'https://www.taobao.com/',
    'https://www.douban.com/')
    loop = asyncio.get_event_loop()
    cos = [show_title(url) for url in urls]
    loop.run_until_complete(asyncio.wait(cos))
    loop.close()


    if __name__ == '__main__':
    main()

    重点异步I/O与多进程的比较

    当程序不需要真正的并发性或并行性,而是更多的依赖于异步处理和回调时,asyncio就是一种很好的选择。如果程序中有大量的等待与休眠时,也应该考虑asyncio,它很适合编写没有实时数据处理需求的 Web 应用服务器。

    Python 还有很多用于处理并行任务的三方库,例如:joblibPyMP等。实际开发中,要提升系统的可扩展性和并发性通常有垂直扩展(增加单个节点的处理能力)和水平扩展(将单个节点变成多个节点)两种做法。可以通过消息队列来实现应用程序的解耦合,消息队列相当于是多线程同步队列的扩展版本,不同机器上的应用程序相当于就是线程,而共享的分布式消息队列就是原来程序中的Queue。消息队列(面向消息的中间件)的最流行和最标准化的实现是 AMQP(高级消息队列协议),AMQP 源于金融行业,提供了排队、路由、可靠传输、安全等功能,最著名的实现包括:Apache 的 ActiveMQ、RabbitMQ 等。

    要实现任务的异步化,可以使用名为Celery的三方库。Celery是 Python 编写的分布式任务队列,它使用分布式消息进行工作,可以基于 RabbitMQ 或 Redis 来作为后端的消息代理。