Python基础入门

Albert Cheung

第01课:初识Python

Python简介

Python是由荷兰人吉多·范罗苏姆(Guido von Rossum)发明的一种编程语言,是目前世界上最受欢迎和拥有最多用户群体的编程语言。

Python的历史

  1. 1989年圣诞节:Guido开始写Python语言的编译器。
  2. 1991年2月:第一个Python解释器诞生,它是用C语言实现的,可以调用C语言的库函数。
  3. 1994年1月:Python 1.0正式发布。
  4. 2000年10月:Python 2.0发布,Python的整个开发过程更加透明,生态圈开始慢慢形成。
  5. 2008年12月:Python 3.0发布,引入了诸多现代编程语言的新特性,但并不完全兼容之前的Python代码。
  6. 2020年1月:在Python 2和Python 3共存了11年之后,官方停止了对Python 2的更新和维护,希望用户尽快过渡到Python 3。

说明:大多数软件的版本号一般分为三段,形如A.B.C,其中A表示大版本号,当软件整体重写升级或出现不向后兼容的改变时,才会增加A;B表示功能更新,出现新功能时增加B;C表示小的改动(例如:修复了某个Bug),只要有修改就增加C。

Python的优缺点

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

  1. 简单明确,跟其他很多语言相比,Python更容易上手。
  2. 能用更少的代码做更多的事情,提升开发效率。
  3. 开放源代码,拥有强大的社区和生态圈。
  4. 能够做的事情非常多,有极强的适应性。
  5. 能够在Windows、macOS、Linux等各种系统上运行。

Python最主要的缺点是执行效率低,但是当我们更看重产品的开发效率而不是执行效率的时候,Python就是很好的选择。

Python的应用领域

目前Python在Web服务器应用开发、云基础设施开发、网络数据采集(爬虫)、数据分析、量化交易、机器学习深度学习、自动化测试、自动化运维等领域都有用武之地。

安装Python环境

想要开始你的Python编程之旅,首先得在计算机上安装Python环境,简单的说就是得安装运行Python程序的工具,通常也称之为Python解释器。我们强烈建议大家安装Python 3的环境,很明显它是目前更好的选择。

Windows环境

可以在Python官方网站 找到下载链接并下载Python 3的安装程序。

对于Windows操作系统,可以下载“executable installer”。需要注意的是,如果在Windows 7环境下安装Python 3,需要先安装Service Pack 1补丁包,大家可以在Windows的“运行”中输入winver命令,从弹出的窗口上可以看到你的系统是否安装了该补丁包。如果没有该补丁包,一定要先通过“Windows Update”或者类似“CCleaner”这样的工具自动安装该补丁包,安装完成后通常需要重启你的Windows系统,然后再开始安装Python环境。

双击运行刚才下载的安装程序,会打开Python环境的安装向导。在执行安装向导的时候,记得勾选“Add Python 3.x to PATH”选项,这个选项会帮助我们将Python的解释器添加到PATH环境变量中(不理解没关系,照做就行),具体的步骤如下图所示。

安装完成后可以打开Windows的“命令行提示符”工具(或“PowerShell”)并输入python --versionpython -V来检查安装是否成功,命令行提示符可以在“运行”中输入cmd来打开或者在“开始菜单”的附件中找到它。如果看了Python解释器对应的版本号(如:Python 3.7.8),说明你的安装已经成功了,如下图所示。

说明:如果安装过程显示安装失败或执行上面的命令报错,很有可能是因为你的Windows系统缺失了一些动态链接库文件或C构建工具导致的问题。可以在微软官网 下载Visual C++ Redistributable for Visual Studio 2015文件进行修复,64位的系统需要下载有x64标记的安装文件。也可以通过下面的百度云盘地址获取修复工具,运行修复工具,按照如下图所示的方式进行修复,链接: https://pan.baidu.com/s/1iNDnU5UVdDX5sKFqsiDg5Q 提取码: cjs3。

QQ20210711-0

除此之外,你还应该检查一下Python的包管理工具是否已经可用,对应的命令是pip --version

macOS环境

macOS自带了Python 2,但是我们需要安装和使用的是Python 3。可以通过Python官方网站提供的下载链接 找到适合macOS的“macOS installer”来安装Python 3,安装过程基本不需要做任何勾选,直接点击“下一步”即可。安装完成后,可以在macOS的“终端”工具中输入python3命令来调用Python 3解释器,因为如果直接输入python,将会调用Python 2的解释器。

总结

Python语言可以做很多的事情,也值得我们去学习。要使用Python语言,首先需要在自己的计算机上安装Python环境,也就是运行Python程序的Python解释器。

第02课:第一个Python程序

在上一课中,我们已经了解了Python语言并安装了运行Python程序所需的环境,相信大家已经迫不及待的想开始自己的Python编程之旅了。首先我们来看看应该在哪里编写我们的Python程序。

编写代码的工具

交互式环境

我们打开Windows的“命令提示符”工具,输入命令python然后回车就可以进入到Python的交互式环境中。所谓交互式环境,就是我们输入一行代码回车,代码马上会被执行,如果代码有产出结果,那么结果会被显示在窗口中。例如:

1
2
3
4
5
6
Python 3.7.6
Type "help", "copyright", "credits" or "license" for more information.
>>> 2 * 3
6
>>> 2 + 3
5

提示:使用macOS系统的用户需要打开“终端”工具,输入python3进入交互式环境。

如果希望退出交互式环境,可以在交互式环境中输入quit(),如下所示。

1
>>> quit()

更好的交互式环境 - IPython

Python默认的交互式环境用户体验并不怎么好,我们可以用IPython来替换掉它,因为IPython提供了更为强大的编辑和交互功能。我们可以使用Python的包管理工具pip来安装IPython,如下所示。

1
pip install ipython

温馨提示:在使用上面的命令安装IPython之前,可以先通过pip config set global.index-url https://pypi.doubanio.com/simple命令将pip的下载源修改为国内的豆瓣网,否则下载安装的过程可能会非常的缓慢。

可以使用下面的命令启动IPython,进入交互式环境。

1
ipython

文本编辑器 - Visual Studio Code

Visual Studio Code(通常简称为VSCode)是一个由微软开发能够在Windows、 Linux和macOS等操作系统上运行的代码编辑神器。它支持语法高亮、自动补全、多点编辑、运行调试等一系列便捷功能,而且能够支持多种编程语言。如果大家要选择一款高级文本编辑工具,强烈建议使用VSCode。关于VSCode的下载 、安装和使用,推荐大家阅读一篇名为《VScode安装使用》 的文章。

集成开发环境 - PyCharm

如果用Python开发商业项目,我们推荐大家使用更为专业的工具PyCharm。PyCharm是由捷克一家名为JetBrains 的公司开发的用于Python项目开发的集成开发环境(IDE)。所谓集成开发环境,通常是指工具中提供了编写代码、运行代码、调试代码、分析代码、版本控制等一系列功能,因此特别适合商业项目的开发。在JetBrains的官方网站上提供了PyCharm的下载链接 ,其中社区版(Community)是免费的但功能相对弱小(其实已经足够强大了),专业版(Professional)功能非常强大,但需要按年或月付费使用,新用户可以试用30天时间。

运行PyCharm,可以看到如下图所示的欢迎界面,可以选择“New Project”来创建一个新的项目。

创建项目的时候需要指定项目的路径并创建运行项目的”虚拟环境“,如下图所示。

项目创建好以后会出现如下图所示的画面,我们可以通过在项目文件夹上点击鼠标右键,选择“New”菜单下的“Python File”来创建一个Python文件,创建好的Python文件会自动打开进入可编辑的状态。

image-20210720133621079

写好代码后,可以在编辑代码的窗口点击鼠标右键,选择“Run”菜单项来运行代码,下面的“Run”窗口会显示代码的执行结果,如下图所示。

image-20210720134039848

PyCharm常用的快捷键如下表所示,我们也可以在“File”菜单的“Settings”中定制PyCharm的快捷键(macOS系统是在“PyCharm”菜单的“Preferences”中对快捷键进行设置)。

表1. PyCharm常用快捷键。

快捷键 作用
ctrl + j 显示可用的代码模板
ctrl + b 查看函数、类、方法的定义
ctrl + alt + l 格式化代码
alt + enter 万能代码修复快捷键
ctrl + / 注释/反注释代码
shift + shift 万能搜索快捷键
ctrl + d / ctrl + y 复制/删除一行代码
ctrl + shift + - / ctrl + shift + + 折叠/展开所有代码
F2 快速定位到错误代码
ctrl + alt + F7 查看哪些地方用到了指定的函数、类、方法

说明:使用macOS系统,可以将上面的ctrl键换成command键,在macOS系统上,可以使用ctrl + space组合键来获得万能提示,在Windows系统上不能使用该快捷键,因为它跟Windows默认的切换输入法的快捷键是冲突的,需要重新设置。

hello, world

按照行业惯例,我们学习任何一门编程语言写的第一个程序都是输出hello, world,因为这段代码是伟大的丹尼斯·里奇(C语言之父,和肯·汤普森一起开发了Unix操作系统)和布莱恩·柯尼汉(awk语言的发明者)在他们的不朽著作The C Programming Language中写的第一段代码。

1
print('hello, world')

运行程序

如果不使用PyCharm这样的集成开发环境,我们可以将上面的代码命名为hello.py,对于Windows操作系统,可以在你保存代码的目录下先按住键盘上的shift键再点击鼠标右键,这时候鼠标右键菜单中会出现“命令提示符”选项,点击该选项就可以打开“命令提示符”工具,我们输入下面的命令。

1
python hello.py

提醒:我们也可以在任意位置打开“命令提示符”,然后将需要执行的Python代码通过拖拽的方式拖入到“命令提示符”中,这样相当于指定了文件的绝对路径来运行该文件中的Python代码。再次提醒,macOS系统要通过python3命令来运行该程序。

你可以尝试将上面程序单引号中的hello, world换成其他内容;你也可以尝试着多写几个这样的语句,看看会运行出怎样的结果。需要提醒大家,上面代码中的print('hello, world')就是一条完整的语句,我们用Python写程序,最好每一行代码中只有一条语句。虽然使用;分隔符可以将多个语句写在一行代码中,但是最好不要这样做,因为代码会变得非常难看。

注释你的代码

注释是编程语言的一个重要组成部分,用于在源代码中解释代码的作用从而增强程序的可读性。当然,我们也可以将源代码中暂时不需要运行的代码段通过注释来去掉,这样当你需要重新使用这些代码的时候,去掉注释符号就可以了。简单的说,注释会让代码更容易看懂但不会影响程序的执行结果

Python中有两种形式的注释:

  1. 单行注释:以#和空格开头,可以注释掉从#开始后面一整行的内容。
  2. 多行注释:三个引号开头,三个引号结尾,通常用于添加多行说明性内容。
1
2
3
4
5
"""
第一个Python程序 - hello, world
"""
# print('hello, world')
print("你好,世界!")

总结

到这里,我们已经把第一个Python程序运行起来了,是不是很有成就感?只要你坚持学习下去,再过一段时间,我们就可以用Python制作小游戏、编写爬虫程序、完成办公自动化操作等。写程序本身就是一件很酷的事情,在未来编程就像英语一样,对很多人来说或都是必须要掌握的技能

第03课:Python语言元素之变量

作为一个程序员,可能经常会被外行问到两个问题,其一是“什么是(计算机)程序”,其二是“写(计算机)程序能做什么”,这里我先对这两个问题做一个回答。程序是指令的集合写程序就是用指令控制计算机做我们想让它做的事情。那么,为什么要用Python语言来写程序呢?因为Python语言简单优雅,相比C、C++、Java这样的编程语言,Python对初学者更加友好,当然这并不是说Python不像其他语言那样强大,Python几乎是无所不能的,在第一节课的时候,我们就说到了Python可以用于服务器程序开发、云平台开发、数据分析、机器学习等各个领域。当然,Python语言还可以用来粘合其他语言开发的系统,所以也经常被戏称为“胶水语言”。

一些计算机常识

在开始系统的学习编程之前,我们先来科普一些计算机的基础知识。计算机的硬件系统通常由五大部件构成,包括:运算器控制器存储器输入设备输出设备。其中,运算器和控制器放在一起就是我们常说的中央处理器,它的功能是执行各种运算和控制指令。刚才我们提到过程序是指令的集合,写程序就是将一系列的指令按照某种方式组织到一起,然后通过这些指令去控制计算机做我们想让它做的事情。目前,我们使用的计算机基本都是“冯·诺依曼体系结构”的计算机,这种计算机有两个关键点:一是要将存储设备与中央处理器分开;二是将数据以二进制方式编码

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

变量和类型

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

  • 整型(int):Python中可以处理任意大小的整数,而且支持二进制(如0b100,换算成十进制是4)、八进制(如0o100,换算成十进制是64)、十进制(100)和十六进制(0x100,换算成十进制是256)的表示法。
  • 浮点型(float):浮点数也就是小数,之所以称为浮点数,是因为按照科学记数法表示时,一个浮点数的小数点位置是可变的,浮点数除了数学写法(如123.456)之外还支持科学计数法(如1.23456e2)。
  • 字符串型(str):字符串是以单引号或双引号括起来的任意文本,比如'hello'"hello"
  • 布尔型(bool):布尔值只有TrueFalse两种值,要么是True,要么是False

变量命名

对于每个变量我们需要给它取一个名字,就如同我们每个人都有自己的名字一样。在Python中,变量命名需要遵循以下这些规则,这些规则又分为必须遵守的硬性规则和建议遵守的非硬性规则。

  • 硬性规则:
    • 规则1:变量名由字母、数字和下划线构成,数字不能开头。需要说明的是,这里说的字母指的是Unicode字符,Unicode称为万国码,囊括了世界上大部分的文字系统,这也就意味着中文、日文、希腊字母等都可以作为变量名中的字符,但是像!@#这些特殊字符是不能出现在变量名中的,而且我们强烈建议大家尽可能使用英文字母
    • 规则2:大小写敏感,简单的说就是大写的A和小写的a是两个不同的变量。
    • 规则3:变量名不要跟Python语言的关键字(有特殊含义的单词,后面会讲到)和保留字(如已有的函数、模块等的名字)发生重名的冲突
  • 非硬性规则:
    • 规则1:变量名通常使用小写英文字母,多个单词用下划线进行连接。
    • 规则2:受保护的变量用单个下划线开头。
    • 规则3:私有的变量用两个下划线开头。

规则2和规则3大家暂时不用理解,后面自然会明白的。当然,作为一个专业的程序员,给变量(事实上应该是所有的标识符)命名时做到见名知意也非常重要。

变量的使用

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

1
2
3
4
5
6
7
8
9
"""
使用变量保存数据并进行加减乘除运算
"""
a = 45 # 变量a保存了45
b = 12 # 变量b保存了12
print(a + b) # 57
print(a - b) # 33
print(a * b) # 540
print(a / b) # 3.75

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

1
2
3
4
5
6
7
8
9
10
11
"""
使用type()检查变量的类型
"""
a = 100
b = 12.345
c = 'hello, world'
d = True
print(type(a)) # <class 'int'>
print(type(b)) # <class 'float'>
print(type(c)) # <class 'str'>
print(type(d)) # <class 'bool'>

不同类型的变量可以相互转换,这一点可以通过Python的内置函数来实现。

  • 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
"""
Python中的类型转换操作
"""
a = 100
b = 12.345
c = 'hello, world'
d = True
# 整数转成浮点数
print(float(a)) # 100.0
# 浮点型转成字符串 (输出字符串时不会看到引号哟)
print(str(b)) # 12.345
# 字符串转成布尔型 (有内容的字符串都会变成True)
print(bool(c)) # True
# 布尔型转成整数 (True会转成1,False会转成0)
print(int(d)) # 1
# 将整数变成对应的字符 (97刚好对应字符表中的字母a)
print(chr(97)) # a
# 将字符转成整数 (Python中字符和字符串表示法相同)
print(ord('a')) # 97

总结

在Python程序中,我们可以使用变量来保存数据变量有不同的类型变量可以做运算(下一课会有详细的讲解),也可以通过内置函数来转换变量类型

第04课:Python语言元素之运算符

Python语言支持很多种运算符,我们先用一个表格为大家列出这些运算符,然后选择一些马上就会用到的运算符为大家进行讲解。

运算符 描述
[] [:] 下标,切片
** 指数
~ + - 按位取反, 正负号
* / % // 乘,除,模,整除
+ - 加,减
>> << 右移,左移
& 按位与
^ | 按位异或,按位或
<= < > >= 小于等于,小于,大于,大于等于
== != 等于,不等于
is is not 身份运算符
in not in 成员运算符
not or and 逻辑运算符
= += -= *= /= %= //= **= &= |= ^= >>= <<= (复合)赋值运算符

说明: 上面这个表格实际上是按照运算符的优先级从上到下列出了各种运算符。所谓优先级就是在一个运算的表达式中,如果出现了多个运算符,应该先执行哪个运算再执行哪个运算的顺序。在实际开发中,如果搞不清楚运算符的优先级,可以使用圆括号来确保运算的执行顺序。

算术运算符

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

1
2
3
4
5
6
7
8
9
10
"""
算术运算符
"""
print(321 + 123) # 加法运算
print(321 - 123) # 减法运算
print(321 * 123) # 乘法运算
print(321 / 123) # 除法运算
print(321 % 123) # 求模运算
print(321 // 123) # 整除运算
print(321 ** 123) # 求幂运算

赋值运算符

赋值运算符应该是最为常见的运算符,它的作用是将右边的值赋给左边的变量。下面的例子演示了赋值运算符和复合赋值运算符的使用。

1
2
3
4
5
6
7
8
"""
赋值运算符和复合赋值运算符
"""
a = 10
b = 3
a += b # 相当于:a = a + b
a *= a + 2 # 相当于:a = a * (a + 2)
print(a) # 算一下这里会输出什么

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

比较运算符有的地方也称为关系运算符,包括==!=<><=>=,我相信没有什么好解释的,大家一看就能懂,需要提醒的是比较相等用的是==,请注意这里是两个等号,因为=是赋值运算符,我们在上面刚刚讲到过,==才是比较相等的运算符;比较不相等用的是!=,这不同于数学上的不等号,Python 2中曾经使用过<>来表示不等关系,大家知道就可以了。比较运算符会产生布尔值,要么是True要么是False

逻辑运算符有三个,分别是andornotand字面意思是“而且”,所以and运算符会连接两个布尔值,如果两个布尔值都是True,那么运算的结果就是True;左右两边的布尔值有一个是False,最终的运算结果就是False。相信大家已经想到了,如果and左边的布尔值是False,不管右边的布尔值是什么,最终的结果都是False,所以在做运算的时候右边的值会被跳过(短路处理),这也就意味着在and运算符左边为False的情况下,右边的表达式根本不会执行。or字面意思是“或者”,所以or运算符也会连接两个布尔值,如果两个布尔值有任意一个是True,那么最终的结果就是True。当然,or运算符也是有短路功能的,在它左边的布尔值为True的情况下,右边的表达式根本不会执行。not运算符的后面会跟上一个布尔值,它的作用是得到与该布尔值相反的值,也就是说,not后面的布尔值如果是True,运算结果就是False;而not后面的布尔值如果是False,运算结果就是True

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
比较运算符和逻辑运算符的使用
"""
flag0 = 1 == 1
flag1 = 3 > 2
flag2 = 2 < 1
flag3 = flag1 and flag2
flag4 = flag1 or flag2
flag5 = not (1 != 2)
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

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

运算符的例子

例子1:华氏温度转换为摄氏温度。

提示:华氏温度到摄氏温度的转换公式为:C = (F - 32) / 1.8

1
2
3
4
5
6
"""
将华氏温度转换为摄氏温度
"""
f = float(input('请输入华氏温度: '))
c = (f - 32) / 1.8
print('%.1f华氏度 = %.1f摄氏度' % (f, c))

说明:在使用print函数输出时,也可以对字符串内容进行格式化处理,上面print函数中的字符串%.1f是一个占位符,稍后会由一个float类型的变量值替换掉它。同理,如果字符串中有%d,后面可以用一个int类型的变量值替换掉它,而%s会被字符串的值替换掉。除了这种格式化字符串的方式外,还可以用下面的方式来格式化字符串,其中{f:.1f}{c:.1f}可以先看成是{f}{c},表示输出时会用变量f和变量c的值替换掉这两个占位符,后面的:.1f表示这是一个浮点数,小数点后保留1位有效数字。

1
print(f'{f:.1f}华氏度 = {c:.1f}摄氏度')

例子2:输入圆的半径计算计算周长和面积。

1
2
3
4
5
6
7
8
"""
输入半径计算圆的周长和面积
"""
radius = float(input('请输入圆的半径: '))
perimeter = 2 * 3.1416 * radius
area = 3.1416 * radius * radius
print('周长: %.2f' % perimeter)
print('面积: %.2f' % area)

例子3:输入年份判断是不是闰年。

1
2
3
4
5
6
"""
输入年份 如果是闰年输出True 否则输出False
"""
year = int(input('请输入年份: '))
is_leap = year % 4 == 0 and year % 100 != 0 or year % 400 == 0
print(is_leap)

说明:比较运算符会产生布尔值,而逻辑运算符andor会对这些布尔值进行组合,最终也是得到一个布尔值,闰年输出True,平年输出False

总结

通过上面的例子相信大家感受到了,学会使用运算符以及由运算符构成的表达式,就可以帮助我们解决很多实际的问题,运算符和表达式对于任何一门编程语言都是非常重要的

第05课:分支结构

应用场景

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

if语句的使用

在Python中,要构造分支结构可以使用ifelifelse关键字。所谓关键字就是有特殊含义的单词,像ifelse就是专门用于构造分支结构的关键字,很显然你不能够使用它作为变量名。下面的例子中演示了如何构造一个分支结构。

1
2
3
4
5
6
7
8
9
10
"""
用户身份验证
"""
username = input('请输入用户名: ')
password = input('请输入口令: ')
# 用户名是admin且密码是123456则身份验证成功否则身份验证失败
if username == 'admin' and password == '123456':
print('身份验证成功!')
else:
print('身份验证失败!')

需要说明的是,不同于C++、Java等编程语言,Python中没有用花括号来构造代码块而是使用了缩进的方式来表示代码的层次结构,如果if条件成立的情况下需要执行多条语句,只要保持多条语句具有相同的缩进就可以了。换句话说连续的代码如果又保持了相同的缩进那么它们属于同一个代码块,相当于是一个执行的整体。缩进可以使用任意数量的空格,但通常使用4个空格,强烈建议大家不要使用制表键来缩进代码,如果你已经习惯了这么做,可以设置代码编辑工具将1个制表键自动变成4个空格,很多的代码编辑工具都支持这项功能。

提示ifelse 的最后面有一个:,它是用英文输入法输入的冒号;程序中输入的'"=()等特殊字符,都是在英文输入法状态下输入的。有很多初学者经常不注意这一点,结果运行代码的时候就会遇到很多莫名其妙的错误提示。强烈建议大家在写代码的时候都打开英文输入法(注意是英文输入法而不是中文输入法的英文输入模式),这样可以避免很多不必要的麻烦。

如果要构造出更多的分支,可以使用if...elif...else...结构或者嵌套的if...else...结构,下面的代码演示了如何利用多分支结构实现分段函数求值。

1
2
3
4
5
6
7
8
9
10
11
"""
分段函数求值
"""
x = float(input('x = '))
if x > 1:
y = 3 * x - 5
elif x >= -1:
y = x + 2
else:
y = 5 * x + 3
print(f'f({x}) = {y}')

当然根据实际开发的需要,分支结构是可以嵌套的,例如判断是否通关以后还要根据你获得的宝物或者道具的数量对你的表现给出等级(比如点亮两颗或三颗星星),那么我们就需要在if的内部构造出一个新的分支结构,同理elifelse中也可以再构造新的分支,我们称之为嵌套的分支结构,也就是说上面的代码也可以写成下面的样子。

1
2
3
4
5
6
7
8
9
10
11
12
"""
分段函数求值
"""
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'f({x}) = {y}')

说明: 大家可以自己感受和评判一下这两种写法到底是哪一种更好。在Python之禅中有这么一句话:“Flat is better than nested”,之所以提倡代码“扁平化”,是因为代码嵌套的层次如果很多,会严重的影响代码的可读性,所以使用更为扁平化的结构在很多场景下都是较好的选择。

一些例子

例子1:英制单位英寸与公制单位厘米互换。

1
2
3
4
5
6
7
8
9
10
11
"""
英制单位英寸和公制单位厘米互换
"""
value = float(input('请输入长度: '))
unit = input('请输入单位: ')
if unit == 'in' or unit == '英寸':
print('%f英寸 = %f厘米' % (value, value * 2.54))
elif unit == 'cm' or unit == '厘米':
print('%f厘米 = %f英寸' % (value, value / 2.54))
else:
print('请输入有效的单位')

例子2:百分制成绩转换为等级制成绩。

要求:如果输入的成绩在90分以上(含90分)输出A;80分-90分(不含90分)输出B;70分-80分(不含80分)输出C;60分-70分(不含70分)输出D;60分以下输出E。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
百分制成绩转换为等级制成绩
"""
score = float(input('请输入成绩: '))
if score >= 90:
grade = 'A'
elif score >= 80:
grade = 'B'
elif score >= 70:
grade = 'C'
elif score >= 60:
grade = 'D'
else:
grade = 'E'
print('对应的等级是:', grade)

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
判断输入的边长能否构成三角形,如果能则计算出三角形的周长和面积
"""
a = float(input('a = '))
b = float(input('b = '))
c = float(input('c = '))
if a + b > c and a + c > b and b + c > a:
peri = a + b + c
print(f'周长: {peri}')
half = peri / 2
area = (half * (half - a) * (half - b) * (half - c)) ** 0.5
print(f'面积: {area}')
else:
print('不能构成三角形')

说明: 上面通过边长计算三角形面积的公式叫做海伦公式

简单的总结

学会了Python中的分支结构和循环结构,我们就可以用Python程序来解决很多实际的问题了。这一节课相信已经帮助大家记住了ifelifelse这几个关键字以及如何使用它们来构造分支结构,下一节课我们为大家介绍循环结构,学完这两次课你一定会发现,你能写出很多很多非常有意思的代码。继续加油!

第06课:循环结构

应用场景

我们在写程序的时候,一定会遇到需要重复执行某条指令或某些指令的场景。例如用程序控制机器人踢足球,如果机器人持球而且还没有进入射门范围,那么我们就要一直发出让机器人向球门方向移动的指令。在这个场景中,让机器人向球门方向移动就是一个需要重复的动作,当然这里还会用到上一课讲的分支结构来判断机器人是否持球以及是否进入射门范围。再举一个简单的例子,如果要实现每隔1秒中在屏幕上打印一次“hello, world”并持续打印一个小时,我们肯定不能够直接把print('hello, world')这句代码写3600遍,这里我们需要构造循环结构。

所谓循环结构,就是程序中控制某条或某些指令重复执行的结构。在Python中构造循环结构有两种做法,一种是for-in循环,另一种是while循环。

for-in循环

如果明确的知道循环执行的次数,我们推荐使用for-in循环,例如输出100行的”hello, world“。 被for-in循环控制的语句块也是通过缩进的方式来构造的,这一点跟分支结构完全相同,大家看看下面的代码就明白了。

1
2
3
4
5
6
7
"""
用for循环实现1~100求和
"""
total = 0
for x in range(1, 101):
total += x
print(total)

需要说明的是上面代码中的range(1, 101)可以用来构造一个从1100的范围,当我们把这样一个范围放到for-in循环中,就可以通过前面的循环变量x依次取出从1100的整数。当然,range的用法非常灵活,下面给出了一个例子:

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

知道了这一点,我们可以用下面的代码来实现1~100之间的偶数求和。

1
2
3
4
5
6
7
"""
用for循环实现1~100之间的偶数求和
"""
total = 0
for x in range(2, 101, 2):
total += x
print(total)

while循环

如果要构造不知道具体循环次数的循环结构,我们推荐使用while循环。while循环通过一个能够产生bool值的表达式来控制循环,当表达式的值为True时则继续循环,当表达式的值为False时则结束循环。

下面我们通过一个“猜数字”的小游戏来看看如何使用while循环。猜数字游戏的规则是:计算机出一个1100之间的随机数,玩家输入自己猜的数字,计算机给出对应的提示信息(大一点、小一点或猜对了),如果玩家猜中了数字,计算机提示用户一共猜了多少次,游戏结束,否则游戏继续。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"""
猜数字游戏
"""
import random

# 产生一个1-100范围的随机数
answer = random.randint(1, 100)
counter = 0
while True:
counter += 1
number = int(input('请输入: '))
if number < answer:
print('大一点')
elif number > answer:
print('小一点')
else:
print('恭喜你猜对了!')
break
# 当退出while循环的时候显示用户一共猜了多少次
print(f'你总共猜了{counter}次')

break和continue

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

嵌套的循环结构

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

1
2
3
4
5
6
7
"""
打印乘法口诀表
"""
for i in range(1, 10):
for j in range(1, i + 1):
print(f'{i}*{j}={i * j}', end='\t')
print()

很显然,在上面的代码中,外层循环用来控制一共会产生9行的输出,而内层循环用来控制每一行会输出多少列。内层循环中的输出就是九九表一行中的所有列,所以在内层循环完成时,有一个print()来实现换行输出的效果。

循环的例子

例子1:输入一个正整数判断它是不是素数。

提示:素数指的是只能被1和自身整除的大于1的整数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""
输入一个正整数判断它是不是素数
"""
num = int(input('请输入一个正整数: '))
end = int(num ** 0.5)
is_prime = True
for x in range(2, end + 1):
if num % x == 0:
is_prime = False
break
if is_prime and num != 1:
print(f'{num}是素数')
else:
print(f'{num}不是素数')

例子2:输入两个正整数,计算它们的最大公约数和最小公倍数。

提示:两个数的最大公约数是两个数的公共因子中最大的那个数;两个数的最小公倍数则是能够同时被两个数整除的最小的那个数。

1
2
3
4
5
6
7
8
9
10
11
"""
输入两个正整数计算它们的最大公约数和最小公倍数
"""

x = int(input('x = '))
y = int(input('y = '))
for factor in range(x, 0, -1):
if x % factor == 0 and y % factor == 0:
print(f'{x}{y}的最大公约数是{factor}')
print(f'{x}{y}的最小公倍数是{x * y // factor}')
break

简单的总结

学会了Python中的分支结构和循环结构,我们就可以解决很多实际的问题了。通过这节课的学习,大家应该已经知道了可以用forwhile关键字来构造循环结构。如果知道循环的次数,我们通常使用for循环;如果循环次数不能确定,可以用while循环。在循环中还可以使用break来提前结束循环

第07课:分支和循环结构的应用

通过上两节课的学习,大家对Python中的分支和循环结构已经有了感性的认识。分支和循环结构的重要性不言而喻,它是构造程序逻辑的基础,对于初学者来说也是比较困难的部分。大部分初学者在学习了分支和循环结构后都能理解它们的用途和用法,但是遇到实际问题的时候又无法下手;看懂别人的代码很容易,但是要自己写出同样的代码却又很难。如果你也有同样的问题和困惑,千万不要沮丧,这只是因为你才刚刚开始编程之旅,你的练习量还没有达到让你可以随心所欲的写出代码的程度,只要加强编程练习,这个问题迟早都会解决的。下面我们就为大家讲解一些经典的案例。

经典小案例

例子1:寻找水仙花数。

说明:水仙花数也被称为超完全数字不变数、自恋数、自幂数、阿姆斯特朗数,它是一个3位数,该数字每个位上数字的立方之和正好等于它本身,例如:

这个题目的关键是将一个三位数拆分为个位、十位、百位,这一点利用Python中的//(整除)和%(求模)运算符其实很容易做到,代码如下所示。

1
2
3
4
5
6
7
8
9
"""
找出所有水仙花数
"""
for num in range(100, 1000):
low = num % 10
mid = num // 10 % 10
high = num // 100
if num == low ** 3 + mid ** 3 + high ** 3:
print(num)

上面利用//%拆分一个数的小技巧在写代码的时候还是很常用的。我们要将一个不知道有多少位的正整数进行反转,例如将12345变成54321,也可以利用这两个运算来实现,代码如下所示。

1
2
3
4
5
6
7
8
9
"""
正整数的反转
"""
num = int(input('num = '))
reversed_num = 0
while num > 0:
reversed_num = reversed_num * 10 + num % 10
num //= 10
print(reversed_num)

例子2:百钱百鸡问题。

说明:百钱百鸡是我国古代数学家张丘建 在《算经》一书中提出的数学问题:鸡翁一值钱五,鸡母一值钱三,鸡雏三值钱一。百钱买百鸡,问鸡翁、鸡母、鸡雏各几何?翻译成现代文是:公鸡5元一只,母鸡3元一只,小鸡1元三只,用100块钱买一百只鸡,问公鸡、母鸡、小鸡各有多少只?

1
2
3
4
5
6
7
8
9
10
"""
《百钱百鸡》问题
"""
# 假设公鸡的数量为x,x的取值范围是0到20
for x in range(0, 21):
# 假设母鸡的数量为y,y的取值范围是0到33
for y in range(0, 34):
z = 100 - x - y
if 5 * x + 3 * y + z // 3 == 100 and z % 3 == 0:
print(f'公鸡: {x}只, 母鸡: {y}只, 小鸡: {z}只')

上面使用的方法叫做穷举法,也称为暴力搜索法,这种方法通过一项一项的列举备选解决方案中所有可能的候选项并检查每个候选项是否符合问题的描述,最终得到问题的解。这种方法看起来比较笨拙,但对于运算能力非常强大的计算机来说,通常都是一个可行的甚至是不错的选择,只要问题的解存在就能够找到它。

例子3:CRAPS赌博游戏。

说明:CRAPS又称花旗骰,是美国拉斯维加斯非常受欢迎的一种的桌上赌博游戏。该游戏使用两粒骰子,玩家通过摇两粒骰子获得点数进行游戏。简化后的规则是:玩家第一次摇骰子如果摇出了7点或11点,玩家胜;玩家第一次如果摇出2点、3点或12点,庄家胜;玩家如果摇出其他点数则玩家继续摇骰子,如果玩家摇出了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
"""
Craps赌博游戏
我们设定游戏开始时玩家有1000元的赌注
游戏结束的条件是玩家破产(输光所有的赌注)
"""
from random import randint

money = 1000
while money > 0:
print(f'你的总资产为: {money}元')
go_on = False
# 下注金额必须大于0小于等于玩家总资产
while True:
debt = int(input('请下注: '))
if 0 < debt <= money:
break
# 第一次摇色子
# 用1到6均匀分布的随机数模拟摇色子得到的点数
first = randint(1, 6) + randint(1, 6)
print(f'\n玩家摇出了{first}点')
if first == 7 or first == 11:
print('玩家胜!\n')
money += debt
elif first == 2 or first == 3 or first == 12:
print('庄家胜!\n')
money -= debt
else:
go_on = True
# 第一次摇色子没有分出胜负游戏继续
while go_on:
go_on = False
current = randint(1, 6) + randint(1, 6)
print(f'玩家摇出了{current}点')
if current == 7:
print('庄家胜!\n')
money -= debt
elif current == first:
print('玩家胜!\n')
money += debt
else:
go_on = True
print('你破产了, 游戏结束!')

例子4:斐波那契数列。

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

1
2
3
4
5
6
7
8
"""
输出斐波那契数列前20个数
"""

a, b = 0, 1
for _ in range(20):
a, b = b, a + b
print(a)

例子5:打印100以内的素数。

说明:素数指的是只能被1和自身整除的正整数(不包括1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
输出100以内的素数
"""
for num in range(2, 100):
# 假设num是素数
is_prime = True
# 在2到num-1之间找num的因子
for factor in range(2, num):
# 如果找到了num的因子,num就不是素数
if num % factor == 0:
is_prime = False
break
# 如果布尔值为True在num是素数
if is_prime:
print(num)

简单的总结

还是那句话:分支结构和循环结构非常重要,是构造程序逻辑的基础,一定要通过大量的练习来达到融会贯通。刚才讲到的CRAPS赌博游戏那个例子可以作为一个标准,如果你能很顺利的完成这段代码,那么分支和循环结构的知识你就已经掌握了。

第08课:常用数据结构之列表

在开始本节课的内容之前,我们先给大家一个编程任务,将一颗色子掷6000次,统计每个点数出现的次数。这个任务对大家来说应该是非常简单的,我们可以用16均匀分布的随机数来模拟掷色子,然后用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
import random

f1 = 0
f2 = 0
f3 = 0
f4 = 0
f5 = 0
f6 = 0
for _ in range(6000):
face = random.randint(1, 6)
if face == 1:
f1 += 1
elif face == 2:
f2 += 1
elif face == 3:
f3 += 1
elif face == 4:
f4 += 1
elif face == 5:
f5 += 1
else:
f6 += 1
print(f'1点出现了{f1}次')
print(f'2点出现了{f2}次')
print(f'3点出现了{f3}次')
print(f'4点出现了{f4}次')
print(f'5点出现了{f5}次')
print(f'6点出现了{f6}次')

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

定义和使用列表

在Python中,列表是由一系元素按特定顺序构成的数据序列,这样就意味着定义一个列表类型的变量,可以保存多个数据,而且允许有重复的数据。跟上一课我们讲到的字符串类型一样,列表也是一种结构化的、非标量类型,操作一个列表类型的变量,除了可以使用运算符还可以使用它的方法。

在Python中,可以使用[]字面量语法来定义列表,列表中的多个元素用逗号进行分隔,代码如下所示。

1
2
items1 = [35, 12, 99, 68, 55, 87]
items2 = ['Python', 'Java', 'Go', 'Kotlin']

除此以外,还可以通过Python内置的list函数将其他序列变成列表。准确的说,list并不是一个普通的函数,它是创建列表对象的构造器(后面会讲到对象和构造器这两个概念)。

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

需要说明的是,列表是一种可变数据类型,也就是说列表可以添加元素、删除元素、更新元素,这一点跟我们上一课讲到的字符串有着鲜明的差别。字符串是一种不可变数据类型,也就是说对字符串做拼接、重复、转换大小写、修剪空格等操作的时候会产生新的字符串,原来的字符串并没有发生任何改变。

列表的运算符

和字符串类型一样,列表也支持拼接、重复、成员运算、索引和切片以及比较运算,对此我们不再进行赘述,请大家参考下面的代码。

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
items1 = [35, 12, 99, 68, 55, 87]
items2 = [45, 8, 29]

# 列表的拼接
items3 = items1 + items2
print(items3) # [35, 12, 99, 68, 55, 87, 45, 8, 29]

# 列表的重复
items4 = ['hello'] * 3
print(items4) # ['hello', 'hello', 'hello']

# 列表的成员运算
print(100 in items3) # False
print('hello' in items4) # True

# 获取列表的长度(元素个数)
size = len(items3)
print(size) # 9

# 列表的索引
print(items3[0], items3[-size]) # 35 35
items3[-1] = 100
print(items3[size - 1], items3[-1]) # 100 100

# 列表的切片
print(items3[:5]) # [35, 12, 99, 68, 55]
print(items3[4:]) # [55, 87, 45, 8, 100]
print(items3[-5:-7:-1]) # [55, 68]
print(items3[::-2]) # [100, 45, 55, 99, 35]

# 列表的比较运算
items5 = [1, 2, 3, 4]
items6 = list(range(1, 5))
# 两个列表比较相等性比的是对应索引位置上的元素是否相等
print(items5 == items6) # True
items7 = [3, 2, 1]
# 两个列表比较大小比的是对应索引位置上的元素的大小
print(items5 <= items7) # True

值得一提的是,由于列表是可变类型,所以通过索引操作既可以获取列表中的元素,也可以更新列表中的元素。对列表做索引操作一样要注意索引越界的问题,对于有N个元素的列表,正向索引的范围是0N-1,负向索引的范围是-1-N,如果超出这个范围,将引发IndexError异常,错误信息为:list index out of range

列表元素的遍历

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

方法一:

1
2
3
4
items = ['Python', 'Java', 'Go', 'Kotlin']

for index in range(len(items)):
print(items[index])

方法二:

1
2
3
4
items = ['Python', 'Java', 'Go', 'Kotlin']

for item in items:
print(item)

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

1
2
3
4
5
6
7
8
import random

counters = [0] * 6
for _ in range(6000):
face = random.randint(1, 6)
counters[face - 1] += 1
for face in range(1, 7):
print(f'{face}点出现了{counters[face - 1]}次')

上面的代码中,我们用counters列表中的六个元素分别表示1到6的点数出现的次数,最开始的时候六个元素的值都是0。接下来用随机数模拟掷色子,如果摇出1点counters[0]的值加1,如果摇出2点counters[1]的值加1,以此类推。大家感受一下,这段代码是不是比之前的代码要简单优雅很多。

列表的方法

和字符串一样,列表类型的方法也很多,下面为大家讲解比较重要的方法。

添加和删除元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
items = ['Python', 'Java', 'Go', 'Kotlin']

# 使用append方法在列表尾部添加元素
items.append('Swift')
print(items) # ['Python', 'Java', 'Go', 'Kotlin', 'Swift']
# 使用insert方法在列表指定索引位置插入元素
items.insert(2, 'SQL')
print(items) # ['Python', 'Java', 'SQL', 'Go', 'Kotlin', 'Swift']

# 删除指定的元素
items.remove('Java')
print(items) # ['Python', 'SQL', 'Go', 'Kotlin', 'Swift']
# 删除指定索引位置的元素
items.pop(0)
items.pop(len(items) - 1)
print(items) # ['SQL', 'Go', 'Kotlin']

# 清空列表中的元素
items.clear()
print(items) # []

需要提醒大家,在使用remove方法删除元素时,如果要删除的元素并不在列表中,会引发ValueError异常,错误消息是:list.remove(x): x not in list。在使用pop方法删除元素时,如果索引的值超出了范围,会引发IndexError异常,错误消息是:pop index out of range

从列表中删除元素其实还有一种方式,就是使用Python中的del关键字后面跟要删除的元素,这种做法跟使用pop方法指定索引删除元素没有实质性的区别,但后者会返回删除的元素,前者在性能上略优(del对应字节码指令是DELETE_SUBSCR,而pop对应的字节码指令是CALL_METHODPOP_TOP,不理解就跳过,不用管它!!!)。

1
2
3
items = ['Python', 'Java', 'Go', 'Kotlin']
del items[1]
print(items) # ['Python', 'Go', 'Kotlin']

元素位置和次数

列表类型的index方法可以查找某个元素在列表中的索引位置;因为列表中允许有重复的元素,所以列表类型提供了count方法来统计一个元素在列表中出现的次数。请看下面的代码。

1
2
3
4
5
6
7
items = ['Python', 'Java', 'Java', 'Go', 'Kotlin', 'Python']

# 查找元素的索引位置
print(items.index('Python')) # 0
print(items.index('Python', 2)) # 5
# 注意:虽然列表中有'Java',但是从索引为3这个位置开始后面是没有'Java'的
print(items.index('Java', 3)) # ValueError: 'Java' is not in list

再来看看下面这段代码。

1
2
3
4
5
6
items = ['Python', 'Java', 'Java', 'Go', 'Kotlin', 'Python']

# 查找元素出现的次数
print(items.count('Python')) # 2
print(items.count('Go')) # 1
print(items.count('Swfit')) # 0

元素排序和反转

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

1
2
3
4
5
6
7
8
items = ['Python', 'Java', 'Go', 'Kotlin', 'Python']

# 排序
items.sort()
print(items) # ['Go', 'Java', 'Kotlin', 'Python', 'Python']
# 反转
items.reverse()
print(items) # ['Python', 'Python', 'Kotlin', 'Java', 'Go']

列表的生成式

在Python中,列表还可以通过一种特殊的字面量语法来创建,这种语法叫做生成式。我们给出两段代码,大家可以做一个对比,看看哪一种方式更加简单优雅。

通过for循环为空列表添加元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 创建一个由1到9的数字构成的列表
items1 = []
for x in range(1, 10):
items1.append(x)
print(items1)

# 创建一个由'hello world'中除空格和元音字母外的字符构成的列表
items2 = []
for x in 'hello world':
if x not in ' aeiou':
items2.append(x)
print(items2)

# 创建一个由个两个字符串中字符的笛卡尔积构成的列表
items3 = []
for x in 'ABC':
for y in '12':
items3.append(x + y)
print(items3)

通过生成式创建列表。

1
2
3
4
5
6
7
8
9
10
11
# 创建一个由1到9的数字构成的列表
items1 = [x for x in range(1, 10)]
print(items1) # [1, 2, 3, 4, 5, 6, 7, 8, 9]

# 创建一个由'hello world'中除空格和元音字母外的字符构成的列表
items2 = [x for x in 'hello world' if x not in ' aeiou']
print(items2) # ['h', 'l', 'l', 'w', 'r', 'l', 'd']

# 创建一个由个两个字符串中字符的笛卡尔积构成的列表
items3 = [x + y for x in 'ABC' for y in '12']
print(items3) # ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']

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

嵌套的列表

Python语言没有限定列表中的元素必须是相同的数据类型,也就是说一个列表中的元素可以任意的数据类型,当然也包括列表。如果列表中的元素又是列表,那么我们可以称之为嵌套的列表。嵌套的列表可以用来表示表格或数学上的矩阵,例如:我们想保存5个学生3门课程的成绩,可以定义一个保存5个元素的列表保存5个学生的信息,而每个列表元素又是3个元素构成的列表,分别代表3门课程的成绩。但是,一定要注意下面的代码是有问题的。

1
2
scores = [[0] * 3] * 5
print(scores) # [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]

看上去我们好像创建了一个5 * 3的嵌套列表,但实际上当我们录入第一个学生的第一门成绩后,你就会发现问题来了,我们看看下面代码的输出。

1
2
3
4
# 嵌套的列表需要多次索引操作才能获取元素
scores[0][0] = 95
print(scores)
# [[95, 0, 0], [95, 0, 0], [95, 0, 0], [95, 0, 0], [95, 0, 0]]

我们不去过多的解释为什么会出现这样的问题,如果想深入研究这个问题,可以通过Python Tutor 网站的可视化代码执行功能,看看创建列表时计算机内存中发生了怎样的变化,下面的图就是在这个网站上生成的。建议大家不去纠结这个问题,现阶段只需要记住不能用[[0] * 3] * 5]这种方式来创建嵌套列表就行了。那么创建嵌套列表的正确做法是什么呢,下面的代码会给你答案。

1
2
3
4
scores = [[0] * 3 for _ in range(5)]
scores[0][0] = 95
print(scores)
# [[95, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]

简单的总结

Python中的列表底层是一个可以动态扩容的数组,列表元素在内存中也是连续存储的,所以可以实现随机访问(通过一个有效的索引获取到对应的元素且操作时间与列表元素个数无关)。我们暂时不去触碰这些底层存储细节以及列表每个方法的渐近时间复杂度(执行这个方法耗费的时间跟列表元素个数的关系),等需要的时候再告诉大家。现阶段,大家只需要知道列表是容器,可以保存各种类型的数据可以通过索引操作列表元素,知道这些就足够了。

第09课:常用数据结构之元组

上一节课为大家讲解了Python中的列表,它是一种容器型数据类型,我们可以通过定义列表类型的变量来保存和操作多个元素。当然,Python中容器型的数据类型肯定不止列表一种,接下来我们为大家讲解另一种重要的容器型数据类型,它的名字叫元组(tuple)。

定义和使用元组

在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
# 定义一个三元组
t1 = (30, 10, 55)
# 定义一个四元组
t2 = ('骆昊', 40, True, '四川成都')

# 查看变量的类型
print(type(t1), type(t2)) # <class 'tuple'> <class 'tuple'>
# 查看元组中元素的数量
print(len(t1), len(t2)) # 3 4

# 通过索引运算获取元组中的元素
print(t1[0], t1[-3]) # 30 30
print(t2[3], t2[-1]) # 四川成都 四川成都

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

# 成员运算
print(100 in t1) # False
print(40 in t2) # True

# 拼接
t3 = t1 + t2
print(t3) # (30, 10, 55, '骆昊', 40, True, '四川成都')

# 切片
print(t3[::3]) # (30, '骆昊', '四川成都')

# 比较运算
print(t1 == t3) # False
print(t1 >= t3) # False
print(t1 < (30, 11, 55)) # True

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

1
2
3
4
5
6
7
8
9
10
11
12
13
# 空元组
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'>

元组的应用场景

讲到这里,相信大家一定迫切的想知道元组有哪些应用场景,我们给大家举几个例子。

例子1:打包和解包操作。

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

1
2
3
4
5
6
# 打包
a = 1, 10, 100
print(type(a), a) # <class 'tuple'> (1, 10, 100)
# 解包
i, j, k = a
print(i, j, k) # 1 10 100

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

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)

有一种解决变量个数少于元素的个数方法,就是使用星号表达式,我们之前讲函数的可变参数时使用过星号表达式。有了星号表达式,我们就可以让一个变量接收多个值,代码如下所示。需要注意的是,用星号表达式修饰的变量会变成一个列表,列表中有0个或多个元素。还有在解包语法中,星号表达式只能出现一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
a = 1, 10, 100, 1000
i, j, *k = a
print(i, j, k) # 1 10 [100, 1000]
i, *j, k = a
print(i, j, k) # 1 [10, 100] 1000
*i, j, k = a
print(i, j, k) # [1, 10] 100 1000
*i, j = a
print(i, j) # [1, 10, 100] 1000
i, *j = a
print(i, j) # 1 [10, 100, 1000]
i, j, k, *l = a
print(i, j, k, l) # 1 10 100 [1000]
i, j, k, l, *m = a
print(i, j, k, l, m) # 1 10 100 1000 []

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

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)

例子2:交换两个变量的值。

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

1
a, b = b, a

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

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

需要说明的是,上面并没有用到打包和解包语法,Python的字节码指令中有ROT_TWOROT_THREE这样的指令可以实现这个操作,效率是非常高的。但是如果有多于三个变量的值要依次互换,这个时候没有直接可用的字节码指令,执行的原理就是我们上面讲解的打包和解包操作。

元组和列表的比较

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

  1. 元组是不可变类型,不可变类型更适合多线程环境,因为它降低了并发访问变量的同步化开销。关于这一点,我们会在后面讲解多线程的时候为大家详细论述。

  2. 元组是不可变类型,通常不可变类型在创建时间和占用空间上面都优于对应的可变类型。我们可以使用sys模块的getsizeof函数来检查保存相同元素的元组和列表各自占用了多少内存空间。我们也可以使用timeit模块的timeit函数来看看创建保存相同元素的元组和列表各自花费的时间,代码如下所示。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import sys
    import timeit

    a = list(range(100000))
    b = tuple(range(100000))
    print(sys.getsizeof(a), sys.getsizeof(b)) # 900120 800056

    print(timeit.timeit('[1, 2, 3, 4, 5, 6, 7, 8, 9]'))
    print(timeit.timeit('(1, 2, 3, 4, 5, 6, 7, 8, 9)'))
  3. Python中的元组和列表是可以相互转换的,我们可以通过下面的代码来做到。

    1
    2
    3
    4
    5
    6
    # 将元组转换成列表
    info = ('骆昊', 175, True, '四川成都')
    print(list(info)) # ['骆昊', 175, True, '四川成都']
    # 将列表转换成元组
    fruits = ['apple', 'banana', 'orange']
    print(tuple(fruits)) # ('apple', 'banana', 'orange')

简单的总结

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

第10课:字符串的使用

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

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

字符串的定义

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

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

1
2
3
4
5
6
7
8
9
s1 = 'hello, world!'
s2 = "你好,世界!"
print(s1, s2)
# 以三个双引号或单引号开头的字符串可以折行
s3 = '''
hello,
world!
'''
print(s3, end='')

提示print函数中的end=''表示输出后不换行,即将默认的结束符\n(换行符)更换为''(空字符)。

转义字符和原始字符串

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

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

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

1
2
3
4
5
6
# 字符串s1中\t是制表符,\n是换行符
s1 = '\time up \now'
print(s1)
# 字符串s2中没有转义字符,每个字符都是原始含义
s2 = r'\time up \now'
print(s2)

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

1
2
3
s1 = '\141\142\143\x61\x62\x63'
s2 = '\u9a86\u660a'
print(s1, s2)

字符串的运算

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

拼接和重复

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

1
2
3
4
5
6
7
8
s1 = 'hello' + ' ' + 'world'
print(s1) # hello world
s2 = '!' * 3
print(s2) # !!!
s1 += s2 # s1 = s1 + s2
print(s1) # hello world!!!
s1 *= 2 # s1 = s1 * 2
print(s1) # hello world!!!hello world!!!

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

比较运算

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

1
2
3
4
5
6
7
8
9
10
11
s1 = 'a whole new world'
s2 = 'hello world'
print(s1 == s2, s1 < s2) # False True
print(s2 == 'hello world') # True
print(s2 == 'Hello world') # False
print(s2 != 'Hello world') # True
s3 = '骆昊'
print(ord('骆'), ord('昊')) # 39558 26122
s4 = '王大锤'
print(ord('王'), ord('大'), ord('锤')) # 29579 22823 38180
print(s3 > s4, s3 <= s4) # True False

需要强调一下的是,字符串的比较运算比较的是字符串的内容,Python中还有一个is运算符(身份运算符),如果用is来比较两个字符串,它比较的是两个变量对应的字符串对象的内存地址(不理解先跳过),简单的说就是两个变量是否对应内存中的同一个字符串。看看下面的代码就比较清楚is运算符的作用了。

1
2
3
4
5
6
7
s1 = 'hello world'
s2 = 'hello world'
s3 = s2
# 比较字符串的内容
print(s1 == s2, s2 == s3) # True True
# 比较字符串的内存地址
print(s1 is s2, s2 is s3) # False True

成员运算

Python中可以用innot in判断一个字符串中是否存在另外一个字符或字符串,innot in运算通常称为成员运算,会产生布尔值TrueFalse,代码如下所示。

1
2
3
4
s1 = 'hello, world'
print('wo' in s1) # True
s2 = 'goodbye'
print(s2 in s1) # False

获取字符串长度

获取字符串长度没有直接的运算符,而是使用内置函数len,我们在上节课的提到过这个内置函数,代码如下所示。

1
2
3
s = 'hello, world'
print(len(s)) # 12
print(len('goodbye, world')) # 14

索引和切片

如果希望从字符串中取出某个字符,我们可以对字符串进行索引运算,运算符是[n],其中n是一个整数,假设字符串的长度为N,那么n可以是从0N-1的整数,其中0是字符串中第一个字符的索引,而N-1是字符串中最后一个字符的索引,通常称之为正向索引;在Python中,字符串的索引也可以是从-1-N的整数,其中-1是最后一个字符的索引,而-N则是第一个字符的索引,通常称之为负向索引。注意,因为字符串是不可变类型,所以不能通过索引运算修改字符串中的字符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
s = 'abc123456'
N = len(s)

# 获取第一个字符
print(s[0], s[-N]) # a a

# 获取最后一个字符
print(s[N-1], s[-1]) # 6 6

# 获取索引为2或-7的字符
print(s[2], s[-7]) # c c

# 获取索引为5和-4的字符
print(s[5], s[-4]) # 3 3

需要提醒大家注意的是,在进行索引操作时,如果索引越界(正向索引不在0N-1范围,负向索引不在-1-N范围),会引发IndexError异常,错误提示信息为:string index out of range(字符串索引超出范围)。

如果要从字符串中取出多个字符,我们可以对字符串进行切片,运算符是[i:j:k],其中i是开始索引,索引对应的字符可以取到;j是结束索引,索引对应的字符不能取到;k是步长,默认值为1,表示从前向后获取相邻字符的连续切片,所以:k部分可以省略。假设字符串的长度为N,当k > 0时表示正向切片(从前向后获取字符),如果没有给出ij的值,则i的默认值是0j的默认值是N;当k < 0时表示负向切片(从后向前获取字符),如果没有给出ij的值,则i的默认值是-1,j的默认值是-N - 1。如果不理解,直接看下面的例子,记住第一个字符的索引是0-N,最后一个字符的索引是N-1-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
46
47
48
49
s = 'abc123456'

# i=2, j=5, k=1的正向切片操作
print(s[2:5]) # c12

# i=-7, j=-4, k=1的正向切片操作
print(s[-7:-4]) # c12

# i=2, j=9, k=1的正向切片操作
print(s[2:]) # c123456

# i=-7, j=9, k=1的正向切片操作
print(s[-7:]) # c123456

# i=2, j=9, k=2的正向切片操作
print(s[2::2]) # c246

# i=-7, j=9, k=2的正向切片操作
print(s[-7::2]) # c246

# i=0, j=9, k=2的正向切片操作
print(s[::2]) # ac246

# i=1, j=-1, k=2的正向切片操作
print(s[1:-1:2]) # b135

# i=7, j=1, k=-1的负向切片操作
print(s[7:1:-1]) # 54321c

# i=-2, j=-8, k=-1的负向切片操作
print(s[-2:-8:-1]) # 54321c

# i=7, j=-10, k=-1的负向切片操作
print(s[7::-1]) # 54321cba

# i=-1, j=1, k=-1的负向切片操作
print(s[:1:-1]) # 654321c

# i=0, j=9, k=1的正向切片
print(s[:]) # abc123456

# i=0, j=9, k=2的正向切片
print(s[::2]) # ac246

# i=-1, j=-10, k=-1的负向切片
print(s[::-1]) # 654321cba

# i=-1, j=-10, k=-2的负向切片
print(s[::-2]) # 642ca

循环遍历每个字符

如果希望从字符串中取出每个字符,可以使用for循环对字符串进行遍历,有两种方式。

方式一:

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

方式二:

1
2
3
s1 = 'hello'
for ch in s1:
print(ch)

字符串的方法

在Python中,我们可以通过字符串类型自带的方法对字符串进行操作和处理,对于一个字符串类型的变量,我们可以用变量名.方法名()的方式来调用它的方法。所谓方法其实就是跟某个类型的变量绑定的函数,后面我们讲面向对象编程的时候还会对这一概念详加说明。

大小写相关操作

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

1
2
3
4
5
6
7
8
9
10
11
12
s1 = 'hello, world!'

# 使用capitalize方法获得字符串首字母大写后的字符串
print(s1.capitalize()) # Hello, world!
# 使用title方法获得字符串每个单词首字母大写后的字符串
print(s1.title()) # Hello, World!
# 使用upper方法获得字符串大写后的字符串
print(s1.upper()) # HELLO, WORLD!

s2 = 'GOODBYE'
# 使用lower方法获得字符串小写后的字符串
print(s2.lower()) # goodbye

查找操作

如果想在一个字符串中从前向后查找有没有另外一个字符串,可以使用字符串的findindex方法。

1
2
3
4
5
6
7
8
9
10
11
12
s = 'hello, world!'

# find方法从字符串中查找另一个字符串所在的位置
# 找到了返回字符串中另一个字符串首字符的索引
print(s.find('or')) # 8
# 找不到返回-1
print(s.find('shit')) # -1
# index方法与find方法类似
# 找到了返回字符串中另一个字符串首字符的索引
print(s.index('or')) # 8
# 找不到引发异常
print(s.index('shit')) # ValueError: substring not found

在使用findindex方法时还可以通过方法的参数来指定查找的范围,也就是查找不必从索引为0的位置开始。findindex方法还有逆向查找(从后向前查找)的版本,分别是rfindrindex,代码如下所示。

1
2
3
4
5
6
7
8
s = 'hello good world!'

# 从前向后查找字符o出现的位置(相当于第一次出现)
print(s.find('o')) # 4
# 从索引为5的位置开始查找字符o出现的位置
print(s.find('o', 5)) # 7
# 从后向前查找字符o出现的位置(相当于最后一次出现)
print(s.rfind('o')) # 12

性质判断

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
s1 = 'hello, world!'

# startwith方法检查字符串是否以指定的字符串开头返回布尔值
print(s1.startswith('He')) # False
print(s1.startswith('hel')) # True
# endswith方法检查字符串是否以指定的字符串结尾返回布尔值
print(s1.endswith('!')) # True

s2 = 'abc123456'

# isdigit方法检查字符串是否由数字构成返回布尔值
print(s2.isdigit()) # False
# isalpha方法检查字符串是否以字母构成返回布尔值
print(s2.isalpha()) # False
# isalnum方法检查字符串是否以数字和字母构成返回布尔值
print(s2.isalnum()) # True

格式化字符串

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

1
2
3
4
5
6
7
8
9
10
11
s = 'hello, world'

# center方法以宽度20将字符串居中并在两侧填充*
print(s.center(20, '*')) # ****hello, world****
# rjust方法以宽度20将字符串右对齐并在左侧填充空格
print(s.rjust(20)) # hello, world
# ljust方法以宽度20将字符串左对齐并在右侧填充~
print(s.ljust(20, '~')) # hello, world~~~~~~~~
# 在字符串的左侧补零
print('33'.zfill(5)) # 00033
print('-33'.zfill(5)) # -0033

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

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

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

1
2
3
a = 321
b = 123
print('{0} * {1} = {2}'.format(a, b, a * b))

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

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

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

变量值 占位符 格式化结果 说明
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' 科学计数法格式

修剪操作

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

1
2
3
s = '   jackfrued@126.com  \t\r\n'
# strip方法获得字符串修剪左右两侧空格之后的字符串
print(s.strip()) # jackfrued@126.com

替换操作

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

1
2
3
s = 'hello, world'
print(s.replace('o', '@')) # hell@, w@rld
print(s.replace('o', '@', 1)) # hell@, world

拆分/合并操作

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

1
2
3
4
s = 'I love you'
words = s.split()
print(words) # ['I', 'love', 'you']
print('#'.join(words)) # I#love#you

需要说明的是,split方法默认使用空格进行拆分,我们也可以指定其他的字符来拆分字符串,而且还可以指定最大拆分次数来控制拆分的效果,代码如下所示。

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

编码/解码操作

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

1
2
3
4
5
6
a = '骆昊'
b = a.encode('utf-8')
c = a.encode('gbk')
print(b, c) # b'\xe9\xaa\x86\xe6\x98\x8a' b'\xc2\xe6\xea\xbb'
print(b.decode('utf-8'))
print(c.decode('gbk'))

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

其他方法

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

简单的总结

知道如何表示和操作字符串对程序员来说是非常重要的,因为我们需要处理文本信息,Python中操作字符串可以用拼接、切片等运算符,也可以使用字符串类型的方法。

第11课:常用数据结构之集合

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

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

Python程序中的集合跟数学上的集合是完全一致的,需要强调的是上面所说的无序性和互异性。无序性说明集合中的元素并不像列中的元素那样一个挨着一个,可以通过索引实现随机访问(随机访问指的是给定一个有效的范围,随机抽取出一个数字,然后通过这个数字可以获取到对应的元素),所以Python中的集合肯定不能够支持索引运算。另外,集合的互异性决定了集合中不能有重复元素,这一点也是集合区别于列表的关键,说得更直白一些就是,Python中的集合类型会对其中的元素做去重处理。Python中的集合一定是支持innot in成员运算的,这样就可以确定一个元素是否属于集合,也就是上面所说的集合的确定性。集合的成员运算在性能上要优于列表的成员运算,这是集合的底层存储特性(哈希存储)决定的,此处我们暂时不做讨论,大家可以先记下这个结论。

创建集合

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 创建集合的字面量语法(重复元素不会出现在集合中)
set1 = {1, 2, 3, 3, 3, 2}
print(set1) # {1, 2, 3}
print(len(set1)) # 3

# 创建集合的构造器语法(后面会讲到什么是构造器)
set2 = set('hello')
print(set2) # {'h', 'l', 'o', 'e'}

# 将列表转换成集合(可以去掉列表中的重复元素)
set3 = set([1, 2, 3, 3, 2, 1])
print(set3) # {1, 2, 3}

# 创建集合的生成式语法(将列表生成式的[]换成{})
set4 = {num for num in range(1, 20) if num % 3 == 0 or num % 5 == 0}
print(set4) # {3, 5, 6, 9, 10, 12, 15, 18}

# 集合元素的循环遍历
for elem in set4:
print(elem)

需要提醒大家,集合中的元素必须是hashable类型。所谓hashable类型指的是能够计算出哈希码的数据类型,大家可以暂时将哈希码理解为和变量对应的唯一的ID值。通常不可变类型都是hashable类型,如整数、浮点、字符串、元组等,而可变类型都不是hashable类型,因为可变类型无法确定唯一的ID值,所以也就不能放到集合中。集合本身也是可变类型,所以集合不能够作为集合中的元素,这一点在使用集合的时候一定要注意。

集合的运算

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

成员运算

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

1
2
3
4
5
6
set1 = {11, 12, 13, 14, 15}
print(10 in set1) # False
print(15 in set1) # True
set2 = {'Python', 'Java', 'Go', 'Swift'}
print('Ruby' in set2) # False
print('Java' in set2) # True

交并差运算

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
set1 = {1, 2, 3, 4, 5, 6, 7}
set2 = {2, 4, 6, 8, 10}

# 交集
# 方法一: 使用 & 运算符
print(set1 & set2) # {2, 4, 6}
# 方法二: 使用intersection方法
print(set1.intersection(set2)) # {2, 4, 6}

# 并集
# 方法一: 使用 | 运算符
print(set1 | set2) # {1, 2, 3, 4, 5, 6, 7, 8, 10}
# 方法二: 使用union方法
print(set1.union(set2)) # {1, 2, 3, 4, 5, 6, 7, 8, 10}

# 差集
# 方法一: 使用 - 运算符
print(set1 - set2) # {1, 3, 5, 7}
# 方法二: 使用difference方法
print(set1.difference(set2)) # {1, 3, 5, 7}

# 对称差
# 方法一: 使用 ^ 运算符
print(set1 ^ set2) # {1, 3, 5, 7, 8, 10}
# 方法二: 使用symmetric_difference方法
print(set1.symmetric_difference(set2)) # {1, 3, 5, 7, 8, 10}
# 方法三: 对称差相当于两个集合的并集减去交集
print((set1 | set2) - (set1 & set2)) # {1, 3, 5, 7, 8, 10}

通过上面的代码可以看出,对两个集合求交集,&运算符和intersection方法的作用是完全相同的,使用运算符的方式更直观而且代码也比较简短。相信大家对交集、并集、差集、对称差这几个概念是比较清楚的,如果没什么印象了可以看看下面的图。

集合的交集、并集、差集运算还可以跟赋值运算一起构成复合赋值运算,如下所示。

1
2
3
4
5
6
7
8
9
10
11
set1 = {1, 3, 5, 7}
set2 = {2, 4, 6}
# 将set1和set2求并集再赋值给set1
# 也可以通过set1.update(set2)来实现
set1 |= set2
print(set1) # {1, 2, 3, 4, 5, 6, 7}
set3 = {3, 6, 9}
# 将set1和set3求交集再赋值给set1
# 也可以通过set1.intersection_update(set3)来实现
set1 &= set3
print(set1) # {3, 6}

比较运算

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

1
2
3
4
5
6
7
8
9
10
11
12
set1 = {1, 3, 5}
set2 = {1, 2, 3, 4, 5}
set3 = set2
# <运算符表示真子集,<=运算符表示子集
print(set1 < set2, set1 <= set2) # True True
print(set2 < set3, set2 <= set3) # False True
# 通过issubset方法也能进行子集判断
print(set1.issubset(set2)) # True

# 反过来可以用issuperset或>运算符进行超集判断
print(set2.issuperset(set1)) # True
print(set2 > set1) # True

集合的方法

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
# 创建一个空集合
set1 = set()

# 通过add方法添加元素
set1.add(33)
set1.add(55)
set1.update({1, 10, 100, 1000})
print(set1) # {33, 1, 100, 55, 1000, 10}

# 通过discard方法删除指定元素
set1.discard(100)
set1.discard(99)
print(set1) # {1, 10, 33, 55, 1000}

# 通过remove方法删除指定元素,建议先做成员运算再删除
# 否则元素如果不在集合中就会引发KeyError异常
if 10 in set1:
set1.remove(10)
print(set1) # {33, 1, 55, 1000}

# pop方法可以从集合中随机删除一个元素并返回该元素
print(set1.pop())

# clear方法可以清空整个集合
set1.clear()

print(set1) # set()

如果要判断两个集合有没有相同的元素可以使用isdisjoint方法,没有相同元素返回True,否则返回False,代码如下所示。

1
2
3
4
5
set1 = {'Java', 'Python', 'Go', 'Kotlin'}
set2 = {'Kotlin', 'Swift', 'Java', 'Objective-C', 'Dart'}
set3 = {'HTML', 'CSS', 'JavaScript'}
print(set1.isdisjoint(set2)) # False
print(set1.isdisjoint(set3)) # True

不可变集合

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

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

简单的总结

Python中的集合底层使用了哈希存储的方式,对于这一点我们暂时不做介绍,在后面的课程有需要的时候再为大家讲解集合的底层原理,现阶段大家只需要知道集合是一种容器,元素必须是hashable类型,与列表不同的地方在于集合中的元素没有序不能用索引运算不能重复

第12课:常用数据结构之字典

迄今为止,我们已经为大家介绍了Python中的三种容器型数据类型,但是这些数据类型仍然不足以帮助我们解决所有的问题。例如,我们要保存一个人的信息,包括姓名、年龄、体重、单位地址、家庭住址、本人手机号、紧急联系人手机号等信息,你会发现我们之前学过的列表、元组和集合都不是最理想的选择。

1
2
3
person1 = ['王大锤', 55, 60, '科华北路62号', '中同仁路8号', '13122334455', '13800998877']
person2 = ('王大锤', 55, 60, '科华北路62号', '中同仁路8号', '13122334455', '13800998877')
person3 = {'王大锤', 55, 60, '科华北路62号', '中同仁路8号', '13122334455', '13800998877'}

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

说到字典这个词,大家一定不陌生,读小学的时候每个人基本上都有一本《新华字典》,如下图所示。

dictionary

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

创建和使用字典

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

1
2
3
4
5
6
7
8
9
10
11
12
xinhua = {
'麓': '山脚下',
'路': '道,往来通行的地方;方面,地区:南~货,外~货;种类:他俩是一~人',
'蕗': '甘草的别名',
'潞': '潞水,水名,即今山西省的浊漳河;潞江,水名,即云南省的怒江'
}
print(xinhua)
person = {
'name': '王大锤', 'age': 55, 'weight': 60, 'office': '科华北路62号',
'home': '中同仁路8号', 'tel': '13122334455', 'econtact': '13800998877'
}
print(person)

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
# dict函数(构造器)中的每一组参数就是字典中的一组键值对
person = dict(name='王大锤', age=55, weight=60, home='中同仁路8号')
print(person) # {'name': '王大锤', 'age': 55, 'weight': 60, 'home': '中同仁路8号'}

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

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

想知道字典中一共有多少组键值对,仍然是使用len函数;如果想对字典进行遍历,可以用for循环,但是需要注意,for循环只是对字典的键进行了遍历,不过没关系,在讲完字典的运算后,我们可以通过字典的键获取到和这个键对应的值。

1
2
3
4
person = {'name': '王大锤', 'age': 55, 'weight': 60, 'office': '科华北路62号'}
print(len(person)) # 4
for key in person:
print(key)

字典的运算

对于字典类型来说,成员运算和索引运算肯定是最为重要的,前者可以判定指定的键在不在字典中,后者可以通过键获取对应的值或者向字典中加入新的键值对。值得注意的是,字典的索引不同于列表的索引,列表中的元素因为有属于自己有序号,所以列表的索引是一个整数;字典中因为保存的是键值对,所以字典的索引是键值对中的键,通过索引操作可以修改原来的值或者向字典中存入新的键值对。需要特别提醒大家注意的是,字典中的键必须是不可变类型,例如整数(int)、浮点数(float)、字符串(str)、元组(tuple)等类型的值;显然,列表(list)和集合(set)是不能作为字典中的键的,当然字典类型本身也不能再作为字典中的键,因为字典也是可变类型,但是字典可以作为字典中的值。关于可变类型不能作为字典中的键的原因,我们在后面的课程中再为大家详细说明。这里,我们先看看下面的代码,了解一下字典的成员运算和索引运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
person = {'name': '王大锤', 'age': 55, 'weight': 60, 'office': '科华北路62号'}
# 检查name和tel两个键在不在person字典中
print('name' in person, 'tel' in person) # True False
# 通过age修将person字典中对应的值修改为25
if 'age' in person:
person['age'] = 25
# 通过索引操作向person字典中存入新的键值对
person['tel'] = '13122334455'
person['signature'] = '你的男朋友是一个盖世垃圾,他会踏着五彩祥云去迎娶你的闺蜜'
print('name' in person, 'tel' in person) # True True
# 检查person字典中键值对的数量
print(len(person)) # 6
# 对字典的键进行循环并通索引运算获取键对应的值
for key in person:
print(f'{key}: {person[key]}')

需要注意,在通过索引运算获取字典中的值时,如指定的键没有在字典中,将会引发KeyError异常。

字典的方法

字典类型的方法基本上都跟字典的键值对操作相关,可以通过下面的例子来了解这些方法的使用。例如,我们要用一个字典来保存学生的信息,我们可以使用学生的学号作为字典中的键,通过学号做索引运算就可以得到对应的学生;我们可以把字典的值也做成一个字典,这样就可以用多组键值对分别存储学生的姓名、性别、年龄、籍贯等信息,代码如下所示。

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
# 字典中的值又是一个字典(嵌套的字典)
students = {
1001: {'name': '狄仁杰', 'sex': True, 'age': 22, 'place': '山西大同'},
1002: {'name': '白元芳', 'sex': True, 'age': 23, 'place': '河北保定'},
1003: {'name': '武则天', 'sex': False, 'age': 20, 'place': '四川广元'}
}

# 使用get方法通过键获取对应的值,如果取不到不会引发KeyError异常而是返回None或设定的默认值
print(students.get(1002)) # {'name': '白元芳', 'sex': True, 'age': 23, 'place': '河北保定'}
print(students.get(1005)) # None
print(students.get(1005, {'name': '无名氏'})) # {'name': '无名氏'}

# 获取字典中所有的键
print(students.keys()) # dict_keys([1001, 1002, 1003])
# 获取字典中所有的值
print(students.values()) # dict_values([{...}, {...}, {...}])
# 获取字典中所有的键值对
print(students.items()) # dict_items([(1001, {...}), (1002, {....}), (1003, {...})])
# 对字典中所有的键值对进行循环遍历
for key, value in students.items():
print(key, '--->', value)

# 使用pop方法通过键删除对应的键值对并返回该值
stu1 = students.pop(1002)
print(stu1) # {'name': '白元芳', 'sex': True, 'age': 23, 'place': '河北保定'}
print(len(students)) # 2
# stu2 = students.pop(1005) # KeyError: 1005
stu2 = students.pop(1005, {})
print(stu2) # {}

# 使用popitem方法删除字典中最后一组键值对并返回对应的二元组
# 如果字典中没有元素,调用该方法将引发KeyError异常
key, value = students.popitem()
print(key, value) # 1003 {'name': '武则天', 'sex': False, 'age': 20, 'place': '四川广元'}

# 如果这个键在字典中存在,setdefault返回原来与这个键对应的值
# 如果这个键在字典中不存在,向字典中添加键值对,返回第二个参数的值,默认为None
result = students.setdefault(1005, {'name': '方启鹤', 'sex': True})
print(result) # {'name': '方启鹤', 'sex': True}
print(students) # {1001: {...}, 1005: {...}}

# 使用update更新字典元素,相同的键会用新值覆盖掉旧值,不同的键会添加到字典中
others = {
1005: {'name': '乔峰', 'sex': True, 'age': 32, 'place': '北京大兴'},
1010: {'name': '王语嫣', 'sex': False, 'age': 19},
1008: {'name': '钟灵', 'sex': False}
}
students.update(others)
print(students) # {1001: {...}, 1005: {...}, 1010: {...}, 1008: {...}}

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

1
2
3
person = {'name': '王大锤', 'age': 25, 'sex': True}
del person['age']
print(person) # {'name': '王大锤', 'sex': True}

字典的应用

我们通过几个简单的例子来讲解字典的应用。

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

1
2
3
4
5
6
7
sentence = input('请输入一段话: ')
counter = {}
for ch in sentence:
if 'A' <= ch <= 'Z' or 'a' <= ch <= 'z':
counter[ch] = counter.get(ch, 0) + 1
for key, value in counter.items():
print(f'字母{key}出现了{value}次.')

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

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

1
2
3
4
5
6
7
8
9
10
11
stocks = {
'AAPL': 191.88,
'GOOG': 1186.96,
'IBM': 149.24,
'ORCL': 48.44,
'ACN': 166.89,
'FB': 208.09,
'SYMC': 21.29
}
stocks2 = {key: value for key, value in stocks.items() if value > 100}
print(stocks2)

简单的总结

Python程序中的字典跟现实生活中字典非常像,允许我们以键值对的形式保存数据,再通过键索引对应的值。这是一种非常有利于数据检索的数据类型,底层原理我们在后续的课程中为大家讲解。再次提醒大家注意,字典中的键必须是不可变类型,字典中的值可以是任意类型。

第13课:函数和模块

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

你可能已经想到了,这个问题其实等同于将8个苹果分成四组且每组至少一个苹果有多少种方案,因此该问题还可以进一步等价于在分隔8个苹果的7个空隙之间插入三个隔板将苹果分成四组有多少种方案,也就是从7个空隙选出3个空隙放入隔板的组合数,所以答案是。组合数的计算公式如下所示。

根据我们前面学习的知识,可以用循环做累乘的方式来计算阶乘,那么通过下面的Python代码我们就可以计算出组合数的值,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"""
输入M和N计算C(M,N)
"""
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的阶乘
fk = 1
for num in range(1, m - n + 1):
fk *= num
# 计算C(M,N)的值
print(fm // fn // fk)

函数的作用

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

定义函数

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

在Python中可以使用def关键字来定义函数,和变量一样每个函数也应该有一个漂亮的名字,命名规则跟变量的命名规则是一致的(赶紧想一想我们之前讲过的变量的命名规则)。在函数名后面的圆括号中可以放置传递给函数的参数,就是我们刚才说到的函数的自变量,而函数执行完成后我们会通过return关键字来返回函数的执行结果,就是我们刚才说的函数的因变量。一个函数要执行的代码块(要做的事情)也是通过缩进的方式来表示的,跟之前分支和循环结构的代码块是一样的。大家不要忘了def那一行的最后面还有一个:,之前提醒过大家,那是在英文输入法状态下输入的冒号。

我们可以通过函数对上面的代码进行重构。所谓重构,是在不影响代码执行结果的前提下对代码的结构进行调整。重构之后的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"""
输入M和N计算C(M,N)
"""


# 定义函数:def是定义函数的关键字、fac是函数名,num是参数(自变量)
def fac(num):
"""求阶乘"""
result = 1
for n in range(1, num + 1):
result *= n
# 返回num的阶乘(因变量)
return result


m = int(input('m = '))
n = int(input('n = '))
# 当需要计算阶乘的时候不用再写重复的代码而是直接调用函数fac
# 调用函数的语法是在函数名后面跟上圆括号并传入参数
print(fac(m) // fac(n) // fac(m - n))

说明:事实上,Python标准库的math模块中有一个名为factorial的函数已经实现了求阶乘的功能,我们可以直接使用该函数来计算阶乘。将来我们使用的函数,要么是自定义的函数,要么是Python标准库或者三方库中提供的函数

函数的参数

参数的默认值

如果函数中没有return语句,那么函数默认返回代表空值的None。另外,在定义函数时,函数也可以没有自变量,但是函数名后面的圆括号是必须有的。Python中还允许函数的参数拥有默认值,我们可以把之前讲过的一个例子“CRAPS赌博游戏”中摇色子获得点数的功能封装成函数,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"""
参数的默认值
"""
from random import randint


# 定义摇色子的函数,n表示色子的个数,默认值为2
def roll_dice(n=2):
"""摇色子返回总的点数"""
total = 0
for _ in range(n):
total += randint(1, 6)
return total


# 如果没有指定参数,那么n使用默认值2,表示摇两颗色子
print(roll_dice())
# 传入参数3,变量n被赋值为3,表示摇三颗色子获得点数
print(roll_dice(3))

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def add(a=0, b=0, c=0):
"""三个数相加求和"""
return a + b + c


# 调用add函数,没有传入参数,那么a、b、c都使用默认值0
print(add()) # 0
# 调用add函数,传入一个参数,那么该参数赋值给变量a, 变量b和c使用默认值0
print(add(1)) # 1
# 调用add函数,传入两个参数,1和2分别赋值给变量a和b,变量c使用默认值0
print(add(1, 2)) # 3
# 调用add函数,传入三个参数,分别赋值给a、b、c三个变量
print(add(1, 2, 3)) # 6
# 传递参数时可以不按照设定的顺序进行传递,但是要用“参数名=参数值”的形式
print(add(c=50, a=100, b=200)) # 350

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

可变参数

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"""
可变参数
"""


# 用星号表达式来表示args可以接收0个或任意多个参数
def add(*args):
total = 0
# 可变参数可以放在for循环中取出每个参数的值
for val in args:
if type(val) in (int, float):
total += val
return total


# 在调用add函数时可以传入0个或任意多个参数
print(add())
print(add(1))
print(add(1, 2))
print(add(1, 2, 3))
print(add(1, 3, 5, 7, 9))

用模块管理函数

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

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


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


foo() # 大家猜猜调用foo函数会输出什么

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

module1.py

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

module2.py

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

test.py

1
2
3
4
5
6
import module1
import module2

# 用“模块名.函数名”的方式(完全限定名)调用函数,
module1.foo() # hello, world!
module2.foo() # goodbye, world!

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

test.py

1
2
3
4
5
import module1 as m1
import module2 as m2

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

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

test.py

1
2
3
4
5
6
7
from module1 import foo

foo() # hello, world!

from module2 import foo

foo() # goodbye, world!

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

test.py

1
2
3
4
from module2 import foo
from module1 import foo

foo() # hello, world!

如果想在上面的代码中同时使用来自两个模块中的foo函数也是有办法的,大家可能已经猜到了,还是用as关键字对导入的函数进行别名,代码如下所示。

test.py

1
2
3
4
5
from module1 import foo as f1
from module2 import foo as f2

f1() # hello, world!
f2() # goodbye, world!

标准库中的模块和函数

Python标准库中提供了大量的模块和函数来简化我们的开发工作,我们之前用过的random模块就为我们提供了生成随机数和进行随机抽样的函数;而time模块则提供了和时间操作相关的函数;上面求阶乘的函数在Python标准库中的math模块中已经有了,实际开发中并不需要我们自己编写,而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

简单的总结

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

第14课:函数的应用

接下来我们通过一些案例来为大家讲解函数的应用。

经典小案例

案例1:设计一个生成验证码的函数。

说明:验证码由数字和英文大小写字母构成,长度可以用参数指定。

1
2
3
4
5
6
7
8
9
10
11
12
13
import random
import string

ALL_CHARS = string.digits + string.ascii_letters


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

:param code_len: 验证码的长度(默认4个字符)
:return: 由大小写英文字母和数字构成的随机验证码字符串
"""
return ''.join(random.choices(ALL_CHARS, k=code_len))

可以用下面的代码生成10组随机验证码来测试上面的函数。

1
2
for _ in range(10):
print(generate_code())

说明random模块的samplechoices函数都可以实现随机抽样,sample实现无放回抽样,这意味着抽样取出的字符是不重复的;choices实现有放回抽样,这意味着可能会重复选中某些字符。这两个函数的第一个参数代表抽样的总体,而参数k代表抽样的数量。

案例2:设计一个函数返回给定文件的后缀名。

说明:文件名通常是一个字符串,而文件的后缀名指的是文件名中最后一个.后面的部分,也称为文件的扩展名,它是某些操作系统用来标记文件类型的一种机制,例如在Windows系统上,后缀名exe表示这是一个可执行程序,而后缀名txt表示这是一个纯文本文件。需要注意的是,在Linux和macOS系统上,文件名可以以.开头,表示这是一个隐藏文件,像.gitignore这样的文件名,.后面并不是后缀名,这个文件没有后缀名或者说后缀名为''

1
2
3
4
5
6
7
8
9
10
11
12
13
def get_suffix(filename, ignore_dot=True):
"""获取文件名的后缀名

:param filename: 文件名
:param ignore_dot: 是否忽略后缀名前面的点
:return: 文件的后缀名
"""
# 从字符串中逆向查找.出现的位置
pos = filename.rfind('.')
# 通过切片操作从文件名中取出后缀名
if pos <= 0:
return ''
return filename[pos + 1:] if ignore_dot else filename[pos:]

可以用下面的代码对上面的函数做一个简单的测验。

1
2
3
4
5
print(get_suffix('readme.txt'))       # txt
print(get_suffix('readme.txt.md')) # md
print(get_suffix('.readme')) #
print(get_suffix('readme.')) #
print(get_suffix('readme')) #

上面的get_suffix函数还有一个更为便捷的实现方式,就是直接使用os.path模块的splitext函数,这个函数会将文件名拆分成带路径的文件名和扩展名两个部分,然后返回一个二元组,二元组中的第二个元素就是文件的后缀名(包含.),如果要去掉后缀名中的.,可以做一个字符串的切片操作,代码如下所示。

1
2
3
4
5
from os.path import splitext


def get_suffix(filename, ignore_dot=True):
return splitext(filename)[1][1:]

思考:如果要给上面的函数增加一个参数,用来控制文件的后缀名是否包含.,应该怎么做?

案例3:写一个判断给定的正整数是不是质数的函数。

1
2
3
4
5
6
7
8
9
10
def is_prime(num: int) -> bool:
"""判断一个正整数是不是质数

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

案例4:写出计算两个正整数最大公约数和最小公倍数的函数。

代码一:

1
2
3
4
5
6
def gcd_and_lcm(x: int, y: int) -> int:
"""求最大公约数和最小公倍数"""
a, b = x, y
while b % a != 0:
a, b = b % a, a
return a, x * y // a

代码二:

1
2
3
4
5
6
7
8
9
10
def gcd(x: int, y: int) -> int:
"""求最大公约数"""
while y % x != 0:
x, y = y % x, x
return x


def lcm(x: int, y: int) -> int:
"""求最小公倍数"""
return x * y // gcd(x, y)

思考:请比较上面的代码一和代码二,想想哪种做法是更好的选择。

案例5:写出计算一组样本数据描述性统计信息的函数。

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
import math


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


def average(data):
"""求均值"""
return sum(data) / len(data)


def variance(data):
"""求方差"""
x_bar = average(data)
temp = [(num - x_bar) ** 2 for num in data]
return sum(temp) / (len(temp) - 1)


def standard_deviation(data):
"""求标准差"""
return math.sqrt(variance(data))


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

简单的总结

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

第15课:函数使用进阶

前面我们讲到了关于函数的知识,我们还讲到过Python中常用的数据类型,这些类型的变量都可以作为函数的参数或返回值,用好函数还可以让我们做更多的事情。

关键字参数

下面是一个判断传入的三条边长能否构成三角形的函数,在调用函数传入参数时,我们可以指定参数名,也可以不指定参数名,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
def is_triangle(a, b, c):
print(f'a = {a}, b = {b}, c = {c}')
return a + b > c and b + c > a and a + c > b


# 调用函数传入参数不指定参数名按位置对号入座
print(is_triangle(1, 2, 3))
# 调用函数通过“参数名=参数值”的形式按顺序传入参数
print(is_triangle(a=1, b=2, c=3))
# 调用函数通过“参数名=参数值”的形式不按顺序传入参数
print(is_triangle(c=3, a=1, b=2))

在没有特殊处理的情况下,函数的参数都是位置参数,也就意味着传入参数的时候对号入座即可,如上面代码的第7行所示,传入的参数值123会依次赋值给参数abc。当然,也可以通过参数名=参数值的方式传入函数所需的参数,因为指定了参数名,传入参数的顺序可以进行调整,如上面代码的第9行和第11行所示。

调用函数时,如果希望函数的调用者必须以参数名=参数值的方式传参,可以用命名关键字参数(keyword-only argument)取代位置参数。所谓命名关键字参数,是在函数的参数列表中,写在*之后的参数,代码如下所示。

1
2
3
4
5
6
7
8
9
10
def is_triangle(*, a, b, c):
print(f'a = {a}, b = {b}, c = {c}')
return a + b > c and b + c > a and a + c > b


# TypeError: is_triangle() takes 0 positional arguments but 3 were given
# print(is_triangle(3, 4, 5))
# 传参时必须使用“参数名=参数值”的方式,位置不重要
print(is_triangle(a=3, b=4, c=5))
print(is_triangle(c=5, b=4, a=3))

注意:上面的is_triangle函数,参数列表中的*是一个分隔符,*前面的参数都是位置参数,而*后面的参数就是命名关键字参数。

我们之前讲过在函数的参数列表中可以使用可变参数*args来接收任意数量的参数,但是我们需要看看,*args是否能够接收带参数名的参数。

1
2
3
4
5
6
7
8
9
def calc(*args):
result = 0
for arg in args:
if type(arg) in (int, float):
result += arg
return result


print(calc(a=1, b=2, c=3))

执行上面的代码会引发TypeError错误,错误消息为calc() got an unexpected keyword argument 'a',由此可见,*args并不能处理带参数名的参数。我们在设计函数时,如果既不知道调用者会传入的参数个数,也不知道调用者会不会指定参数名,那么同时使用可变参数和关键字参数。关键字参数会将传入的带参数名的参数组装成一个字典,参数名就是字典中键值对的键,而参数值就是字典中键值对的值,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def calc(*args, **kwargs):
result = 0
for arg in args:
if type(arg) in (int, float):
result += arg
for value in kwargs.values():
if type(value) in (int, float):
result += value
return result


print(calc()) # 0
print(calc(1, 2, 3)) # 6
print(calc(a=1, b=2, c=3)) # 6
print(calc(1, 2, c=3, d=4)) # 10

提示不带参数名的参数(位置参数)必须出现在带参数名的参数(关键字参数)之前,否则将会引发异常。例如,执行calc(1, 2, c=3, d=4, 5)将会引发SyntaxError错误,错误消息为positional argument follows keyword argument,翻译成中文意思是“位置参数出现在关键字参数之后”。

高阶函数的用法

在前面几节课中,我们讲到了面向对象程序设计,在面向对象的世界中,一切皆为对象,所以类和函数也是对象。函数的参数和返回值可以是任意类型的对象,这就意味着函数本身也可以作为函数的参数或返回值,这就是所谓的高阶函数

如果我们希望上面的calc函数不仅仅可以做多个参数求和,还可以做多个参数求乘积甚至更多的二元运算,我们就可以使用高阶函数的方式来改写上面的代码,将加法运算从函数中移除掉,具体的做法如下所示。

1
2
3
4
5
6
7
8
9
def calc(*args, init_value, op, **kwargs):
result = init_value
for arg in args:
if type(arg) in (int, float):
result = op(result, arg)
for value in kwargs.values():
if type(value) in (int, float):
result = op(result, value)
return result

注意,上面的函数增加了两个参数,其中init_value代表运算的初始值,op代表二元运算函数。经过改造的calc函数不仅仅可以实现多个参数的累加求和,也可以实现多个参数的累乘运算,代码如下所示。

1
2
3
4
5
6
7
8
9
10
def add(x, y):
return x + y


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


print(calc(1, 2, 3, init_value=0, op=add, x=4, y=5)) # 15
print(calc(1, 2, x=3, y=4, z=5, init_value=1, op=mul)) # 120

通过对高阶函数的运用,calc函数不再和加法运算耦合,所以灵活性和通用性会变强,这是一种解耦合的编程技巧,但是最初学者来说可能会稍微有点难以理解。需要注意的是,将函数作为参数和调用函数是有显著的区别的,调用函数需要在函数名后面跟上圆括号,而把函数作为参数时只需要函数名即可。上面的代码也可以不用定义addmul函数,因为Python标准库中的operator模块提供了代表加法运算的add和代表乘法运算的mul函数,我们直接使用即可,代码如下所示。

1
2
3
4
import operator

print(calc(1, 2, 3, init_value=0, op=operator.add, x=4, y=5)) # 15
print(calc(1, 2, x=3, y=4, z=5, init_value=1, op=operator.mul)) # 120

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

1
2
3
4
5
6
7
8
9
10
11
def is_even(num):
return num % 2 == 0


def square(num):
return num ** 2


numbers1 = [35, 12, 8, 99, 60, 52]
numbers2 = list(map(square, filter(is_even, numbers1)))
print(numbers2) # [144, 64, 3600, 2704]

当然,要完成上面代码的功能,也可以使用列表生成式,列表生成式的做法更为简单优雅。

1
2
3
numbers1 = [35, 12, 8, 99, 60, 52]
numbers2 = [num ** 2 for num in numbers1 if num % 2 == 0]
print(numbers2) # [144, 64, 3600, 2704]

Lambda函数

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

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

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

如果需要使用加减乘除这种简单的二元函数,也可以用Lambda函数来书写,例如调用上面的calc函数时,可以通过传入Lambda函数来作为op参数的参数值。当然,op参数也可以有默认值,例如我们可以用一个代表加法运算的Lambda函数来作为op参数的默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def calc(*args, init_value=0, op=lambda x, y: x + y, **kwargs):
result = init_value
for arg in args:
if type(arg) in (int, float):
result = op(result, arg)
for value in kwargs.values():
if type(value) in (int, float):
result = op(result, value)
return result


# 调用calc函数,使用init_value和op的默认值
print(calc(1, 2, 3, x=4, y=5)) # 15
# 调用calc函数,通过lambda函数给op参数赋值
print(calc(1, 2, 3, x=4, y=5, init_value=1, op=lambda x, y: x * y)) # 120

提示:注意上面的代码中的calc函数,它同时使用了可变参数、关键字参数、命名关键字参数,其中命名关键字参数要放在可变参数和关键字参数之间,传参时先传入可变参数,关键字参数和命名关键字参数的先后顺序并不重要。

有很多函数在Python中用一行代码就能实现,我们可以用Lambda函数来定义这些函数,调用Lambda函数就跟调用普通函数一样,代码如下所示。

1
2
3
4
5
6
7
8
9
10
import operator, functools

# 一行代码定义求阶乘的函数
fac = lambda num: functools.reduce(operator.mul, range(1, num + 1), 1)
# 一行代码定义判断素数的函数
is_prime = lambda x: x > 1 and all(map(lambda f: x % f, range(2, int(x ** 0.5) + 1)))

# 调用Lambda函数
print(fac(10)) # 3628800
print(is_prime(9)) # False

提示1:上面使用的reduce函数是Python标准库functools模块中的函数,它可以实现对数据的归约操作,通常情况下,过滤(filter)、映射(map)和归约(reduce)是处理数据中非常关键的三个步骤,而Python的标准库也提供了对这三个操作的支持。

提示2:上面使用的all函数是Python内置函数,如果传入的序列中所有布尔值都是Trueall函数就返回True,否则all函数就返回False

简单的总结

Python中的函数可以使用可变参数*args和关键字参数**kwargs来接收任意数量的参数,而且传入参数时可以带上参数名也可以没有参数名,可变参数会被处理成一个元组,而关键字参数会被处理成一个字典。Python中的函数是一等函数,可以赋值给变量,也可以作为函数的参数和返回值,这也就意味着我们可以在Python中使用高阶函数。如果我们要定义的函数非常简单,只有一行代码且不需要函数名,可以使用Lambda函数(匿名函数)。

第16课:函数的高级应用

在上一节课中,我们已经对函数进行了更为深入的研究,还探索了Python中的高阶函数和Lambda函数。在这些知识的基础上,这节课我们为大家分享两个和函数相关的内容,一个是装饰器,一个是函数的递归调用。

装饰器

装饰器是Python中用一个函数装饰另外一个函数或类并为其提供额外功能的语法现象。装饰器本身是一个函数,它的参数是被装饰的函数或类,它的返回值是一个带有装饰功能的函数。很显然,装饰器是一个高阶函数,它的参数和返回值都是函数。下面我们先通过一个简单的例子来说明装饰器的写法和作用,假设已经有名为downlaodupload的两个函数,分别用于文件的上传和下载,下面的代码用休眠一段随机时间的方式模拟了下载和上传需要花费的时间,并没有联网做上传下载。

说明:用Python语言实现联网的上传下载也很简单,继续你的学习,这个环节很快就会来到。

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


def download(filename):
print(f'开始下载{filename}.')
time.sleep(random.randint(2, 6))
print(f'{filename}下载完成.')


def upload(filename):
print(f'开始上传{filename}.')
time.sleep(random.randint(4, 8))
print(f'{filename}上传完成.')


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

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

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

通过上面的代码,我们可以得到下载和上传花费的时间,但不知道大家是否注意到,上面记录时间、计算和显示执行时间的代码都是重复代码。有编程经验的人都知道,重复的代码是万恶之源,那么有没有办法在不写重复代码的前提下,用一种简单优雅的方式记录下函数的执行时间呢?在Python中,装饰器就是解决这类问题的最佳选择。我们可以把记录函数执行时间的功能封装到一个装饰器中,在有需要的地方直接使用这个装饰器就可以了,代码如下所示。

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


# 定义装饰器函数,它的参数是被装饰的函数或类
def record_time(func):

# 定义一个带装饰功能(记录被装饰函数的执行时间)的函数
# 因为不知道被装饰的函数有怎样的参数所以使用*args和**kwargs接收所有参数
# 在Python中函数可以嵌套的定义(函数中可以再定义函数)
def wrapper(*args, **kwargs):
# 在执行被装饰的函数之前记录开始时间
start = time.time()
# 执行被装饰的函数并获取返回值
result = func(*args, **kwargs)
# 在执行被装饰的函数之后记录结束时间
end = time.time()
# 计算和显示被装饰函数的执行时间
print(f'{func.__name__}执行时间: {end - start:.3f}秒')
# 返回被装饰函数的返回值(装饰器通常不会改变被装饰函数的执行结果)
return result

# 返回带装饰功能的wrapper函数
return wrapper

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

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

上面的代码中已经没有重复代码了,虽然写装饰器会花费一些心思,但是这是一个一劳永逸的骚操作,如果还有其他的函数也需要记录执行时间,按照上面的代码如法炮制即可。

在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
import random
import time


def record_time(func):

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

return wrapper


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


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


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

上面的代码,我们通过装饰器语法糖为downloadupload函数添加了装饰器,这样调用downloadupload函数时,会记录下函数的执行时间。事实上,被装饰后的downloadupload函数是我们在装饰器record_time中返回的wrapper函数,调用它们其实就是在调用wrapper函数,所以拥有了记录函数执行时间的功能。

如果希望取消装饰器的作用,那么在定义装饰器函数的时候,需要做一些额外的工作。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:.3f}秒')
return result

return wrapper


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


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


download('MySQL从删库到跑路.avi')
upload('Python从入门到住院.pdf')
# 取消装饰器
download.__wrapped__('MySQL必知必会.pdf')
upload = upload.__wrapped__
upload('Python从新手到大师.pdf')

装饰器函数本身也可以参数化,简单的说就是通过我们的装饰器也是可以通过调用者传入的参数来定制的,这个知识点我们在后面用到它的时候再为大家讲解。

递归调用

Python中允许函数嵌套定义,也允许函数之间相互调用,而且一个函数还可以直接或间接的调用自身。函数自己调用自己称为递归调用,那么递归调用有什么用处呢?现实中,有很多问题的定义本身就是一个递归定义,例如我们之前讲到的阶乘,非负整数N的阶乘是N乘以N-1的阶乘,即,定义的左边和右边都出现了阶乘的概念,所以这是一个递归定义。既然如此,我们可以使用递归调用的方式来写一个求阶乘的函数,代码如下所示。

1
2
3
4
def fac(num):
if num in (0, 1):
return 1
return num * fac(num - 1)

上面的代码中,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官方解释器,默认将函数调用的栈结构最大深度设置为1000层。如果超出这个深度,就会发生上面说的RecursionError。当然,我们可以使用sys模块的setrecursionlimit函数来改变递归调用的最大深度,例如:sys.setrecursionlimit(10000),这样就可以让上面的fac(5000)顺利执行出结果,但是我们不建议这样做,因为让递归快速收敛才是我们应该做的事情,否则就应该考虑使用循环递推而不是递归。

再举一个之前讲过的生成斐波那契数列的例子,因为斐波那契数列前两个数都是1,从第3个数开始,每个数是前两个数相加的和,可以记为f(n) = f(n - 1) + f(n - 2),很显然这又是一个递归的定义,所以我们可以用下面的递归调用函数来计算第​n个斐波那契数。

1
2
3
4
5
6
7
8
9
def fib(n):
if n in (1, 2):
return 1
return fib(n - 1) + fib(n - 2)


# 打印前20个斐波那契数
for i in range(1, 21):
print(fib(i))

需要提醒大家,上面计算斐波那契数的代码虽然看起来非常简单明了,但执行性能是比较糟糕的,原因大家可以自己思考一下,更好的做法还是之前讲过的使用循环递推的方式,代码如下所示。

1
2
3
4
5
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a

简单的总结

装饰器是Python中的特色语法,可以通过装饰器来增强现有的函数,这是一种非常有用的编程技巧。一些复杂的问题用函数递归调用的方式写起来真的很简单,但是函数的递归调用一定要注意收敛条件和递归公式,找到递归公式才有机会使用递归调用,而收敛条件确定了递归什么时候停下来。函数调用通过内存中的栈空间来保存现场和恢复现场,栈空间通常都很小,所以递归如果不能迅速收敛,很可能会引发栈溢出错误,从而导致程序的崩溃

第17课:面向对象编程入门

面向对象编程是一种非常流行的编程范式(programming paradigm),所谓编程范式就是程序设计的方法论,简单的说就是程序员对程序的认知和理解以及他们编写代码的方式。

在前面的课程中,我们说过“程序是指令的集合”,运行程序时,程序中的语句会变成一条或多条指令,然后由CPU(中央处理器)去执行。为了简化程序的设计,我们又讲到了函数,把相对独立且经常重复使用的代码放置到函数中,在需要使用这些代码的时候调用函数即可。如果一个函数的功能过于复杂和臃肿,我们又可以进一步将函数进一步拆分为多个子函数来降低系统的复杂性。

不知大家是否发现,我们的编程工作其实是写程序的人按照计算机的工作方式通过代码控制机器完成任务。但是,计算机的工作方式与人类正常的思维模式是不同的,如果编程就必须抛弃人类正常的思维方式去迎合计算机,编程的乐趣就少了很多,而“每个人都应该学习编程”的豪言壮语也就只能喊喊口号而已。这里,我想说的并不是我们不能按照计算机的工作方式去编写代码,但是当我们需要开发一个复杂的系统时,这种方式会让代码过于复杂,从而导致开发和维护工作都变得举步维艰。

随着软件复杂性的增加,编写正确可靠的代码会变成了一项极为艰巨的任务,这也是很多人都坚信“软件开发是人类改造世界所有活动中最为复杂的活动”的原因。如何用程序描述复杂系统和解决复杂问题,就成为了所有程序员必须要思考和直面的问题。诞生于上世纪70年代的Smalltalk语言让软件开发者看到了希望,因为它引入了一种新的编程范式叫面向对象编程。在面向对象编程的世界里,程序中的数据和操作数据的函数是一个逻辑上的整体,我们称之为对象对象可以接收消息,解决问题的方法就是创建对象并向对象发出各种各样的消息;通过消息传递,程序中的多个对象可以协同工作,这样就能构造出复杂的系统并解决现实中的问题。当然,面向对象编程的雏形还可以向前追溯到更早期的Simula语言,但这不是我们现在要讨论的重点。

说明: 今天我们使用的很多高级程序设计语言都支持面向对象编程,但是面向对象编程也不是解决软件开发中所有问题的“银弹”,或者说在软件开发这个行业目前还找不到这种所谓的“银弹”。关于这个问题,大家可以参考IBM360系统之父弗雷德里克·布鲁克斯所发表的论文《没有银弹:软件工程的本质性与附属性工作》或软件工程的经典著作《人月神话》一书。

类和对象

如果要用一句话来概括面向对象编程,我认为下面的说法是相当精辟和准确的。

面向对象编程:把一组数据和处理数据的方法组成对象,把行为相同的对象归纳为,通过封装隐藏对象的内部细节,通过继承实现类的特化和泛化,通过多态实现基于对象类型的动态分派。

这句话对初学者来说可能不那么容易理解,但是我可以先为大家圈出几个关键词:对象(object)、(class)、封装(encapsulation)、继承(inheritance)、多态(polymorphism)。

我们先说说类和对象这两个词。在面向对象编程中,类是一个抽象的概念,对象是一个具体的概念。我们把同一类对象的共同特征抽取出来就是一个类,比如我们经常说的人类,这是一个抽象概念,而我们每个人就是人类的这个抽象概念下的实实在在的存在,也就是一个对象。简而言之,类是对象的蓝图和模板,对象是类的实例,是可以接受消息的实体

在面向对象编程的世界中,一切皆为对象对象都有属性和行为每个对象都是独一无二的,而且对象一定属于某个类。对象的属性是对象的静态特征,对象的行为是对象的动态特征。按照上面的说法,如果我们把拥有共同特征的对象的属性和行为都抽取出来,就可以定义出一个类。

定义类

在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
2
3
4
5
stu1 = Student()
stu2 = Student()
print(stu1) # <__main__.Student object at 0x10ad5ac50>
print(stu2) # <__main__.Student object at 0x10ad5acd0>
print(hex(id(stu1)), hex(id(stu2))) # 0x10ad5ac50 0x10ad5acd0

在类的名字后跟上圆括号就是所谓的构造器语法,上面的代码创建了两个学生对象,一个赋值给变量stu1,一个复制给变量stu2。当我们用print函数打印stu1stu2两个变量时,我们会看到输出了对象在内存中的地址(十六进制形式),跟我们用id函数查看对象标识获得的值是相同的。现在我们可以告诉大家,我们定义的变量其实保存的是一个对象在内存中的逻辑地址(位置),通过这个逻辑地址,我们就可以在内存中找到这个对象。所以stu3 = stu2这样的赋值语句并没有创建新的对象,只是用一个新的变量保存了已有对象的地址。

接下来,我们尝试给对象发消息,即调用对象的方法。刚才的Student类中我们定义了studyplay两个方法,两个方法的第一个参数self代表了接收消息的学生对象,study方法的第二个参数是学习的课程名称。Python中,给对象发消息有两种方式,请看下面的代码。

1
2
3
4
5
6
7
# 通过“类.方法”调用方法,第一个参数是接收消息的对象,第二个参数是学习的课程名称
Student.study(stu1, 'Python程序设计') # 学生正在学习Python程序设计.
# 通过“对象.方法”调用方法,点前面的对象就是接收消息的对象,只需要传入第二个参数
stu1.study('Python程序设计') # 学生正在学习Python程序设计.

Student.play(stu2) # 学生正在玩游戏.
stu2.play() # 学生正在玩游戏.

初始化方法

大家可能已经注意到了,刚才我们创建的学生对象只有行为没有属性,如果要给学生对象定义属性,我们可以修改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):
"""初始化方法"""
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
6
# 由于初始化方法除了self之外还有两个参数
# 所以调用Student类的构造器创建对象时要传入这两个参数
stu1 = Student('骆昊', 40)
stu2 = Student('王大锤', 15)
stu1.study('Python程序设计') # 骆昊正在学习Python程序设计.
stu2.play() # 王大锤正在玩游戏.

打印对象

上面我们通过__init__方法在创建对象时为对象绑定了属性并赋予了初始值。在Python中,以两个下划线__(读作“dunder”)开头和结尾的方法通常都是有特殊用途和意义的方法,我们一般称之为魔术方法魔法方法。如果我们在打印对象的时候不希望看到对象的地址而是看到我们自定义的信息,可以通过在类中放置__repr__魔术方法来做到,该方法返回的字符串就是用print函数打印对象的时候会显示的内容,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Student:
"""学生"""

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

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

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

def __repr__(self):
return f'{self.name}: {self.age}'


stu1 = Student('骆昊', 40)
print(stu1) # 骆昊: 40
students = [stu1, Student('李元芳', 36), Student('王大锤', 25)]
print(students) # [骆昊: 40, 李元芳: 36, 王大锤: 25]

面向对象的支柱

面向对象编程有三大支柱,就是我们之前给大家划重点的时候圈出的三个词:封装继承多态。后面两个概念在下一节课中会详细说明,这里我们先说一下什么是封装。我自己对封装的理解是:隐藏一切可以隐藏的实现细节,只向外界暴露简单的调用接口。我们在类中定义的对象方法其实就是一种封装,这种封装可以让我们在创建对象之后,只需要给对象发送一个消息就可以执行方法中的代码,也就是说我们在只知道方法的名字和参数(方法的外部视图),不知道方法内部实现细节(方法的内部视图)的情况下就完成了对方法的使用。

举一个例子,假如要控制一个机器人帮我倒杯水,如果不使用面向对象编程,不做任何的封装,那么就需要向这个机器人发出一系列的指令,如站起来、向左转、向前走5步、拿起面前的水杯、向后转、向前走10步、弯腰、放下水杯、按下出水按钮、等待10秒、松开出水按钮、拿起水杯、向右转、向前走5步、放下水杯等,才能完成这个简单的操作,想想都觉得麻烦。按照面向对象编程的思想,我们可以将倒水的操作封装到机器人的一个方法中,当需要机器人帮我们倒水的时候,只需要向机器人对象发出倒水的消息就可以了,这样做不是更好吗?

在很多场景下,面向对象编程其实就是一个三步走的问题。第一步定义类,第二步创建对象,第三步给对象发消息。当然,有的时候我们是不需要第一步的,因为我们想用的类可能已经存在了。之前我们说过,Python内置的listsetdict其实都不是函数而是类,如果要创建列表、集合、字典对象,我们就不用自定义类了。当然,有的类并不是Python标准库中直接提供的,它可能来自于第三方的代码,如何安装和使用三方代码在后续课程中会进行讨论。在某些特殊的场景中,我们会用到名为“内置对象”的对象,所谓“内置对象”就是说上面三步走的第一步和第二步都不需要了,因为类已经存在而且对象已然创建过了,直接向对象发消息就可以了,这也就是我们常说的“开箱即用”。

经典案例

案例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
import time


# 定义数字时钟类
class Clock(object):
"""数字时钟"""

def __init__(self, hour=0, minute=0, second=0):
"""初始化方法
:param hour: 时
:param minute: 分
:param second: 秒
"""
self.hour = hour
self.min = minute
self.sec = second

def run(self):
"""走字"""
self.sec += 1
if self.sec == 60:
self.sec = 0
self.min += 1
if self.min == 60:
self.min = 0
self.hour += 1
if self.hour == 24:
self.hour = 0

def show(self):
"""显示时间"""
return f'{self.hour:0>2d}:{self.min:0>2d}:{self.sec:0>2d}'


# 创建时钟对象
clock = Clock(23, 59, 58)
while True:
# 给时钟对象发消息读取时间
print(clock.show())
# 休眠1秒钟
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
class Point(object):
"""屏面上的点"""

def __init__(self, x=0, y=0):
"""初始化方法
:param x: 横坐标
:param y: 纵坐标
"""
self.x, self.y = x, y

def distance_to(self, other):
"""计算与另一个点的距离
:param other: 另一个点
"""
dx = self.x - other.x
dy = self.y - other.y
return (dx * dx + dy * dy) ** 0.5

def __str__(self):
return f'({self.x}, {self.y})'


p1 = Point(3, 5)
p2 = Point(6, 9)
print(p1, p2)
print(p1.distance_to(p2))

简单的总结

面向对象编程是一种非常流行的编程范式,除此之外还有指令式编程函数式编程等编程范式。由于现实世界是由对象构成的,而对象是可以接收消息的实体,所以面向对象编程更符合人类正常的思维习惯。类是抽象的,对象是具体的,有了类就能创建对象,有了对象就可以接收消息,这就是面向对象编程的基础。定义类的过程是一个抽象的过程,找到对象公共的属性属于数据抽象,找到对象公共的方法属于行为抽象。抽象的过程是一个仁者见仁智者见智的过程,对同一类对象进行抽象可能会得到不同的结果,如下图所示。

说明: 本节课的插图来自于 Grady Booc 等撰写的《面向对象分析与设计》一书,该书是讲解面向对象编程的经典著作,有兴趣的读者可以购买和阅读这本书来了解更多的面向对象的相关知识。

第18课:面向对象编程进阶

上一节课我们讲解了Python面向对象编程的基础知识,这一节课我们继续来讨论面向对象编程相关的内容。

可见性和属性装饰器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
class Student:

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

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


stu = Student('王大锤', 20)
stu.study('Python程序设计')
print(stu.__name)

上面代码的最后一行会引发AttributeError(属性错误)异常,异常消息为:'Student' object has no attribute '__name'。由此可见,以__开头的属性__name是私有的,在类的外面无法直接访问,但是类里面的study方法中可以通过self.__name访问该属性。

需要提醒大家的是,Python并没有从语法上严格保证私有属性的私密性,它只是给私有的属性和方法换了一个名字来阻挠对它们的访问,事实上如果你知道更换名字的规则仍然可以访问到它们,我们可以对上面的代码稍作修改就可以访问到私有的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Student:

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

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


stu = Student('王大锤', 20)
stu.study('Python程序设计')
print(stu._Student__name, stu._Student__age)

Python中有一句名言:“We are all consenting adults here”(大家都是成年人)。Python语言的设计者认为程序员要为自己的行为负责,而不是由Python语言本身来严格限制访问可见性,而大多数的程序员都认为开放比封闭要好,把对象的属性私有化并不是编程语言必须的东西,所以Python并没有从语法上做出最严格的限定。

Python中可以通过property装饰器为“私有”属性提供读取和修改的方法,之前我们提到过,装饰器通常会放在类、函数或方法的声明之前,通过一个@符号表示将装饰器应用于类、函数或方法。

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 Student:

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

# 属性访问器(getter方法) - 获取__name属性
@property
def name(self):
return self.__name

# 属性修改器(setter方法) - 修改__name属性
@name.setter
def name(self, name):
# 如果name参数不为空就赋值给对象的__name属性
# 否则将__name属性赋值为'无名氏',有两种写法
# self.__name = name if name else '无名氏'
self.__name = name or '无名氏'

@property
def age(self):
return self.__age


stu = Student('王大锤', 20)
print(stu.name, stu.age) # 王大锤 20
stu.name = ''
print(stu.name) # 无名氏
# stu.age = 30 # AttributeError: can't set attribute

在实际项目开发中,我们并不经常使用私有属性,属性装饰器的使用也比较少,所以上面的知识点大家简单了解一下就可以了。

动态属性

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

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

1
2
3
4
5
6
7
8
9
10
class Student:

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


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

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

1
2
3
4
5
6
7
8
9
10
11
class Student:
__slots__ = ('name', 'age')

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


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

静态方法和类方法

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

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

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 Triangle(object):
"""三角形类"""

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

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

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

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

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

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

继承和多态

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

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
class Person:
"""人类"""

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

def eat(self):
print(f'{self.name}正在吃饭.')

def sleep(self):
print(f'{self.name}正在睡觉.')


class Student(Person):
"""学生类"""

def __init__(self, name, age):
# super(Student, self).__init__(name, age)
super().__init__(name, age)

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


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

def __init__(self, name, age, title):
# super(Teacher, self).__init__(name, age)
super().__init__(name, age)
self.title = title

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



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

继承的语法是在定义类的时候,在类名后的圆括号中指定当前类的父类。如果定义一个类的时候没有指定它的父类是谁,那么默认的父类是object类。object类是Python中的顶级类,这也就意味着所有的类都是它的子类,要么直接继承它,要么间接继承它。Python语言允许多重继承,也就是说一个类可以有一个或多个父类,关于多重继承的问题我们在后面会有更为详细的讨论。在子类的初始化方法中,我们可以通过super().__init__()来调用父类初始化方法,super函数是Python内置函数中专门为获取当前对象的父类对象而设计的。从上面的代码可以看出,子类除了可以通过继承得到父类提供的属性和方法外,还可以定义自己特有的属性和方法,所以子类比父类拥有的更多的能力。在实际开发中,我们经常会用子类对象去替换掉一个父类对象,这是面向对象编程中一个常见的行为,也叫做“里氏替换原则”(Liskov Substitution Principle)。

子类继承父类的方法后,还可以对方法进行重写(重新实现该方法),不同的子类可以对父类的同一个方法给出不同的实现版本,这样的方法在程序运行时就会表现出多态行为(调用相同的方法,做了不同的事情)。多态是面向对象编程中最精髓的部分,当然也是对初学者来说最难以理解和灵活运用的部分,我们会在下一节课中用专门的例子来讲解多态这个知识点。

简单的总结

Python是动态语言,Python中的对象可以动态的添加属性。在面向对象的世界中,一切皆为对象,我们定义的类也是对象,所以类也可以接收消息,对应的方法是类方法或静态方法。通过继承,我们可以从已有的类创建新类,实现对已有类代码的复用。

第19课:面向对象编程应用

面向对象编程对初学者来说不难理解但很难应用,虽然我们为大家总结过面向对象的三步走方法(定义类、创建对象、给对象发消息),但是说起来容易做起来难。大量的编程练习阅读优质的代码可能是这个阶段最能够帮助到大家的两件事情。接下来我们还是通过经典的案例来剖析面向对象编程的知识,同时也通过这些案例为大家讲解如何运用之前学过的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
6
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
12
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
card1 = Card(Suite.SPADE, 5)
card2 = Card(Suite.HEART, 13)
print(card1, card2) # ♠5 ♥K

接下来我们定义扑克类。

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
import random


class Poker:
"""扑克"""

def __init__(self):
# 通过列表的生成式语法创建一个装52张牌的列表
self.cards = [Card(suite, face) for suite in Suite
for face in range(1, 14)]
# current属性表示发牌的位置
self.current = 0

def shuffle(self):
"""洗牌"""
self.current = 0
# 通过random模块的shuffle函数实现列表的随机乱序
random.shuffle(self.cards)

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
poker = Poker()
poker.shuffle()
print(poker.cards)

定义玩家类。

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()

创建四个玩家并将牌发到玩家的手上。

1
2
3
4
5
6
7
8
9
10
poker = Poker()
poker.shuffle()
players = [Player('东邪'), Player('西毒'), Player('南帝'), Player('北丐')]
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
17
18
19
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),游戏的规则可以自己在网上找一找。

案例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
8
9
10
emps = [
Manager('刘备'), Programmer('诸葛亮'), Manager('曹操'),
Programmer('荀彧'), Salesman('吕布'), Programmer('张辽'),
]
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}元')

简单的总结

面向对象的编程思想非常的好,也符合人类的正常思维习惯,但是要想灵活运用面向对象编程中的抽象、封装、继承、多态需要长时间的积累和沉淀,这件事情无法一蹴而就,属于“路漫漫其修远兮,吾将上下而求索”的东西。

第20课:Python标准库初探

Python语言最可爱的地方在于它的标准库和三方库实在是太丰富了,日常开发工作中的很多任务都可以通过这些标准库或者三方库直接解决。下面我们先介绍Python标准库中的一些常用模块,后面的课程中再陆陆续续为大家介绍Python常用三方库的用途和用法。

base64 - Base64编解码模块

Base64是一种基于64个可打印字符来表示二进制数据的方法。由于,所以Base64以6个比特(二进制位,可以表示0或1)为一个单元,每个单元对应一个可打印字符。对于3字节(24比特)的二进制数据,我们可以将其处理成对应于4个Base64单元,即3个字节可由4个可打印字符来表示。Base64编码可用来作为电子邮件的传输编码,也可以用于其他需要将二进制数据转成文本字符的场景,这使得在XML、JSON、YAML这些文本数据格式中传输二进制内容成为可能。在Base64中的可打印字符包括A-Za-z0-9,这里一共是62个字符,另外两个可打印符号通常是+/=用于在Base64编码最后进行补位。

关于Base64编码的细节,大家可以参考《Base64笔记》 一文,Python标准库中的base64模块提供了b64encodeb64decode两个函数,专门用于实现Base64的编码和解码,下面演示了在Python的交互式环境中执行这两个函数的效果。

1
2
3
4
5
6
7
8
>>> import base64
>>>
>>> content = 'Man is distinguished, not only by his reason, but by this singular passion from other animals, which is a lust of the mind, that by a perseverance of delight in the continued and indefatigable generation of knowledge, exceeds the short vehemence of any carnal pleasure.'
>>> base64.b64encode(content.encode())
b'TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sIGJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYWxzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJzZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRlZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZSBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4='
>>> content = b'TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sIGJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYWxzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJzZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRlZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZSBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4='
>>> base64.b64decode(content).decode()
'Man is distinguished, not only by his reason, but by this singular passion from other animals, which is a lust of the mind, that by a perseverance of delight in the continued and indefatigable generation of knowledge, exceeds the short vehemence of any carnal pleasure.'

collections - 容器数据类型模块

collections模块提供了诸多非常好用的数据结构,主要包括:

  • namedtuple:命令元组,它是一个类工厂,接受类型的名称和属性列表来创建一个类。
  • deque:双端队列,是列表的替代实现。Python中的列表底层是基于数组来实现的,而deque底层是双向链表,因此当你需要在头尾添加和删除元素是,deque会表现出更好的性能,渐近时间复杂度为
  • Counterdict的子类,键是元素,值是元素的计数,它的most_common()方法可以帮助我们获取出现频率最高的元素。Counterdict的继承关系我认为是值得商榷的,按照CARP原则,Counterdict的关系应该设计为关联关系更为合理。
  • OrderedDictdict的子类,它记录了键值对插入的顺序,看起来既有字典的行为,也有链表的行为。
  • defaultdict:类似于字典类型,但是可以通过默认的工厂函数来获得键对应的默认值,相比字典中的setdefault()方法,这种做法更加高效。

下面是在Python交互式环境中使用namedtuple创建扑克牌类的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> from collections import namedtuple
>>>
>>> Card = namedtuple('Card', ('suite', 'face'))
>>> card1 = Card('红桃', 5)
>>> card2 = Card('草花', 9)
>>> card1
Card(suite='红桃', face=5)
>>> card2
Card(suite='草花', face=9)
>>> print(f'{card1.suite}{card1.face}')
红桃5
>>> print(f'{card2.suite}{card2.face}')
草花9

下面是使用Counter类统计列表中出现次数最多的三个元素的例子。

1
2
3
4
5
6
7
8
9
10
11
12
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)
# 打印words列表中出现频率最高的3个元素及其出现次数
for elem, count in counter.most_common(3):
print(elem, count)

hashlib - 哈希函数模块

哈希函数又称哈希算法或散列函数,是一种为已有的数据创建“数字指纹”(哈希摘要)的方法。哈希函数把数据压缩成摘要,对于相同的输入,哈希函数可以生成相同的摘要(数字指纹),需要注意的是这个过程并不可逆(不能通过摘要计算出输入的内容)。一个优质的哈希函数能够为不同的输入生成不同的摘要,出现哈希冲突(不同的输入产生相同的摘要)的概率极低,MD5 SHA家族就是这类好的哈希函数。

说明:在2011年的时候,RFC 6151中已经禁止将MD5用作密钥散列消息认证码,这个问题不在我们讨论的范围内。

Python标准库的hashlib模块提供了对哈希函数的封装,通过使用md5sha1sha256等类,我们可以轻松的生成“数字指纹”。举一个简单的例子,用户注册时我们希望在数据库中保存用户的密码,很显然我们不能将用户密码直接保存在数据库中,这样可能会导致用户隐私的泄露,所以在数据库中保存用户密码时,通常都会将密码的“指纹”保存起来,用户登录时通过哈希函数计算密码的“指纹”再进行匹配来判断用户登录是否成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
import hashlib

# 计算字符串"123456"的MD5摘要
print(hashlib.md5('123456'.encode()).hexdigest())

# 计算文件"Python-3.7.1.tar.xz"的MD5摘要
hasher = hashlib.md5()
with open('Python-3.7.1.tar.xz', 'rb') as file:
data = file.read(512)
while data:
hasher.update(data)
data = file.read(512)
print(hasher.hexdigest())

说明:很多网站在下载链接的旁边都提供了哈希摘要,完成文件下载后,我们可以计算该文件的哈希摘要并检查它与网站上提供的哈希摘要是否一致(指纹比对)。如果计算出的哈希摘要与网站提供的并不一致,很有可能是下载出错或该文件在传输过程中已经被篡改,这时候就不应该直接使用这个文件。

heapq - 堆排序模块

heapq模块实现了堆排序算法,如果希望使用堆排序,尤其是要解决TopK问题(从序列中找到K个最大或最小元素),直接使用该模块即可,代码如下所示。

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

list1 = [34, 25, 12, 99, 87, 63, 58, 78, 88, 92]
# 找出列表中最大的三个元素
print(heapq.nlargest(3, list1))
# 找出列表中最小的三个元素
print(heapq.nsmallest(3, list1))

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, list2, key=lambda x: x['price']))
# 找出持有数量最高的三只股票
print(heapq.nlargest(3, list2, key=lambda x: x['shares']))

itertools - 迭代工具模块

itertools可以帮助我们生成各种各样的迭代器,大家可以看看下面的例子。

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

# 产生ABCD的全排列
for value in itertools.permutations('ABCD'):
print(value)

# 产生ABCDE的五选三组合
for value in itertools.combinations('ABCDE', 3):
print(value)

# 产生ABCD和123的笛卡尔积
for value in itertools.product('ABCD', '123'):
print(value)

# 产生ABC的无限循环序列
it = itertools.cycle(('A', 'B', 'C'))
print(next(it))
print(next(it))
print(next(it))
print(next(it))

random - 随机数和随机抽样模块

这个模块我们之前已经用过很多次了,生成随机数、实现随机乱序和随机抽样,下面是常用函数的列表。

  • getrandbits(k):返回具有k个随机比特位的整数。
  • randrange(start, stop[, step]):从range(start, stop, step) 返回一个随机选择的元素,但实际上并没有构建一个range对象。
  • randint(a, b):返回随机整数N满足a <= N <= b,相当于randrange(a, b+1)
  • choice(seq):从非空序列seq返回一个随机元素。 如果seq为空,则引发IndexError
  • choices(population, weight=None, *, cum_weights=None, k=1):从population中选择替换,返回大小为k的元素列表。 如果population为空,则引发IndexError
  • shuffle(x[, random]):将序列x随机打乱位置。
  • sample(population, k):返回从总体序列或集合中选择k个不重复元素构造的列表,用于无重复的随机抽样。
  • random():返回[0.0, 1.0)范围内的下一个随机浮点数。
  • expovariate(lambd):指数分布。
  • gammavariate(alpha, beta):伽玛分布。
  • gauss(mu, sigma) / normalvariate(mu, sigma):正态分布。
  • paretovariate(alpha):帕累托分布。
  • weibullvariate(alpha, beta):威布尔分布。

os.path - 路径操作相关模块

os.path模块封装了操作路径的工具函数,如果程序中需要对文件路径做拼接、拆分、获取以及获取文件的存在性和其他属性,这个模块将会非常有帮助,下面为大家罗列一些常用的函数。

  • dirname(path):返回路径path的目录名称。
  • exists(path):如果path指向一个已存在的路径或已打开的文件描述符,返回 True
  • getatime(path) / getmtime(path) / getctime(path):返回path的最后访问时间/最后修改时间/创建时间。
  • getsize(path):返回path的大小,以字节为单位。如果该文件不存在或不可访问,则抛出OSError异常。
  • isfile(path):如果path是普通文件,则返回 True
  • isdir(path):如果path是目录(文件夹),则返回True
  • join(path, *paths):合理地拼接一个或多个路径部分。返回值是pathpaths所有值的连接,每个非空部分后面都紧跟一个目录分隔符 (os.sep),除了最后一部分。这意味着如果最后一部分为空,则结果将以分隔符结尾。如果参数中某个部分是绝对路径,则绝对路径前的路径都将被丢弃,并从绝对路径部分开始连接。
  • splitext(path):将路径path拆分为一对,即(root, ext),使得root + ext == path,其中ext为空或以英文句点开头,且最多包含一个句点。

uuid - UUID生成模块

uuid模块可以帮助我们生成全局唯一标识符(Universal Unique IDentity)。该模块提供了四个用于生成UUID的函数,分别是:

  • uuid1():由MAC地址、当前时间戳、随机数生成,可以保证全球范围内的唯一性。
  • uuid3(namespace, name):通过计算命名空间和名字的MD5哈希摘要(“指纹”)值得到,保证了同一命名空间中不同名字的唯一性,和不同命名空间的唯一性,但同一命名空间的同一名字会生成相同的UUID。
  • uuid4():由伪随机数生成UUID,有一定的重复概率,该概率可以计算出来。
  • uuid5():算法与uuid3相同,只不过哈希函数用SHA-1取代了MD5。

由于uuid4存在概率型重复,那么在真正需要全局唯一标识符的地方最好不用使用它。在分布式环境下,uuid1是很好的选择,因为它能够保证生成ID的全局唯一性。下面是在Python交互式环境中使用uuid1函数生成全局唯一标识符的例子。

1
2
3
4
5
6
7
>>> import uuid
>>> uuid.uuid1().hex
'622a8334baab11eaaa9c60f81da8d840'
>>> uuid.uuid1().hex
'62b066debaab11eaaa9c60f81da8d840'
>>> uuid.uuid1().hex
'642c0db0baab11eaaa9c60f81da8d840'

简单的总结

Python标准库中有大量的模块,日常开发中有很多常见的任务在Python标准库中都有封装好的函数或类可供使用,这也是Python这门语言最可爱的地方。

第21课:文件读写和异常处理

实际开发中常常会遇到对数据进行持久化的场景,所谓持久化是指将数据从无法长久保存数据的存储介质(通常是内存)转移到可以长久保存数据的存储介质(通常是硬盘)中。实现数据持久化最直接简单的方式就是通过文件系统将数据保存到文件中。

计算机的文件系统是一种存储和组织计算机数据的方法,它使得对数据的访问和查找变得容易,文件系统使用文件树形目录的抽象逻辑概念代替了硬盘、光盘、闪存等物理设备的数据块概念,用户使用文件系统来保存数据时,不必关心数据实际保存在硬盘的哪个数据块上,只需要记住这个文件的路径和文件名。在写入新数据之前,用户不必关心硬盘上的哪个数据块没有被使用,硬盘上的存储空间管理(分配和释放)功能由文件系统自动完成,用户只需要记住数据被写入到了哪个文件中。

打开和关闭文件

有了文件系统,我们可以非常方便的通过文件来读写数据;在Python中要实现文件操作是非常简单的。我们可以使用Python内置的open函数来打开文件,在使用open函数时,我们可以通过函数的参数指定文件名操作模式字符编码等信息,接下来就可以对文件进行读写操作了。这里所说的操作模式是指要打开什么样的文件(字符文件或二进制文件)以及做什么样的操作(读、写或追加),具体如下表所示。

操作模式 具体含义
'r' 读取 (默认)
'w' 写入(会先截断之前的内容)
'x' 写入,如果文件已经存在会产生异常
'a' 追加,将内容写入到已有文件的末尾
'b' 二进制模式
't' 文本模式(默认)
'+' 更新(既可以读又可以写)

下图展示了如何根据程序的需要来设置open函数的操作模式。

在使用open函数时,如果打开的文件是字符文件(文本文件),可以通过encoding参数来指定读写文件使用的字符编码。如果对字符编码和字符集这些概念不了解,可以看看《字符集和字符编码》 一文,此处不再进行赘述。

使用open函数打开文件成功后会返回一个文件对象,通过这个对象,我们就可以实现对文件的读写操作;如果打开文件失败,open函数会引发异常,稍后会对此加以说明。如果要关闭打开的文件,可以使用文件对象的close方法,这样可以在结束文件操作时释放掉这个文件。

读写文本文件

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
file = open('致橡树.txt', 'a', encoding='utf-8')
file.write('\n标题:《致橡树》')
file.write('\n作者:舒婷')
file.write('\n时间:1977年3月')
file.close()

异常处理机制

请注意上面的代码,如果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
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,通过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)

上下文语法

对于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('读取文件时解码错误!')

读写二进制文件

读写二进制文件跟读写文本文件的操作类似,但是需要注意,在使用open函数打开文件时,如果要进行读操作,操作模式是'rb',如果要进行写操作,操作模式是'wb'。还有一点,读写文本文件时,read方法的返回值以及write方法的参数是str对象(字符串),而读写二进制文件时,read方法的返回值以及write方法的参数是bytes-like对象(字节串)。下面的代码实现了将当前路径下名为guido.jpg的图片文件复制到吉多.jpg文件中的操作。

1
2
3
4
5
6
7
8
9
10
try:
with open('guido.jpg', 'rb') as file1:
data = file1.read()
with open('吉多.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('程序执行结束.')

简单的总结

通过读写文件的操作,我们可以实现数据持久化。在Python中可以通过open函数来获得文件对象,可以通过文件对象的readwrite方法实现文件读写操作。程序在运行时可能遭遇无法预料的异常状况,可以使用Python的异常机制来处理这些状况。Python的异常机制主要包括tryexceptelsefinallyraise这五个核心关键字。try后面的except语句不是必须的,finally语句也不是必须的,但是二者必须要有一个;except语句可以有一个或多个,多个except会按照书写的顺序依次匹配指定的异常,如果异常已经处理就不会再进入后续的except语句;except语句中还可以通过元组同时指定多个异常类型进行捕获;except语句后面如果不指定异常类型,则默认捕获所有异常;捕获异常后可以使用raise要再次抛出,但是不建议捕获并抛出同一个异常;不建议在不清楚逻辑的情况下捕获所有异常,这可能会掩盖程序中严重的问题。最后强调一点,不要使用异常机制来处理正常业务逻辑或控制程序流程,简单的说就是不要滥用异常机制,这是初学者常犯的错误。

第22课:对象的序列化和反序列化

###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: "骆昊",
age: 40,
friends: ["王大锤", "白元芳"],
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: "骆昊",
age: 40,
friends: ["王大锤", "白元芳"],
cars: [
{"brand": "BMW", "max_speed": 240},
{"brand": "Benz", "max_speed": 280},
{"brand": "Audi", "max_speed": 280}
]
}
image-20210820143756353

上面的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

读写JSON格式的数据

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

1
2
3
4
5
6
7
8
9
10
11
12
13
import json

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

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

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

如果要将字典处理成JSON格式并写入文本文件,只需要将dumps函数换成dump函数并传入文件对象即可,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import json

my_dict = {
'name': '骆昊',
'age': 40,
'friends': ['王大锤', '白元芳'],
'cars': [
{'brand': 'BMW', 'max_speed': 240},
{'brand': 'Audi', 'max_speed': 280},
{'brand': 'Benz', 'max_speed': 280}
]
}
with open('data.json', 'w') as file:
json.dump(my_dict, file)

执行上面的代码,会创建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)

包管理工具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

使用网络API获取数据

如果想在我们自己的程序中显示天气、路况、航班等信息,这些信息我们自己没有能力提供,所以必须使用网络数据服务。目前绝大多数的网络数据服务(或称之为网络API)都是基于HTTP 或HTTPS提供JSON格式的数据,我们可以通过Python程序发送HTTP请求给指定的URL(统一资源定位符),这个URL就是所谓的网络API,如果请求成功,它会返回HTTP响应,而HTTP响应的消息体中就有我们需要的JSON格式的数据。关于HTTP的相关知识,可以看看阮一峰的《HTTP协议入门》 一文。

国内有很多提供网络API接口的网站,例如聚合数据 阿凡达数据 等,这些网站上有免费的和付费的数据接口,国外的{API}Search 网站也提供了类似的功能,有兴趣的可以自行研究。下面的例子演示了如何使用requests库(基于HTTP进行网络资源访问的三方库)访问网络API获取国内新闻并显示新闻标题和链接。在这个例子中,我们使用了名为天行数据 的网站提供的国内新闻数据接口,其中的APIKey需要自己到网站上注册申请。在天行数据网站注册账号后会自动分配APIKey,但是要访问接口获取数据,需要绑定验证邮箱或手机,然后还要申请需要使用的接口,如下图所示。

image-20210820151134034

Python通过URL接入网络,我们推荐大家使用requests三方库,它简单且强大,但需要自行安装。

1
pip install requests

获取国内新闻并显示新闻标题和链接。

1
2
3
4
5
6
7
8
9
import requests

resp = requests.get('http://api.tianapi.com/guonei/?key=APIKey&num=10')
if resp.status_code == 200:
data_model = resp.json()
for news in data_model['newslist']:
print(news['title'])
print(news['url'])
print('-' * 60)

上面的代码通过requests模块的get函数向天行数据的国内新闻接口发起了一次请求,如果请求过程没有出现问题,get函数会返回一个Response对象,通过该对象的status_code属性表示HTTP响应状态码,如果不理解没关系,你只需要关注它的值,如果值等于200或者其他2字头的值,那么我们的请求是成功的。通过Response对象的json()方法可以将返回的JSON格式的数据直接处理成Python字典,非常方便。天行数据国内新闻接口返回的JSON格式的数据(部分)如下图所示。

提示:上面代码中的APIKey需要换成自己在天行数据网站申请的APIKey。天行数据网站上还有提供了很多非常有意思的API接口,例如:垃圾分类、周公解梦等,大家可以仿照上面的代码来调用这些接口。每个接口都有对应的接口文档,文档中有关于如何使用接口的详细说明。

简单的总结

Python中实现序列化和反序列化除了使用json模块之外,还可以使用pickleshelve模块,但是这两个模块是使用特有的序列化协议来序列化数据,因此序列化后的数据只能被Python识别,关于这两个模块的相关知识,有兴趣的读者可以自己查找网络上的资料。处理JSON格式的数据很显然是程序员必须掌握的一项技能,因为不管是访问网络API接口还是提供网络API接口给他人使用,都需要具备处理JSON格式数据的相关知识。

第23课:用Python读写CSV文件

CSV文件介绍

CSV(Comma Separated Values)全称逗号分隔值文件是一种简单、通用的文件格式,被广泛的应用于应用程序(数据库、电子表格等)数据的导入和导出以及异构系统之间的数据交换。因为CSV是纯文本文件,不管是什么操作系统和编程语言都是可以处理纯文本的,而且很多编程语言中都提供了对读写CSV文件的支持,因此CSV格式在数据处理和数据科学中被广泛应用。

CSV文件有以下特点:

  1. 纯文本,使用某种字符集(如ASCII Unicode GB2312 )等);
  2. 由一条条的记录组成(典型的是每行一条记录);
  3. 每条记录被分隔符(如逗号、分号、制表符等)分隔为字段(列);
  4. 每条记录都有同样的字段序列。

CSV文件可以使用文本编辑器或类似于Excel电子表格这类工具打开和编辑,当使用Excel这类电子表格打开CSV文件时,你甚至感觉不到CSV和Excel文件的区别。很多数据库系统都支持将数据导出到CSV文件中,当然也支持从CSV文件中读入数据保存到数据库中,这些内容并不是现在要讨论的重点。

将数据写入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 file:
writer = csv.writer(file)
writer.writerow(['姓名', '语文', '数学', '英语'])
names = ['关羽', '张飞', '赵云', '马超', '黄忠']
for name in names:
scores = [random.randrange(50, 101) for _ in range(3)]
scores.insert(0, name)
writer.writerow(scores)

生成的CSV文件的内容。

1
2
3
4
5
6
姓名,语文,数学,英语
关羽,98,86,61
张飞,86,58,80
赵云,95,73,70
马超,83,97,55
黄忠,61,54,87

需要说明的是上面的writer函数,除了传入要写入数据的文件对象外,还可以dialect参数,它表示CSV文件的方言,默认值是excel。除此之外,还可以通过delimiterquotecharquoting参数来指定分隔符(默认是逗号)、包围值的字符(默认是双引号)以及包围的方式。其中,包围值的字符主要用于当字段中有特殊符号时,通过添加包围值的字符可以避免二义性。大家可以尝试将上面第5行代码修改为下面的代码,然后查看生成的CSV文件。

1
writer = csv.writer(file, delimiter='|', quoting=csv.QUOTE_ALL)

生成的CSV文件的内容。

1
2
3
4
5
6
"姓名"|"语文"|"数学"|"英语"
"关羽"|"88"|"64"|"65"
"张飞"|"76"|"93"|"79"
"赵云"|"78"|"55"|"76"
"马超"|"72"|"77"|"68"
"黄忠"|"70"|"72"|"51"

从CSV文件读取数据

如果要读取刚才创建的CSV文件,可以使用下面的代码,通过csv模块的reader函数可以创建出csvreader对象,该对象是一个迭代器,可以通过next函数或for-in循环读取到文件中的数据。

1
2
3
4
5
6
7
8
9
import csv

with open('scores.csv', 'r') as file:
reader = csv.reader(file, delimiter='|')
for data_list in reader:
print(reader.line_num, end='\t')
for elem in data_list:
print(elem, end='\t')
print()

注意:上面的代码对csvreader对象做for循环时,每次会取出一个列表对象,该列表对象包含了一行中所有的字段。

简单的总结

将来如果大家使用Python做数据分析,很有可能会用到名为pandas的三方库,它是Python数据分析的神器之一。pandas中封装了名为read_csvto_csv的函数用来读写CSV文件,其中read_CSV会将读取到的数据变成一个DataFrame对象,而DataFrame就是pandas库中最重要的类型,它封装了一系列用于数据处理的方法(清洗、转换、聚合等);而to_csv会将DataFrame对象中的数据写入CSV文件,完成数据的持久化。read_csv函数和to_csv函数远远比原生的csvreadercsvwriter强大。

第24课:用Python读写Excel文件-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

读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
31
32
33
34
35
36
37
38
import xlrd

# 使用xlrd模块的open_workbook函数打开指定Excel文件并获得Book对象(工作簿)
wb = xlrd.open_workbook('阿里巴巴2020年股票数据.xls')
# 通过Book对象的sheet_names方法可以获取所有表单名称
sheetnames = wb.sheet_names()
print(sheetnames)
# 通过指定的表单名称获取Sheet对象(工作表)
sheet = wb.sheet_by_name(sheetnames[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类型处理成小数点后保留两位有效数字的浮点数
else:
value = f'{value:.2f}'
print(value, end='\t')
print()
# 获取最后一个单元格的数据类型
# 0 - 空值,1 - 字符串,2 - 数字,3 - 日期,4 - 布尔,5 - 错误
last_cell_type = sheet.cell_type(sheet.nrows - 1, sheet.ncols - 1)
print(last_cell_type)
# 获取第一行的值(列表)
print(sheet.row_values(0))
# 获取指定行指定列范围的数据(列表)
# 第一个参数代表行索引,第二个和第三个参数代表列的开始(含)和结束(不含)索引
print(sheet.row_slice(3, 0, 5))

提示:上面代码中使用的Excel文件“阿里巴巴2020年股票数据.xls”可以通过后面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。

相信通过上面的代码,大家已经了解到了如何读取一个Excel文件,如果想知道更多关于xlrd模块的知识,可以阅读它的官方文档

写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 = ['关羽', '张飞', '赵云', '马超', '黄忠']
scores = [[random.randrange(50, 101) for _ in range(3)] for _ in range(5)]
# 创建工作簿对象(Workbook)
wb = xlwt.Workbook()
# 创建工作表对象(Worksheet)
sheet = wb.add_sheet('一年级二班')
# 添加表头数据
titles = ('姓名', '语文', '数学', '英语')
for index, title in enumerate(titles):
sheet.write(0, index, title)
# 将学生姓名和考试成绩写入单元格
for row in range(len(scores)):
sheet.write(row + 1, 0, student_names[row])
for col in range(len(scores[row])):
sheet.write(row + 1, col + 1, scores[row][col])
# 保存Excel工作簿
wb.save('考试成绩表.xls')

调整单元格样式

在写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)

公式计算

对于前面打开的“阿里巴巴2020年股票数据.xls”文件,如果要统计全年收盘价(Close字段)的平均值以及全年交易量(Volume字段)的总和,可以使用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')

说明:上面的代码有一些小瑕疵,有兴趣的读者可以自行探索并思考如何解决。

简单的总结

掌握了Python程序操作Excel的方法,可以解决日常办公中很多繁琐的处理Excel电子表格工作,最常见就是将多个数据格式相同的Excel文件合并到一个文件以及从多个Excel文件或表单中提取指定的数据。当然,如果要对表格数据进行处理,使用Python数据分析神器之一的pandas库可能更为方便。

第25课:用Python读写Excel文件-2

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文件。

读取Excel文件

例如在当前文件夹下有一个名为“阿里巴巴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
36
import datetime

import openpyxl

# 加载一个工作簿 ---> Workbook
wb = openpyxl.load_workbook('阿里巴巴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()

提示:上面代码中使用的Excel文件“阿里巴巴2020年股票数据.xlsx”可以通过后面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。

需要提醒大家一点,openpyxl获取指定的单元格有两种方式,一种是通过cell方法,需要注意,该方法的行索引和列索引都是从1开始的,这是为了照顾用惯了Excel的人的习惯;另一种是通过索引运算,通过指定单元格的坐标,例如C3G255,也可以取得对应的单元格,再通过单元格对象的value属性,就可以获取到单元格的值。通过上面的代码,相信大家还注意到了,可以通过类似sheet['A2:C5']sheet['A2':'C5']这样的切片操作获取多个单元格,该操作将返回嵌套的元组,相当于获取到了多行多列。

写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
import random

import openpyxl

# 第一步:创建工作簿(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(50, 101))

# 第四步:保存工作簿
wb.save('考试成绩表.xlsx')

调整样式和公式计算

在使用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
import openpyxl
from openpyxl.styles import Font, Alignment, Border, Side

# 对齐方式
alignment = Alignment(horizontal='center', vertical='center')
# 边框线条
side = Side(color='ff7f50', style='mediumDashed')

wb = openpyxl.load_workbook('考试成绩表.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(size=18, bold=True, color='ff1493', name='华文楷体')
# 设置对齐方式
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('考试成绩表.xlsx')

生成统计图表

通过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文件,效果如下图所示。

image-20210819235009026

简单的总结

掌握了Python程序操作Excel的方法,可以解决日常办公中很多繁琐的处理Excel电子表格工作,最常见就是将多个数据格式相同的Excel文件合并到一个文件以及从多个Excel文件或表单中提取指定的数据。如果数据体量较大或者处理数据的方式比较复杂,我们还是推荐大家使用Python数据分析神器之一的pandas库。

第26课:用Python操作Word和PowerPoint

在日常工作中,有很多简单重复的劳动其实完全可以交给Python程序,比如根据样板文件(模板文件)批量的生成很多个Word文件或PowerPoint文件。Word是微软公司开发的文字处理程序,相信大家都不陌生,日常办公中很多正式的文档都是用Word进行撰写和编辑的,目前使用的Word文件后缀名一般为.docx。PowerPoint是微软公司开发的演示文稿程序,是微软的Office系列软件中的一员,被商业人士、教师、学生等群体广泛使用,通常也将其称之为“幻灯片”。在Python中,可以使用名为python-docx 的三方库来操作Word,可以使用名为python-pptx的三方库来生成PowerPoint。

操作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/guido.jpg', width=Cm(5.2))

# 添加分节符
document.add_section()

records = (
('骆昊', '男', '1995-5-5'),
('孙美丽', '女', '1992-2-2')
)
# 添加表格
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('demo.docx')

提示:上面代码第7行中的注释# type: Doc是为了在PyCharm中获得代码补全提示,因为如果不清楚对象具体的数据类型,PyCharm无法在后续代码中给出Doc对象的代码补全提示。

执行上面的代码,打开生成的Word文档,效果如下图所示。

image-20210820002742341  image-20210820002843696

对于一个已经存在的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
0
1 离 职 证 明
2
3 兹证明 王大锤 ,身份证号码: 100200199512120001 ,于 2018 年 8 月 7 日至 2020 年 6 月 28 日在我单位 开发部 部门担任 Java开发工程师 职务,在职期间无不良表现。因 个人 原因,于 2020 年 6 月 28 日起终止解除劳动合同。现已结清财务相关费用,办理完解除劳动关系相关手续,双方不存在任何劳动争议。
4
5 特此证明!
6
7
8 公司名称(盖章):成都风车车科技有限公司
9 2020 年 6 月 28 日

讲到这里,相信很多读者已经想到了,我们可以把上面的离职证明制作成一个模板文件,把姓名、身份证号、入职和离职日期等信息用占位符代替,这样通过对占位符的替换,就可以根据实际需要写入对应的信息,这样就可以批量的生成Word文档。

按照上面的思路,我们首先编辑一个离职证明的模板文件,如下图所示。

image-20210820004223731

接下来我们读取该文件,将占位符替换为真实信息,就可以生成一个新的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文档,如下图所示。

image-20210820004825183

生成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文件如下图所示。

image-20210820010306008

简单的总结

用Python程序解决办公自动化的问题真的非常酷,它可以将我们从繁琐乏味的劳动中解放出来。写这类代码就是去做一件一劳永逸的事情,写代码的过程即便不怎么愉快,使用这些代码的时候应该是非常开心的。

第27课:用Python操作PDF文件

PDF是Portable Document Format的缩写,这类文件通常使用.pdf作为其扩展名。在日常开发工作中,最容易遇到的就是从PDF中读取文本内容以及用已有的内容生成PDF文档这两个任务。

从PDF中提取文本

在Python中,可以使用名为PyPDF2的三方库来读取PDF文件,可以使用下面的命令来安装它。

1
pip install PyPDF2

PyPDF2没有办法从PDF文档中提取图像、图表或其他媒体,但它可以提取文本,并将其返回为Python字符串。

1
2
3
4
5
import PyPDF2

reader = PyPDF2.PdfReader('test.pdf')
for page in reader.pages:
print(page.extract_text())

提示:上面代码中使用的PDF文件“test.pdf”以及下面的代码中需要用到的PDF文件,也可以通过下面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。

当然,PyPDF2并不是什么样的PDF文档都能提取出文字来,这个问题就我所知并没有什么特别好的解决方法,尤其是在提取中文的时候。网上也有很多讲解从PDF中提取文字的文章,推荐大家自行阅读《三大神器助力Python提取pdf文档信息》 一文进行了解。

要从PDF文件中提取文本也可以直接使用三方的命令行工具,具体的做法如下所示。

1
2
pip install pdfminer.six
pdf2text.py test.pdf

旋转和叠加页面

上面的代码中通过创建PdfFileReader对象的方式来读取PDF文档,该对象的getPage方法可以获得PDF文档的指定页并得到一个PageObject对象,通过PageObject对象的rotateClockwiserotateCounterClockwise方法可以实现页面的顺时针和逆时针方向旋转,通过PageObject对象的addBlankPage方法可以添加一个新的空白页,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
reader = PyPDF2.PdfReader('XGBoost.pdf')
writer = PyPDF2.PdfWriter()

for no, page in enumerate(reader.pages):
if no % 2 == 0:
new_page = page.rotate(-90)
else:
new_page = page.rotate(90)
writer.add_page(new_page)

with open('temp.pdf', 'wb') as file_obj:
writer.write(file_obj)

加密PDF文件

使用PyPDF2中的PdfFileWrite对象可以为PDF文档加密,如果需要给一系列的PDF文档设置统一的访问口令,使用Python程序来处理就会非常的方便。

1
2
3
4
5
6
7
8
9
10
11
12
import PyPDF2

reader = PyPDF2.PdfReader('XGBoost.pdf')
writer = PyPDF2.PdfWriter()

for page in reader.pages:
writer.add_page(page)

writer.encrypt('foobared')

with open('temp.pdf', 'wb') as file_obj:
writer.write(file_obj)

批量添加水印

上面提到的PageObject对象还有一个名为mergePage的方法,可以两个PDF页面进行叠加,通过这个操作,我们很容易实现给PDF文件添加水印的功能。例如要给上面的“XGBoost.pdf”文件添加一个水印,我们可以先准备好一个提供水印页面的PDF文件,然后将包含水印的PageObject读取出来,然后再循环遍历“XGBoost.pdf”文件的每个页,获取到PageObject对象,然后通过mergePage方法实现水印页和原始页的合并,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
reader1 = PyPDF2.PdfReader('XGBoost.pdf')
reader2 = PyPDF2.PdfReader('watermark.pdf')
writer = PyPDF2.PdfWriter()
watermark_page = reader2.pages[0]

for page in reader1.pages:
page.merge_page(watermark_page)
writer.add_page(page)

with open('temp.pdf', 'wb') as file_obj:
writer.write(file_obj)

如果愿意,还可以让奇数页和偶数页使用不同的水印,大家可以自己思考下应该怎么做。

创建PDF文件

创建PDF文档需要三方库reportlab的支持,安装的方法如下所示。

1
pip install reportlab

下面通过一个例子为大家展示reportlab的用法。

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
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas

pdf_canvas = canvas.Canvas('resources/demo.pdf', pagesize=A4)
width, height = A4

# 绘图
image = canvas.ImageReader('resources/guido.jpg')
pdf_canvas.drawImage(image, 20, height - 395, 250, 375)

# 显示当前页
pdf_canvas.showPage()

# 注册字体文件
pdfmetrics.registerFont(TTFont('Font1', 'resources/fonts/Vera.ttf'))
pdfmetrics.registerFont(TTFont('Font2', 'resources/fonts/青呱石头体.ttf'))

# 写字
pdf_canvas.setFont('Font2', 40)
pdf_canvas.setFillColorRGB(0.9, 0.5, 0.3, 1)
pdf_canvas.drawString(width // 2 - 120, height // 2, '你好,世界!')
pdf_canvas.setFont('Font1', 40)
pdf_canvas.setFillColorRGB(0, 1, 0, 0.5)
pdf_canvas.rotate(18)
pdf_canvas.drawString(250, 250, 'hello, world!')

# 保存
pdf_canvas.save()

上面的代码如果不太理解也没有关系,等真正需要用Python创建PDF文档的时候,再好好研读一下reportlab官方文档 就可以了。

提示:上面代码中用到的图片和字体,也可以通过下面的百度云盘链接获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。

简单的总结

在学习完上面的内容之后,相信大家已经知道像合并多个PDF文件这样的工作应该如何用Python代码来处理了,赶紧自己动手试一试吧。

第28课:用Python处理图像

入门知识

  1. 颜色。如果你有使用颜料画画的经历,那么一定知道混合红、黄、蓝三种颜料可以得到其他的颜色,事实上这三种颜色就是美术中的三原色,它们是不能再分解的基本颜色。在计算机中,我们可以将红、绿、蓝三种色光以不同的比例叠加来组合成其他的颜色,因此这三种颜色就是色光三原色。在计算机系统中,我们通常会将一个颜色表示为一个RGB值或RGBA值(其中的A表示Alpha通道,它决定了透过这个图像的像素,也就是透明度)。

    名称 RGB值 名称 RGB值
    White(白) (255, 255, 255) Red(红) (255, 0, 0)
    Green(绿) (0, 255, 0) Blue(蓝) (0, 0, 255)
    Gray(灰) (128, 128, 128) Yellow(黄) (255, 255, 0)
    Black(黑) (0, 0, 0) Purple(紫) (128, 0, 128)
  2. 像素。对于一个由数字序列表示的图像来说,最小的单位就是图像上单一颜色的小方格,这些小方块都有一个明确的位置和被分配的色彩数值,而这些一小方格的颜色和位置决定了该图像最终呈现出来的样子,它们是不可分割的单位,我们通常称之为像素(pixel)。每一个图像都包含了一定量的像素,这些像素决定图像在屏幕上所呈现的大小,大家如果爱好拍照或者自拍,对像素这个词就不会陌生。

用Pillow处理图像

Pillow是由从著名的Python图像处理库PIL发展出来的一个分支,通过Pillow可以实现图像压缩和图像处理等各种操作。可以使用下面的命令来安装Pillow。

1
pip install pillow

Pillow中最为重要的是Image类,可以通过Image模块的open函数来读取图像并获得Image类型的对象。

  1. 读取和显示图像

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from PIL import Image

    # 读取图像获得Image对象
    image = Image.open('guido.jpg')
    # 通过Image对象的format属性获得图像的格式
    print(image.format) # JPEG
    # 通过Image对象的size属性获得图像的尺寸
    print(image.size) # (500, 750)
    # 通过Image对象的mode属性获取图像的模式
    print(image.mode) # RGB
    # 通过Image对象的show方法显示图像
    image.show()
  2. 剪裁图像

    1
    2
    # 通过Image对象的crop方法指定剪裁区域剪裁图像
    image.crop((80, 20, 310, 360)).show()
  3. 生成缩略图

    1
    2
    3
    # 通过Image对象的thumbnail方法生成指定尺寸的缩略图
    image.thumbnail((128, 128))
    image.show()
  4. 缩放和黏贴图像

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 读取骆昊的照片获得Image对象
    luohao_image = Image.open('luohao.png')
    # 读取吉多的照片获得Image对象
    guido_image = Image.open('guido.jpg')
    # 从吉多的照片上剪裁出吉多的头
    guido_head = guido_image.crop((80, 20, 310, 360))
    width, height = guido_head.size
    # 使用Image对象的resize方法修改图像的尺寸
    # 使用Image对象的paste方法将吉多的头粘贴到骆昊的照片上
    luohao_image.paste(guido_head.resize((int(width / 1.5), int(height / 1.5))), (172, 40))
    luohao_image.show()
  5. 旋转和翻转

    1
    2
    3
    4
    5
    6
    7
    image = Image.open('guido.jpg')
    # 使用Image对象的rotate方法实现图像的旋转
    image.rotate(45).show()
    # 使用Image对象的transpose方法实现图像翻转
    # Image.FLIP_LEFT_RIGHT - 水平翻转
    # Image.FLIP_TOP_BOTTOM - 垂直翻转
    image.transpose(Image.FLIP_TOP_BOTTOM).show()
  6. 操作像素

    1
    2
    3
    4
    5
    for x in range(80, 310):
    for y in range(20, 360):
    # 通过Image对象的putpixel方法修改图像指定像素点
    image.putpixel((x, y), (128, 128, 128))
    image.show()
  7. 滤镜效果

    1
    2
    3
    4
    5
    from PIL import ImageFilter

    # 使用Image对象的filter方法对图像进行滤镜处理
    # ImageFilter模块包含了诸多预设的滤镜也可以自定义滤镜
    image.filter(ImageFilter.CONTOUR).show()

使用Pillow绘图

Pillow中有一个名为ImageDraw的模块,该模块的Draw函数会返回一个ImageDraw对象,通过ImageDraw对象的arclinerectangleellipsepolygon等方法,可以在图像上绘制出圆弧、线条、矩形、椭圆、多边形等形状,也可以通过该对象的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
29
30
31
32
33
34
35
36
import random

from PIL import Image, ImageDraw, ImageFont


def random_color():
"""生成随机颜色"""
red = random.randint(0, 255)
green = random.randint(0, 255)
blue = random.randint(0, 255)
return red, green, blue


width, height = 800, 600
# 创建一个800*600的图像,背景色为白色
image = Image.new(mode='RGB', size=(width, height), color=(255, 255, 255))
# 创建一个ImageDraw对象
drawer = ImageDraw.Draw(image)
# 通过指定字体和大小获得ImageFont对象
font = ImageFont.truetype('Kongxin.ttf', 32)
# 通过ImageDraw对象的text方法绘制文字
drawer.text((300, 50), 'Hello, world!', fill=(255, 0, 0), font=font)
# 通过ImageDraw对象的line方法绘制两条对角直线
drawer.line((0, 0, width, height), fill=(0, 0, 255), width=2)
drawer.line((width, 0, 0, height), fill=(0, 0, 255), width=2)
xy = width // 2 - 60, height // 2 - 60, width // 2 + 60, height // 2 + 60
# 通过ImageDraw对象的rectangle方法绘制矩形
drawer.rectangle(xy, outline=(255, 0, 0), width=2)
# 通过ImageDraw对象的ellipse方法绘制椭圆
for i in range(4):
left, top, right, bottom = 150 + i * 120, 220, 310 + i * 120, 380
drawer.ellipse((left, top, right, bottom), outline=random_color(), width=8)
# 显示图像
image.show()
# 保存图像
image.save('result.png')

注意:上面代码中使用的字体文件需要根据自己准备,可以选择自己喜欢的字体文件并放置在代码目录下。

简单的总结

使用Python语言做开发,除了可以用Pillow来处理图像外,还可以使用更为强大的OpenCV库来完成图形图像的处理,OpenCV(Open Source Computer Vision Library)是一个跨平台的计算机视觉库,可以用来开发实时图像处理、计算机视觉和模式识别程序。在我们的日常工作中,有很多繁琐乏味的任务其实都可以通过Python程序来处理,编程的目的就是让计算机帮助我们解决问题,减少重复乏味的劳动。通过本章节的学习,相信大家已经感受到了使用Python程序绘图P图的乐趣,其实Python能做的事情还远不止这些,继续你的学习吧。

第29课:用Python发送邮件和短信

在前面的课程中,我们已经教会大家如何用Python程序自动的生成Excel、Word、PDF文档,接下来我们还可以更进一步,就是通过邮件将生成好的文档发送给指定的收件人,然后用短信告知对方我们发出了邮件。这些事情利用Python程序也可以轻松愉快的解决。

发送电子邮件

在即时通信软件如此发达的今天,电子邮件仍然是互联网上使用最为广泛的应用之一,公司向应聘者发出录用通知、网站向用户发送一个激活账号的链接、银行向客户推广它们的理财产品等几乎都是通过电子邮件来完成的,而这些任务应该都是由程序自动完成的。

我们可以用HTTP(超文本传输协议)来访问网站资源,HTTP是一个应用级协议,它建立在TCP(传输控制协议)之上,TCP为很多应用级协议提供了可靠的数据传输服务。如果要发送电子邮件,需要使用SMTP(简单邮件传输协议),它也是建立在TCP之上的应用级协议,规定了邮件的发送者如何跟邮件服务器进行通信的细节。Python通过名为smtplib的模块将这些操作简化成了SMTP_SSL对象,通过该对象的loginsend_mail方法,就能够完成发送邮件的操作。

我们先尝试一下发送一封极为简单的邮件,该邮件不包含附件、图片以及其他超文本内容。发送邮件首先需要接入邮件服务器,我们可以自己架设邮件服务器,这件事情对新手并不友好,但是我们可以选择使用第三方提供的邮件服务。例如,我在www.126.com已经注册了账号,登录成功之后,就可以在设置中开启SMTP服务,这样就相当于获得了邮件服务器,具体的操作如下所示。

image-20210820190306861

image-20210820190816557

用手机扫码上面的二维码可以通过发送短信的方式来获取授权码,短信发送成功后,点击“我已发送”就可以获得授权码。授权码需要妥善保管,因为一旦泄露就会被其他人冒用你的身份来发送邮件。接下来,我们就可以编写发送邮件的代码了,如下所示。

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 smtplib
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

# 创建邮件主体对象
email = MIMEMultipart()
# 设置发件人、收件人和主题
email['From'] = 'xxxxxxxxx@126.com'
email['To'] = 'yyyyyy@qq.com;zzzzzz@1000phone.com'
email['Subject'] = Header('上半年工作情况汇报', 'utf-8')
# 添加邮件正文内容
content = """据德国媒体报道,当地时间9日,德国火车司机工会成员进行了投票,
定于当地时间10日起进行全国性罢工,货运交通方面的罢工已于当地时间10日19时开始。
此后,从11日凌晨2时到13日凌晨2时,德国全国范围内的客运和铁路基础设施将进行48小时的罢工。"""
email.attach(MIMEText(content, 'plain', 'utf-8'))

# 创建SMTP_SSL对象(连接邮件服务器)
smtp_obj = smtplib.SMTP_SSL('smtp.126.com', 465)
# 通过用户名和授权码进行登录
smtp_obj.login('xxxxxxxxx@126.com', '邮件服务器的授权码')
# 发送邮件(发件人、收件人、邮件内容(字符串))
smtp_obj.sendmail(
'xxxxxxxxx@126.com',
['yyyyyy@qq.com', 'zzzzzz@1000phone.com'],
email.as_string()
)

如果要发送带有附件的邮件,只需要将附件的内容处理成BASE64编码,那么它就和普通的文本内容几乎没有什么区别。BASE64是一种基于64个可打印字符来表示二进制数据的表示方法,常用于某些需要表示、传输、存储二进制数据的场合,电子邮件就是其中之一。对这种编码方式不理解的同学,推荐阅读《Base64笔记》 一文。在之前的内容中,我们也提到过,Python标准库的base64模块提供了对BASE64编解码的支持。

下面的代码演示了如何发送带附件的邮件。

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
import smtplib
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from urllib.parse import quote

# 创建邮件主体对象
email = MIMEMultipart()
# 设置发件人、收件人和主题
email['From'] = 'xxxxxxxxx@126.com'
email['To'] = 'zzzzzzzz@1000phone.com'
email['Subject'] = Header('请查收离职证明文件', 'utf-8')
# 添加邮件正文内容(带HTML标签排版的内容)
content = """<p>亲爱的前同事:</p>
<p>你需要的离职证明在附件中,请查收!</p>
<br>
<p>祝,好!</p>
<hr>
<p>孙美丽 即日</p>"""
email.attach(MIMEText(content, 'html', 'utf-8'))
# 读取作为附件的文件
with open(f'resources/王大锤离职证明.docx', 'rb') as file:
attachment = MIMEText(file.read(), 'base64', 'utf-8')
# 指定内容类型
attachment['content-type'] = 'application/octet-stream'
# 将中文文件名处理成百分号编码
filename = quote('王大锤离职证明.docx')
# 指定如何处置内容
attachment['content-disposition'] = f'attachment; filename="{filename}"'

# 创建SMTP_SSL对象(连接邮件服务器)
smtp_obj = smtplib.SMTP_SSL('smtp.126.com', 465)
# 通过用户名和授权码进行登录
smtp_obj.login('xxxxxxxxx@126.com', '邮件服务器的授权码')
# 发送邮件(发件人、收件人、邮件内容(字符串))
smtp_obj.sendmail(
'xxxxxxxxx@126.com',
'zzzzzzzz@1000phone.com',
email.as_string()
)

为了方便大家用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
import smtplib
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from urllib.parse import quote

# 邮件服务器域名(自行修改)
EMAIL_HOST = 'smtp.126.com'
# 邮件服务端口(通常是465)
EMAIL_PORT = 465
# 登录邮件服务器的账号(自行修改)
EMAIL_USER = 'xxxxxxxxx@126.com'
# 开通SMTP服务的授权码(自行修改)
EMAIL_AUTH = '邮件服务器的授权码'


def send_email(*, from_user, to_users, subject='', content='', filenames=[]):
"""发送邮件

:param from_user: 发件人
:param to_users: 收件人,多个收件人用英文分号进行分隔
:param subject: 邮件的主题
:param content: 邮件正文内容
:param filenames: 附件要发送的文件路径
"""
email = MIMEMultipart()
email['From'] = from_user
email['To'] = to_users
email['Subject'] = subject

message = MIMEText(content, 'plain', 'utf-8')
email.attach(message)
for filename in filenames:
with open(filename, 'rb') as file:
pos = filename.rfind('/')
display_filename = filename[pos + 1:] if pos >= 0 else filename
display_filename = quote(display_filename)
attachment = MIMEText(file.read(), 'base64', 'utf-8')
attachment['content-type'] = 'application/octet-stream'
attachment['content-disposition'] = f'attachment; filename="{display_filename}"'
email.attach(attachment)

smtp = smtplib.SMTP_SSL(EMAIL_HOST, EMAIL_PORT)
smtp.login(EMAIL_USER, EMAIL_AUTH)
smtp.sendmail(from_user, to_users.split(';'), email.as_string())

发送短信

发送短信也是项目中常见的功能,网站的注册码、验证码、营销信息基本上都是通过短信来发送给用户的。发送短信需要三方平台的支持,下面我们以螺丝帽平台 为例,为大家介绍如何用Python程序发送短信。注册账号和购买短信服务的细节我们不在这里进行赘述,大家可以咨询平台的客服。

image-20210820194420911

接下来,我们可以通过requests库向平台提供的短信网关发起一个HTTP请求,通过将接收短信的手机号和短信内容作为参数,就可以发送短信,代码如下所示。

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 random

import requests


def send_message_by_luosimao(tel, message):
"""发送短信(调用螺丝帽短信网关)"""
resp = requests.post(
url='http://sms-api.luosimao.com/v1/send.json',
auth=('api', 'key-注册成功后平台分配的KEY'),
data={
'mobile': tel,
'message': message
},
timeout=10,
verify=False
)
return resp.json()


def gen_mobile_code(length=6):
"""生成指定长度的手机验证码"""
return ''.join(random.choices('0123456789', k=length))


def main():
code = gen_mobile_code()
message = f'您的短信验证码是{code},打死也不能告诉别人哟!【Python小课】'
print(send_message_by_luosimao('13500112233', message))


if __name__ == '__main__':
main()

上面请求螺丝帽的短信网关http://sms-api.luosimao.com/v1/send.json会返回JSON格式的数据,如果返回{'error': 0, 'msg': 'OK'}就说明短信已经发送成功了,如果error的值不是0,可以通过查看官方的开发文档 了解到底哪个环节出了问题。螺丝帽平台常见的错误类型如下图所示。

image-20210820195505761

目前,大多数短信平台都会要求短信内容必须附上签名,下图是我在螺丝帽平台配置的短信签名“【Python小课】”。有些涉及到敏感内容的短信,还需要提前配置短信模板,有兴趣的读者可以自行研究。一般情况下,平台为了防范短信被盗用,还会要求设置“IP白名单”,不清楚如何配置的可以咨询平台客服。

image-20210820194653785

当然国内的短信平台很多,读者可以根据自己的需要进行选择(通常会考虑费用预算、短信达到率、使用的难易程度等指标),如果需要在商业项目中使用短信服务建议购买短信平台提供的套餐服务。

简单的总结

其实,发送邮件和发送短信一样,也可以通过调用三方服务来完成,在实际的商业项目中,建议自己架设邮件服务器或购买三方服务来发送邮件,这个才是比较靠谱的选择。

第30课:正则表达式的应用

正则表达式相关知识

在编写处理字符串的程时,经常会遇到在一段文本中查找符合某些规则的字符串的需求,正则表达式就是用于描述这些规则的工具,换句话说,我们可以使用正则表达式来定义字符串的匹配模式,即如何检查一个字符串是否有跟某种模式匹配的部分或者从一个字符串中将与模式匹配的部分提取出来或者替换掉。

举一个简单的例子,如果你在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
(?#) 注释
(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次以上,但尽可能少重复

说明: 如果需要匹配的字符是正则表达式中的特殊字符,那么可以使用\进行转义处理,例如想匹配小数点可以写成\.就可以了,因为直接写.会匹配任意字符;同理,想匹配圆括号必须写成\(\),否则圆括号被视为正则表达式中的分组。

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)

简单的总结

正则表达式在字符串的处理和匹配上真的非常强大,通过上面的例子相信大家已经感受到了正则表达式的魅力,当然写一个正则表达式对新手来说并不是那么容易,但是很多事情都是熟能生巧,大胆的去尝试就行了,有一个在线的正则表达式测试工具 相信能够在一定程度上帮到大家。

第31课:网络数据采集概述

爬虫(crawler)也经常被称为网络蜘蛛(spider),是按照一定的规则自动浏览网站并获取所需信息的机器人程序(自动化脚本代码),被广泛的应用于互联网搜索引擎和数据采集。使用过互联网和浏览器的人都知道,网页中除了供用户阅读的文字信息之外,还包含一些超链接,网络爬虫正是通过网页中的超链接信息,不断获得网络上其它页面的地址,然后持续的进行数据采集。正因如此,网络数据采集的过程就像一个爬虫或者蜘蛛在网络上漫游,所以才被形象的称为爬虫或者网络蜘蛛。

爬虫的应用领域

在理想的状态下,所有 ICP(Internet Content Provider)都应该为自己的网站提供 API 接口来共享它们允许其他程序获取的数据,在这种情况下就根本不需要爬虫程序。国内比较有名的电商平台(如淘宝、京东等)、社交平台(如微博、微信等)等都提供了自己的 API 接口,但是这类 API 接口通常会对可以抓取的数据以及抓取数据的频率进行限制。对于大多数的公司而言,及时的获取行业数据和竞对数据是企业生存的重要环节之一,然而对大部分企业来说,数据都是其与生俱来的短板。在这种情况下,合理的利用爬虫来获取数据并从中提取出有商业价值的信息对这些企业来说就显得至关重要的。

爬虫的应用领域其实非常广泛,下面我们列举了其中的一部分,有兴趣的读者可以自行探索相关内容。

  1. 搜索引擎
  2. 新闻聚合
  3. 社交应用
  4. 舆情监控
  5. 行业数据

爬虫合法性探讨

经常听人说起“爬虫写得好,牢饭吃到饱”,那么编程爬虫程序是否违法呢?关于这个问题,我们可以从以下几个角度进行解读。

  1. 网络爬虫这个领域目前还属于拓荒阶段,虽然互联网世界已经通过自己的游戏规则建立起了一定的道德规范,即 Robots 协议(全称是“网络爬虫排除标准”),但法律部分还在建立和完善中,也就是说,现在这个领域暂时还是灰色地带。
  2. “法不禁止即为许可”,如果爬虫就像浏览器一样获取的是前端显示的数据(网页上的公开信息)而不是网站后台的私密敏感信息,就不太担心法律法规的约束,因为目前大数据产业链的发展速度远远超过了法律的完善程度。
  3. 在爬取网站的时候,需要限制自己的爬虫遵守 Robots 协议,同时控制网络爬虫程序的抓取数据的速度;在使用数据的时候,必须要尊重网站的知识产权(从Web 2.0时代开始,虽然Web上的数据很多都是由用户提供的,但是网站平台是投入了运营成本的,当用户在注册和发布内容时,平台通常就已经获得了对数据的所有权、使用权和分发权)。如果违反了这些规定,在打官司的时候败诉几率相当高。
  4. 适当的隐匿自己的身份在编写爬虫程序时必要的,而且最好不要被对方举证你的爬虫有破坏别人动产(例如服务器)的行为。
  5. 不要在公网(如代码托管平台)上去开源或者展示你的爬虫代码,这些行为通常会给自己带来不必要的麻烦。

Robots协议

大多数网站都会定义robots.txt文件,这是一个君子协议,并不是所有爬虫都必须遵守的游戏规则。下面以淘宝的robots.txt文件为例,看看淘宝网对爬虫有哪些限制。

1
2
3
4
5
User-agent: Baiduspider
Disallow: /

User-agent: baiduspider
Disallow: /

通过上面的文件可以看出,淘宝禁止百度爬虫爬取它任何资源,因此当你在百度搜索“淘宝”的时候,搜索结果下方会出现:“由于该网站的robots.txt文件存在限制指令(限制搜索引擎抓取),系统无法提供该页面的内容描述”。百度作为一个搜索引擎,至少在表面上遵守了淘宝网的robots.txt协议,所以用户不能从百度上搜索到淘宝内部的产品信息。

图1. 百度搜索淘宝的结果

下面是豆瓣网的robots.txt文件,大家可以自行解读,看看它做出了什么样的限制。

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
User-agent: *
Disallow: /subject_search
Disallow: /amazon_search
Disallow: /search
Disallow: /group/search
Disallow: /event/search
Disallow: /celebrities/search
Disallow: /location/drama/search
Disallow: /forum/
Disallow: /new_subject
Disallow: /service/iframe
Disallow: /j/
Disallow: /link2/
Disallow: /recommend/
Disallow: /doubanapp/card
Disallow: /update/topic/
Disallow: /share/
Allow: /ads.txt
Sitemap: https://www.douban.com/sitemap_index.xml
Sitemap: https://www.douban.com/sitemap_updated_index.xml
# Crawl-delay: 5

User-agent: Wandoujia Spider
Disallow: /

User-agent: Mediapartners-Google
Disallow: /subject_search
Disallow: /amazon_search
Disallow: /search
Disallow: /group/search
Disallow: /event/search
Disallow: /celebrities/search
Disallow: /location/drama/search
Disallow: /j/

超文本传输协议(HTTP)

在开始讲解爬虫之前,我们稍微对超文本传输协议(HTTP)做一些回顾,因为我们在网页上看到的内容通常是浏览器执行 HTML (超文本标记语言)得到的结果,而 HTTP 就是传输 HTML 数据的协议。HTTP 和其他很多应用级协议一样是构建在 TCP(传输控制协议)之上的,它利用了 TCP 提供的可靠的传输服务实现了 Web 应用中的数据交换。按照维基百科上的介绍,设计 HTTP 最初的目的是为了提供一种发布和接收 HTML 页面的方法,也就是说,这个协议是浏览器和 Web 服务器之间传输的数据的载体。关于 HTTP 的详细信息以及目前的发展状况,大家可以阅读《HTTP 协议入门》 《互联网协议入门》 《图解 HTTPS 协议》 等文章进行了解。

下图是我在四川省网络通信技术重点实验室工作期间用开源协议分析工具 Ethereal(WireShark 的前身)截取的访问百度首页时的 HTTP 请求和响应的报文(协议数据),由于 Ethereal 截取的是经过网络适配器的数据,因此可以清晰的看到从物理链路层到应用层的协议数据。

图2. HTTP请求

http-request

HTTP 请求通常是由请求行、请求头、空行、消息体四个部分构成,如果没有数据发给服务器,消息体就不是必须的部分。请求行中包含了请求方法(GET、POST 等,如下表所示)、资源路径和协议版本;请求头由若干键值对构成,包含了浏览器、编码方式、首选语言、缓存策略等信息;请求头的后面是空行和消息体。

图3. HTTP响应

http-response

HTTP 响应通常是由响应行、响应头、空行、消息体四个部分构成,其中消息体是服务响应的数据,可能是 HTML 页面,也有可能是JSON或二进制数据等。响应行中包含了协议版本和响应状态码,响应状态码有很多种,常见的如下表所示。

相关工具

下面我们先介绍一些开发爬虫程序的辅助工具,这些工具相信能帮助你事半功倍。

  1. Chrome Developer Tools:谷歌浏览器内置的开发者工具。该工具最常用的几个功能模块是:

    • 元素(ELements):用于查看或修改 HTML 元素的属性、CSS 属性、监听事件等。CSS 可以即时修改,即时显示,大大方便了开发者调试页面。
    • 控制台(Console):用于执行一次性代码,查看 JavaScript 对象,查看调试日志信息或异常信息。控制台其实就是一个执行 JavaScript 代码的交互式环境。
    • 源代码(Sources):用于查看页面的 HTML 文件源代码、JavaScript 源代码、CSS 源代码,此外最重要的是可以调试 JavaScript 源代码,可以给代码添加断点和单步执行。
    • 网络(Network):用于 HTTP 请求、HTTP 响应以及与网络连接相关的信息。
    • 应用(Application):用于查看浏览器本地存储、后台任务等内容,本地存储主要包括Cookie、Local Storage、Session Storage等。

    chrome-developer-tools

  2. Postman:功能强大的网页调试与 RESTful 请求工具。Postman可以帮助我们模拟请求,非常方便的定制我们的请求以及查看服务器的响应。

    postman

  3. HTTPie:命令行HTTP客户端。

    安装。

    1
    pip install httpie

    使用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    http --header http --header https://movie.douban.com/

    HTTP/1.1 200 OK
    Connection: keep-alive
    Content-Encoding: gzip
    Content-Type: text/html; charset=utf-8
    Date: Tue, 24 Aug 2021 16:48:00 GMT
    Keep-Alive: timeout=30
    Server: dae
    Set-Cookie: bid=58h4BdKC9lM; Expires=Wed, 24-Aug-22 16:48:00 GMT; Domain=.douban.com; Path=/
    Strict-Transport-Security: max-age=15552000
    Transfer-Encoding: chunked
    X-Content-Type-Options: nosniff
    X-DOUBAN-NEWBID: 58h4BdKC9lM
  4. builtwith库:识别网站所用技术的工具。

    安装。

    1
    pip install builtwith

    使用。

    1
    2
    3
    4
    5
    6
    import ssl

    import builtwith

    ssl._create_default_https_context = ssl._create_unverified_context
    print(builtwith.parse('http://www.bootcss.com/'))
  5. python-whois库:查询网站所有者的工具。

    安装。

    1
    pip3 install python-whois

    使用。

    1
    2
    3
    import whois

    print(whois.whois('https://www.bootcss.com'))

爬虫的基本工作流程

一个基本的爬虫通常分为数据采集(网页下载)、数据处理(网页解析)和数据存储(将有用的信息持久化)三个部分的内容,当然更为高级的爬虫在数据采集和处理时会使用并发编程或分布式技术,这就需要有调度器(安排线程或进程执行对应的任务)、后台管理程序(监控爬虫的工作状态以及检查数据抓取的结果)等的参与。

crawler-workflow

一般来说,爬虫的工作流程包括以下几个步骤:

  1. 设定抓取目标(种子页面/起始页面)并获取网页。
  2. 当服务器无法访问时,按照指定的重试次数尝试重新下载页面。
  3. 在需要的时候设置用户代理或隐藏真实IP,否则可能无法访问页面。
  4. 对获取的页面进行必要的解码操作然后抓取出需要的信息。
  5. 在获取的页面中通过某种方式(如正则表达式)抽取出页面中的链接信息。
  6. 对链接进行进一步的处理(获取页面并重复上面的动作)。
  7. 将有用的信息进行持久化以备后续的处理。

第32课:用Python获取网络数据

网络数据采集是 Python 语言非常擅长的领域,上节课我们讲到,实现网络数据采集的程序通常称之为网络爬虫或蜘蛛程序。即便是在大数据时代,数据对于中小企业来说仍然是硬伤和短板,有些数据需要通过开放或付费的数据接口来获得,其他的行业数据和竞对数据则必须要通过网络数据采集的方式来获得。不管使用哪种方式获取网络数据资源,Python 语言都是非常好的选择,因为 Python 的标准库和三方库都对网络数据采集提供了良好的支持。

requests库

要使用 Python 获取网络数据,我们推荐大家使用名为requests 的三方库,这个库我们在之前的课程中其实已经使用过了。按照官方网站的解释,requests是基于 Python 标准库进行了封装,简化了通过 HTTP 或 HTTPS 访问网络资源的操作。上课我们提到过,HTTP 是一个请求响应式的协议,当我们在浏览器中输入正确的 URL (通常也称为网址)并按下 Enter 键时,我们就向网络上的 Web 服务器 发送了一个 HTTP 请求,服务器在收到请求后会给我们一个 HTTP 响应。在 Chrome 浏览器中的菜单中打开“开发者工具”切换到“Network”选项卡就能够查看 HTTP 请求和响应到底是什么样子的,如下图所示。

通过requests库,我们可以让 Python 程序向浏览器一样向 Web 服务器发起请求,并接收服务器返回的响应,从响应中我们就可以提取出想要的数据。浏览器呈现给我们的网页是用 HTML 编写的,浏览器相当于是 HTML 的解释器环境,我们看到的网页中的内容都包含在 HTML 的标签中。在获取到 HTML 代码后,就可以从标签的属性或标签体中提取内容。下面例子演示了如何获取网页 HTML 代码,我们通过requests库的get函数,获取了搜狐首页的代码。

1
2
3
4
5
import requests

resp = requests.get('https://www.sohu.com/')
if resp.status_code == 200:
print(resp.text)

说明:上面代码中的变量resp是一个Response对象(requests库封装的类型),通过该对象的status_code属性可以获取响应状态码,而该对象的text属性可以帮我们获取到页面的 HTML 代码。

由于Response对象的text是一个字符串,所以我们可以利用之前讲过的正则表达式的知识,从页面的 HTML 代码中提取新闻的标题和链接,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
import re

import requests

pattern = re.compile(r'<a.*?href="(.*?)".*?title="(.*?)".*?>')
resp = requests.get('https://www.sohu.com/')
if resp.status_code == 200:
all_matches = pattern.findall(resp.text)
for href, title in all_matches:
print(href)
print(title)

除了文本内容,我们也可以使用requests库通过 URL 获取二进制资源。下面的例子演示了如何获取百度 Logo 并保存到名为baidu.png的本地文件中。可以在百度的首页上右键点击百度Logo,并通过“复制图片地址”菜单项获取图片的 URL。

1
2
3
4
5
import requests

resp = requests.get('https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png')
with open('baidu.png', 'wb') as file:
file.write(resp.content)

说明Response对象的content属性可以获得服务器响应的二进制数据。

requests库非常好用而且功能上也比较强大和完整,具体的内容我们在使用的过程中为大家一点点剖析。想解锁关于requests库更多的知识,可以阅读它的官方文档

编写爬虫代码

接下来,我们以“豆瓣电影”为例,为大家讲解如何编写爬虫代码。按照上面提供的方法,我们先使用requests获取到网页的HTML代码,然后将整个代码看成一个长字符串,这样我们就可以使用正则表达式的捕获组从字符串提取我们需要的内容。下面的代码演示了如何从豆瓣电影 获取排前250名的电影的名称。豆瓣电影Top250 的页面结构和对应代码如下图所示,可以看出,每页共展示了25部电影,如果要获取到 Top250 数据,我们共需要访问10个页面,对应的地址是https://movie.douban.com/top250?start=xxx,这里的`xxx`如果为`0`就是第一页,如果`xxx`的值是`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
import random
import re
import time

import requests

for page in range(1, 11):
resp = requests.get(
url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
# 如果不设置HTTP请求头中的User-Agent,豆瓣会检测出不是浏览器而阻止我们的请求。
# 通过get函数的headers参数设置User-Agent的值,具体的值可以在浏览器的开发者工具查看到。
# 用爬虫访问大部分网站时,将爬虫伪装成来自浏览器的请求都是非常重要的一步。
headers={'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36'}
)
# 通过正则表达式获取class属性为title且标签体不以&开头的span标签并用捕获组提取标签内容
pattern1 = re.compile(r'<span class="title">([^&]*?)</span>')
titles = pattern1.findall(resp.text)
# 通过正则表达式获取class属性为rating_num的span标签并用捕获组提取标签内容
pattern2 = re.compile(r'<span class="rating_num".*?>(.*?)</span>')
ranks = pattern2.findall(resp.text)
# 使用zip压缩两个列表,循环遍历所有的电影标题和评分
for title, rank in zip(titles, ranks):
print(title, rank)
# 随机休眠1-5秒,避免爬取页面过于频繁
time.sleep(random.random() * 4 + 1)

说明:通过分析豆瓣网的robots协议,我们发现豆瓣网并不拒绝百度爬虫获取它的数据,因此我们也可以将爬虫伪装成百度的爬虫,将get函数的headers参数修改为:headers={'User-Agent': 'BaiduSpider'}

使用 IP 代理

让爬虫程序隐匿自己的身份对编写爬虫程序来说是比较重要的,很多网站对爬虫都比较反感的,因为爬虫会耗费掉它们很多的网络带宽并制造很多无效的流量。要隐匿身份通常需要使用商业 IP 代理(如蘑菇代理、芝麻代理、快代理等),让被爬取的网站无法获取爬虫程序来源的真实 IP 地址,也就无法简单的通过 IP 地址对爬虫程序进行封禁。

下面以蘑菇代理 为例,为大家讲解商业 IP 代理的使用方法。首先需要在该网站注册一个账号,注册账号后就可以购买 相应的套餐来获得商业 IP 代理。作为商业用途,建议大家购买不限量套餐,这样可以根据实际需要获取足够多的代理 IP 地址;作为学习用途,可以购买包时套餐或根据自己的需求来决定。蘑菇代理提供了两种接入代理的方式,分别是 API 私密代理和 HTTP 隧道代理,前者是通过请求蘑菇代理的 API 接口获取代理服务器地址,后者是直接使用统一的入口(蘑菇代理提供的域名)进行接入。

下面,我们以HTTP隧道代理为例,为大家讲解接入 IP 代理的方式,大家也可以直接参考蘑菇代理官网提供的代码来为爬虫设置代理。

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 requests

APP_KEY = 'Wnp******************************XFx'
PROXY_HOST = 'secondtransfer.moguproxy.com:9001'

for page in range(1, 11):
resp = requests.get(
url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
# 需要在HTTP请求头设置代理的身份认证方式
headers={
'Proxy-Authorization': f'Basic {APP_KEY}',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.4'
},
# 设置代理服务器
proxies={
'http': f'http://{PROXY_HOST}',
'https': f'https://{PROXY_HOST}'
},
verify=False
)
pattern1 = re.compile(r'<span class="title">([^&]*?)</span>')
titles = pattern1.findall(resp.text)
pattern2 = re.compile(r'<span class="rating_num".*?>(.*?)</span>')
ranks = pattern2.findall(resp.text)
for title, rank in zip(titles, ranks):
print(title, rank)

说明:上面的代码需要修改APP_KEY为自己创建的订单对应的Appkey值,这个值可以在用户中心用户订单中查看到。蘑菇代理提供了免费的 API 代理和 HTTP 隧道代理试用,但是试用的代理接通率不能保证,建议大家还是直接购买一个在自己支付能力范围内的代理服务来体验。

另注:蘑菇代理目前已经停止服务了,大家可以按照上面讲解的方式使用其他商业代理即可。

简单的总结

Python 语言能做的事情真的很多,就网络数据采集这一项而言,Python 几乎是一枝独秀的,大量的企业和个人都在使用 Python 从网络上获取自己需要的数据,这可能也是你将来日常工作的一部分。另外,用编写正则表达式的方式从网页中提取内容虽然可行,但是写出一个能够满足需求的正则表达式本身也不是件容易的事情,这一点对于新手来说尤为明显。在下一节课中,我们将会为大家介绍另外两种从页面中提取数据的方法,虽然从性能上来讲,它们可能不如正则表达式,但是却降低了编码的复杂性,相信大家会喜欢上它们的。

第33课:用Python解析HTML页面

在前面的课程中,我们讲到了使用request三方库获取网络资源,还介绍了一些前端的基础知识。接下来,我们继续探索如何解析 HTML 代码,从页面中提取出有用的信息。之前,我们尝试过用正则表达式的捕获组操作提取页面内容,但是写出一个正确的正则表达式也是一件让人头疼的事情。为了解决这个问题,我们得先深入的了解一下 HTML 页面的结构,并在此基础上研究另外的解析页面的方法。

HTML 页面的结构

我们在浏览器中打开任意一个网站,然后通过鼠标右键菜单,选择“显示网页源代码”菜单项,就可以看到网页对应的 HTML 代码。

代码的第1行是文档类型声明,第2行的<html>标签是整个页面根标签的开始标签,最后一行是根标签的结束标签</html><html>标签下面有两个子标签<head><body>,放在<body>标签下的内容会显示在浏览器窗口中,这部分内容是网页的主体;放在<head>标签下的内容不会显示在浏览器窗口中,但是却包含了页面重要的元信息,通常称之为网页的头部。HTML 页面大致的代码结构如下所示。

1
2
3
4
5
6
7
8
9
<!doctype html>
<html>
<head>
<!-- 页面的元信息,如字符编码、标题、关键字、媒体查询等 -->
</head>
<body>
<!-- 页面的主体,显示在浏览器窗口中的内容 -->
</body>
</html>

标签、层叠样式表(CSS)、JavaScript 是构成 HTML 页面的三要素,其中标签用来承载页面要显示的内容,CSS 负责对页面的渲染,而 JavaScript 用来控制页面的交互式行为。要实现 HTML 页面的解析,可以使用 XPath 的语法,它原本是 XML 的一种查询语法,可以根据 HTML 标签的层次结构提取标签中的内容或标签属性;此外,也可以使用 CSS 选择器来定位页面元素,就跟用 CSS 渲染页面元素是同样的道理。

XPath 解析

XPath 是在 XML(eXtensible Markup Language)文档中查找信息的一种语法,XML 跟 HTML 类似也是一种用标签承载数据的标签语言,不同之处在于 XML 的标签是可扩展的,可以自定义的,而且 XML 对语法有更严格的要求。XPath 使用路径表达式来选取 XML 文档中的节点或者节点集,这里所说的节点包括元素、属性、文本、命名空间、处理指令、注释、根节点等。下面我们通过一个例子来说明如何使用 XPath 对页面进行解析。

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8"?>
<bookstore>
<book>
<title lang="eng">Harry Potter</title>
<price>29.99</price>
</book>
<book>
<title lang="zh">Learning XML</title>
<price>39.95</price>
</book>
</bookstore>

对于上面的 XML 文件,我们可以用如下所示的 XPath 语法获取文档中的节点。

路径表达式 结果
/bookstore 选取根元素 bookstore。注意:假如路径起始于正斜杠( / ),则此路径始终代表到某元素的绝对路径!
//book 选取所有 book 子元素,而不管它们在文档中的位置。
//@lang 选取名为 lang 的所有属性。
/bookstore/book[1] 选取属于 bookstore 子元素的第一个 book 元素。
/bookstore/book[last()] 选取属于 bookstore 子元素的最后一个 book 元素。
/bookstore/book[last()-1] 选取属于 bookstore 子元素的倒数第二个 book 元素。
/bookstore/book[position()<3] 选取最前面的两个属于 bookstore 元素的子元素的 book 元素。
//title[@lang] 选取所有拥有名为 lang 的属性的 title 元素。
//title[@lang='eng'] 选取所有 title 元素,且这些元素拥有值为 eng 的 lang 属性。
/bookstore/book[price>35.00] 选取 bookstore 元素的所有 book 元素,且其中的 price 元素的值须大于 35.00。
/bookstore/book[price>35.00]/title 选取 bookstore 元素中的 book 元素的所有 title 元素,且其中的 price 元素的值须大于 35.00。

XPath还支持通配符用法,如下所示。

路径表达式 结果
/bookstore/* 选取 bookstore 元素的所有子元素。
//* 选取文档中的所有元素。
//title[@*] 选取所有带有属性的 title 元素。

如果要选取多个节点,可以使用如下所示的方法。

路径表达式 结果
//book/title | //book/price 选取 book 元素的所有 title 和 price 元素。
//title | //price 选取文档中的所有 title 和 price 元素。
/bookstore/book/title | //price 选取属于 bookstore 元素的 book 元素的所有 title 元素,以及文档中所有的 price 元素。

说明:上面的例子来自于“菜鸟教程”网站上的 XPath 教程 ,有兴趣的读者可以自行阅读原文。

当然,如果不理解或不熟悉 XPath 语法,可以在浏览器的开发者工具中按照如下所示的方法查看元素的 XPath 语法,下图是在 Chrome 浏览器的开发者工具中查看豆瓣网电影详情信息中影片标题的 XPath 语法。

实现 XPath 解析需要三方库lxml 的支持,可以使用下面的命令安装lxml

1
pip install lxml

下面我们用 XPath 解析方式改写之前获取豆瓣电影 Top250的代码,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from lxml import etree
import requests

for page in range(1, 11):
resp = requests.get(
url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
headers={'User-Agent': 'BaiduSpider'}
)
tree = etree.HTML(resp.text)
# 通过XPath语法从页面中提取电影标题
title_spans = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]')
# 通过XPath语法从页面中提取电影评分
rank_spans = tree.xpath('//*[@id="content"]/div/div[1]/ol/li[1]/div/div[2]/div[2]/div/span[2]')
for title_span, rank_span in zip(title_spans, rank_spans):
print(title_span.text, rank_span.text)

CSS 选择器解析

对于熟悉 CSS 选择器和 JavaScript 的开发者来说,通过 CSS 选择器获取页面元素可能是更为简单的选择,因为浏览器中运行的 JavaScript 本身就可以document对象的querySelector()querySelectorAll()方法基于 CSS 选择器获取页面元素。在 Python 中,我们可以利用三方库beautifulsoup4pyquery来做同样的事情。Beautiful Soup 可以用来解析 HTML 和 XML 文档,修复含有未闭合标签等错误的文档,通过为待解析的页面在内存中创建一棵树结构,实现对从页面中提取数据操作的封装。可以用下面的命令来安装 Beautiful Soup。

1
pip install beautifulsoup4

下面是使用bs4改写的获取豆瓣电影Top250电影名称的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import bs4
import requests

for page in range(1, 11):
resp = requests.get(
url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
headers={'User-Agent': 'BaiduSpider'}
)
# 创建BeautifulSoup对象
soup = bs4.BeautifulSoup(resp.text, 'lxml')
# 通过CSS选择器从页面中提取包含电影标题的span标签
title_spans = soup.select('div.info > div.hd > a > span:nth-child(1)')
# 通过CSS选择器从页面中提取包含电影评分的span标签
rank_spans = soup.select('div.info > div.bd > div > span.rating_num')
for title_span, rank_span in zip(title_spans, rank_spans):
print(title_span.text, rank_span.text)

关于 BeautifulSoup 更多的知识,可以参考它的官方文档

简单的总结

下面我们对三种解析方式做一个简单比较。

解析方式 对应的模块 速度 使用难度
正则表达式解析 re 困难
XPath 解析 lxml 一般
CSS 选择器解析 bs4pyquery 不确定 简单

第34课:Python中的并发编程-1

现如今,我们使用的计算机早已是多 CPU 或多核的计算机,而我们使用的操作系统基本都支持“多任务”,这使得我们可以同时运行多个程序,也可以将一个程序分解为若干个相对独立的子任务,让多个子任务“并行”或“并发”的执行,从而缩短程序的执行时间,同时也让用户获得更好的体验。因此当下,不管用什么编程语言进行开发,实现“并行”或“并发”编程已经成为了程序员的标配技能。为了讲述如何在 Python 程序中实现“并行”或“并发”,我们需要先了解两个重要的概念:进程和线程。

线程和进程

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

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

在程序中使用多线程技术通常都会带来不言而喻的好处,最主要的体现在提升程序的性能和改善用户体验,今天我们使用的软件几乎都用到了多线程技术,这一点可以利用系统自带的进程监控工具(如 macOS 中的“活动监视器”、Windows 中的“任务管理器”)来证实,如下图所示。

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

多线程编程

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

不使用多线程的下载。

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 random
import time


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


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


if __name__ == '__main__':
main()

说明:上面的代码并没有真正实现联网下载的功能,而是通过time.sleep()休眠一段时间来模拟下载文件需要一些时间上的开销,跟实际下载的状况比较类似。

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

1
2
3
4
5
6
7
8
9
10
开始下载Python从入门到住院.pdf.
Python从入门到住院.pdf下载完成.
下载耗时: 3.005秒.
开始下载MySQL从删库到跑路.avi.
MySQL从删库到跑路.avi下载完成.
下载耗时: 5.006秒.
开始下载Linux从精通到放弃.mp4.
Linux从精通到放弃.mp3下载完成.
下载耗时: 6.007秒.
总耗时: 14.018秒.

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

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 random
import time
from threading import Thread


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


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


if __name__ == '__main__':
main()

某次的运行结果如下所示。

1
2
3
4
5
6
7
8
9
10
开始下载 Python从入门到住院.pdf.
开始下载 MySQL从删库到跑路.avi.
开始下载 Linux从精通到放弃.mp4.
MySQL从删库到跑路.avi 下载完成.
下载耗时: 3.005秒.
Python从入门到住院.pdf 下载完成.
下载耗时: 5.006秒.
Linux从精通到放弃.mp4 下载完成.
下载耗时: 6.003秒.
总耗时: 6.004秒.

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

使用 Thread 类创建线程对象

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

继承 Thread 类自定义线程

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

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 threading import Thread


class DownloadThread(Thread):

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

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


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


if __name__ == '__main__':
main()

使用线程池

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

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
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Thread


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


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


if __name__ == '__main__':
main()

守护线程

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import time
from threading import Thread


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


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


if __name__ == '__main__':
main()

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import time
from threading import Thread

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

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

if __name__ == '__main__':
main()

上面的代码,我们在主线程中添加了一行time.sleep(5)让主线程休眠5秒,在这个过程中,输出PingPong的守护线程会持续运转,直到主线程在5秒后结束,这两个守护线程也被销毁,不再继续运行。

思考:如果将上面代码第12行的daemon=True去掉,代码会怎样执行?有兴趣的读者可以尝试一下,并看看实际执行的结果跟你想象的是否一致。

资源竞争

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

from concurrent.futures import ThreadPoolExecutor


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

def __init__(self):
self.balance = 0.0

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


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


if __name__ == '__main__':
main()

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

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

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
import time

from concurrent.futures import ThreadPoolExecutor
from threading import RLock


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

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

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


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


if __name__ == '__main__':
main()

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

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
import time

from concurrent.futures import ThreadPoolExecutor
from threading import RLock


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

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

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


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


if __name__ == '__main__':
main()

思考:将上面的代码修改为5个线程向银行账户存钱,5个线程从银行账户取钱,取钱的线程在银行账户余额不足时,需要停下来等待存钱的线程将钱存入后再尝试取钱。这里需要用到线程调度的知识,大家可以自行研究下threading模块中的Condition类,看看是否能够完成这个任务。

GIL问题

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

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

第35课:Python中的并发编程-2

在上一课中我们说过,由于 GIL 的存在,CPython 中的多线程并不能发挥 CPU 的多核优势,如果希望突破 GIL 的限制,可以考虑使用多进程。对于多进程的程序,每个进程都有一个属于自己的 GIL,所以多进程不会受到 GIL 的影响。那么,我们应该如何在 Python 程序中创建和使用多进程呢?

###创建进程

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

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
from multiprocessing import Process, current_process
from time import sleep


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


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


if __name__ == '__main__':
main()

说明:上面的代码通过current_process函数获取当前进程对象,再通过进程对象的pid属性获取进程ID。在 Python 中,使用os模块的getpid函数也可以达到同样的效果。

如果愿意,也可以使用os模块的fork函数来创建进程,调用该函数时,操作系统自动把当前进程(父进程)复制一份(子进程),父进程的fork函数会返回子进程的ID,而子进程中的fork函数会返回0,也就是说这个函数调用一次会在父进程和子进程中得到两个不同的返回值。需要注意的是,Windows 系统并不支持fork函数,如果你使用的是 Linux 或 macOS 系统,可以试试下面的代码。

1
2
3
4
5
6
7
8
9
10
import os

print(f'PID: {os.getpid()}')
pid = os.fork()
if pid == 0:
print(f'子进程 - PID: {os.getpid()}')
print('Todo: 在子进程中执行的代码')
else:
print(f'父进程 - PID: {os.getpid()}')
print('Todo: 在父进程中执行的代码')

简而言之,我们还是推荐大家通过直接使用Process类、继承Process类和使用进程池(ProcessPoolExecutor)这三种方式来创建和使用多进程,这三种方式不同于上面的fork函数,能够保证代码的兼容性和可移植性。具体的做法跟之前讲过的创建和使用多线程的方式比较接近,此处不再进行赘述。

多进程和多线程的比较

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

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

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
import concurrent.futures

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


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


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


if __name__ == '__main__':
main()

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

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

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

多进程的版本。

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
import concurrent.futures

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


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


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


if __name__ == '__main__':
main()

提示:运行上面的代码时,可以通过操作系统的任务管理器(资源监视器)来查看是否启动了多个 Python 解释器进程。

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

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

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

1
2
3
import os

print(os.cpu_count())

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

进程间通信

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from multiprocessing import Process
from time import sleep

counter = 0


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


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


if __name__ == '__main__':
main()

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

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
import time
from multiprocessing import Process, Queue


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


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


if __name__ == '__main__':
main()

提示multiprocessing.Queue对象的get方法默认在队列为空时是会阻塞的,直到获取到数据才会返回。如果不希望该方法阻塞以及需要指定阻塞的超时时间,可以通过指定blocktimeout参数进行设定。

上面的代码通过Queue类的getput方法让三个进程(p1p2和主进程)实现了数据的共享,这就是所谓的进程间的通信,通过这种方式,当Queue中取出的值已经大于等于50时,p1p2就会跳出while循环,从而终止进程的执行。代码第22行的循环是为了等待p1p2两个进程中的一个结束,这时候主进程还需要向Queue中放置一个大于等于50的值,这样另一个尚未结束的进程也会因为读到这个大于等于50的值而终止。

进程间通信的方式还有很多,比如使用套接字也可以实现两个进程的通信,甚至于这两个进程并不在同一台主机上,有兴趣的读者可以自行了解。

简单的总结

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

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

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

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

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

第36课:Python中的并发编程-3

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

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

基本概念

阻塞

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

非阻塞

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

同步

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

异步

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

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

生成器和协程

前面我们说过,异步编程是一种“协作式并发”,即通过多个子程序相互协作的方式提升 CPU 的利用率,从而减少程序在阻塞和等待中浪费的时间,最终达到并发的效果。我们可以将多个相互协作的子程序称为“协程”,它是实现异步编程的关键。在介绍协程之前,我们先通过下面的代码,看看什么是生成器。

1
2
3
4
5
def fib(max_count):
a, b = 0, 1
for _ in range(max_count):
a, b = b, a + b
yield a

上面我们编写了一个生成斐波那契数列的生成器,调用上面的fib函数并不是执行该函数获得返回值,因为fib函数中有一个特殊的关键字yield。这个关键字使得fib函数跟普通的函数有些区别,调用该函数会得到一个生成器对象,我们可以通过下面的代码来验证这一点。

1
2
gen_obj = fib(20)
print(gen_obj)

输出:

1
<generator object fib at 0x106daee40>

我们可以使用内置函数next从生成器对象中获取斐波那契数列的值,也可以通过for-in循环对生成器能够提供的值进行遍历,代码如下所示。

1
2
for value in gen_obj:
print(value)

生成器经过预激活,就是一个协程,它可以跟其他子程序协作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def calc_average():
total, counter = 0, 0
avg_value = None
while True:
curr_value = yield avg_value
total += curr_value
counter += 1
avg_value = total / counter


def main():
obj = calc_average()
# 生成器预激活
obj.send(None)
for _ in range(5):
print(obj.send(float(input())))


if __name__ == '__main__':
main()

上面的main函数首先通过生成器对象的send方法发送一个None值来将其激活为协程,也可以通过next(obj)达到同样的效果。接下来,协程对象会接收main函数发送的数据并产出(yield)数据的平均值。通过上面的例子,不知道大家是否看出两段子程序是怎么“协作”的。

异步函数

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

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


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


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


if __name__ == '__main__':
main()

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

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

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


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


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


if __name__ == '__main__':
main()

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

aiohttp库

我们之前使用的requests三方库并不支持异步 I/O,如果希望使用异步 I/O 的方式来加速爬虫代码的执行,我们可以安装和使用名为aiohttp的三方库。

安装aiohttp

1
pip install aiohttp

下面的代码使用aiohttp抓取了10个网站的首页并解析出它们的标题。

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
import asyncio
import re

import aiohttp
from aiohttp import ClientSession

TITLE_PATTERN = re.compile(r'<title.*?>(.*?)</title>', re.DOTALL)


async def fetch_page_title(url):
async with aiohttp.ClientSession(headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
}) as session: # type: ClientSession
async with session.get(url, ssl=False) as resp:
if resp.status == 200:
html_code = await resp.text()
matcher = TITLE_PATTERN.search(html_code)
title = matcher.group(1).strip()
print(title)


def main():
urls = [
'https://www.python.org/',
'https://www.jd.com/',
'https://www.baidu.com/',
'https://www.taobao.com/',
'https://git-scm.com/',
'https://www.sohu.com/',
'https://gitee.com/',
'https://www.amazon.com/',
'https://www.usa.gov/',
'https://www.nasa.gov/'
]
objs = [fetch_page_title(url) for url in urls]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(objs))
loop.close()


if __name__ == '__main__':
main()

输出:

1
2
3
4
5
6
7
8
9
10
京东(JD.COM)-正品低价、品质保障、配送及时、轻松购物!
搜狐
淘宝网 - 淘!我喜欢
百度一下,你就知道
Gitee - 基于 Git 的代码托管和研发协作平台
Git
NASA
Official Guide to Government Information and Services &#124; USAGov
Amazon.com. Spend less. Smile more.
Welcome to Python.org

从上面的输出可以看出,网站首页标题的输出顺序跟它们的 URL 在列表中的顺序没有关系。代码的第11行到第13行创建了ClientSession对象,通过它的get方法可以向指定的 URL 发起请求,如第14行所示,跟requests中的Session对象并没有本质区别,唯一的区别是这里使用了异步上下文。代码第16行的await会让因为 I/O 操作阻塞的子程序放弃对 CPU 的占用,这使得其他的子程序可以运转起来去抓取页面。代码的第17行和第18行使用了正则表达式捕获组操作解析网页标题。fetch_page_title是一个被async关键字修饰的异步函数,调用该函数会获得协程对象,如代码第35行所示。后面的代码跟之前的例子没有什么区别,相信大家能够理解。

大家可以尝试将aiohttp换回到requests,看看不使用异步 I/O 也不使用多线程,到底和上面的代码有什么区别,相信通过这样的对比,大家能够更深刻的理解我们之前强调的几个概念:同步和异步,阻塞和非阻塞。

第37课:并发编程在爬虫中的应用

之前的课程,我们已经为大家介绍了 Python 中的多线程、多进程和异步编程,通过这三种手段,我们可以实现并发或并行编程,这一方面可以加速代码的执行,另一方面也可以带来更好的用户体验。爬虫程序是典型的 I/O 密集型任务,对于 I/O 密集型任务来说,多线程和异步 I/O 都是很好的选择,因为当程序的某个部分因 I/O 操作阻塞时,程序的其他部分仍然可以运转,这样我们不用在等待和阻塞中浪费大量的时间。下面我们以爬取“360图片 ”网站的图片并保存到本地为例,为大家分别展示使用单线程、多线程和异步 I/O 编程的爬虫程序有什么区别,同时也对它们的执行效率进行简单的对比。

“360图片”网站的页面使用了 Ajax 技术,这是很多网站都会使用的一种异步加载数据和局部刷新页面的技术。简单的说,页面上的图片都是通过 JavaScript 代码异步获取 JSON 数据并动态渲染生成的,而且整个页面还使用了瀑布式加载(一边向下滚动,一边加载更多的图片)。我们在浏览器的“开发者工具”中可以找到提供动态内容的数据接口,如下图所示,我们需要的图片信息就在服务器返回的 JSON 数据中。

例如,要获取“美女”频道的图片,我们可以请求如下所示的URL,其中参数ch表示请求的频道,=后面的参数值beauty就代表了“美女”频道,参数sn相当于是页码,0表示第一页(共30张图片),30表示第二页,60表示第三页,以此类推。

1
https://image.so.com/zjl?ch=beauty&sn=0

单线程版本

通过上面的 URL 下载“美女”频道共90张图片。

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
"""
example04.py - 单线程版本爬虫
"""
import os

import requests


def download_picture(url):
filename = url[url.rfind('/') + 1:]
resp = requests.get(url)
if resp.status_code == 200:
with open(f'images/beauty/{filename}', 'wb') as file:
file.write(resp.content)


def main():
if not os.path.exists('images/beauty'):
os.makedirs('images/beauty')
for page in range(3):
resp = requests.get(f'https://image.so.com/zjl?ch=beauty&sn={page * 30}')
if resp.status_code == 200:
pic_dict_list = resp.json()['list']
for pic_dict in pic_dict_list:
download_picture(pic_dict['qhimg_url'])

if __name__ == '__main__':
main()

在 macOS 或 Linux 系统上,我们可以使用time命令来了解上面代码的执行时间以及 CPU 的利用率,如下所示。

1
time python3 example04.py

下面是单线程爬虫代码在我的电脑上执行的结果。

1
python3 example04.py  2.36s user 0.39s system 12% cpu 21.578 total

这里我们只需要关注代码的总耗时为21.578秒,CPU 利用率为12%

多线程版本

我们使用之前讲到过的线程池技术,将上面的代码修改为多线程版本。

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
"""
example05.py - 多线程版本爬虫
"""
import os
from concurrent.futures import ThreadPoolExecutor

import requests


def download_picture(url):
filename = url[url.rfind('/') + 1:]
resp = requests.get(url)
if resp.status_code == 200:
with open(f'images/beauty/{filename}', 'wb') as file:
file.write(resp.content)


def main():
if not os.path.exists('images/beauty'):
os.makedirs('images/beauty')
with ThreadPoolExecutor(max_workers=16) as pool:
for page in range(3):
resp = requests.get(f'https://image.so.com/zjl?ch=beauty&sn={page * 30}')
if resp.status_code == 200:
pic_dict_list = resp.json()['list']
for pic_dict in pic_dict_list:
pool.submit(download_picture, pic_dict['qhimg_url'])


if __name__ == '__main__':
main()

执行如下所示的命令。

1
time python3 example05.py

代码的执行结果如下所示:

1
python3 example05.py  2.65s user 0.40s system 95% cpu 3.193 total

异步I/O版本

我们使用aiohttp将上面的代码修改为异步 I/O 的版本。为了以异步 I/O 的方式实现网络资源的获取和写文件操作,我们首先得安装三方库aiohttpaiofile,命令如下所示。

1
pip install aiohttp aiofile

aiohttp 的用法在之前的课程中已经做过简要介绍,aiofile模块中的async_open函数跟 Python 内置函数open的用法大致相同,只不过它支持异步操作。下面是异步 I/O 版本的爬虫代码。

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
"""
example06.py - 异步I/O版本爬虫
"""
import asyncio
import json
import os

import aiofile
import aiohttp


async def download_picture(session, url):
filename = url[url.rfind('/') + 1:]
async with session.get(url, ssl=False) as resp:
if resp.status == 200:
data = await resp.read()
async with aiofile.async_open(f'images/beauty/{filename}', 'wb') as file:
await file.write(data)


async def fetch_json():
async with aiohttp.ClientSession() as session:
for page in range(3):
async with session.get(
url=f'https://image.so.com/zjl?ch=beauty&sn={page * 30}',
ssl=False
) as resp:
if resp.status == 200:
json_str = await resp.text()
result = json.loads(json_str)
for pic_dict in result['list']:
await download_picture(session, pic_dict['qhimg_url'])


def main():
if not os.path.exists('images/beauty'):
os.makedirs('images/beauty')
loop = asyncio.get_event_loop()
loop.run_until_complete(fetch_json())
loop.close()


if __name__ == '__main__':
main()

执行如下所示的命令。

1
time python3 example06.py

代码的执行结果如下所示:

1
python3 example06.py  0.82s user 0.21s system 27% cpu 3.782 total

总结

通过上面三段代码执行结果的比较,我们可以得出一个结论,使用多线程和异步 I/O 都可以改善爬虫程序的性能,因为我们不用将时间浪费在因 I/O 操作造成的等待和阻塞上,而time命令的执行结果也告诉我们,单线程的代码 CPU 利用率仅仅只有12%,而多线程版本的 CPU 利用率则高达95%;单线程版本的爬虫执行时间约21秒,而多线程和异步 I/O 的版本仅执行了3秒钟。另外,在运行时间差别不大的情况下,多线程的代码比异步 I/O 的代码耗费了更多的 CPU 资源,这是因为多线程的调度和切换也需要花费 CPU 时间。至此,三种方式在 I/O 密集型任务上的优劣已经一目了然,当然这只是在我的电脑上跑出来的结果。如果网络状况不是很理想或者目标网站响应很慢,那么使用多线程和异步 I/O 的优势将更为明显,有兴趣的读者可以自行试验。

第38课:抓取网页动态内容

根据权威机构发布的全球互联网可访问性审计报告,全球约有四分之三的网站其内容或部分内容是通过JavaScript动态生成的,这就意味着在浏览器窗口中“查看网页源代码”时无法在HTML代码中找到这些内容,也就是说我们之前用的抓取数据的方式无法正常运转了。解决这样的问题基本上有两种方案,一是获取提供动态内容的数据接口,这种方式也适用于抓取手机 App 的数据;另一种是通过自动化测试工具 Selenium 运行浏览器获取渲染后的动态内容。对于第一种方案,我们可以使用浏览器的“开发者工具”或者更为专业的抓包工具(如:Charles、Fiddler、Wireshark等)来获取到数据接口,后续的操作跟上一个章节中讲解的获取“360图片”网站的数据是一样的,这里我们不再进行赘述。这一章我们重点讲解如何使用自动化测试工具 Selenium 来获取网站的动态内容。

Selenium 介绍

Selenium 是一个自动化测试工具,利用它可以驱动浏览器执行特定的行为,最终帮助爬虫开发者获取到网页的动态内容。简单的说,只要我们在浏览器窗口中能够看到的内容,都可以使用 Selenium 获取到,对于那些使用了 JavaScript 动态渲染技术的网站,Selenium 会是一个重要的选择。下面,我们还是以 Chrome 浏览器为例,来讲解 Selenium 的用法,大家需要先安装 Chrome 浏览器并下载它的驱动。Chrome 浏览器的驱动程序可以在ChromeDriver官网 进行下载,驱动的版本要跟浏览器的版本对应,如果没有完全对应的版本,就选择版本代号最为接近的版本。

使用Selenium

我们可以先通过pip来安装 Selenium,命令如下所示。

1
pip install selenium

加载页面

接下来,我们通过下面的代码驱动 Chrome 浏览器打开百度。

1
2
3
4
5
6
from selenium import webdriver

# 创建Chrome浏览器对象
browser = webdriver.Chrome()
# 加载指定的页面
browser.get('https://www.baidu.com/')

如果不愿意使用 Chrome 浏览器,也可以修改上面的代码操控其他浏览器,只需创建对应的浏览器对象(如 Firefox、Safari 等)即可。运行上面的程序,如果看到如下所示的错误提示,那是说明我们还没有将 Chrome 浏览器的驱动添加到 PATH 环境变量中,也没有在程序中指定 Chrome 浏览器驱动所在的位置。

1
selenium.common.exceptions.WebDriverException: Message: 'chromedriver' executable needs to be in PATH. Please see https://sites.google.com/a/chromium.org/chromedriver/home

解决这个问题的办法有三种:

  1. 将下载的 ChromeDriver 放到已有的 PATH 环境变量下,建议直接跟 Python 解释器放在同一个目录,因为之前安装 Python 的时候我们已经将 Python 解释器的路径放到 PATH 环境变量中了。

  2. 将 ChromeDriver 放到项目虚拟环境下的 bin 文件夹中(Windows 系统对应的目录是 Scripts),这样 ChromeDriver 就跟虚拟环境下的 Python 解释器在同一个位置,肯定是能够找到的。

  3. 修改上面的代码,在创建 Chrome 对象时,通过service参数配置Service对象,并通过创建Service对象的executable_path参数指定 ChromeDriver 所在的位置,如下所示:

    1
    2
    3
    4
    5
    from selenium import webdriver
    from selenium.webdriver.chrome.service import Service

    browser = webdriver.Chrome(service=Service(executable_path='venv/bin/chromedriver'))
    browser.get('https://www.baidu.com/')

查找元素和模拟用户行为

接下来,我们可以尝试模拟用户在百度首页的文本框输入搜索关键字并点击“百度一下”按钮。在完成页面加载后,可以通过Chrome对象的find_elementfind_elements方法来获取页面元素,Selenium 支持多种获取元素的方式,包括:CSS 选择器、XPath、元素名字(标签名)、元素 ID、类名等,前者可以获取单个页面元素(WebElement对象),后者可以获取多个页面元素构成的列表。获取到WebElement对象以后,可以通过send_keys来模拟用户输入行为,可以通过click来模拟用户点击操作,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
from selenium import webdriver
from selenium.webdriver.common.by import By

browser = webdriver.Chrome()
browser.get('https://www.baidu.com/')
# 通过元素ID获取元素
kw_input = browser.find_element(By.ID, 'kw')
# 模拟用户输入行为
kw_input.send_keys('Python')
# 通过CSS选择器获取元素
su_button = browser.find_element(By.CSS_SELECTOR, '#su')
# 模拟用户点击行为
su_button.click()

如果要执行一个系列动作,例如模拟拖拽操作,可以创建ActionChains对象,有兴趣的读者可以自行研究。

隐式等待和显式等待

这里还有一个细节需要大家知道,网页上的元素可能是动态生成的,在我们使用find_elementfind_elements方法获取的时候,可能还没有完成渲染,这时会引发NoSuchElementException错误。为了解决这个问题,我们可以使用隐式等待的方式,通过设置等待时间让浏览器完成对页面元素的渲染。除此之外,我们还可以使用显示等待,通过创建WebDriverWait对象,并设置等待时间和条件,当条件没有满足时,我们可以先等待再尝试进行后续的操作,具体的代码如下所示。

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
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait

browser = webdriver.Chrome()
# 设置浏览器窗口大小
browser.set_window_size(1200, 800)
browser.get('https://www.baidu.com/')
# 设置隐式等待时间为10秒
browser.implicitly_wait(10)
kw_input = browser.find_element(By.ID, 'kw')
kw_input.send_keys('Python')
su_button = browser.find_element(By.CSS_SELECTOR, '#su')
su_button.click()
# 创建显示等待对象
wait_obj = WebDriverWait(browser, 10)
# 设置等待条件(等搜索结果的div出现)
wait_obj.until(
expected_conditions.presence_of_element_located(
(By.CSS_SELECTOR, '#content_left')
)
)
# 截屏
browser.get_screenshot_as_file('python_result.png')

上面设置的等待条件presence_of_element_located表示等待指定元素出现,下面的表格列出了常用的等待条件及其含义。

等待条件 具体含义
title_is / title_contains 标题是指定的内容 / 标题包含指定的内容
visibility_of 元素可见
presence_of_element_located 定位的元素加载完成
visibility_of_element_located 定位的元素变得可见
invisibility_of_element_located 定位的元素变得不可见
presence_of_all_elements_located 定位的所有元素加载完成
text_to_be_present_in_element 元素包含指定的内容
text_to_be_present_in_element_value 元素的value属性包含指定的内容
frame_to_be_available_and_switch_to_it 载入并切换到指定的内部窗口
element_to_be_clickable 元素可点击
element_to_be_selected 元素被选中
element_located_to_be_selected 定位的元素被选中
alert_is_present 出现 Alert 弹窗

执行JavaScript代码

对于使用瀑布式加载的页面,如果希望在浏览器窗口中加载更多的内容,可以通过浏览器对象的execute_scripts方法执行 JavaScript 代码来实现。对于一些高级的爬取操作,也很有可能会用到类似的操作,如果你的爬虫代码需要 JavaScript 的支持,建议先对 JavaScript 进行适当的了解,尤其是 JavaScript 中的 BOM 和 DOM 操作。我们在上面的代码中截屏之前加入下面的代码,这样就可以利用 JavaScript 将网页滚到最下方。

1
2
# 执行JavaScript代码
browser.execute_script('document.documentElement.scrollTop = document.documentElement.scrollHeight')

Selenium反爬的破解

有一些网站专门针对 Selenium 设置了反爬措施,因为使用 Selenium 驱动的浏览器,在控制台中可以看到如下所示的webdriver属性值为true,如果要绕过这项检查,可以在加载页面之前,先通过执行 JavaScript 代码将其修改为undefined

另一方面,我们还可以将浏览器窗口上的“Chrome正受到自动测试软件的控制”隐藏掉,完整的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建Chrome参数对象
options = webdriver.ChromeOptions()
# 添加试验性参数
options.add_experimental_option('excludeSwitches', ['enable-automation'])
options.add_experimental_option('useAutomationExtension', False)
# 创建Chrome浏览器对象并传入参数
browser = webdriver.Chrome(options=options)
# 执行Chrome开发者协议命令(在加载页面时执行指定的JavaScript代码)
browser.execute_cdp_cmd(
'Page.addScriptToEvaluateOnNewDocument',
{'source': 'Object.defineProperty(navigator, "webdriver", {get: () => undefined})'}
)
browser.set_window_size(1200, 800)
browser.get('https://www.baidu.com/')

无头浏览器

很多时候,我们在爬取数据时并不需要看到浏览器窗口,只要有 Chrome 浏览器以及对应的驱动程序,我们的爬虫就能够运转起来。如果不想看到浏览器窗口,我们可以通过下面的方式设置使用无头浏览器。

1
2
3
options = webdriver.ChromeOptions()
options.add_argument('--headless')
browser = webdriver.Chrome(options=options)

API参考

Selenium 相关的知识还有很多,我们在此就不一一赘述了,下面为大家罗列一些浏览器对象和WebElement对象常用的属性和方法。具体的内容大家还可以参考 Selenium 官方文档的中文翻译

浏览器对象

表1. 常用属性

属性名 描述
current_url 当前页面的URL
current_window_handle 当前窗口的句柄(引用)
name 浏览器的名称
orientation 当前设备的方向(横屏、竖屏)
page_source 当前页面的源代码(包括动态内容)
title 当前页面的标题
window_handles 浏览器打开的所有窗口的句柄

表2. 常用方法

方法名 描述
back / forward 在浏览历史记录中后退/前进
close / quit 关闭当前浏览器窗口 / 退出浏览器实例
get 加载指定 URL 的页面到浏览器中
maximize_window 将浏览器窗口最大化
refresh 刷新当前页面
set_page_load_timeout 设置页面加载超时时间
set_script_timeout 设置 JavaScript 执行超时时间
implicit_wait 设置等待元素被找到或目标指令完成
get_cookie / get_cookies 获取指定的Cookie / 获取所有Cookie
add_cookie 添加 Cookie 信息
delete_cookie / delete_all_cookies 删除指定的 Cookie / 删除所有 Cookie
find_element / find_elements 查找单个元素 / 查找一系列元素

WebElement对象

表1. WebElement常用属性

属性名 描述
location 元素的位置
size 元素的尺寸
text 元素的文本内容
id 元素的 ID
tag_name 元素的标签名

表2. 常用方法

方法名 描述
clear 清空文本框或文本域中的内容
click 点击元素
get_attribute 获取元素的属性值
is_displayed 判断元素对于用户是否可见
is_enabled 判断元素是否处于可用状态
is_selected 判断元素(单选框和复选框)是否被选中
send_keys 模拟输入文本
submit 提交表单
value_of_css_property 获取指定的CSS属性值
find_element / find_elements 获取单个子元素 / 获取一系列子元素
screenshot 为元素生成快照

简单案例

下面的例子演示了如何使用 Selenium 从“360图片”网站搜索和下载图片。

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
import os
import time
from concurrent.futures import ThreadPoolExecutor

import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys

DOWNLOAD_PATH = 'images/'


def download_picture(picture_url: str):
"""
下载保存图片
:param picture_url: 图片的URL
"""
filename = picture_url[picture_url.rfind('/') + 1:]
resp = requests.get(picture_url)
with open(os.path.join(DOWNLOAD_PATH, filename), 'wb') as file:
file.write(resp.content)


if not os.path.exists(DOWNLOAD_PATH):
os.makedirs(DOWNLOAD_PATH)
browser = webdriver.Chrome()
browser.get('https://image.so.com/z?ch=beauty')
browser.implicitly_wait(10)
kw_input = browser.find_element(By.CSS_SELECTOR, 'input[name=q]')
kw_input.send_keys('苍老师')
kw_input.send_keys(Keys.ENTER)
for _ in range(10):
browser.execute_script(
'document.documentElement.scrollTop = document.documentElement.scrollHeight'
)
time.sleep(1)
imgs = browser.find_elements(By.CSS_SELECTOR, 'div.waterfall img')
with ThreadPoolExecutor(max_workers=32) as pool:
for img in imgs:
pic_url = img.get_attribute('src')
pool.submit(download_picture, pic_url)

运行上面的代码,检查指定的目录下是否下载了根据关键词搜索到的图片。

第39课:爬虫框架Scrapy简介

当你写了很多个爬虫程序之后,你会发现每次写爬虫程序时,都需要将页面获取、页面解析、爬虫调度、异常处理、反爬应对这些代码从头至尾实现一遍,这里面有很多工作其实都是简单乏味的重复劳动。那么,有没有什么办法可以提升我们编写爬虫代码的效率呢?答案是肯定的,那就是利用爬虫框架,而在所有的爬虫框架中,Scrapy 应该是最流行、最强大的框架。

Scrapy 概述

Scrapy 是基于 Python 的一个非常流行的网络爬虫框架,可以用来抓取 Web 站点并从页面中提取结构化的数据。下图展示了 Scrapy 的基本架构,其中包含了主要组件和系统的数据处理流程(图中带数字的红色箭头)。

Scrapy的组件

我们先来说说 Scrapy 中的组件。

  1. Scrapy 引擎(Engine):用来控制整个系统的数据处理流程。
  2. 调度器(Scheduler):调度器从引擎接受请求并排序列入队列,并在引擎发出请求后返还给它们。
  3. 下载器(Downloader):下载器的主要职责是抓取网页并将网页内容返还给蜘蛛(Spiders)。
  4. 蜘蛛程序(Spiders):蜘蛛是用户自定义的用来解析网页并抓取特定URL的类,每个蜘蛛都能处理一个域名或一组域名,简单的说就是用来定义特定网站的抓取和解析规则的模块。
  5. 数据管道(Item Pipeline):管道的主要责任是负责处理有蜘蛛从网页中抽取的数据条目,它的主要任务是清理、验证和存储数据。当页面被蜘蛛解析后,将被发送到数据管道,并经过几个特定的次序处理数据。每个数据管道组件都是一个 Python 类,它们获取了数据条目并执行对数据条目进行处理的方法,同时还需要确定是否需要在数据管道中继续执行下一步或是直接丢弃掉不处理。数据管道通常执行的任务有:清理 HTML 数据、验证解析到的数据(检查条目是否包含必要的字段)、检查是不是重复数据(如果重复就丢弃)、将解析到的数据存储到数据库(关系型数据库或 NoSQL 数据库)中。
  6. 中间件(Middlewares):中间件是介于引擎和其他组件之间的一个钩子框架,主要是为了提供自定义的代码来拓展 Scrapy 的功能,包括下载器中间件和蜘蛛中间件。

数据处理流程

Scrapy 的整个数据处理流程由引擎进行控制,通常的运转流程包括以下的步骤:

  1. 引擎询问蜘蛛需要处理哪个网站,并让蜘蛛将第一个需要处理的 URL 交给它。

  2. 引擎让调度器将需要处理的 URL 放在队列中。

  3. 引擎从调度那获取接下来进行爬取的页面。

  4. 调度将下一个爬取的 URL 返回给引擎,引擎将它通过下载中间件发送到下载器。

  5. 当网页被下载器下载完成以后,响应内容通过下载中间件被发送到引擎;如果下载失败了,引擎会通知调度器记录这个 URL,待会再重新下载。

  6. 引擎收到下载器的响应并将它通过蜘蛛中间件发送到蜘蛛进行处理。

  7. 蜘蛛处理响应并返回爬取到的数据条目,此外还要将需要跟进的新的 URL 发送给引擎。

  8. 引擎将抓取到的数据条目送入数据管道,把新的 URL 发送给调度器放入队列中。

上述操作中的第2步到第8步会一直重复直到调度器中没有需要请求的 URL,爬虫就停止工作。

安装和使用Scrapy

可以使用 Python 的包管理工具pip来安装 Scrapy。

1
pip install scrapy

在命令行中使用scrapy命令创建名为demo的项目。

1
scrapy startproject demo

项目的目录结构如下图所示。

1
2
3
4
5
6
7
8
9
10
demo
|____ demo
|________ spiders
|____________ __init__.py
|________ __init__.py
|________ items.py
|________ middlewares.py
|________ pipelines.py
|________ settings.py
|____ scrapy.cfg

切换到demo 目录,用下面的命令创建名为douban的蜘蛛程序。

1
scrapy genspider douban movie.douban.com

一个简单的例子

接下来,我们实现一个爬取豆瓣电影 Top250 电影标题、评分和金句的爬虫。

  1. items.pyItem类中定义字段,这些字段用来保存数据,方便后续的操作。

    1
    2
    3
    4
    5
    6
    7
    import scrapy


    class DoubanItem(scrapy.Item):
    title = scrapy.Field()
    score = scrapy.Field()
    motto = scrapy.Field()
  2. 修改spiders文件夹中名为douban.py 的文件,它是蜘蛛程序的核心,需要我们添加解析页面的代码。在这里,我们可以通过对Response对象的解析,获取电影的信息,代码如下所示。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import scrapy
    from scrapy import Selector, Request
    from scrapy.http import HtmlResponse

    from demo.items import MovieItem


    class DoubanSpider(scrapy.Spider):
    name = 'douban'
    allowed_domains = ['movie.douban.com']
    start_urls = ['https://movie.douban.com/top250?start=0&filter=']

    def parse(self, response: HtmlResponse):
    sel = Selector(response)
    movie_items = sel.css('#content > div > div.article > ol > li')
    for movie_sel in movie_items:
    item = MovieItem()
    item['title'] = movie_sel.css('.title::text').extract_first()
    item['score'] = movie_sel.css('.rating_num::text').extract_first()
    item['motto'] = movie_sel.css('.inq::text').extract_first()
    yield item

    通过上面的代码不难看出,我们可以使用 CSS 选择器进行页面解析。当然,如果你愿意也可以使用 XPath 或正则表达式进行页面解析,对应的方法分别是xpathre

    如果还要生成后续爬取的请求,我们可以用yield产出Request对象。Request对象有两个非常重要的属性,一个是url,它代表了要请求的地址;一个是callback,它代表了获得响应之后要执行的回调函数。我们可以将上面的代码稍作修改。

    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 scrapy
    from scrapy import Selector, Request
    from scrapy.http import HtmlResponse

    from demo.items import MovieItem


    class DoubanSpider(scrapy.Spider):
    name = 'douban'
    allowed_domains = ['movie.douban.com']
    start_urls = ['https://movie.douban.com/top250?start=0&filter=']

    def parse(self, response: HtmlResponse):
    sel = Selector(response)
    movie_items = sel.css('#content > div > div.article > ol > li')
    for movie_sel in movie_items:
    item = MovieItem()
    item['title'] = movie_sel.css('.title::text').extract_first()
    item['score'] = movie_sel.css('.rating_num::text').extract_first()
    item['motto'] = movie_sel.css('.inq::text').extract_first()
    yield item

    hrefs = sel.css('#content > div > div.article > div.paginator > a::attr("href")')
    for href in hrefs:
    full_url = response.urljoin(href.extract())
    yield Request(url=full_url)

    到这里,我们已经可以通过下面的命令让爬虫运转起来。

    1
    scrapy crawl movie

    可以在控制台看到爬取到的数据,如果想将这些数据保存到文件中,可以通过-o参数来指定文件名,Scrapy 支持我们将爬取到的数据导出成 JSON、CSV、XML 等格式。

    1
    scrapy crawl moive -o result.json

    不知大家是否注意到,通过运行爬虫获得的 JSON 文件中有275条数据,那是因为首页被重复爬取了。要解决这个问题,可以对上面的代码稍作调整,不在parse方法中解析获取新页面的 URL,而是通过start_requests方法提前准备好待爬取页面的 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
    import scrapy
    from scrapy import Selector, Request
    from scrapy.http import HtmlResponse

    from demo.items import MovieItem


    class DoubanSpider(scrapy.Spider):
    name = 'douban'
    allowed_domains = ['movie.douban.com']

    def start_requests(self):
    for page in range(10):
    yield Request(url=f'https://movie.douban.com/top250?start={page * 25}')

    def parse(self, response: HtmlResponse):
    sel = Selector(response)
    movie_items = sel.css('#content > div > div.article > ol > li')
    for movie_sel in movie_items:
    item = MovieItem()
    item['title'] = movie_sel.css('.title::text').extract_first()
    item['score'] = movie_sel.css('.rating_num::text').extract_first()
    item['motto'] = movie_sel.css('.inq::text').extract_first()
    yield item
  3. 如果希望完成爬虫数据的持久化,可以在数据管道中处理蜘蛛程序产生的Item对象。例如,我们可以通过前面讲到的openpyxl操作 Excel 文件,将数据写入 Excel 文件中,代码如下所示。

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

    from demo.items import MovieItem


    class MovieItemPipeline:

    def __init__(self):
    self.wb = openpyxl.Workbook()
    self.sheet = self.wb.active
    self.sheet.title = 'Top250'
    self.sheet.append(('名称', '评分', '名言'))

    def process_item(self, item: MovieItem, spider):
    self.sheet.append((item['title'], item['score'], item['motto']))
    return item

    def close_spider(self, spider):
    self.wb.save('豆瓣电影数据.xlsx')

    上面的process_itemclose_spider都是回调方法(钩子函数), 简单的说就是 Scrapy 框架会自动去调用的方法。当蜘蛛程序产生一个Item对象交给引擎时,引擎会将该Item对象交给数据管道,这时我们配置好的数据管道的parse_item方法就会被执行,所以我们可以在该方法中获取数据并完成数据的持久化操作。另一个方法close_spider是在爬虫结束运行前会自动执行的方法,在上面的代码中,我们在这个地方进行了保存 Excel 文件的操作,相信这段代码大家是很容易读懂的。

    总而言之,数据管道可以帮助我们完成以下操作:

    • 清理 HTML 数据,验证爬取的数据。
    • 丢弃重复的不必要的内容。
    • 将爬取的结果进行持久化操作。
  4. 修改settings.py文件对项目进行配置,主要需要修改以下几个配置。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    # 用户浏览器
    USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36'

    # 并发请求数量
    CONCURRENT_REQUESTS = 4

    # 下载延迟
    DOWNLOAD_DELAY = 3
    # 随机化下载延迟
    RANDOMIZE_DOWNLOAD_DELAY = True

    # 是否遵守爬虫协议
    ROBOTSTXT_OBEY = True

    # 配置数据管道
    ITEM_PIPELINES = {
    'demo.pipelines.MovieItemPipeline': 300,
    }

    说明:上面配置文件中的ITEM_PIPELINES选项是一个字典,可以配置多个处理数据的管道,后面的数字代表了执行的优先级,数字小的先执行。

第40课:关系型数据库和MySQL概述

关系型数据库概述

  1. 数据持久化 - 将数据保存到能够长久保存数据的存储介质中,在掉电的情况下数据也不会丢失。

  2. 数据库发展史 - 网状数据库、层次数据库、关系数据库、NoSQL 数据库、NewSQL 数据库。

    1970年,IBM的研究员E.F.Codd在Communication of the ACM上发表了名为A Relational Model of Data for Large Shared Data Banks的论文,提出了关系模型的概念,奠定了关系模型的理论基础。后来Codd又陆续发表多篇文章,论述了范式理论和衡量关系系统的12条标准,用数学理论奠定了关系数据库的基础。

  3. 关系数据库特点。

    • 理论基础:关系代数(关系运算、集合论、一阶谓词逻辑)。

    • 具体表象:用二维表(有行和列)组织数据。

    • 编程语言:结构化查询语言(SQL)。

  4. ER模型(实体关系模型)和概念模型图。

    ER模型,全称为实体关系模型(Entity-Relationship Model),由美籍华裔计算机科学家陈品山先生提出,是概念数据模型的高层描述方式,如下图所示。

    • 实体 - 矩形框
    • 属性 - 椭圆框
    • 关系 - 菱形框
    • 重数 - 1:1(一对一) / 1:N(一对多) / M:N(多对多)

    实际项目开发中,我们可以利用数据库建模工具(如:PowerDesigner)来绘制概念数据模型(其本质就是 ER 模型),然后再设置好目标数据库系统,将概念模型转换成物理模型,最终生成创建二维表的 SQL(很多工具都可以根据我们设计的物理模型图以及设定的目标数据库来导出 SQL 或直接生成数据表)。

  5. 关系数据库产品。

    • Oracle - 目前世界上使用最为广泛的数据库管理系统,作为一个通用的数据库系统,它具有完整的数据管理功能;作为一个关系数据库,它是一个完备关系的产品;作为分布式数据库,它实现了分布式处理的功能。在 Oracle 最新的 12c 版本中,还引入了多承租方架构,使用该架构可轻松部署和管理数据库云。
    • DB2 - IBM 公司开发的、主要运行于 Unix(包括 IBM 自家的 AIX )、Linux、以及 Windows 服务器版等系统的关系数据库产品。DB2 历史悠久且被认为是最早使用 SQL 的数据库产品,它拥有较为强大的商业智能功能。
    • SQL Server - 由 Microsoft 开发和推广的关系型数据库产品,最初适用于中小企业的数据管理,但是近年来它的应用范围有所扩展,部分大企业甚至是跨国公司也开始基于它来构建自己的数据管理系统。
    • MySQL - MySQL 是开放源代码的,任何人都可以在 GPL(General Public License)的许可下下载并根据个性化的需要对其进行修改。MySQL 因为其速度、可靠性和适应性而备受关注。
    • PostgreSQL - 在 BSD 许可证下发行的开放源代码的关系数据库产品。

MySQL 简介

MySQL 最早是由瑞典的 MySQL AB 公司开发的一个开放源码的关系数据库管理系统,该公司于2008年被昇阳微系统公司(Sun Microsystems)收购。在2009年,甲骨文公司(Oracle)收购昇阳微系统公司,因此 MySQL 目前也是 Oracle 旗下产品。

MySQL 在过去由于性能高、成本低、可靠性好,已经成为最流行的开源数据库,因此被广泛地应用于中小型网站开发。随着 MySQL 的不断成熟,它也逐渐被应用于更多大规模网站和应用,比如维基百科、谷歌(Google)、脸书(Facebook)、淘宝网等网站都使用了 MySQL 来提供数据持久化服务。

甲骨文公司收购后昇阳微系统公司,大幅调涨 MySQL 商业版的售价,且甲骨文公司不再支持另一个自由软件项目 OpenSolaris 的发展,因此导致自由软件社区对于 Oracle 是否还会持续支持 MySQL 社区版(MySQL 的各个发行版本中唯一免费的版本)有所担忧,MySQL 的创始人麦克尔·维德纽斯以 MySQL 为基础,创建了 MariaDB (以他女儿的名字命名的数据库)分支。有许多原来使用 MySQL 数据库的公司(例如:维基百科)已经陆续完成了从 MySQL 数据库到 MariaDB 数据库的迁移。

安装 MySQL

Windows 环境

  1. 通过官方网站 提供的下载链接 下载“MySQL社区版服务器”安装程序,如下图所示,建议大家下载离线安装版的MySQL Installer。

  2. 运行 Installer,按照下面的步骤进行安装。

    • 选择自定义安装。
    • 选择需要安装的组件。
    • 如果缺少依赖项,需要先安装依赖项。
    • 准备开始安装。
    • 安装完成。
    • 准备执行配置向导。
  3. 执行安装后的配置向导。

    • 配置服务器类型和网络。
    • 配置认证方法(保护密码的方式)。

    • 配置用户和角色。

    • 配置Windows服务名以及是否开机自启。

    • 配置日志。

    • 配置高级选项。

      ACAC15B8633133B65476286A49BFBD7E
    • 应用配置。

  4. 可以在 Windows 系统的“服务”窗口中启动或停止 MySQL。

  5. 配置 PATH 环境变量,以便在命令行提示符窗口使用 MySQL 客户端工具。

    • 打开 Windows 的“系统”窗口并点击“高级系统设置”。

    • 在“系统属性”的“高级”窗口,点击“环境变量”按钮。

    • 修改PATH环境变量,将MySQL安装路径下的bin文件夹的路径配置到PATH环境变量中。

    • 配置完成后,可以尝试在“命令提示符”下使用 MySQL 的命令行工具。

Linux 环境

下面以 CentOS 7.x 环境为例,演示如何安装 MySQL 5.7.x,如果需要在其他 Linux 系统下安装其他版本的 MySQL,请读者自行在网络上查找对应的安装教程。

  1. 安装 MySQL。

    可以在 MySQL 官方网站 下载安装文件。首先在下载页面中选择平台和版本,然后找到对应的下载链接,直接下载包含所有安装文件的归档文件,解归档之后通过包管理工具进行安装。

    1
    2
    wget https://dev.mysql.com/get/Downloads/MySQL-5.7/mysql-5.7.26-1.el7.x86_64.rpm-bundle.tar
    tar -xvf mysql-5.7.26-1.el7.x86_64.rpm-bundle.tar

    如果系统上有 MariaDB 相关的文件,需要先移除 MariaDB 相关的文件。

    1
    yum list installed | grep mariadb | awk '{print $1}' | xargs yum erase -y

    更新和安装可能用到的底层依赖库。

    1
    2
    yum update
    yum install -y libaio libaio-devel

    接下来可以按照如下所示的顺序用 RPM(Redhat Package Manager)工具安装 MySQL。

    1
    2
    3
    4
    5
    6
    rpm -ivh mysql-community-common-5.7.26-1.el7.x86_64.rpm
    rpm -ivh mysql-community-libs-5.7.26-1.el7.x86_64.rpm
    rpm -ivh mysql-community-libs-compat-5.7.26-1.el7.x86_64.rpm
    rpm -ivh mysql-community-devel-5.7.26-1.el7.x86_64.rpm
    rpm -ivh mysql-community-client-5.7.26-1.el7.x86_64.rpm
    rpm -ivh mysql-community-server-5.7.26-1.el7.x86_64.rpm

    可以使用下面的命令查看已经安装的 MySQL 相关的包。

    1
    rpm -qa | grep mysql
  2. 配置 MySQL。

    MySQL 的配置文件在/etc目录下,名为my.cnf,默认的配置文件内容如下所示。

    1
    cat /etc/my.cnf
    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
    # For advice on how to change settings please see
    # http://dev.mysql.com/doc/refman/5.7/en/server-configuration-defaults.html

    [mysqld]
    #
    # Remove leading # and set to the amount of RAM for the most important data
    # cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%.
    # innodb_buffer_pool_size = 128M
    #
    # Remove leading # to turn on a very important data integrity option: logging
    # changes to the binary log between backups.
    # log_bin
    #
    # Remove leading # to set options mainly useful for reporting servers.
    # The server defaults are faster for transactions and fast SELECTs.
    # Adjust sizes as needed, experiment to find the optimal values.
    # join_buffer_size = 128M
    # sort_buffer_size = 2M
    # read_rnd_buffer_size = 2M
    datadir=/var/lib/mysql
    socket=/var/lib/mysql/mysql.sock

    # Disabling symbolic-links is recommended to prevent assorted security risks
    symbolic-links=0

    log-error=/var/log/mysqld.log
    pid-file=/var/run/mysqld/mysqld.pid

    通过配置文件,我们可以修改 MySQL 服务使用的端口、字符集、最大连接数、套接字队列大小、最大数据包大小、日志文件的位置、日志过期时间等配置。当然,我们还可以通过修改配置文件来对 MySQL 服务器进行性能调优和安全管控。

  3. 启动 MySQL 服务。

    可以使用下面的命令来启动 MySQL。

    1
    service mysqld start

    在 CentOS 7 中,更推荐使用下面的命令来启动 MySQL。

    1
    systemctl start mysqld

    启动 MySQL 成功后,可以通过下面的命令来检查网络端口使用情况,MySQL 默认使用3306端口。

    1
    netstat -ntlp | grep mysql

    也可以使用下面的命令查找是否有名为mysqld的进程。

    1
    pgrep mysqld
  4. 使用 MySQL 客户端工具连接服务器。

    命令行工具:

    1
    mysql -u root -p

    说明:启动客户端时,-u参数用来指定用户名,MySQL 默认的超级管理账号为root-p表示要输入密码(用户口令);如果连接的是其他主机而非本机,可以用-h来指定连接主机的主机名或IP地址。

    如果是首次安装 MySQL,可以使用下面的命令来找到默认的初始密码。

    1
    cat /var/log/mysqld.log | grep password

    上面的命令会查看 MySQL 的日志带有password的行,在显示的结果中root@localhost:后面的部分就是默认设置的初始密码。

    进入客户端工具后,可以通过下面的指令来修改超级管理员(root)的访问口令为123456

    1
    2
    3
    set global validate_password_policy=0;
    set global validate_password_length=6;
    alter user 'root'@'localhost' identified by '123456';

    说明:MySQL 较新的版本默认不允许使用弱口令作为用户口令,所以上面的代码修改了验证用户口令的策略和口令的长度。事实上我们不应该使用弱口令,因为存在用户口令被暴力破解的风险。近年来,攻击数据库窃取数据和劫持数据库勒索比特币的事件屡见不鲜,要避免这些潜在的风险,最为重要的一点是不要让数据库服务器暴露在公网上(最好的做法是将数据库置于内网,至少要做到不向公网开放数据库服务器的访问端口),另外要保管好root账号的口令,应用系统需要访问数据库时,通常不使用root账号进行访问,而是创建其他拥有适当权限的账号来访问

    再次使用客户端工具连接 MySQL 服务器时,就可以使用新设置的口令了。在实际开发中,为了方便用户操作,可以选择图形化的客户端工具来连接 MySQL 服务器,包括:

    • MySQL Workbench(官方工具)

    • Navicat for MySQL(界面简单友好)

macOS环境

macOS 系统安装 MySQL 是比较简单的,只需要从刚才说到的官方网站下载 DMG 安装文件并运行就可以了,下载的时候需要根据自己使用的是 Intel 的芯片还是苹果的 M1 芯片选择下载链接,如下图所示。

安装成功后,可以在“系统偏好设置”中找到“MySQL”,在如下所示的画面中,可以启动和停止 MySQL 服务器,也可以对 MySQL 核心文件的路径进行配置。

MySQL 基本命令

查看命令

  1. 查看所有数据库
1
show databases;
  1. 查看所有字符集
1
show character set;
  1. 查看所有的排序规则
1
show collation;
  1. 查看所有的引擎
1
show engines;
  1. 查看所有日志文件
1
show binary logs;
  1. 查看数据库下所有表
1
show tables;

获取帮助

在 MySQL 命令行工具中,可以使用help命令或?来获取帮助,如下所示。

  1. 查看show命令的帮助。

    1
    ? show
  2. 查看有哪些帮助内容。

    1
    ? contents
  3. 获取函数的帮助。

    1
    ? functions
  4. 获取数据类型的帮助。

    1
    ? data types

其他命令

  1. 新建/重建服务器连接 - connect / resetconnection

  2. 清空当前输入 - \c。在输入错误时,可以及时使用\c清空当前输入并重新开始。

  3. 修改终止符(定界符)- delimiter。默认的终止符是;,可以使用该命令修改成其他的字符,例如修改为$符号,可以用delimiter $命令。

  4. 打开系统默认编辑器 - edit。编辑完成保存关闭之后,命令行会自动执行编辑的内容。

  5. 查看服务器状态 - status

  6. 修改默认提示符 - prompt

  7. 执行系统命令 - system。可以将系统命令跟在system命令的后面执行,system命令也可以缩写为\!

  8. 执行 SQL 文件 - sourcesource命令后面跟 SQL 文件路径。

  9. 重定向输出 - tee / notee。可以将命令的输出重定向到指定的文件中。

  10. 切换数据库 - use

  11. 显示警告信息 - warnings

  12. 退出命令行 - quitexit

第41课:SQL详解之DDL

我们通常可以将 SQL 分为四类,分别是 DDL(数据定义语言)、DML(数据操作语言)、DQL(数据查询语言)和 DCL(数据控制语言)。DDL 主要用于创建、删除、修改数据库中的对象,比如创建、删除和修改二维表,核心的关键字包括createdropalter;DML 主要负责数据的插入、删除和更新,关键词包括insertdeleteupdate;DQL 负责数据查询,最重要的一个关键词是select;DCL 通常用于授予和召回权限,核心关键词是grantrevoke

说明:SQL 是不区分大小写的语言,有人会建议将关键字大写,其他部分小写。为了书写和识别方便,下面的 SQL 我都是使用小写字母进行书写的。 如果公司的 SQL 编程规范有强制规定,那么就按照公司的要求来,个人的喜好不应该凌驾于公司的编程规范之上,这一点对职业人来说应该是常识。

建库建表

下面我们来实现一个非常简单的学校选课系统的数据库。我们将数据库命名为school,四个关键的实体分别是学院、老师、学生和课程,其中,学生跟学院是从属关系,这个关系从数量上来讲是多对一关系,因为一个学院可以有多名学生,而一个学生通常只属于一个学院;同理,老师跟学院的从属关系也是多对一关系。一名老师可以讲授多门课程,一门课程如果只有一个授课老师的话,那么课程跟老师也是多对一关系;如果允许多个老师合作讲授一门课程,那么课程和老师就是多对多关系。简单起见,我们将课程和老师设计为多对一关系。学生和课程是典型的多对多关系,因为一个学生可以选择多门课程,一门课程也可以被多个学生选择,而关系型数据库需要借助中间表才能维持维持两个实体的多对多关系。最终,我们的学校选课系统一共有五张表,分别是学院表(tb_college)、学生表(tb_student)、教师表(tb_teacher)、课程表(tb_course)和选课记录表(tb_record),其中选课记录表就是维持学生跟课程多对多关系的中间表。

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
-- 如果存在名为school的数据库就删除它
drop database if exists `school`;

-- 创建名为school的数据库并设置默认的字符集和排序方式
create database `school` default character set utf8mb4 collate utf8mb4_general_ci;

-- 切换到school数据库上下文环境
use `school`;

-- 创建学院表
create table `tb_college`
(
`col_id` int unsigned auto_increment comment '编号',
`col_name` varchar(50) not null comment '名称',
`col_intro` varchar(500) default '' comment '介绍',
primary key (`col_id`)
) engine=innodb auto_increment=1 comment '学院表';

-- 创建学生表
create table `tb_student`
(
`stu_id` int unsigned not null comment '学号',
`stu_name` varchar(20) not null comment '姓名',
`stu_sex` boolean default 1 not null comment '性别',
`stu_birth` date not null comment '出生日期',
`stu_addr` varchar(255) default '' comment '籍贯',
`col_id` int unsigned not null comment '所属学院',
primary key (`stu_id`),
constraint `fk_student_col_id` foreign key (`col_id`) references `tb_college` (`col_id`)
) engine=innodb comment '学生表';

-- 创建教师表
create table `tb_teacher`
(
`tea_id` int unsigned not null comment '工号',
`tea_name` varchar(20) not null comment '姓名',
`tea_title` varchar(10) default '助教' comment '职称',
`col_id` int unsigned not null comment '所属学院',
primary key (`tea_id`),
constraint `fk_teacher_col_id` foreign key (`col_id`) references `tb_college` (`col_id`)
) engine=innodb comment '老师表';

-- 创建课程表
create table `tb_course`
(
`cou_id` int unsigned not null comment '编号',
`cou_name` varchar(50) not null comment '名称',
`cou_credit` int not null comment '学分',
`tea_id` int unsigned not null comment '授课老师',
primary key (`cou_id`),
constraint `fk_course_tea_id` foreign key (`tea_id`) references `tb_teacher` (`tea_id`)
) engine=innodb comment '课程表';

-- 创建选课记录表
create table `tb_record`
(
`rec_id` bigint unsigned auto_increment comment '选课记录号',
`stu_id` int unsigned not null comment '学号',
`cou_id` int unsigned not null comment '课程编号',
`sel_date` date not null comment '选课日期',
`score` decimal(4,1) comment '考试成绩',
primary key (`rec_id`),
constraint `fk_record_stu_id` foreign key (`stu_id`) references `tb_student` (`stu_id`),
constraint `fk_record_cou_id` foreign key (`cou_id`) references `tb_course` (`cou_id`),
constraint `uk_record_stu_cou` unique (`stu_id`, `cou_id`)
) engine=innodb comment '选课记录表';

上面的DDL有几个地方需要强调一下:

  • 首先,上面 SQL 中的数据库名、表名、字段名都被反引号(`)包裹起来,反引号并不是必须的,但是却可以解决表名、字段名等跟 SQL 关键字(SQL 中有特殊含义的单词)冲突的问题。

  • 创建数据库时,我们通过default character set utf8mb4指定了数据库默认使用的字符集为utf8mb4(最大4字节的utf-8编码),我们推荐使用该字符集,它也是 MySQL 8.x 默认使用的字符集,因为它能够支持国际化编码,还可以存储 Emoji 字符。可以通过下面的命令查看 MySQL 支持的字符集以及默认的排序规则。

    1
    show character set;
    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
    +----------+---------------------------------+---------------------+--------+
    | Charset | Description | Default collation | Maxlen |
    +----------+---------------------------------+---------------------+--------+
    | big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 |
    | dec8 | DEC West European | dec8_swedish_ci | 1 |
    | cp850 | DOS West European | cp850_general_ci | 1 |
    | hp8 | HP West European | hp8_english_ci | 1 |
    | koi8r | KOI8-R Relcom Russian | koi8r_general_ci | 1 |
    | latin1 | cp1252 West European | latin1_swedish_ci | 1 |
    | latin2 | ISO 8859-2 Central European | latin2_general_ci | 1 |
    | swe7 | 7bit Swedish | swe7_swedish_ci | 1 |
    | ascii | US ASCII | ascii_general_ci | 1 |
    | ujis | EUC-JP Japanese | ujis_japanese_ci | 3 |
    | sjis | Shift-JIS Japanese | sjis_japanese_ci | 2 |
    | hebrew | ISO 8859-8 Hebrew | hebrew_general_ci | 1 |
    | tis620 | TIS620 Thai | tis620_thai_ci | 1 |
    | euckr | EUC-KR Korean | euckr_korean_ci | 2 |
    | koi8u | KOI8-U Ukrainian | koi8u_general_ci | 1 |
    | gb2312 | GB2312 Simplified Chinese | gb2312_chinese_ci | 2 |
    | greek | ISO 8859-7 Greek | greek_general_ci | 1 |
    | cp1250 | Windows Central European | cp1250_general_ci | 1 |
    | gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 |
    | latin5 | ISO 8859-9 Turkish | latin5_turkish_ci | 1 |
    | armscii8 | ARMSCII-8 Armenian | armscii8_general_ci | 1 |
    | utf8 | UTF-8 Unicode | utf8_general_ci | 3 |
    | ucs2 | UCS-2 Unicode | ucs2_general_ci | 2 |
    | cp866 | DOS Russian | cp866_general_ci | 1 |
    | keybcs2 | DOS Kamenicky Czech-Slovak | keybcs2_general_ci | 1 |
    | macce | Mac Central European | macce_general_ci | 1 |
    | macroman | Mac West European | macroman_general_ci | 1 |
    | cp852 | DOS Central European | cp852_general_ci | 1 |
    | latin7 | ISO 8859-13 Baltic | latin7_general_ci | 1 |
    | utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 |
    | cp1251 | Windows Cyrillic | cp1251_general_ci | 1 |
    | utf16 | UTF-16 Unicode | utf16_general_ci | 4 |
    | utf16le | UTF-16LE Unicode | utf16le_general_ci | 4 |
    | cp1256 | Windows Arabic | cp1256_general_ci | 1 |
    | cp1257 | Windows Baltic | cp1257_general_ci | 1 |
    | utf32 | UTF-32 Unicode | utf32_general_ci | 4 |
    | binary | Binary pseudo charset | binary | 1 |
    | geostd8 | GEOSTD8 Georgian | geostd8_general_ci | 1 |
    | cp932 | SJIS for Windows Japanese | cp932_japanese_ci | 2 |
    | eucjpms | UJIS for Windows Japanese | eucjpms_japanese_ci | 3 |
    | gb18030 | China National Standard GB18030 | gb18030_chinese_ci | 4 |
    +----------+---------------------------------+---------------------+--------+
    41 rows in set (0.00 sec)

    如果要设置 MySQL 服务启动时默认使用的字符集,可以修改MySQL的配置并添加以下内容。

    1
    2
    [mysqld]
    character-set-server=utf8

    提示:如果不清楚如何修改 MySQL 的配置文件就先不要管它。

  • 创建和删除数据库时,关键字database也可以替换为schema,二者作用相同。

  • 建表语句中的not null是非空约束,它限定了字段不能为空;default用于为字段指定默认值,我们称之为默认值约束;primary key是主键约束,它设定了能够唯一确定一条记录的列,也确保了每条记录都是独一无二的,因为主键不允许重复;foreign key是外键约束,它维持了两张表的参照完整性,举个例子,由于学生表中为 col_id 字段添加了外键约束,限定其必须引用(references)学院表中的 col_id,因此学生表中的学院编号必须来自于学院表中的学院编号,不能够随意为该字段赋值。如果需要给主键约束、外键约束等起名字,可以使用constriant关键字并在后面跟上约束的名字。

  • 建表语句中的comment 关键字用来给列和表添加注释,增强代码的可读性和可维护性。

  • 在创建表的时候,可以自行选择底层的存储引擎。MySQL 支持多种存储引擎,可以通过show engines命令进行查看。MySQL 5.5 以后的版本默认使用的存储引擎是 InnoDB,它是我们推荐大家使用的存储引擎(因为更适合当下互联网应用对高并发、性能以及事务支持等方面的需求),为了 SQL 语句的向下兼容性,我们可以在建表语句结束处右圆括号的后面通过engine=innodb来指定使用 InnoDB 存储引擎。

    1
    show engines\G

    说明:上面的 \G 是为了换一种输出方式,在命令行客户端中,如果表的字段很多一行显示不完,就会导致输出的内容看起来非常不舒服,使用 \G 可以将记录的每个列以独占整行的的方式输出,这种输出方式在命令行客户端中看起来会舒服很多。

    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
    *************************** 1. row ***************************
    Engine: InnoDB
    Support: DEFAULT
    Comment: Supports transactions, row-level locking, and foreign keys
    Transactions: YES
    XA: YES
    Savepoints: YES
    *************************** 2. row ***************************
    Engine: MRG_MYISAM
    Support: YES
    Comment: Collection of identical MyISAM tables
    Transactions: NO
    XA: NO
    Savepoints: NO
    *************************** 3. row ***************************
    Engine: MEMORY
    Support: YES
    Comment: Hash based, stored in memory, useful for temporary tables
    Transactions: NO
    XA: NO
    Savepoints: NO
    *************************** 4. row ***************************
    Engine: BLACKHOLE
    Support: YES
    Comment: /dev/null storage engine (anything you write to it disappears)
    Transactions: NO
    XA: NO
    Savepoints: NO
    *************************** 5. row ***************************
    Engine: MyISAM
    Support: YES
    Comment: MyISAM storage engine
    Transactions: NO
    XA: NO
    Savepoints: NO
    *************************** 6. row ***************************
    Engine: CSV
    Support: YES
    Comment: CSV storage engine
    Transactions: NO
    XA: NO
    Savepoints: NO
    *************************** 7. row ***************************
    Engine: ARCHIVE
    Support: YES
    Comment: Archive storage engine
    Transactions: NO
    XA: NO
    Savepoints: NO
    *************************** 8. row ***************************
    Engine: PERFORMANCE_SCHEMA
    Support: YES
    Comment: Performance Schema
    Transactions: NO
    XA: NO
    Savepoints: NO
    *************************** 9. row ***************************
    Engine: FEDERATED
    Support: NO
    Comment: Federated MySQL storage engine
    Transactions: NULL
    XA: NULL
    Savepoints: NULL
    9 rows in set (0.00 sec)

    下面的表格对MySQL几种常用的数据引擎进行了简单的对比。

    特性 InnoDB MRG_MYISAM MEMORY MyISAM
    存储限制 没有
    事务 支持
    锁机制 行锁 表锁 表锁 表锁
    B树索引 支持 支持 支持 支持
    哈希索引 支持
    全文检索 支持(5.6+) 支持
    集群索引 支持
    数据缓存 支持 支持
    索引缓存 支持 支持 支持 支持
    数据可压缩 支持
    内存使用
    存储空间使用
    批量插入性能
    是否支持外键 支持

    通过上面的比较我们可以了解到,InnoDB 是唯一能够支持外键、事务以及行锁的存储引擎,所以我们之前说它更适合互联网应用,而且在较新版本的 MySQL 中,它也是默认使用的存储引擎。

  • 在定义表结构为每个字段选择数据类型时,如果不清楚哪个数据类型更合适,可以通过 MySQL 的帮助系统来了解每种数据类型的特性、数据的长度和精度等相关信息。

    1
    ? data types

    说明:在 MySQLWorkbench 中,不能使用?获取帮助,要使用对应的命令help

    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
    You asked for help about help category: "Data Types"
    For more information, type 'help <item>', where <item> is one of the following
    topics:
    AUTO_INCREMENT
    BIGINT
    BINARY
    BIT
    BLOB
    BLOB DATA TYPE
    BOOLEAN
    CHAR
    CHAR BYTE
    DATE
    DATETIME
    DEC
    DECIMAL
    DOUBLE
    DOUBLE PRECISION
    ENUM
    FLOAT
    INT
    INTEGER
    LONGBLOB
    LONGTEXT
    MEDIUMBLOB
    MEDIUMINT
    MEDIUMTEXT
    SET DATA TYPE
    SMALLINT
    TEXT
    TIME
    TIMESTAMP
    TINYBLOB
    TINYINT
    TINYTEXT
    VARBINARY
    VARCHAR
    YEAR DATA TYPE

    获取 varchar 类型的帮助:

    1
    ? varchar

    执行结果:

    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
    Name: 'VARCHAR'
    Description:
    [NATIONAL] VARCHAR(M) [CHARACTER SET charset_name] [COLLATE
    collation_name]

    A variable-length string. M represents the maximum column length in
    characters. The range of M is 0 to 65,535. The effective maximum length
    of a VARCHAR is subject to the maximum row size (65,535 bytes, which is
    shared among all columns) and the character set used. For example, utf8
    characters can require up to three bytes per character, so a VARCHAR
    column that uses the utf8 character set can be declared to be a maximum
    of 21,844 characters. See
    http://dev.mysql.com/doc/refman/5.7/en/column-count-limit.html.

    MySQL stores VARCHAR values as a 1-byte or 2-byte length prefix plus
    data. The length prefix indicates the number of bytes in the value. A
    VARCHAR column uses one length byte if values require no more than 255
    bytes, two length bytes if values may require more than 255 bytes.

    *Note*:

    MySQL follows the standard SQL specification, and does not remove
    trailing spaces from VARCHAR values.

    VARCHAR is shorthand for CHARACTER VARYING. NATIONAL VARCHAR is the
    standard SQL way to define that a VARCHAR column should use some
    predefined character set. MySQL uses utf8 as this predefined character
    set. http://dev.mysql.com/doc/refman/5.7/en/charset-national.html.
    NVARCHAR is shorthand for NATIONAL VARCHAR.

    URL: http://dev.mysql.com/doc/refman/5.7/en/string-type-overview.html

    在数据类型的选择上,保存字符串数据通常都使用 VARCHAR 和 CHAR 两种类型,前者通常称为变长字符串,而后者通常称为定长字符串;对于 InnoDB 存储引擎,行存储格式没有区分固定长度和可变长度列,因此 VARCHAR 类型和 CHAR 类型没有本质区别,后者不一定比前者性能更好。如果要保存的很大字符串,可以使用 TEXT 类型;如果要保存很大的字节串,可以使用 BLOB(二进制大对象)类型。在 MySQL 中,TEXT 和 BLOB又分别包括 TEXT、MEDIUMTEXT、LONGTEXT 和 BLOB、MEDIUMBLOB、LONGBLOB 三种不同的类型,它们主要的区别在于存储数据的最大大小不同。保存浮点数可以用 FLOAT 或 DOUBLE 类型,FLOAT 已经不推荐使用了,而且在 MySQL 后续的版本中可能会被移除掉。而保存定点数应该使用 DECIMAL 类型,它可以指定小数点前后有效数字的位数。如果要保存时间日期,DATETIME 类型优于 TIMESTAMP 类型,因为前者能表示的时间日期范围更大,后者底层其实就是一个整数,记录了指定的日期时间和 1970-01-01 00:00:00 相差多少个毫秒,该类型在 2038-01-19 03:14:07 之后就会溢出。

    对于自增字段 AUTO_INCREMENT,如果使用 MySQL 5.x 版本要注意自增字段的回溯问题,当然这个问题在 MySQL 8.x 中已经得到了很好的解决,当然,MySQL 8.x 还有很多其他的好处,不管是功能还是性能上都有很多的优化和调整,因此强烈推荐大家使用 MySQL 8.x 版本。对于高并发访问数据库的场景,AUTO_INCREMENT 不仅存在性能上的问题,还可能在多机结构上产生重复的 ID 值,在这种场景下,使用分布式 ID 生成算法(SnowFlake、TinyID等)才是最好的选择,有兴趣的读者可以自行研究。

删除表和修改表

下面以学生表为例,为大家说明如何删除表和修改表。删除表可以使用drop table,代码如下所示。

1
drop table `tb_student`;

1
drop table if exists `tb_student`;

需要注意的是,如果学生表已经录入了数据而且该数据被其他表引用了,那么就不能删除学生表,否则上面的操作会报错。在下一课中,我们会讲解如何向表中插入数据,到时候大家可以试一试,能否顺利删除学生表。

如果要修改学生表,可以使用alter table,具体可以分为以下几种情况:

修改表,添加一个新列,例如给学生表添加一个联系电话的列。

1
alter table `tb_student` add column `stu_tel` varchar(20) not null comment '联系电话';

注意:如果新增列的时候指定了非空约束(not null),那么学生表不能够有数据,否则原来的数据增加了 stu_tel 列之后是没有数据的,这就违反了非空约束的要求;当然,我们在添加列的时候也可以使用默认值约束来解决这个问题。

修改表,删除指定的列,例如将上面添加的联系电话列删除掉。

1
alter table `tb_student` drop column `stu_tel`;

修改表,修改列的数据类型,例如将学生表的 stu_sex 修改为字符。

1
alter table `tb_student` modify column `stu_sex` char(1) not null default 'M' comment '性别';

修改表,修改列的命名,例如将学生表的 stu_sex 修改为 stu_gender。

1
alter table `tb_student` change column `stu_sex` `stu_gender` boolean default 1 comment '性别';

修改表,删除约束条件,例如删除学生表的 col_id 列的外键约束。

1
alter table `tb_student` drop foreign key `fk_student_col_id`;

修改表,添加约束条件,例如给学生表的 col_id 列加上外键约束。

1
alter table `tb_student` add foreign key (`col_id`) references `tb_college` (`col_id`);

1
alter table `tb_student` add constraint `fk_student_col_id` foreign key (`col_id`) references `tb_college` (`col_id`);

说明:在添加外键约束时,还可以通过on updateon delete来指定在被引用的表发生删除和更新操作时,应该进行何种处理,二者的默认值都是restrict,表示如果存在外键约束,则不允许更新和删除被引用的数据。除了restrict之外,这里可能的取值还有cascade(级联操作)和set null(设置为空),有兴趣的读者可以自行研究。

修改表的名字,例如将学生表的名字修改为 tb_stu_info。

1
alter table `tb_student` rename to `tb_stu_info`;

提示:一般情况下,请不要轻易修改数据库或表的名字。

第42课:SQL详解之DML

我们接着上一课中创建的学校选课系统数据库,为大家讲解 DML 的使用。DML 可以帮助将数据插入到二维表(insert操作)、从二维表删除数据(delete操作)以及更新二维表的数据(update操作)。在执行 DML 之前,我们先通过下面的use命令切换到school数据库。

1
use `school`;

insert操作

顾名思义,insert是用来插入行到二维表中的,插入的方式包括:插入完整的行、插入行的一部分、插入多行、插入查询的结果。我们通过如下所示的 SQL 向学院表中添加一个学院。

1
insert into `tb_college` values (default, '计算机学院', '学习计算机科学与技术的地方');

其中,由于学院表的主键是一个自增字段,因此上面的 SQL 中用default表示该列使用默认值,我们也可以使用下面的方式完成同样的操作。

1
insert into `tb_college` (`col_name`, `col_intro`) values ('计算机学院', '学习计算机科学与技术的地方');

我们推荐大家使用下面这种做法,指定为哪些字段赋值,这样做可以不按照建表时设定的字段顺序赋值,可以按照values前面的元组中给定的字段顺序为字段赋值,但是需要注意,除了允许为null和有默认值的字段外,其他的字段都必须要一一列出并在values后面的元组中为其赋值。如果希望一次性插入多条记录,我们可以在values后面跟上多个元组来实现批量插入,代码如下所示。

1
2
3
4
5
6
insert into `tb_college`
(`col_name`, `col_intro`)
values
('外国语学院', '学习歪果仁的语言的学院'),
('经济管理学院', '经世济民,治理国家;管理科学,兴国之道'),
('体育学院', '发展体育运动,增强人民体质');

在插入数据时,要注意主键是不能重复的,如果插入的数据与表中已有记录主键相同,那么insert操作将会产生 Duplicated Entry 的报错信息。再次提醒大家,如果insert操作省略了某些列,那么这些列要么有默认值,要么允许为null,否则也将产生错误。在业务系统中,为了让insert操作不影响其他操作(主要是后面要讲的select操作)的性能,可以在insertinto之间加一个low_priority来降低insert操作的优先级,这个做法也适用于下面要讲的deleteupdate操作。

假如有一张名为tb_temp的表中有ab两个列,分别保存了学院的名称和学院的介绍,我们也可以通过查询操作获得tb_temp表的数据并插入到学院表中,如下所示,其中的select就是我们之前提到的 DQL,在下一课中会详细讲解。

1
2
3
insert into `tb_college`
(`col_name`, `col_intro`)
select `a`, `b` from `tb_temp`;

delete 操作

如果需要从表中删除数据,可以使用delete操作,它可以帮助我们删除指定行或所有行,例如我们要删除编号为1的学院,就可以使用如下所示的 SQL。

1
delete from `tb_college` where col_id=1;

注意,上面的delete操作中的where子句是用来指定条件的,只有满足条件的行会被删除。如果我们不小心写出了下面的 SQL,就会删除学院表中所有的记录,这是相当危险的,在实际工作中通常也不会这么做。

1
delete from `tb_college`;

需要说明的是,即便删除了所有的数据,delete操作不会删除表本身,也不会让 AUTO_INCREMENT 字段的值回到初始值。如果需要删除所有的数据而且让 AUTO_INCREMENT 字段回到初始值,可以使用truncate table执行截断表操作,truncate的本质是删除原来的表并重新创建一个表,它的速度其实更快,因为不需要逐行删除数据。但是请大家记住一点,用truncate table删除数据是非常危险的,因为它会删除所有的数据,而且由于原来的表已经被删除了,要想恢复误删除的数据也会变得极为困难。

update 操作

如果要修改表中的数据,可以使用update操作,它可以用来删除指定的行或所有的行。例如,我们将学生表中的“杨过”修改为“杨逍”,这里我们假设“杨过”的学号为1001,代码如下所示。

1
update `tb_student` set `stu_name`='杨逍' where `stu_id`=1001;

注意上面 SQL 中的where子句,我们使用学号作为条件筛选出对应的学生,然后通过前面的赋值操作将其姓名修改为“杨逍”。这里为什么不直接使用姓名作为筛选条件,那是因为学生表中可能有多个名为“杨过”的学生,如果使用 stu_name 作为筛选条件,那么我们的update操作有可能会一次更新多条数据,这显然不是我们想要看到的。还有一个需要注意的地方是update操作中的set关键字,因为 SQL 中的=并不表示赋值,而是判断相等的运算符,只有出现在set 关键字后面的=,才具备赋值的能力。

如果要同时修改学生的姓名和生日,我们可以对上面的update语句稍作修改,如下所示。

1
update `tb_student` set `stu_name`='杨逍', `stu_birth`='1975-12-29' where `stu_id`=1001;

update语句中也可以使用查询的方式获得数据并以此来更新指定的表数据,有兴趣的读者可以自行研究。在书写update语句时,通常都会有where子句,因为实际工作中几乎不太会用到更新全表的操作,这一点大家一定要注意。

完整的数据

下面我们给出完整的向 school 数据库的五张表中插入数据的 SQL。

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
use `school`;

-- 插入学院数据
insert into `tb_college`
(`col_name`, `col_intro`)
values
('计算机学院', '计算机学院1958年设立计算机专业,1981年建立计算机科学系,1998年设立计算机学院,2005年5月,为了进一步整合教学和科研资源,学校决定,计算机学院和软件学院行政班子合并统一运作、实行教学和学生管理独立运行的模式。 学院下设三个系:计算机科学与技术系、物联网工程系、计算金融系;两个研究所:图象图形研究所、网络空间安全研究院(2015年成立);三个教学实验中心:计算机基础教学实验中心、IBM技术中心和计算机专业实验中心。'),
('外国语学院', '外国语学院设有7个教学单位,6个文理兼收的本科专业;拥有1个一级学科博士授予点,3个二级学科博士授予点,5个一级学科硕士学位授权点,5个二级学科硕士学位授权点,5个硕士专业授权领域,同时还有2个硕士专业学位(MTI)专业;有教职员工210余人,其中教授、副教授80余人,教师中获得中国国内外名校博士学位和正在职攻读博士学位的教师比例占专任教师的60%以上。'),
('经济管理学院', '经济学院前身是创办于1905年的经济科;已故经济学家彭迪先、张与九、蒋学模、胡寄窗、陶大镛、胡代光,以及当代学者刘诗白等曾先后在此任教或学习。');

-- 插入学生数据
insert into `tb_student`
(`stu_id`, `stu_name`, `stu_sex`, `stu_birth`, `stu_addr`, `col_id`)
values
(1001, '杨过', 1, '1990-3-4', '湖南长沙', 1),
(1002, '任我行', 1, '1992-2-2', '湖南长沙', 1),
(1033, '王语嫣', 0, '1989-12-3', '四川成都', 1),
(1572, '岳不群', 1, '1993-7-19', '陕西咸阳', 1),
(1378, '纪嫣然', 0, '1995-8-12', '四川绵阳', 1),
(1954, '林平之', 1, '1994-9-20', '福建莆田', 1),
(2035, '东方不败', 1, '1988-6-30', null, 2),
(3011, '林震南', 1, '1985-12-12', '福建莆田', 3),
(3755, '项少龙', 1, '1993-1-25', '四川成都', 3),
(3923, '杨不悔', 0, '1985-4-17', '四川成都', 3);

-- 插入老师数据
insert into `tb_teacher`
(`tea_id`, `tea_name`, `tea_title`, `col_id`)
values
(1122, '张三丰', '教授', 1),
(1133, '宋远桥', '副教授', 1),
(1144, '杨逍', '副教授', 1),
(2255, '范遥', '副教授', 2),
(3366, '韦一笑', default, 3);

-- 插入课程数据
insert into `tb_course`
(`cou_id`, `cou_name`, `cou_credit`, `tea_id`)
values
(1111, 'Python程序设计', 3, 1122),
(2222, 'Web前端开发', 2, 1122),
(3333, '操作系统', 4, 1122),
(4444, '计算机网络', 2, 1133),
(5555, '编译原理', 4, 1144),
(6666, '算法和数据结构', 3, 1144),
(7777, '经贸法语', 3, 2255),
(8888, '成本会计', 2, 3366),
(9999, '审计学', 3, 3366);

-- 插入选课数据
insert into `tb_record`
(`stu_id`, `cou_id`, `sel_date`, `score`)
values
(1001, 1111, '2017-09-01', 95),
(1001, 2222, '2017-09-01', 87.5),
(1001, 3333, '2017-09-01', 100),
(1001, 4444, '2018-09-03', null),
(1001, 6666, '2017-09-02', 100),
(1002, 1111, '2017-09-03', 65),
(1002, 5555, '2017-09-01', 42),
(1033, 1111, '2017-09-03', 92.5),
(1033, 4444, '2017-09-01', 78),
(1033, 5555, '2017-09-01', 82.5),
(1572, 1111, '2017-09-02', 78),
(1378, 1111, '2017-09-05', 82),
(1378, 7777, '2017-09-02', 65.5),
(2035, 7777, '2018-09-03', 88),
(2035, 9999, '2019-09-02', null),
(3755, 1111, '2019-09-02', null),
(3755, 8888, '2019-09-02', null),
(3755, 9999, '2017-09-01', 92);

注意:上面的insert语句使用了批处理的方式来插入数据,这种做法插入数据的效率比较高。

第43课:SQL详解之DQL

接下来,我们利用之前创建的学校选课系统数据库,为大家讲解 DQL 的应用。无论对于开发人员还是数据分析师,DQL 都是非常重要的,它关系着我们能否从关系数据库中获取我们需要的数据。建议大家把上上一节课中建库建表的 DDL 以及 上一节课中插入数据的 DML 重新执行一次,确保表和数据跟没有问题再执行下面的操作。

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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
use `school`;

-- 01. 查询所有学生的所有信息
select *
from tb_student;

select stu_id
, stu_name
, stu_sex
, stu_birth
, stu_addr
, col_id
from tb_student;

-- 02. 查询学生的学号、姓名和籍贯(投影和别名)
select stu_id as 学号
, stu_name as 姓名
, stu_addr as 籍贯
from tb_student;

-- 03. 查询所有课程的名称及学分(投影和别名)
select cou_name as 课程名称
, cou_credit as 学分
from tb_course;

-- 04. 查询所有女学生的姓名和出生日期(数据筛选)
select stu_name
, stu_birth
from tb_student
where stu_sex = 0;

-- 05. 查询籍贯为“四川成都”的女学生的姓名和出生日期(数据筛选)
select stu_name
, stu_birth
from tb_student
where stu_sex = 0 and stu_addr = '四川成都';

-- 06. 查询籍贯为“四川成都”或者性别是女的学生(数据筛选)
select stu_name
, stu_birth
from tb_student
where stu_sex = 0 or stu_addr = '四川成都';

-- 07. 查询所有80后学生的姓名、性别和出生日期(数据筛选)
select stu_name
, stu_sex
, stu_birth
from tb_student
where '1980-1-1' <= stu_birth and stu_birth <= '1989-12-31';
select stu_name
, stu_sex
, stu_birth
from tb_student
where stu_birth between '1980-1-1' and '1989-12-31';

-- MySQL方言
select stu_name
, if(stu_sex, '男', '女') as stu_sex
, stu_birth
from tb_student
where stu_birth between '1980-1-1' and '1989-12-31';

select stu_name
, case stu_sex
when 1 then '男'
else '女'
end as stu_sex
, stu_birth
from tb_student
where stu_birth between '1980-1-1' and '1989-12-31';

-- 08. 查询学分大于2分的课程名称和学分(数据筛选)
select cou_name
, cou_credit
from tb_course
where cou_credit > 2;

-- 09. 查询学分是奇数的课程的名称和学分(数据筛选)
select cou_name
, cou_credit
from tb_course
where cou_credit mod 2 <> 0;

-- 10. 查询选择选了1111的课程考试成绩在90分以上的学生学号(数据筛选)
select stu_id
from tb_record
where cou_id = 1111 and score > 90;

-- 11. 查询名字叫“杨过”的学生的姓名和性别
select stu_name
, stu_sex
from tb_student
where stu_name = '杨过';

-- 12. 查询姓“杨”的学生姓名和性别(模糊查询)
-- wild card - 通配符 - % - 代表零个或任意多个字符
select stu_name
, stu_sex
from tb_student
where stu_name like '杨%';

-- 13. 查询姓“杨”名字两个字的学生姓名和性别(模糊查询)
-- wild card - 通配符 - _ - 精确匹配一个字符
select stu_name
, stu_sex
from tb_student
where stu_name like '杨_';

-- 14. 查询姓“杨”名字三个字的学生姓名和性别(模糊查询)
select stu_name
, stu_sex
from tb_student
where stu_name like '杨__';

-- 15. 查询名字中有“不”字或“嫣”字的学生的姓名(模糊查询)
select stu_name
from tb_student
where stu_name like '%不%' or stu_name like '%嫣%';
select stu_name
from tb_student
where stu_name like '%不%'
union
select stu_name
from tb_student
where stu_name like '%嫣%';
update tb_student
set stu_name = '岳不嫣'
where stu_id = 1572;

select stu_name
from tb_student
where stu_name like '%不%'
union all
select stu_name
from tb_student
where stu_name like '%嫣%';

-- 16. 查询姓“杨”或姓“林”名字三个字的学生的姓名(正则表达式模糊查询)
-- regular expression
select stu_name
from tb_student
where stu_name regexp '[杨林][\\u4e00-\\u9fa5]{2}';

-- 17. 查询没有录入籍贯的学生姓名(空值处理)
select stu_name
from tb_student
where stu_addr is null or trim(stu_addr) = '';
update tb_student
set stu_addr = ' '
where stu_id = 1572;

-- 18. 查询录入了籍贯的学生姓名(空值处理)
select stu_name
from tb_student
where stu_addr is not null and trim(stu_addr) <> '';

-- 19. 查询学生选课的所有日期(去重)
select distinct sel_date
from tb_record;

-- 20. 查询学生的籍贯(空值处理和去重)
select distinct stu_addr
from tb_student
where stu_addr is not null and trim(stu_addr) <> '';

-- 21. 查询男学生的姓名和生日按年龄从大到小排列(排序)
-- ascending / descending
select stu_name
, stu_birth
from tb_student
where stu_sex = 1
order by stu_birth asc;

-- 22. 将上面查询中的生日换算成年龄(日期函数、数值函数)
-- 获取当前日期:curdate()
-- 计算时间差:timestampdiff(unit, date1, date2)
select stu_name
, timestampdiff(year, stu_birth, curdate()) as stu_age
from tb_student
where stu_sex = 1
order by stu_age desc;

-- 聚合函数:max / min / avg / sum / count / std / variance
-- 聚合函数会自动忽略掉null
-- 23. 查询年龄最大的学生的出生日期(聚合函数)
select min(stu_birth)
from tb_student;

-- 24. 查询年龄最小的学生的出生日期(聚合函数)
select max(stu_birth)
from tb_student;

-- 25. 查询编号为1111的课程考试成绩的最高分(聚合函数)
select max(score)
from tb_record
where cou_id = 1111;

-- 26. 查询学号为1001的学生考试成绩的最低分(聚合函数)
select min(score)
from tb_record
where stu_id = 1001;

-- 27. 查询学号为1001的学生考试成绩的平均分和标准差(聚合函数)
-- 四舍五入函数:round(num, n)
select round(avg(score), 1) as avg_score
, round(std(score), 4) as std_score
from tb_record
where stu_id = 1001;

-- 28. 查询学号为1001的学生考试成绩的平均分,如果有null值,null值算0分(聚合函数)
select sum(score) / count(*)
from tb_record
where stu_id = 1001;

-- 29. 查询男女学生的人数(分组和聚合函数)
select case stu_sex when 1 then '男' else '女' end as stu_sex
, count(*) as total
from tb_student
group by stu_sex;

-- 30. 查询每个学院学生人数(分组和聚合函数)
select col_id
, count(*) as total
from tb_student
group by col_id
with rollup;

-- 31. 查询每个学院男女学生人数(分组和聚合函数)
select col_id
, case stu_sex when 1 then '男' else '女' end as stu_sex
, count(*) as total
from tb_student
group by col_id, stu_sex;

-- 32. 查询选课学生的学号和平均成绩(分组和聚合函数)
select stu_id
, round(avg(score), 1) as avg_score
from tb_record
group by stu_id;

-- 33. 查询平均成绩大于等于90分的学生的学号和平均成绩(分组和聚合函数)
-- 结论:分组前的筛选使用where子句,分组后的筛选使用having子句
select stu_id
, round(avg(score), 1) as avg_score
from tb_record
group by stu_id
having avg(score) >= 90;

-- 34. 查询所有课程成绩大于80分的同学的学号(分组和聚合函数)
select stu_id
from tb_record
group by stu_id
having min(score) > 80;

-- Error Code: 1242. Subquery returns more than 1 row
select stu_id
, stu_name
from tb_student
where stu_id in (select stu_id
from tb_record
group by stu_id
having min(score) > 80);

-- 35. 查询年龄最大的学生的姓名(嵌套查询)
-- 嵌套查询/子查询:把一个查询的结果作为另外一个查询的一部分来使用
select @a := min(stu_birth)
from tb_student;

select stu_name
from tb_student
where stu_birth = @a;
select stu_name
from tb_student
where stu_birth = (select min(stu_birth)
from tb_student);

-- 36. 查询选了两门以上的课程的学生姓名(嵌套查询/分组/数据筛选)
select stu_name
from tb_student
where stu_id in (select stu_id
from tb_record
group by stu_id
having count(*) > 2);

-- 37. 查询学生的姓名、生日和所在学院名称(连接查询)
select stu_name
, stu_birth
, col_name
from tb_student, tb_college
where tb_student.col_id = tb_college.col_id;

select stu_name
, stu_birth
, col_name
from tb_student inner join tb_college
on tb_student.col_id = tb_college.col_id;

select stu_name
, stu_birth
, col_name
from tb_student natural join tb_college;

-- 38. 查询学生姓名、课程名称以及成绩(连接查询)
select stu_name
, cou_name
, score
from tb_student, tb_course, tb_record
where tb_student.stu_id = tb_record.stu_id
and tb_course.cou_id = tb_record.cou_id
and score is not null;

select stu_name
, cou_name
, score
from tb_student
inner join tb_record
on tb_student.stu_id = tb_record.stu_id
inner join tb_course
on tb_course.cou_id = tb_record.cou_id
where score is not null;
select stu_name
, cou_name
, score
from tb_student
natural join tb_record
natural join tb_course
where score is not null;
-- 39. 上面的查询结果按课程和成绩排序取前5条数据(分页查询)
select stu_name
, cou_name
, score
from tb_student
natural join tb_record
natural join tb_course
where score is not null
order by cou_id asc, score desc
limit 5;

-- 40. 上面的查询结果按课程和成绩排序取第6-10条数据(分页查询)
select stu_name
, cou_name
, score
from tb_student
natural join tb_record
natural join tb_course
where score is not null
order by cou_id asc, score desc
limit 5
offset 5;

-- 41. 上面的查询结果按课程和成绩排序取第11-15条数据(分页查询)
select stu_name
, cou_name
, score
from tb_student
natural join tb_record
natural join tb_course
where score is not null
order by cou_id asc, score desc
limit 5
offset 10;

-- 42. 查询选课学生的姓名和平均成绩(嵌套查询和连接查询)
select stu_name
, avg_score
from tb_student
natural join (select stu_id
, avg(score) as avg_score
from tb_record
group by stu_id) as tmp;

-- 43. 查询学生的姓名和选课的数量(嵌套查询和连接查询)
select stu_name
, total
from tb_student
inner join (select stu_id
, count(*) as total
from tb_record
group by stu_id) as tmp
on tb_student.stu_id = tmp.stu_id;

-- 44. 查询所有学生的姓名和选课数量(左外连接和嵌套查询)
-- 左外连接:把左表(写在join左边的表)所有的数据都拿到,不满足连表条件的地方填充null - left outer join
-- 右外连接:把右表(写在join右边的表)所有的数据都拿到,不满足连表条件的地方填充null - right outer join
-- 全外连接:把左表和右表的数据全部拿到即便它们不满足连表条件,MySQL不支持全外连接 - full outer join
select stu_name
, coalesce(total, 0) as total
from tb_student
left join (select stu_id
, count(*) as total
from tb_record
group by stu_id) as tmp
on tb_student.stu_id = tmp.stu_id;

-- 45. 查询没有选课的学生的姓名(左外连接和数据筛选)
select stu_name
from tb_student
left join tb_record
on tb_student.stu_id = tb_record.stu_id
where tb_record.stu_id is null;

上面的 DQL 有几个地方需要加以说明:

  1. MySQL目前的版本不支持全外连接,上面我们通过union操作,将左外连接和右外连接的结果求并集实现全外连接的效果。大家可以通过下面的图来加深对连表操作的认识。

  2. MySQL 中支持多种类型的运算符,包括:算术运算符(+-*/%)、比较运算符(=<><=><<=>>=BETWEEN...AND...、INIS NULLIS NOT NULLLIKERLIKEREGEXP)、逻辑运算符(NOTANDORXOR)和位运算符(&|^~>><<),我们可以在 DML 中使用这些运算符处理数据。

  3. 在查询数据时,可以在SELECT语句及其子句(如WHERE子句、ORDER BY子句、HAVING子句等)中使用函数,这些函数包括字符串函数、数值函数、时间日期函数、流程函数等,如下面的表格所示。

    常用字符串函数。

    函数 功能
    CONCAT 将多个字符串连接成一个字符串
    FORMAT 将数值格式化成字符串并指定保留几位小数
    FROM_BASE64 / TO_BASE64 BASE64解码/编码
    BIN / OCT / HEX 将数值转换成二进制/八进制/十六进制字符串
    LOCATE 在字符串中查找一个子串的位置
    LEFT / RIGHT 返回一个字符串左边/右边指定长度的字符
    LENGTH / CHAR_LENGTH 返回字符串的长度以字节/字符为单位
    LOWER / UPPER 返回字符串的小写/大写形式
    LPAD / RPAD 如果字符串的长度不足,在字符串左边/右边填充指定的字符
    LTRIM / RTRIM 去掉字符串前面/后面的空格
    ORD / CHAR 返回字符对应的编码/返回编码对应的字符
    STRCMP 比较字符串,返回-1、0、1分别表示小于、等于、大于
    SUBSTRING 返回字符串指定范围的子串

    常用数值函数。

    函数 功能
    ABS 返回一个数的绝度值
    CEILING / FLOOR 返回一个数上取整/下取整的结果
    CONV 将一个数从一种进制转换成另一种进制
    CRC32 计算循环冗余校验码
    EXP / LOG / LOG2 / LOG10 计算指数/对数
    POW 求幂
    RAND 返回[0,1)范围的随机数
    ROUND 返回一个数四舍五入后的结果
    SQRT 返回一个数的平方根
    TRUNCATE 截断一个数到指定的精度
    SIN / COS / TAN / COT / ASIN / ACOS / ATAN 三角函数

    常用时间日期函数。

    函数 功能
    CURDATE / CURTIME / NOW 获取当前日期/时间/日期和时间
    ADDDATE / SUBDATE 将两个日期表达式相加/相减并返回结果
    DATE / TIME 从字符串中获取日期/时间
    YEAR / MONTH / DAY 从日期中获取年/月/日
    HOUR / MINUTE / SECOND 从时间中获取时/分/秒
    DATEDIFF / TIMEDIFF 返回两个时间日期表达式相差多少天/小时
    MAKEDATE / MAKETIME 制造一个日期/时间

    常用流程函数。

    函数 功能
    IF 根据条件是否成立返回不同的值
    IFNULL 如果为NULL则返回指定的值否则就返回本身
    NULLIF 两个表达式相等就返回NULL否则返回第一个表达式的值

    其他常用函数。

    函数 功能
    MD5 / SHA1 / SHA2 返回字符串对应的哈希摘要
    CHARSET / COLLATION 返回字符集/校对规则
    USER / CURRENT_USER 返回当前用户
    DATABASE 返回当前数据库名
    VERSION 返回当前数据库版本
    FOUND_ROWS / ROW_COUNT 返回查询到的行数/受影响的行数
    LAST_INSERT_ID 返回最后一个自增主键的值
    UUID / UUID_SHORT 返回全局唯一标识符

第44课:SQL详解之DCL

数据库服务器通常包含了非常重要的数据,可以通过访问控制来确保这些数据的安全,而 DCL 就是解决这一问题的,它可以为指定的用户授予访问权限或者从指定用户处召回指定的权限。DCL 对数据库管理员来说非常重要,因为用户权限的管理关系到数据库的安全。简单的说,我们可以通过 DCL 允许受信任的用户访问数据库,阻止不受信任的用户访问数据库,同时还可以通过 DCL 将每个访问者的的权限最小化(让访问者的权限刚刚够用)。

创建用户

我们可以使用下面的 SQL 来创建一个用户并为其指定访问口令。

1
create user 'wangdachui'@'%' identified by 'Wang.618';

上面的 SQL 创建了名为 wangdachui 的用户,它的访问口令是 Wang.618,该用户可以从任意主机访问数据库服务器,因为 @ 后面使用了可以表示任意多个字符的通配符 %。如果要限制 wangdachui 这个用户只能从 192.168.0.x 这个网段的主机访问数据库服务器,可以按照下面的方式来修改 SQL 语句。

1
2
3
drop user if exists 'wangdachui'@'%';

create user 'wangdachui'@'192.168.0.%' identified by 'Wang.618';

此时,如果我们使用 wangdachui 这个账号访问数据库服务器,我们几乎不能做任何操作,因为该账号没有任何操作权限。

授予权限

我们用下面的语句为 wangdachui 授予查询 school 数据库学院表(tb_college)的权限。

1
grant select on `school`.`tb_college` to 'wangdachui'@'192.168.0.%';

我们也可以让 wangdachui 对 school 数据库的所有对象都具有查询权限,代码如下所示。

1
grant select on `school`.* to 'wangdachui'@'192.168.0.%';

如果我们希望 wangdachui 还有 insert、delete 和 update 权限,可以使用下面的方式进行操作。

1
grant insert, delete, update on `school`.* to 'wangdachui'@'192.168.0.%';

如果我们还想授予 wangdachui 执行 DDL 的权限,可以使用如下所示的 SQL。

1
grant create, drop, alter on `school`.* to 'wangdachui'@'192.168.0.%';

如果我们希望 wangdachui 账号对所有数据库的所有对象都具备所有的操作权限,可以执行如下所示的操作,但是一般情况下,我们不会这样做,因为我们之前说过,权限刚刚够用就行,一个普通的账号不应该拥有这么大的权限。

1
grant all privileges on *.* to 'wangdachui'@'192.168.0.%';

召回权限

如果要召回 wangdachui 对 school 数据库的 insert、delete 和 update 权限,可以使用下面的操作。

1
revoke insert, delete, update on `school`.* from 'wangdachui'@'192.168.0.%';

如果要召回所有的权限,可以按照如下所示的方式进行操作。

1
revoke all privileges on *.* from 'wangdachui'@'192.168.0.%';

需要说明的是,由于数据库可能会缓存用户的权限,可以在授予或召回权限后执行下面的语句使新的权限即时生效。

1
flush privileges;

第45课:索引

索引是关系型数据库中用来提升查询性能最为重要的手段。关系型数据库中的索引就像一本书的目录,我们可以想象一下,如果要从一本书中找出某个知识点,但是这本书没有目录,这将是一件多么可怕的事情!我们估计得一篇一篇的翻下去,才能确定这个知识点到底在什么位置。创建索引虽然会带来存储空间上的开销,就像一本书的目录会占用一部分篇幅一样,但是在牺牲空间后换来的查询时间的减少也是非常显著的。

MySQL 数据库中所有数据类型的列都可以被索引。对于MySQL 8.0 版本的 InnoDB 存储引擎来说,它支持三种类型的索引,分别是 B+ 树索引、全文索引和 R 树索引。这里,我们只介绍使用得最为广泛的 B+ 树索引。使用 B+ 树的原因非常简单,因为它是目前在基于磁盘进行海量数据存储和排序上最有效率的数据结构。B+ 树是一棵平衡树 ,树的高度通常为3或4,但是却可以保存从百万级到十亿级的数据,而从这些数据里面查询一条数据,只需要3次或4次 I/O 操作。

B+ 树由根节点、中间节点和叶子节点构成,其中叶子节点用来保存排序后的数据。由于记录在索引上是排序过的,因此在一个叶子节点内查找数据时可以使用二分查找,这种查找方式效率非常的高。当数据很少的时候,B+ 树只有一个根节点,数据也就保存在根节点上。随着记录越来越多,B+ 树会发生分裂,根节点不再保存数据,而是提供了访问下一层节点的指针,帮助快速确定数据在哪个叶子节点上。

在创建二维表时,我们通常都会为表指定主键列,主键列上默认会创建索引,而对于 MySQL InnoDB 存储引擎来说,因为它使用的是索引组织表这种数据存储结构,所以主键上的索引就是整张表的数据,而这种索引我们也将其称之为聚集索引(clustered index)。很显然,一张表只能有一个聚集索引,否则表的数据岂不是要保存多次。我们自己创建的索引都是二级索引(secondary index),更常见的叫法是非聚集索引(non-clustered index)。通过我们自定义的非聚集索引只能定位记录的主键,在获取数据时可能需要再通过主键上的聚集索引进行查询,这种现象称为“回表”,因此通过非聚集索引检索数据通常比使用聚集索引检索数据要慢。

接下来我们通过一个简单的例子来说明索引的意义,比如我们要根据学生的姓名来查找学生,这个场景在实际开发中应该经常遇到,就跟通过商品名称查找商品是一个道理。我们可以使用 MySQL 的explain关键字来查看 SQL 的执行计划(数据库执行 SQL 语句的具体步骤)。

1
explain select * from tb_student where stuname='林震南'\G
1
2
3
4
5
6
7
8
9
10
11
12
13
14
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: tb_student
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 11
filtered: 10.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)

在上面的 SQL 执行计划中,有几项值得我们关注:

  1. select_type:查询的类型。
    • SIMPLE:简单 SELECT,不需要使用 UNION 操作或子查询。
    • PRIMARY:如果查询包含子查询,最外层的 SELECT 被标记为 PRIMARY。
    • UNION:UNION 操作中第二个或后面的 SELECT 语句。
    • SUBQUERY:子查询中的第一个 SELECT。
    • DERIVED:派生表的 SELECT 子查询。
  2. table:查询对应的表。
  3. type:MySQL 在表中找到满足条件的行的方式,也称为访问类型,包括:ALL(全表扫描)、index(索引全扫描,只遍历索引树)、range(索引范围扫描)、ref(非唯一索引扫描)、eq_ref(唯一索引扫描)、const / system(常量级查询)、NULL(不需要访问表或索引)。在所有的访问类型中,很显然 ALL 是性能最差的,它代表的全表扫描是指要扫描表中的每一行才能找到匹配的行。
  4. possible_keys:MySQL 可以选择的索引,但是有可能不会使用
  5. key:MySQL 真正使用的索引,如果为NULL就表示没有使用索引。
  6. key_len:使用的索引的长度,在不影响查询的情况下肯定是长度越短越好。
  7. rows:执行查询需要扫描的行数,这是一个预估值
  8. extra:关于查询额外的信息。
    • Using filesort:MySQL 无法利用索引完成排序操作。
    • Using index:只使用索引的信息而不需要进一步查表来获取更多的信息。
    • Using temporary:MySQL 需要使用临时表来存储结果集,常用于分组和排序。
    • Impossible wherewhere子句会导致没有符合条件的行。
    • Distinct:MySQL 发现第一个匹配行后,停止为当前的行组合搜索更多的行。
    • Using where:查询的列未被索引覆盖,筛选条件并不是索引的前导列。

从上面的执行计划可以看出,当我们通过学生名字查询学生时实际上是进行了全表扫描,不言而喻这个查询性能肯定是非常糟糕的,尤其是在表中的行很多的时候。如果我们需要经常通过学生姓名来查询学生,那么就应该在学生姓名对应的列上创建索引,通过索引来加速查询。

1
create index idx_student_name on tb_student(stuname);

再次查看刚才的 SQL 对应的执行计划。

1
explain select * from tb_student where stuname='林震南'\G
1
2
3
4
5
6
7
8
9
10
11
12
13
14
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: tb_student
partitions: NULL
type: ref
possible_keys: idx_student_name
key: idx_student_name
key_len: 62
ref: const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)

可以注意到,在对学生姓名创建索引后,刚才的查询已经不是全表扫描而是基于索引的查询,而且扫描的行只有唯一的一行,这显然大大的提升了查询的性能。MySQL 中还允许创建前缀索引,即对索引字段的前N个字符创建索引,这样的话可以减少索引占用的空间(但节省了空间很有可能会浪费时间,时间和空间是不可调和的矛盾),如下所示。

1
create index idx_student_name_1 on tb_student(stuname(1));

上面的索引相当于是根据学生姓名的第一个字来创建的索引,我们再看看 SQL 执行计划。

1
explain select * from tb_student where stuname='林震南'\G
1
2
3
4
5
6
7
8
9
10
11
12
13
14
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: tb_student
partitions: NULL
type: ref
possible_keys: idx_student_name
key: idx_student_name
key_len: 5
ref: const
rows: 2
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)

不知道大家是否注意到,这一次扫描的行变成了2行,因为学生表中有两个姓“林”的学生,我们只用姓名的第一个字作为索引的话,在查询时通过索引就会找到这两行。

如果要删除索引,可以使用下面的SQL。

1
alter table tb_student drop index idx_student_name;

或者

1
drop index idx_student_name on tb_student;

在创建索引时,我们还可以使用复合索引、函数索引(MySQL 5.7 开始支持),用好复合索引实现索引覆盖可以减少不必要的排序和回表操作,这样就会让查询的性能成倍的提升,有兴趣的读者可以自行研究。

我们简单的为大家总结一下索引的设计原则:

  1. 最适合索引的列是出现在WHERE子句和连接子句中的列。
  2. 索引列的基数越大(取值多、重复值少),索引的效果就越好。
  3. 使用前缀索引可以减少索引占用的空间,内存中可以缓存更多的索引。
  4. 索引不是越多越好,虽然索引加速了读操作(查询),但是写操作(增、删、改)都会变得更慢,因为数据的变化会导致索引的更新,就如同书籍章节的增删需要更新目录一样。
  5. 使用 InnoDB 存储引擎时,表的普通索引都会保存主键的值,所以主键要尽可能选择较短的数据类型,这样可以有效的减少索引占用的空间,提升索引的缓存效果。

最后,还有一点需要说明,InnoDB 使用的 B-tree 索引,数值类型的列除了等值判断时索引会生效之外,使用><>=<=BETWEEN...AND... <>时,索引仍然生效;对于字符串类型的列,如果使用不以通配符开头的模糊查询,索引也是起作用的,但是其他的情况会导致索引失效,这就意味着很有可能会做全表查询。

第46课:视图、函数和过程

为了讲解视图、函数和过程,我们首先用下面的 DDL 和 DML 创建名为 hrs 的数据库并为其二维表添加如下所示的数据。

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
-- 创建名为hrs的数据库并指定默认的字符集
create database `hrs` default charset utf8mb4;

-- 切换到hrs数据库
use `hrs`;

-- 创建部门表
create table `tb_dept`
(
`dno` int not null comment '编号',
`dname` varchar(10) not null comment '名称',
`dloc` varchar(20) not null comment '所在地',
primary key (`dno`)
);

-- 插入4个部门
insert into `tb_dept` values
(10, '会计部', '北京'),
(20, '研发部', '成都'),
(30, '销售部', '重庆'),
(40, '运维部', '深圳');

-- 创建员工表
create table `tb_emp`
(
`eno` int not null comment '员工编号',
`ename` varchar(20) not null comment '员工姓名',
`job` varchar(20) not null comment '员工职位',
`mgr` int comment '主管编号',
`sal` int not null comment '员工月薪',
`comm` int comment '每月补贴',
`dno` int not null comment '所在部门编号',
primary key (`eno`),
constraint `fk_emp_mgr` foreign key (`mgr`) references tb_emp (`eno`),
constraint `fk_emp_dno` foreign key (`dno`) references tb_dept (`dno`)
);

-- 插入14个员工
insert into `tb_emp` values
(7800, '张三丰', '总裁', null, 9000, 1200, 20),
(2056, '乔峰', '分析师', 7800, 5000, 1500, 20),
(3088, '李莫愁', '设计师', 2056, 3500, 800, 20),
(3211, '张无忌', '程序员', 2056, 3200, null, 20),
(3233, '丘处机', '程序员', 2056, 3400, null, 20),
(3251, '张翠山', '程序员', 2056, 4000, null, 20),
(5566, '宋远桥', '会计师', 7800, 4000, 1000, 10),
(5234, '郭靖', '出纳', 5566, 2000, null, 10),
(3344, '黄蓉', '销售主管', 7800, 3000, 800, 30),
(1359, '胡一刀', '销售员', 3344, 1800, 200, 30),
(4466, '苗人凤', '销售员', 3344, 2500, null, 30),
(3244, '欧阳锋', '程序员', 3088, 3200, null, 20),
(3577, '杨过', '会计', 5566, 2200, null, 10),
(3588, '朱九真', '会计', 5566, 2500, null, 10);

视图

视图是关系型数据库中将一组查询指令构成的结果集组合成可查询的数据表的对象。简单的说,视图就是虚拟的表,但与数据表不同的是,数据表是一种实体结构,而视图是一种虚拟结构,你也可以将视图理解为保存在数据库中被赋予名字的 SQL 语句。

使用视图可以获得以下好处:

  1. 可以将实体数据表隐藏起来,让外部程序无法得知实际的数据结构,让访问者可以使用表的组成部分而不是整个表,降低数据库被攻击的风险。
  2. 在大多数的情况下视图是只读的(更新视图的操作通常都有诸多的限制),外部程序无法直接透过视图修改数据。
  3. 重用 SQL 语句,将高度复杂的查询包装在视图表中,直接访问该视图即可取出需要的数据;也可以将视图视为数据表进行连接查询。
  4. 视图可以返回与实体数据表不同格式的数据,在创建视图的时候可以对数据进行格式化处理。

创建视图。

1
2
3
4
5
6
7
create view `vw_emp_simple`
as
select `eno`,
`ename`,
`job`,
`dno`
from `tb_emp`;

提示:因为视图不包含数据,所以每次使用视图时,都必须执行查询以获得数据,如果你使用了连接查询、嵌套查询创建了较为复杂的视图,你可能会发现查询性能下降得很厉害。因此,在使用复杂的视图前,应该进行测试以确保其性能能够满足应用的需求。

有了上面的视图,我们就可以使用之前讲过的 DCL, 限制某些用户只能从视图中获取员工信息,这样员工表中的工资(sal)、补贴(comm)等敏感字段便不会暴露给用户。下面的代码演示了如何从视图中获取数据。

1
select * from `vw_emp_simple`;

查询结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+------+-----------+--------------+-----+
| eno | ename | job | dno |
+------+-----------+--------------+-----+
| 1359 | 胡二刀 | 销售员 | 30 |
| 2056 | 乔峰 | 分析师 | 20 |
| 3088 | 李莫愁 | 设计师 | 20 |
| 3211 | 张无忌 | 程序员 | 20 |
| 3233 | 丘处机 | 程序员 | 20 |
| 3244 | 欧阳锋 | 程序员 | 20 |
| 3251 | 张翠山 | 程序员 | 20 |
| 3344 | 黄蓉 | 销售主管 | 30 |
| 3577 | 杨过 | 会计 | 10 |
| 3588 | 朱九真 | 会计 | 10 |
| 4466 | 苗人凤 | 销售员 | 30 |
| 5234 | 郭靖 | 出纳 | 10 |
| 5566 | 宋远桥 | 会计师 | 10 |
| 7800 | 张三丰 | 总裁 | 20 |
+------+-----------+--------------+-----+

既然视图是一张虚拟的表,那么视图的中的数据可以更新吗?视图的可更新性要视具体情况而定,以下类型的视图是不能更新的:

  1. 使用了聚合函数(SUMMINMAXAVGCOUNT等)、DISTINCTGROUP BYHAVINGUNION或者UNION ALL的视图。
  2. SELECT中包含了子查询的视图。
  3. FROM子句中包含了一个不能更新的视图的视图。
  4. WHERE子句的子查询引用了FROM子句中的表的视图。

删除视图。

1
drop view if exists `vw_emp_simple`;

说明:如果希望更新视图,可以先用上面的命令删除视图,也可以通过create or replace view来更新视图。

视图的规则和限制。

  1. 视图可以嵌套,可以利用从其他视图中检索的数据来构造一个新的视图。视图也可以和表一起使用。
  2. 创建视图时可以使用order by子句,但如果从视图中检索数据时也使用了order by,那么该视图中原先的order by会被覆盖。
  3. 视图无法使用索引,也不会激发触发器(实际开发中因为性能等各方面的考虑,通常不建议使用触发器,所以我们也不对这个概念进行介绍)的执行。

函数

MySQL 中的函数跟 Python 中的函数大同小异,因为函数都是用来封装功能上相对独立且会被重复使用的代码的。如果非要找出一些差别来,那么 MySQL 中的函数是可以执行 SQL 语句的。下面的例子,我们通过自定义函数实现了截断超长字符串的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
delimiter $$

create function fn_truncate_string(
content varchar(10000),
max_length int unsigned
) returns varchar(10000) no sql
begin
declare result varchar(10000) default content;
if char_length(content) > max_length then
set result = left(content, max_length);
set result = concat(result, '……');
end if;
return result;
end $$

delimiter ;

说明1:函数声明后面的no sql是声明函数体并没有使用 SQL 语句;如果函数体中需要通过 SQL 读取数据,需要声明为reads sql data

说明2:定义函数前后的delimiter命令是为了修改终止符(定界符),因为函数体中的语句都是用;表示结束,如果不重新定义定界符,那么遇到的;的时候代码就会被截断执行,显然这不是我们想要的效果。

在查询中调用自定义函数。

1
select fn_truncate_string('和我在成都的街头走一走,直到所有的灯都熄灭了也不停留', 10) as short_string;
1
2
3
4
5
+--------------------------------------+
| short_string |
+--------------------------------------+
| 和我在成都的街头走一…… |
+--------------------------------------+

过程

过程(又称存储过程)是事先编译好存储在数据库中的一组 SQL 的集合,调用过程可以简化应用程序开发人员的工作,减少与数据库服务器之间的通信,对于提升数据操作的性能也是有帮助的。其实迄今为止,我们使用的 SQL 语句都是针对一个或多个表的单条语句,但在实际开发中经常会遇到某个操作需要多条 SQL 语句才能完成的情况。例如,电商网站在受理用户订单时,需要做以下一系列的处理。

  1. 通过查询来核对库存中是否有对应的物品以及库存是否充足。
  2. 如果库存有物品,需要锁定库存以确保这些物品不再卖给别人, 并且要减少可用的物品数量以反映正确的库存量。
  3. 如果库存不足,可能需要进一步与供应商进行交互或者至少产生一条系统提示消息。
  4. 不管受理订单是否成功,都需要产生流水记录,而且需要给对应的用户产生一条通知信息。

我们可以通过过程将复杂的操作封装起来,这样不仅有助于保证数据的一致性,而且将来如果业务发生了变动,只需要调整和修改过程即可。对于调用过程的用户来说,过程并没有暴露数据表的细节,而且执行过程比一条条的执行一组 SQL 要快得多。

下面的过程实现 hrs 数据库中员工工资的普调,具体的规则是:10部门的员工薪资上浮30020部门的员工薪资上浮80030部门的员工薪资上浮500

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
delimiter $$

create procedure sp_upgrade_salary()
begin
declare flag boolean default 1;
-- 定义一个异常处理器
declare continue handler for sqlexception set flag=0;

-- 开启事务环境
start transaction;

update tb_emp set sal=sal+300 where dno=10;
update tb_emp set sal=sal+800 where dno=20;
update tb_emp set sal=sal+500 where dno=30;

-- 提交或回滚事务
if flag then
commit;
else
rollback;
end if;
end $$

delimiter ;

说明:上面的过程代码中使用了start transaction来开启事务环境,关于事务,在本课的最后有一个简单的介绍。为了确定代码中是否发生异常,从而提交或回滚事务,上面的过程中定义了一个名为flag的变量和一个异常处理器,如果发生了异常,flag将会被赋值为0,后面的分支结构会根据flag的值来决定是执行commit,还是执行rollback

调用过程。

1
call sp_upgrade_salary();

删除过程。

1
drop procedure if exists sp_upgrade_salary;

在过程中,我们可以定义变量、条件,可以使用分支和循环语句,可以通过游标操作查询结果,还可以使用事件调度器,这些内容我们暂时不在此处进行介绍。虽然我们说了很多过程的好处,但是在实际开发中,如果频繁的使用过程并将大量复杂的运算放到过程中,会给据库服务器造成巨大的压力,而数据库往往都是性能瓶颈所在,使用过程无疑是雪上加霜的操作。所以,对于互联网产品开发,我们一般建议让数据库只做好存储,复杂的运算和处理交给应用服务器上的程序去完成,如果应用服务器变得不堪重负了,我们可以比较容易的部署多台应用服务器来分摊这些压力。

如果大家对上面讲到的视图、函数、过程包括我们没有讲到的触发器这些知识有兴趣,建议大家阅读 MySQL 的入门读物《MySQL必知必会》 进行一般性了解即可,因为这些知识点在大家将来的工作中未必用得上,学了也可能仅仅是为了应付面试而已。

其他内容

范式理论

范式理论是设计关系型数据库中二维表的指导思想。

  1. 第一范式:数据表的每个列的值域都是由原子值组成的,不能够再分割。
  2. 第二范式:数据表里的所有数据都要和该数据表的键(主键与候选键)有完全依赖关系。
  3. 第三范式:所有非键属性都只和候选键有相关性,也就是说非键属性之间应该是独立无关的。

说明:实际工作中,出于效率的考虑,我们在设计表时很有可能做出反范式设计,即故意降低方式级别,增加冗余数据来获得更好的操作性能。

数据完整性

  1. 实体完整性 - 每个实体都是独一无二的

    • 主键(primary key) / 唯一约束(unique
  2. 引用完整性(参照完整性)- 关系中不允许引用不存在的实体

    • 外键(foreign key
  3. 域(domain)完整性 - 数据是有效的

    • 数据类型及长度

    • 非空约束(not null

    • 默认值约束(default

    • 检查约束(check

      说明:在 MySQL 8.x 以前,检查约束并不起作用。

数据一致性

  1. 事务:一系列对数据库进行读/写的操作,这些操作要么全都成功,要么全都失败。

  2. 事务的 ACID 特性

    • 原子性:事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行
    • 一致性:事务应确保数据库的状态从一个一致状态转变为另一个一致状态
    • 隔离性:多个事务并发执行时,一个事务的执行不应影响其他事务的执行
    • 持久性:已被提交的事务对数据库的修改应该永久保存在数据库中
  3. MySQL 中的事务操作

    • 开启事务环境

      1
      start transaction
    • 提交事务

      1
      commit
    • 回滚事务

      1
      rollback
  4. 查看事务隔离级别

    1
    show variables like 'transaction_isolation';
    1
    2
    3
    4
    5
    +-----------------------+-----------------+
    | Variable_name | Value |
    +-----------------------+-----------------+
    | transaction_isolation | REPEATABLE-READ |
    +-----------------------+-----------------+

    可以看出,MySQL 默认的事务隔离级别是REPEATABLE-READ

  5. 修改(当前会话)事务隔离级别

    1
    set session transaction isolation level read committed;

    重新查看事务隔离级别,结果如下所示。

    1
    2
    3
    4
    5
    +-----------------------+----------------+
    | Variable_name | Value |
    +-----------------------+----------------+
    | transaction_isolation | READ-COMMITTED |
    +-----------------------+----------------+

关系型数据库的事务是一个很大的话题,因为当存在多个并发事务访问数据时,就有可能出现三类读数据的问题(脏读、不可重复读、幻读)和两类更新数据的问题(第一类丢失更新、第二类丢失更新)。想了解这五类问题的,可以阅读我发布在 CSDN 网站上的《Java面试题全集(上)》 一文的第80题。为了避免这些问题,关系型数据库底层是有对应的锁机制的,按锁定对象不同可以分为表级锁和行级锁,按并发事务锁定关系可以分为共享锁和独占锁。然而直接使用锁是非常麻烦的,为此数据库为用户提供了自动锁机制,只要用户指定适当的事务隔离级别,数据库就会通过分析 SQL 语句,然后为事务访问的资源加上合适的锁。此外,数据库还会维护这些锁通过各种手段提高系统的性能,这些对用户来说都是透明的。想了解 MySQL 事务和锁的细节知识,推荐大家阅读进阶读物《高性能MySQL》 ,这也是数据库方面的经典书籍。

ANSI/ISO SQL 92标准定义了4个等级的事务隔离级别,如下表所示。需要说明的是,事务隔离级别和数据访问的并发性是对立的,事务隔离级别越高并发性就越差。所以要根据具体的应用来确定到底使用哪种事务隔离级别,这个地方没有万能的原则。

总结

关于 MySQL 的知识肯定远远不止上面列出的这些,比如 MySQL 性能调优、MySQL 运维相关工具、MySQL 数据的备份和恢复、监控 MySQL 服务、部署高可用架构等,这一系列的问题在这里都没有办法逐一展开来讨论,那就留到有需要的时候再进行讲解吧,各位读者也可以自行探索。

第47课:MySQL 新特性

JSON类型

很多开发者在使用关系型数据库做数据持久化的时候,常常感到结构化的存储缺乏灵活性,因为必须事先设计好所有的列以及对应的数据类型。在业务发展和变化的过程中,如果需要修改表结构,这绝对是比较麻烦和难受的事情。从 MySQL 5.7 版本开始,MySQL引入了对 JSON 数据类型的支持(MySQL 8.0 解决了 JSON 的日志性能瓶颈问题),用好 JSON 类型,其实就是打破了关系型数据库和非关系型数据库之间的界限,为数据持久化操作带来了更多的便捷。

JSON 类型主要分为 JSON 对象和 JSON数组两种,如下所示。

  1. JSON 对象
1
{"name": "骆昊", "tel": "13122335566", "QQ": "957658"}
  1. JSON 数组
1
[1, 2, 3]
1
[{"name": "骆昊", "tel": "13122335566"}, {"name": "王大锤", "QQ": "123456"}]

哪些地方需要用到JSON类型呢?举一个简单的例子,现在很多产品的用户登录都支持多种方式,例如手机号、微信、QQ、新浪微博等,但是一般情况下我们又不会要求用户提供所有的这些信息,那么用传统的设计方式,就需要设计多个列来对应多种登录方式,可能还需要允许这些列存在空值,这显然不是很好的选择;另一方面,如果产品又增加了一种登录方式,那么就必然要修改之前的表结构,这就更让人痛苦了。但是,有了 JSON 类型,刚才的问题就迎刃而解了,我们可以做出如下所示的设计。

1
2
3
4
5
6
7
8
9
10
create table `tb_test`
(
`user_id` bigint unsigned,
`login_info` json,
primary key (`user_id`)
) engine=innodb;

insert into `tb_test` values
(1, '{"tel": "13122335566", "QQ": "654321", "wechat": "jackfrued"}'),
(2, '{"tel": "13599876543", "weibo": "wangdachui123"}');

如果要查询用户的手机和微信号,可以用如下所示的 SQL 语句。

1
2
3
4
5
select
`user_id`,
json_unquote(json_extract(`login_info`, '$.tel')) as 手机号,
json_unquote(json_extract(`login_info`, '$.wechat')) as 微信
from `tb_test`;
1
2
3
4
5
6
+---------+-------------+-----------+
| user_id | 手机号 | 微信 |
+---------+-------------+-----------+
| 1 | 13122335566 | jackfrued |
| 2 | 13599876543 | NULL |
+---------+-------------+-----------+

因为支持 JSON 类型,MySQL 也提供了配套的处理 JSON 数据的函数,就像上面用到的json_extractjson_unquote。当然,上面的 SQL 还有更为便捷的写法,如下所示。

1
2
3
4
5
select
`user_id`,
`login_info` ->> '$.tel' as 手机号,
`login_info` ->> '$.wechat' as 微信
from `tb_test`;

再举个例子,如果我们的产品要实现用户画像功能(给用户打标签),然后基于用户画像给用户推荐平台的服务或消费品之类的东西,我们也可以使用 JSON 类型来保存用户画像数据,示意代码如下所示。

创建画像标签表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
create table `tb_tags`
(
`tag_id` int unsigned not null comment '标签ID',
`tag_name` varchar(20) not null comment '标签名',
primary key (`tag_id`)
) engine=innodb;

insert into `tb_tags` (`tag_id`, `tag_name`)
values
(1, '70后'),
(2, '80后'),
(3, '90后'),
(4, '00后'),
(5, '爱运动'),
(6, '高学历'),
(7, '小资'),
(8, '有房'),
(9, '有车'),
(10, '爱看电影'),
(11, '爱网购'),
(12, '常点外卖');

为用户打标签。

1
2
3
4
5
6
7
8
9
10
create table `tb_users_tags`
(
`user_id` bigint unsigned not null comment '用户ID',
`user_tags` json not null comment '用户标签'
) engine=innodb;

insert into `tb_users_tags` values
(1, '[2, 6, 8, 10]'),
(2, '[3, 10, 12]'),
(3, '[3, 8, 9, 11]');

接下来,我们通过一组查询来了解 JSON 类型的巧妙之处。

  1. 查询爱看电影(有10这个标签)的用户ID。

    1
    select `user_id` from `tb_users_tags` where 10 member of (`user_tags`->'$');
  2. 查询爱看电影(有10这个标签)的80后(有2这个标签)用户ID。

    1
    select `user_id` from `tb_users_tags` where json_contains(`user_tags`->'$', '[2, 10]');
  3. 查询爱看电影或80后或90后的用户ID。

    1
    select `user_id` from `tb_users_tags` where json_overlaps(user_tags->'$', '[2, 3, 10]');

说明:上面的查询用到了member of谓词和两个 JSON 函数,json_contains可以检查 JSON 数组是否包含了指定的元素,而json_overlaps可以检查 JSON 数组是否与指定的数组有重叠部分。

窗口函数

MySQL 从8.0开始支持窗口函数,大多数商业数据库和一些开源数据库早已提供了对窗口函数的支持,有的也将其称之为 OLAP(联机分析和处理)函数,听名字就知道跟统计和分析相关。为了帮助大家理解窗口函数,我们先说说窗口的概念。

窗口可以理解为记录的集合,窗口函数也就是在满足某种条件的记录集合上执行的特殊函数,对于每条记录都要在此窗口内执行函数。窗口函数和我们上面讲到的聚合函数比较容易混淆,二者的区别主要在于聚合函数是将多条记录聚合为一条记录,窗口函数是每条记录都会执行,执行后记录条数不会变。窗口函数不仅仅是几个函数,它是一套完整的语法,函数只是该语法的一部分,基本语法如下所示:

1
<窗口函数> over (partition by <用于分组的列名> order by <用户排序的列名>)

上面语法中,窗口函数的位置可以放以下两种函数:

  1. 专用窗口函数,包括:leadlagfirst_valuelast_valuerankdense_rankrow_number等。
  2. 聚合函数,包括:sumavgmaxmincount等。

下面为大家举几个使用窗口函数的简单例子,我们直接使用上一课创建的 hrs 数据库。

例子1:查询按月薪从高到低排在第4到第6名的员工的姓名和月薪。

1
2
3
4
5
6
select * from (
select
`ename`, `sal`,
row_number() over (order by `sal` desc) as `rank`
from `tb_emp`
) `temp` where `rank` between 4 and 6;

上面使用的函数row_number()可以为每条记录生成一个行号,在实际工作中可以根据需要将其替换为rank()dense_rank()函数,三者的区别可以参考官方文档或阅读《通俗易懂的学会:SQL窗口函数》 进行了解。在MySQL 8以前的版本,我们可以通过下面的方式来完成类似的操作。

1
2
3
4
select `rank`, `ename`, `sal` from (
select @a:=@a+1 as `rank`, `ename`, `sal`
from `tb_emp`, (select @a:=0) as t1 order by `sal` desc
) as `temp` where `rank` between 4 and 6;

例子2:查询每个部门月薪最高的两名的员工的姓名和部门名称。

1
2
3
4
5
6
7
select `ename`, `sal`, `dname`
from (
select
`ename`, `sal`, `dno`,
rank() over (partition by `dno` order by `sal` desc) as `rank`
from `tb_emp`
) as `temp` natural join `tb_dept` where `rank`<=2;

说明:在MySQL 8以前的版本,我们可以通过下面的方式来完成类似的操作。

1
2
3
4
5
6
select `ename`, `sal`, `dname` from `tb_emp` as `t1`
natural join `tb_dept`
where (
select count(*) from `tb_emp` as `t2`
where `t1`.`dno`=`t2`.`dno` and `t2`.`sal`>`t1`.`sal`
)<2 order by `dno` asc, `sal` desc;

第48课:Python程序接入MySQL数据库

在 Python3 中,我们可以使用mysqlclient或者pymysql三方库来接入 MySQL 数据库并实现数据持久化操作。二者的用法完全相同,只是导入的模块名不一样。我们推荐大家使用纯 Python 的三方库pymysql,因为它更容易安装成功。下面我们仍然以之前创建的名为hrs的数据库为例,为大家演示如何通过 Python 程序操作 MySQL 数据库实现数据持久化操作。

接入MySQL

首先,我们可以在命令行或者 PyCharm 的终端中通过下面的命令安装pymysql,如果需要接入 MySQL 8,还需要安装一个名为cryptography的三方库来支持 MySQL 8 的密码认证方式。

1
pip install pymysql cryptography

使用pymysql操作 MySQL 的步骤如下所示:

  1. 创建连接。MySQL 服务器启动后,提供了基于 TCP (传输控制协议)的网络服务。我们可以通过pymysql模块的connect函数连接 MySQL 服务器。在调用connect函数时,需要指定主机(host)、端口(port)、用户名(user)、口令(password)、数据库(database)、字符集(charset)等参数,该函数会返回一个Connection对象。
  2. 获取游标。连接 MySQL 服务器成功后,接下来要做的就是向数据库服务器发送 SQL 语句,MySQL 会执行接收到的 SQL 并将执行结果通过网络返回。要实现这项操作,需要先通过连接对象的cursor方法获取游标(Cursor)对象。
  3. 发出 SQL。通过游标对象的execute方法,我们可以向数据库发出 SQL 语句。
  4. 如果执行insertdeleteupdate操作,需要根据实际情况提交或回滚事务。因为创建连接时,默认开启了事务环境,在操作完成后,需要使用连接对象的commitrollback方法,实现事务的提交或回滚,rollback方法通常会放在异常捕获代码块except中。如果执行select操作,需要通过游标对象抓取查询的结果,对应的方法有三个,分别是:fetchonefetchmanyfetchall。其中fetchone方法会抓取到一条记录,并以元组或字典的方式返回;fetchmanyfetchall方法会抓取到多条记录,以嵌套元组或列表装字典的方式返回。
  5. 关闭连接。在完成持久化操作后,请不要忘记关闭连接,释放外部资源。我们通常会在finally代码块中使用连接对象的close方法来关闭连接。

代码实操

下面,我们通过代码实操的方式为大家演示上面说的五个步骤。

插入数据

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
import pymysql

no = int(input('部门编号: '))
name = input('部门名称: ')
location = input('部门所在地: ')

# 1. 创建连接(Connection)
conn = pymysql.connect(host='127.0.0.1', port=3306,
user='guest', password='Guest.618',
database='hrs', charset='utf8mb4')
try:
# 2. 获取游标对象(Cursor)
with conn.cursor() as cursor:
# 3. 通过游标对象向数据库服务器发出SQL语句
affected_rows = cursor.execute(
'insert into `tb_dept` values (%s, %s, %s)',
(no, name, location)
)
if affected_rows == 1:
print('新增部门成功!!!')
# 4. 提交事务(transaction)
conn.commit()
except pymysql.MySQLError as err:
# 4. 回滚事务
conn.rollback()
print(type(err), err)
finally:
# 5. 关闭连接释放资源
conn.close()

说明:上面的127.0.0.1称为回环地址,它代表的是本机。下面的guest是我提前创建好的用户,该用户拥有对hrs数据库的insertdeleteupdateselect权限。我们不建议大家在项目中直接使用root超级管理员账号访问数据库,这样做实在是太危险了。我们可以使用下面的命令创建名为guest的用户并为其授权。

1
2
create user 'guest'@'%' identified by 'Guest.618';
grant insert, delete, update, select on `hrs`.* to 'guest'@'%';

如果要插入大量数据,建议使用游标对象的executemany方法做批处理(一个insert操作后面跟上多组数据),大家可以尝试向一张表插入10000条记录,然后看看不使用批处理一条条的插入和使用批处理有什么差别。游标对象的executemany方法第一个参数仍然是 SQL 语句,第二个参数可以是包含多组数据的列表或元组。

删除数据

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

no = int(input('部门编号: '))

# 1. 创建连接(Connection)
conn = pymysql.connect(host='127.0.0.1', port=3306,
user='guest', password='Guest.618',
database='hrs', charset='utf8mb4',
autocommit=True)
try:
# 2. 获取游标对象(Cursor)
with conn.cursor() as cursor:
# 3. 通过游标对象向数据库服务器发出SQL语句
affected_rows = cursor.execute(
'delete from `tb_dept` where `dno`=%s',
(no, )
)
if affected_rows == 1:
print('删除部门成功!!!')
finally:
# 5. 关闭连接释放资源
conn.close()

说明:如果不希望每次 SQL 操作之后手动提交或回滚事务,可以connect函数中加一个名为autocommit的参数并将它的值设置为True,表示每次执行 SQL 成功后自动提交。但是我们建议大家手动提交或回滚,这样可以根据实际业务需要来构造事务环境。如果不愿意捕获异常并进行处理,可以在try代码块后直接跟finally块,省略except意味着发生异常时,代码会直接崩溃并将异常栈显示在终端中。

更新数据

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
import pymysql

no = int(input('部门编号: '))
name = input('部门名称: ')
location = input('部门所在地: ')

# 1. 创建连接(Connection)
conn = pymysql.connect(host='127.0.0.1', port=3306,
user='guest', password='Guest.618',
database='hrs', charset='utf8mb4')
try:
# 2. 获取游标对象(Cursor)
with conn.cursor() as cursor:
# 3. 通过游标对象向数据库服务器发出SQL语句
affected_rows = cursor.execute(
'update `tb_dept` set `dname`=%s, `dloc`=%s where `dno`=%s',
(name, location, no)
)
if affected_rows == 1:
print('更新部门信息成功!!!')
# 4. 提交事务
conn.commit()
except pymysql.MySQLError as err:
# 4. 回滚事务
conn.rollback()
print(type(err), err)
finally:
# 5. 关闭连接释放资源
conn.close()

查询数据

  1. 查询部门表的数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import pymysql

# 1. 创建连接(Connection)
conn = pymysql.connect(host='127.0.0.1', port=3306,
user='guest', password='Guest.618',
database='hrs', charset='utf8mb4')
try:
# 2. 获取游标对象(Cursor)
with conn.cursor() as cursor:
# 3. 通过游标对象向数据库服务器发出SQL语句
cursor.execute('select `dno`, `dname`, `dloc` from `tb_dept`')
# 4. 通过游标对象抓取数据
row = cursor.fetchone()
while row:
print(row)
row = cursor.fetchone()
except pymysql.MySQLError as err:
print(type(err), err)
finally:
# 5. 关闭连接释放资源
conn.close()

说明:上面的代码中,我们通过构造一个while循环实现了逐行抓取查询结果的操作。这种方式特别适合查询结果有非常多行的场景。因为如果使用fetchall一次性将所有记录抓取到一个嵌套元组中,会造成非常大的内存开销,这在很多场景下并不是一个好主意。如果不愿意使用while循环,还可以考虑使用iter函数构造一个迭代器来逐行抓取数据,有兴趣的读者可以自行研究。

  1. 分页查询员工表的数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pymysql

page = int(input('页码: '))
size = int(input('大小: '))

# 1. 创建连接(Connection)
con = pymysql.connect(host='127.0.0.1', port=3306,
user='guest', password='Guest.618',
database='hrs', charset='utf8')
try:
# 2. 获取游标对象(Cursor)
with con.cursor(pymysql.cursors.DictCursor) as cursor:
# 3. 通过游标对象向数据库服务器发出SQL语句
cursor.execute(
'select `eno`, `ename`, `job`, `sal` from `tb_emp` order by `sal` desc limit %s,%s',
((page - 1) * size, size)
)
# 4. 通过游标对象抓取数据
for emp_dict in cursor.fetchall():
print(emp_dict)
finally:
# 5. 关闭连接释放资源
con.close()

案例讲解

下面我们为大家讲解一个将数据库表数据导出到 Excel 文件的例子,我们需要先安装openpyxl三方库,命令如下所示。

1
pip install openpyxl

接下来,我们通过下面的代码实现了将数据库hrs中所有员工的编号、姓名、职位、月薪、补贴和部门名称导出到一个 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
import openpyxl
import pymysql

# 创建工作簿对象
workbook = openpyxl.Workbook()
# 获得默认的工作表
sheet = workbook.active
# 修改工作表的标题
sheet.title = '员工基本信息'
# 给工作表添加表头
sheet.append(('工号', '姓名', '职位', '月薪', '补贴', '部门'))
# 创建连接(Connection)
conn = pymysql.connect(host='127.0.0.1', port=3306,
user='guest', password='Guest.618',
database='hrs', charset='utf8mb4')
try:
# 获取游标对象(Cursor)
with conn.cursor() as cursor:
# 通过游标对象执行SQL语句
cursor.execute(
'select `eno`, `ename`, `job`, `sal`, coalesce(`comm`, 0), `dname` '
'from `tb_emp` natural join `tb_dept`'
)
# 通过游标抓取数据
row = cursor.fetchone()
while row:
# 将数据逐行写入工作表中
sheet.append(row)
row = cursor.fetchone()
# 保存工作簿
workbook.save('hrs.xlsx')
except pymysql.MySQLError as err:
print(err)
finally:
# 关闭连接释放资源
conn.close()

大家可以参考上面的例子,试一试把 Excel 文件的数据导入到指定数据库的指定表中,看看是否可以成功。

  • Title: Python基础入门
  • Author: Albert Cheung
  • Created at : 2024-11-07 11:38:20
  • Updated at : 2024-11-11 01:10:24
  • Link: https://www.albertc9.github.io/2024/11/07/introduction-to-python-basics/
  • License: This work is licensed under CC BY-NC-SA 4.0.
On this page
Python基础入门