Python基础入门
第01课:初识Python
Python简介
Python是由荷兰人吉多·范罗苏姆(Guido von Rossum)发明的一种编程语言,是目前世界上最受欢迎和拥有最多用户群体的编程语言。

Python的历史
- 1989年圣诞节:Guido开始写Python语言的编译器。
- 1991年2月:第一个Python解释器诞生,它是用C语言实现的,可以调用C语言的库函数。
- 1994年1月:Python 1.0正式发布。
- 2000年10月:Python 2.0发布,Python的整个开发过程更加透明,生态圈开始慢慢形成。
- 2008年12月:Python 3.0发布,引入了诸多现代编程语言的新特性,但并不完全兼容之前的Python代码。
- 2020年1月:在Python 2和Python 3共存了11年之后,官方停止了对Python 2的更新和维护,希望用户尽快过渡到Python 3。
说明:大多数软件的版本号一般分为三段,形如A.B.C,其中A表示大版本号,当软件整体重写升级或出现不向后兼容的改变时,才会增加A;B表示功能更新,出现新功能时增加B;C表示小的改动(例如:修复了某个Bug),只要有修改就增加C。
Python的优缺点
Python的优点很多,简单为大家列出几点。
- 简单明确,跟其他很多语言相比,Python更容易上手。
- 能用更少的代码做更多的事情,提升开发效率。
- 开放源代码,拥有强大的社区和生态圈。
- 能够做的事情非常多,有极强的适应性。
- 能够在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 --version
或python -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。
除此之外,你还应该检查一下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 | Python 3.7.6 |
提示:使用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文件会自动打开进入可编辑的状态。
写好代码后,可以在编辑代码的窗口点击鼠标右键,选择“Run”菜单项来运行代码,下面的“Run”窗口会显示代码的执行结果,如下图所示。
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 | """ |
总结
到这里,我们已经把第一个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
):布尔值只有True
、False
两种值,要么是True
,要么是False
。
变量命名
对于每个变量我们需要给它取一个名字,就如同我们每个人都有自己的名字一样。在Python中,变量命名需要遵循以下这些规则,这些规则又分为必须遵守的硬性规则和建议遵守的非硬性规则。
- 硬性规则:
- 规则1:变量名由字母、数字和下划线构成,数字不能开头。需要说明的是,这里说的字母指的是Unicode字符,Unicode称为万国码,囊括了世界上大部分的文字系统,这也就意味着中文、日文、希腊字母等都可以作为变量名中的字符,但是像
!
、@
、#
这些特殊字符是不能出现在变量名中的,而且我们强烈建议大家尽可能使用英文字母。 - 规则2:大小写敏感,简单的说就是大写的
A
和小写的a
是两个不同的变量。 - 规则3:变量名不要跟Python语言的关键字(有特殊含义的单词,后面会讲到)和保留字(如已有的函数、模块等的名字)发生重名的冲突。
- 规则1:变量名由字母、数字和下划线构成,数字不能开头。需要说明的是,这里说的字母指的是Unicode字符,Unicode称为万国码,囊括了世界上大部分的文字系统,这也就意味着中文、日文、希腊字母等都可以作为变量名中的字符,但是像
- 非硬性规则:
- 规则1:变量名通常使用小写英文字母,多个单词用下划线进行连接。
- 规则2:受保护的变量用单个下划线开头。
- 规则3:私有的变量用两个下划线开头。
规则2和规则3大家暂时不用理解,后面自然会明白的。当然,作为一个专业的程序员,给变量(事实上应该是所有的标识符)命名时做到见名知意也非常重要。
变量的使用
下面通过例子来说明变量的类型和变量的使用。
1 | """ |
在Python中可以使用type
函数对变量的类型进行检查。程序设计中函数的概念跟数学上函数的概念基本一致,数学上的函数相信大家并不陌生,它包括了函数名、自变量和因变量。如果暂时不理解函数这个概念也不要紧,我们会在后续的内容中专门讲解函数的定义和使用。
1 | """ |
不同类型的变量可以相互转换,这一点可以通过Python的内置函数来实现。
int()
:将一个数值或字符串转换成整数,可以指定进制。float()
:将一个字符串转换成浮点数。str()
:将指定的对象转换成字符串形式,可以指定编码。chr()
:将整数转换成该编码对应的字符串(一个字符)。ord()
:将字符串(一个字符)转换成对应的编码(整数)。
下面的例子为大家演示了Python中类型转换的操作。
1 | """ |
总结
在Python程序中,我们可以使用变量来保存数据,变量有不同的类型,变量可以做运算(下一课会有详细的讲解),也可以通过内置函数来转换变量类型。
第04课:Python语言元素之运算符
Python语言支持很多种运算符,我们先用一个表格为大家列出这些运算符,然后选择一些马上就会用到的运算符为大家进行讲解。
运算符 | 描述 |
---|---|
[] [:] |
下标,切片 |
** |
指数 |
~ + - |
按位取反, 正负号 |
* / % // |
乘,除,模,整除 |
+ - |
加,减 |
>> << |
右移,左移 |
& |
按位与 |
^ | |
按位异或,按位或 |
<= < > >= |
小于等于,小于,大于,大于等于 |
== != |
等于,不等于 |
is is not |
身份运算符 |
in not in |
成员运算符 |
not or and |
逻辑运算符 |
= += -= *= /= %= //= **= &= |= ^= >>= <<= |
(复合)赋值运算符 |
说明: 上面这个表格实际上是按照运算符的优先级从上到下列出了各种运算符。所谓优先级就是在一个运算的表达式中,如果出现了多个运算符,应该先执行哪个运算再执行哪个运算的顺序。在实际开发中,如果搞不清楚运算符的优先级,可以使用圆括号来确保运算的执行顺序。
算术运算符
Python中的算术运算符非常丰富,除了大家最为熟悉的加减乘除之外,还有整除运算符、求模(求余数)运算符和求幂运算符。下面的例子为大家展示了算术运算符的使用。
1 | """ |
赋值运算符
赋值运算符应该是最为常见的运算符,它的作用是将右边的值赋给左边的变量。下面的例子演示了赋值运算符和复合赋值运算符的使用。
1 | """ |
###比较运算符和逻辑运算符
比较运算符有的地方也称为关系运算符,包括==
、!=
、<
、>
、<=
、>=
,我相信没有什么好解释的,大家一看就能懂,需要提醒的是比较相等用的是==
,请注意这里是两个等号,因为=
是赋值运算符,我们在上面刚刚讲到过,==
才是比较相等的运算符;比较不相等用的是!=
,这不同于数学上的不等号,Python 2中曾经使用过<>
来表示不等关系,大家知道就可以了。比较运算符会产生布尔值,要么是True
要么是False
。
逻辑运算符有三个,分别是and
、or
和not
。and
字面意思是“而且”,所以and
运算符会连接两个布尔值,如果两个布尔值都是True
,那么运算的结果就是True
;左右两边的布尔值有一个是False
,最终的运算结果就是False
。相信大家已经想到了,如果and
左边的布尔值是False
,不管右边的布尔值是什么,最终的结果都是False
,所以在做运算的时候右边的值会被跳过(短路处理),这也就意味着在and
运算符左边为False
的情况下,右边的表达式根本不会执行。or
字面意思是“或者”,所以or
运算符也会连接两个布尔值,如果两个布尔值有任意一个是True
,那么最终的结果就是True
。当然,or
运算符也是有短路功能的,在它左边的布尔值为True
的情况下,右边的表达式根本不会执行。not
运算符的后面会跟上一个布尔值,它的作用是得到与该布尔值相反的值,也就是说,not
后面的布尔值如果是True
,运算结果就是False
;而not
后面的布尔值如果是False
,运算结果就是True
。
1 | """ |
说明:比较运算符的优先级高于赋值运算符,所以
flag0 = 1 == 1
先做1 == 1
产生布尔值True
,再将这个值赋值给变量flag0
。,
进行分隔,输出的内容之间默认以空格分开。
运算符的例子
例子1:华氏温度转换为摄氏温度。
提示:华氏温度到摄氏温度的转换公式为:
C = (F - 32) / 1.8
。
1 | """ |
说明:在使用
%.1f
是一个占位符,稍后会由一个float
类型的变量值替换掉它。同理,如果字符串中有%d
,后面可以用一个int
类型的变量值替换掉它,而%s
会被字符串的值替换掉。除了这种格式化字符串的方式外,还可以用下面的方式来格式化字符串,其中{f:.1f}
和{c:.1f}
可以先看成是{f}
和{c}
,表示输出时会用变量f
和变量c
的值替换掉这两个占位符,后面的:.1f
表示这是一个浮点数,小数点后保留1位有效数字。
1 print(f'{f:.1f}华氏度 = {c:.1f}摄氏度')
例子2:输入圆的半径计算计算周长和面积。
1 | """ |
例子3:输入年份判断是不是闰年。
1 | """ |
说明:比较运算符会产生布尔值,而逻辑运算符
and
和or
会对这些布尔值进行组合,最终也是得到一个布尔值,闰年输出True
,平年输出False
。
总结
通过上面的例子相信大家感受到了,学会使用运算符以及由运算符构成的表达式,就可以帮助我们解决很多实际的问题,运算符和表达式对于任何一门编程语言都是非常重要的。
第05课:分支结构
应用场景
迄今为止,我们写的Python代码都是一条一条语句顺序执行,这种代码结构通常称之为顺序结构。然而仅有顺序结构并不能解决所有的问题,比如我们设计一个游戏,游戏第一关的通关条件是玩家获得1000分,那么在完成本局游戏后,我们要根据玩家得到分数来决定究竟是进入第二关,还是告诉玩家“Game Over”,这里就会产生两个分支,而且这两个分支只有一个会被执行。类似的场景还有很多,我们将这种结构称之为“分支结构”或“选择结构”。给大家一分钟的时间,你应该可以想到至少5个以上这样的例子,赶紧试一试。
if语句的使用
在Python中,要构造分支结构可以使用if
、elif
和else
关键字。所谓关键字就是有特殊含义的单词,像if
和else
就是专门用于构造分支结构的关键字,很显然你不能够使用它作为变量名。下面的例子中演示了如何构造一个分支结构。
1 | """ |
需要说明的是,不同于C++、Java等编程语言,Python中没有用花括号来构造代码块而是使用了缩进的方式来表示代码的层次结构,如果if
条件成立的情况下需要执行多条语句,只要保持多条语句具有相同的缩进就可以了。换句话说连续的代码如果又保持了相同的缩进那么它们属于同一个代码块,相当于是一个执行的整体。缩进可以使用任意数量的空格,但通常使用4个空格,强烈建议大家不要使用制表键来缩进代码,如果你已经习惯了这么做,可以设置代码编辑工具将1个制表键自动变成4个空格,很多的代码编辑工具都支持这项功能。
提示:
if
和else
的最后面有一个:
,它是用英文输入法输入的冒号;程序中输入的'
、"
、=
、(
、)
等特殊字符,都是在英文输入法状态下输入的。有很多初学者经常不注意这一点,结果运行代码的时候就会遇到很多莫名其妙的错误提示。强烈建议大家在写代码的时候都打开英文输入法(注意是英文输入法而不是中文输入法的英文输入模式),这样可以避免很多不必要的麻烦。
如果要构造出更多的分支,可以使用if...elif...else...
结构或者嵌套的if...else...
结构,下面的代码演示了如何利用多分支结构实现分段函数求值。
1 | """ |
当然根据实际开发的需要,分支结构是可以嵌套的,例如判断是否通关以后还要根据你获得的宝物或者道具的数量对你的表现给出等级(比如点亮两颗或三颗星星),那么我们就需要在if
的内部构造出一个新的分支结构,同理elif
和else
中也可以再构造新的分支,我们称之为嵌套的分支结构,也就是说上面的代码也可以写成下面的样子。
1 | """ |
说明: 大家可以自己感受和评判一下这两种写法到底是哪一种更好。在Python之禅中有这么一句话:“Flat is better than nested”,之所以提倡代码“扁平化”,是因为代码嵌套的层次如果很多,会严重的影响代码的可读性,所以使用更为扁平化的结构在很多场景下都是较好的选择。
一些例子
例子1:英制单位英寸与公制单位厘米互换。
1 | """ |
例子2:百分制成绩转换为等级制成绩。
要求:如果输入的成绩在90分以上(含90分)输出A;80分-90分(不含90分)输出B;70分-80分(不含80分)输出C;60分-70分(不含70分)输出D;60分以下输出E。
1 | """ |
例子3:输入三条边长,如果能构成三角形就计算周长和面积。
1 | """ |
说明: 上面通过边长计算三角形面积的公式叫做海伦公式 。
简单的总结
学会了Python中的分支结构和循环结构,我们就可以用Python程序来解决很多实际的问题了。这一节课相信已经帮助大家记住了if
、elif
、else
这几个关键字以及如何使用它们来构造分支结构,下一节课我们为大家介绍循环结构,学完这两次课你一定会发现,你能写出很多很多非常有意思的代码。继续加油!
第06课:循环结构
应用场景
我们在写程序的时候,一定会遇到需要重复执行某条指令或某些指令的场景。例如用程序控制机器人踢足球,如果机器人持球而且还没有进入射门范围,那么我们就要一直发出让机器人向球门方向移动的指令。在这个场景中,让机器人向球门方向移动就是一个需要重复的动作,当然这里还会用到上一课讲的分支结构来判断机器人是否持球以及是否进入射门范围。再举一个简单的例子,如果要实现每隔1秒中在屏幕上打印一次“hello, world”并持续打印一个小时,我们肯定不能够直接把print('hello, world')
这句代码写3600遍,这里我们需要构造循环结构。
所谓循环结构,就是程序中控制某条或某些指令重复执行的结构。在Python中构造循环结构有两种做法,一种是for-in
循环,另一种是while
循环。
for-in循环
如果明确的知道循环执行的次数,我们推荐使用for-in
循环,例如输出100行的”hello, world“。 被for-in
循环控制的语句块也是通过缩进的方式来构造的,这一点跟分支结构完全相同,大家看看下面的代码就明白了。
1 | """ |
需要说明的是上面代码中的range(1, 101)
可以用来构造一个从1
到100
的范围,当我们把这样一个范围放到for-in
循环中,就可以通过前面的循环变量x
依次取出从1
到100
的整数。当然,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 | """ |
while循环
如果要构造不知道具体循环次数的循环结构,我们推荐使用while
循环。while
循环通过一个能够产生bool
值的表达式来控制循环,当表达式的值为True
时则继续循环,当表达式的值为False
时则结束循环。
下面我们通过一个“猜数字”的小游戏来看看如何使用while
循环。猜数字游戏的规则是:计算机出一个1
到100
之间的随机数,玩家输入自己猜的数字,计算机给出对应的提示信息(大一点、小一点或猜对了),如果玩家猜中了数字,计算机提示用户一共猜了多少次,游戏结束,否则游戏继续。
1 | """ |
break和continue
上面的代码中使用while True
构造了一个条件恒成立的循环,也就意味着如果不做特殊处理,循环是不会结束的,这也就是常说的“死循环”。为了在用户猜中数字时能够退出循环结构,我们使用了break
关键字,它的作用是提前结束循环。需要注意的是,break
只能终止它所在的那个循环,这一点在使用嵌套循环结构时需要引起注意,下面的例子我们会讲到什么是嵌套的循环结构。除了break
之外,还有另一个关键字是continue
,它可以用来放弃本次循环后续的代码直接让循环进入下一轮。
嵌套的循环结构
和分支结构一样,循环结构也是可以嵌套的,也就是说在循环中还可以构造循环结构。下面的例子演示了如何通过嵌套的循环来输出一个乘法口诀表(九九表)。
1 | """ |
很显然,在上面的代码中,外层循环用来控制一共会产生9
行的输出,而内层循环用来控制每一行会输出多少列。内层循环中的输出就是九九表一行中的所有列,所以在内层循环完成时,有一个print()
来实现换行输出的效果。
循环的例子
例子1:输入一个正整数判断它是不是素数。
提示:素数指的是只能被1和自身整除的大于1的整数。
1 | """ |
例子2:输入两个正整数,计算它们的最大公约数和最小公倍数。
提示:两个数的最大公约数是两个数的公共因子中最大的那个数;两个数的最小公倍数则是能够同时被两个数整除的最小的那个数。
1 | """ |
简单的总结
学会了Python中的分支结构和循环结构,我们就可以解决很多实际的问题了。通过这节课的学习,大家应该已经知道了可以用for
和while
关键字来构造循环结构。如果知道循环的次数,我们通常使用for
循环;如果循环次数不能确定,可以用while
循环。在循环中还可以使用break
来提前结束循环。
第07课:分支和循环结构的应用
通过上两节课的学习,大家对Python中的分支和循环结构已经有了感性的认识。分支和循环结构的重要性不言而喻,它是构造程序逻辑的基础,对于初学者来说也是比较困难的部分。大部分初学者在学习了分支和循环结构后都能理解它们的用途和用法,但是遇到实际问题的时候又无法下手;看懂别人的代码很容易,但是要自己写出同样的代码却又很难。如果你也有同样的问题和困惑,千万不要沮丧,这只是因为你才刚刚开始编程之旅,你的练习量还没有达到让你可以随心所欲的写出代码的程度,只要加强编程练习,这个问题迟早都会解决的。下面我们就为大家讲解一些经典的案例。
经典小案例
例子1:寻找水仙花数。
说明:水仙花数也被称为超完全数字不变数、自恋数、自幂数、阿姆斯特朗数,它是一个3位数,该数字每个位上数字的立方之和正好等于它本身,例如:
。
这个题目的关键是将一个三位数拆分为个位、十位、百位,这一点利用Python中的//
(整除)和%
(求模)运算符其实很容易做到,代码如下所示。
1 | """ |
上面利用//
和%
拆分一个数的小技巧在写代码的时候还是很常用的。我们要将一个不知道有多少位的正整数进行反转,例如将12345
变成54321
,也可以利用这两个运算来实现,代码如下所示。
1 | """ |
例子2:百钱百鸡问题。
说明:百钱百鸡是我国古代数学家张丘建 在《算经》一书中提出的数学问题:鸡翁一值钱五,鸡母一值钱三,鸡雏三值钱一。百钱买百鸡,问鸡翁、鸡母、鸡雏各几何?翻译成现代文是:公鸡5元一只,母鸡3元一只,小鸡1元三只,用100块钱买一百只鸡,问公鸡、母鸡、小鸡各有多少只?
1 | """ |
上面使用的方法叫做穷举法,也称为暴力搜索法,这种方法通过一项一项的列举备选解决方案中所有可能的候选项并检查每个候选项是否符合问题的描述,最终得到问题的解。这种方法看起来比较笨拙,但对于运算能力非常强大的计算机来说,通常都是一个可行的甚至是不错的选择,只要问题的解存在就能够找到它。
例子3:CRAPS赌博游戏。
说明:CRAPS又称花旗骰,是美国拉斯维加斯非常受欢迎的一种的桌上赌博游戏。该游戏使用两粒骰子,玩家通过摇两粒骰子获得点数进行游戏。简化后的规则是:玩家第一次摇骰子如果摇出了7点或11点,玩家胜;玩家第一次如果摇出2点、3点或12点,庄家胜;玩家如果摇出其他点数则玩家继续摇骰子,如果玩家摇出了7点,庄家胜;如果玩家摇出了第一次摇的点数,玩家胜;其他点数玩家继续摇骰子,直到分出胜负。
1 | """ |
例子4:斐波那契数列。
说明:斐波那契数列(Fibonacci sequence),通常也被称作黄金分割数列,是意大利数学家莱昂纳多·斐波那契(Leonardoda Fibonacci)在《计算之书》中研究在理想假设条件下兔子成长率问题而引入的数列,因此这个数列也常被戏称为“兔子数列”。斐波那契数列的特点是数列的前两个数都是1,从第三个数开始,每个数都是它前面两个数的和,按照这个规律,斐波那契数列的前10个数是:
1, 1, 2, 3, 5, 8, 13, 21, 34, 55
。斐波那契数列在现代物理、准晶体结构、化学等领域都有直接的应用。
1 | """ |
例子5:打印100以内的素数。
说明:素数指的是只能被1和自身整除的正整数(不包括1)。
1 | """ |
简单的总结
还是那句话:分支结构和循环结构非常重要,是构造程序逻辑的基础,一定要通过大量的练习来达到融会贯通。刚才讲到的CRAPS赌博游戏那个例子可以作为一个标准,如果你能很顺利的完成这段代码,那么分支和循环结构的知识你就已经掌握了。
第08课:常用数据结构之列表
在开始本节课的内容之前,我们先给大家一个编程任务,将一颗色子掷6000
次,统计每个点数出现的次数。这个任务对大家来说应该是非常简单的,我们可以用1
到6
均匀分布的随机数来模拟掷色子,然后用6
个变量分别记录每个点数出现的次数,相信大家都能写出下面的代码。
1 | import random |
看看上面的代码,相信大家一定觉得它非常的“笨重”和“丑陋”,更可怕的是,如果要统计掷两颗或者更多的色子统计每个点数出现的次数,那就需要定义更多的变量,写更多的分支结构。讲到这里,相信大家一定想问:有没有办法用一个变量来保存多个数据,有没有办法用统一的代码对多个数据进行操作?答案是肯定的,在Python中我们可以通过容器类型的变量来保存和操作多个数据,我们首先为大家介绍列表(list)这种新的数据类型。
定义和使用列表
在Python中,列表是由一系元素按特定顺序构成的数据序列,这样就意味着定义一个列表类型的变量,可以保存多个数据,而且允许有重复的数据。跟上一课我们讲到的字符串类型一样,列表也是一种结构化的、非标量类型,操作一个列表类型的变量,除了可以使用运算符还可以使用它的方法。
在Python中,可以使用[]
字面量语法来定义列表,列表中的多个元素用逗号进行分隔,代码如下所示。
1 | items1 = [35, 12, 99, 68, 55, 87] |
除此以外,还可以通过Python内置的list
函数将其他序列变成列表。准确的说,list
并不是一个普通的函数,它是创建列表对象的构造器(后面会讲到对象和构造器这两个概念)。
1 | items1 = list(range(1, 10)) |
需要说明的是,列表是一种可变数据类型,也就是说列表可以添加元素、删除元素、更新元素,这一点跟我们上一课讲到的字符串有着鲜明的差别。字符串是一种不可变数据类型,也就是说对字符串做拼接、重复、转换大小写、修剪空格等操作的时候会产生新的字符串,原来的字符串并没有发生任何改变。
列表的运算符
和字符串类型一样,列表也支持拼接、重复、成员运算、索引和切片以及比较运算,对此我们不再进行赘述,请大家参考下面的代码。
1 | items1 = [35, 12, 99, 68, 55, 87] |
值得一提的是,由于列表是可变类型,所以通过索引操作既可以获取列表中的元素,也可以更新列表中的元素。对列表做索引操作一样要注意索引越界的问题,对于有N
个元素的列表,正向索引的范围是0
到N-1
,负向索引的范围是-1
到-N
,如果超出这个范围,将引发IndexError
异常,错误信息为:list index out of range
。
列表元素的遍历
如果想逐个取出列表中的元素,可以使用for
循环的,有以下两种做法。
方法一:
1 | items = ['Python', 'Java', 'Go', 'Kotlin'] |
方法二:
1 | items = ['Python', 'Java', 'Go', 'Kotlin'] |
讲到这里,我们可以用列表的知识来重构上面“掷色子统计每个点数出现次数”的代码。
1 | import random |
上面的代码中,我们用counters
列表中的六个元素分别表示1到6的点数出现的次数,最开始的时候六个元素的值都是0
。接下来用随机数模拟掷色子,如果摇出1点counters[0]
的值加1
,如果摇出2点counters[1]
的值加1
,以此类推。大家感受一下,这段代码是不是比之前的代码要简单优雅很多。
列表的方法
和字符串一样,列表类型的方法也很多,下面为大家讲解比较重要的方法。
添加和删除元素
1 | items = ['Python', 'Java', 'Go', 'Kotlin'] |
需要提醒大家,在使用remove
方法删除元素时,如果要删除的元素并不在列表中,会引发ValueError
异常,错误消息是:list.remove(x): x not in list
。在使用pop
方法删除元素时,如果索引的值超出了范围,会引发IndexError
异常,错误消息是:pop index out of range
。
从列表中删除元素其实还有一种方式,就是使用Python中的del
关键字后面跟要删除的元素,这种做法跟使用pop
方法指定索引删除元素没有实质性的区别,但后者会返回删除的元素,前者在性能上略优(del
对应字节码指令是DELETE_SUBSCR
,而pop
对应的字节码指令是CALL_METHOD
和POP_TOP
,不理解就跳过,不用管它!!!)。
1 | items = ['Python', 'Java', 'Go', 'Kotlin'] |
元素位置和次数
列表类型的index
方法可以查找某个元素在列表中的索引位置;因为列表中允许有重复的元素,所以列表类型提供了count
方法来统计一个元素在列表中出现的次数。请看下面的代码。
1 | items = ['Python', 'Java', 'Java', 'Go', 'Kotlin', 'Python'] |
再来看看下面这段代码。
1 | items = ['Python', 'Java', 'Java', 'Go', 'Kotlin', 'Python'] |
元素排序和反转
列表的sort
操作可以实现列表元素的排序,而reverse
操作可以实现元素的反转,代码如下所示。
1 | items = ['Python', 'Java', 'Go', 'Kotlin', 'Python'] |
列表的生成式
在Python中,列表还可以通过一种特殊的字面量语法来创建,这种语法叫做生成式。我们给出两段代码,大家可以做一个对比,看看哪一种方式更加简单优雅。
通过for
循环为空列表添加元素。
1 | # 创建一个由1到9的数字构成的列表 |
通过生成式创建列表。
1 | # 创建一个由1到9的数字构成的列表 |
下面这种方式不仅代码简单优雅,而且性能也优于上面使用for
循环和append
方法向空列表中追加元素的方式。可以简单跟大家交待下为什么生成式拥有更好的性能,那是因为Python解释器的字节码指令中有专门针对生成式的指令(LIST_APPEND
指令);而for
循环是通过方法调用(LOAD_METHOD
和CALL_METHOD
指令)的方式为列表添加元素,方法调用本身就是一个相对耗时的操作。对这一点不理解也没有关系,记住“强烈建议用生成式语法来创建列表”这个结论就可以了。
嵌套的列表
Python语言没有限定列表中的元素必须是相同的数据类型,也就是说一个列表中的元素可以任意的数据类型,当然也包括列表。如果列表中的元素又是列表,那么我们可以称之为嵌套的列表。嵌套的列表可以用来表示表格或数学上的矩阵,例如:我们想保存5个学生3门课程的成绩,可以定义一个保存5个元素的列表保存5个学生的信息,而每个列表元素又是3个元素构成的列表,分别代表3门课程的成绩。但是,一定要注意下面的代码是有问题的。
1 | scores = [[0] * 3] * 5 |
看上去我们好像创建了一个5 * 3
的嵌套列表,但实际上当我们录入第一个学生的第一门成绩后,你就会发现问题来了,我们看看下面代码的输出。
1 | # 嵌套的列表需要多次索引操作才能获取元素 |
我们不去过多的解释为什么会出现这样的问题,如果想深入研究这个问题,可以通过Python Tutor 网站的可视化代码执行功能,看看创建列表时计算机内存中发生了怎样的变化,下面的图就是在这个网站上生成的。建议大家不去纠结这个问题,现阶段只需要记住不能用[[0] * 3] * 5]
这种方式来创建嵌套列表就行了。那么创建嵌套列表的正确做法是什么呢,下面的代码会给你答案。
1 | scores = [[0] * 3 for _ in range(5)] |
简单的总结
Python中的列表底层是一个可以动态扩容的数组,列表元素在内存中也是连续存储的,所以可以实现随机访问(通过一个有效的索引获取到对应的元素且操作时间与列表元素个数无关)。我们暂时不去触碰这些底层存储细节以及列表每个方法的渐近时间复杂度(执行这个方法耗费的时间跟列表元素个数的关系),等需要的时候再告诉大家。现阶段,大家只需要知道列表是容器,可以保存各种类型的数据,可以通过索引操作列表元素,知道这些就足够了。
第09课:常用数据结构之元组
上一节课为大家讲解了Python中的列表,它是一种容器型数据类型,我们可以通过定义列表类型的变量来保存和操作多个元素。当然,Python中容器型的数据类型肯定不止列表一种,接下来我们为大家讲解另一种重要的容器型数据类型,它的名字叫元组(tuple)。
定义和使用元组
在Python中,元组也是多个元素按照一定的顺序构成的序列。元组和列表的不同之处在于,元组是不可变类型,这就意味着元组类型的变量一旦定义,其中的元素不能再添加或删除,而且元素的值也不能进行修改。定义元组通常使用()
字面量语法,也建议大家使用这种方式来创建元组。元组类型支持的运算符跟列表是一样。下面的代码演示了元组的定义和运算。
1 | # 定义一个三元组 |
一个元组中如果有两个元素,我们就称之为二元组;一个元组中如果五个元素,我们就称之为五元组。需要提醒大家注意的是,()
表示空元组,但是如果元组中只有一个元素,需要加上一个逗号,否则()
就不是代表元组的字面量语法,而是改变运算优先级的圆括号,所以('hello', )
和(100, )
才是一元组,而('hello')
和(100)
只是字符串和整数。我们可以通过下面的代码来加以验证。
1 | # 空元组 |
元组的应用场景
讲到这里,相信大家一定迫切的想知道元组有哪些应用场景,我们给大家举几个例子。
例子1:打包和解包操作。
当我们把多个用逗号分隔的值赋给一个变量时,多个值会打包成一个元组类型;当我们把一个元组赋值给多个变量时,元组会解包成多个值然后分别赋给对应的变量,如下面的代码所示。
1 | # 打包 |
在解包时,如果解包出来的元素个数和变量个数不对应,会引发ValueError
异常,错误信息为:too many values to unpack
(解包的值太多)或not enough values to unpack
(解包的值不足)。
1 | a = 1, 10, 100, 1000 |
有一种解决变量个数少于元素的个数方法,就是使用星号表达式,我们之前讲函数的可变参数时使用过星号表达式。有了星号表达式,我们就可以让一个变量接收多个值,代码如下所示。需要注意的是,用星号表达式修饰的变量会变成一个列表,列表中有0个或多个元素。还有在解包语法中,星号表达式只能出现一次。
1 | a = 1, 10, 100, 1000 |
需要说明一点,解包语法对所有的序列都成立,这就意味着对列表以及我们之前讲到的range
函数返回的范围序列都可以使用解包语法。大家可以尝试运行下面的代码,看看会出现怎样的结果。
1 | a, b, *c = range(1, 10) |
例子2:交换两个变量的值。
交换两个变量的值是编程语言中的一个经典案例,在很多编程语言中,交换两个变量的值都需要借助一个中间变量才能做到,如果不用中间变量就需要使用比较晦涩的位运算来实现。在Python中,交换两个变量a
和b
的值只需要使用如下所示的代码。
1 | a, b = b, a |
同理,如果要将三个变量a
、b
、c
的值互换,即b
赋给a
,c
赋给b
,a
赋给c
,也可以如法炮制。
1 | a, b, c = b, c, a |
需要说明的是,上面并没有用到打包和解包语法,Python的字节码指令中有ROT_TWO
和ROT_THREE
这样的指令可以实现这个操作,效率是非常高的。但是如果有多于三个变量的值要依次互换,这个时候没有直接可用的字节码指令,执行的原理就是我们上面讲解的打包和解包操作。
元组和列表的比较
这里还有一个非常值得探讨的问题,Python中已经有了列表类型,为什么还需要元组这样的类型呢?这个问题对于初学者来说似乎有点困难,不过没有关系,我们先抛出观点,大家可以一边学习一边慢慢体会。
元组是不可变类型,不可变类型更适合多线程环境,因为它降低了并发访问变量的同步化开销。关于这一点,我们会在后面讲解多线程的时候为大家详细论述。
元组是不可变类型,通常不可变类型在创建时间和占用空间上面都优于对应的可变类型。我们可以使用
sys
模块的getsizeof
函数来检查保存相同元素的元组和列表各自占用了多少内存空间。我们也可以使用timeit
模块的timeit
函数来看看创建保存相同元素的元组和列表各自花费的时间,代码如下所示。1
2
3
4
5
6
7
8
9import 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)'))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 | s1 = 'hello, world!' |
提示:
end=''
表示输出后不换行,即将默认的结束符\n
(换行符)更换为''
(空字符)。
转义字符和原始字符串
可以在字符串中使用\
(反斜杠)来表示转义,也就是说\
后面的字符不再是它原来的意义,例如:\n
不是代表反斜杠和字符n
,而是表示换行;\t
也不是代表反斜杠和字符t
,而是表示制表符。所以如果字符串本身又包含了'
、"
、\
这些特殊的字符,必须要通过\
进行转义处理。例如要输出一个带单引号或反斜杠的字符串,需要用如下所示的方法。
1 | s1 = '\'hello, world!\'' |
Python中的字符串可以r
或R
开头,这种字符串被称为原始字符串,意思是字符串中的每个字符都是它本来的含义,没有所谓的转义字符。例如,在字符串'hello\n'
中,\n
表示换行;而在r'hello\n'
中,\n
不再表示换行,就是反斜杠和字符n
。大家可以运行下面的代码,看看会输出什么。
1 | # 字符串s1中\t是制表符,\n是换行符 |
Python中还允许在\
后面还可以跟一个八进制或者十六进制数来表示字符,例如\141
和\x61
都代表小写字母a
,前者是八进制的表示法,后者是十六进制的表示法。另外一种表示字符的方式是在\u
后面跟Unicode字符编码,例如\u9a86\u660a
代表的是中文“骆昊”。运行下面的代码,看看输出了什么。
1 | s1 = '\141\142\143\x61\x62\x63' |
字符串的运算
Python为字符串类型提供了非常丰富的运算符,我们可以使用+
运算符来实现字符串的拼接,可以使用*
运算符来重复一个字符串的内容,可以使用in
和not in
来判断一个字符串是否包含另外一个字符串,我们也可以用[]
和[:]
运算符从字符串取出某个字符或某些字符。
拼接和重复
下面的例子演示了使用+
和*
运算符来实现字符串的拼接和重复操作。
1 | s1 = '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 | s1 = 'a whole new world' |
需要强调一下的是,字符串的比较运算比较的是字符串的内容,Python中还有一个is
运算符(身份运算符),如果用is
来比较两个字符串,它比较的是两个变量对应的字符串对象的内存地址(不理解先跳过),简单的说就是两个变量是否对应内存中的同一个字符串。看看下面的代码就比较清楚is
运算符的作用了。
1 | s1 = 'hello world' |
成员运算
Python中可以用in
和not in
判断一个字符串中是否存在另外一个字符或字符串,in
和not in
运算通常称为成员运算,会产生布尔值True
或False
,代码如下所示。
1 | s1 = 'hello, world' |
获取字符串长度
获取字符串长度没有直接的运算符,而是使用内置函数len
,我们在上节课的提到过这个内置函数,代码如下所示。
1 | s = 'hello, world' |
索引和切片
如果希望从字符串中取出某个字符,我们可以对字符串进行索引运算,运算符是[n]
,其中n
是一个整数,假设字符串的长度为N
,那么n
可以是从0
到N-1
的整数,其中0
是字符串中第一个字符的索引,而N-1
是字符串中最后一个字符的索引,通常称之为正向索引;在Python中,字符串的索引也可以是从-1
到-N
的整数,其中-1
是最后一个字符的索引,而-N
则是第一个字符的索引,通常称之为负向索引。注意,因为字符串是不可变类型,所以不能通过索引运算修改字符串中的字符。
1 | s = 'abc123456' |
需要提醒大家注意的是,在进行索引操作时,如果索引越界(正向索引不在0
到N-1
范围,负向索引不在-1
到-N
范围),会引发IndexError
异常,错误提示信息为:string index out of range
(字符串索引超出范围)。
如果要从字符串中取出多个字符,我们可以对字符串进行切片,运算符是[i:j:k]
,其中i
是开始索引,索引对应的字符可以取到;j
是结束索引,索引对应的字符不能取到;k
是步长,默认值为1
,表示从前向后获取相邻字符的连续切片,所以:k
部分可以省略。假设字符串的长度为N
,当k > 0
时表示正向切片(从前向后获取字符),如果没有给出i
和j
的值,则i
的默认值是0
,j
的默认值是N
;当k < 0
时表示负向切片(从后向前获取字符),如果没有给出i
和j
的值,则i
的默认值是-1
,j的默认值是-N - 1
。如果不理解,直接看下面的例子,记住第一个字符的索引是0
或-N
,最后一个字符的索引是N-1
或-1
就行了。
1 | s = 'abc123456' |
循环遍历每个字符
如果希望从字符串中取出每个字符,可以使用for
循环对字符串进行遍历,有两种方式。
方式一:
1 | s1 = 'hello' |
方式二:
1 | s1 = 'hello' |
字符串的方法
在Python中,我们可以通过字符串类型自带的方法对字符串进行操作和处理,对于一个字符串类型的变量,我们可以用变量名.方法名()
的方式来调用它的方法。所谓方法其实就是跟某个类型的变量绑定的函数,后面我们讲面向对象编程的时候还会对这一概念详加说明。
大小写相关操作
下面的代码演示了和字符串大小写变换相关的方法。
1 | s1 = 'hello, world!' |
查找操作
如果想在一个字符串中从前向后查找有没有另外一个字符串,可以使用字符串的find
或index
方法。
1 | s = 'hello, world!' |
在使用find
和index
方法时还可以通过方法的参数来指定查找的范围,也就是查找不必从索引为0
的位置开始。find
和index
方法还有逆向查找(从后向前查找)的版本,分别是rfind
和rindex
,代码如下所示。
1 | s = 'hello good world!' |
性质判断
可以通过字符串的startswith
、endswith
来判断字符串是否以某个字符串开头和结尾;还可以用is
开头的方法判断字符串的特征,这些方法都返回布尔值,代码如下所示。
1 | s1 = 'hello, world!' |
格式化字符串
在Python中,字符串类型可以通过center
、ljust
、rjust
方法做居中、左对齐和右对齐的处理。如果要在字符串的左侧补零,也可以使用zfill
方法。
1 | s = 'hello, world' |
我们之前讲过,在用print
函数输出字符串时,可以用下面的方式对字符串进行格式化。
1 | a = 321 |
当然,我们也可以用字符串的方法来完成字符串的格式,代码如下所示。
1 | a = 321 |
从Python 3.6开始,格式化字符串还有更为简洁的书写方式,就是在字符串前加上f
来格式化字符串,在这种以f
打头的字符串中,{变量名}
是一个占位符,会被变量对应的值将其替换掉,代码如下所示。
1 | a = 321 |
如果需要进一步控制格式化语法中变量值的形式,可以参照下面的表格来进行字符串格式化操作。
变量值 | 占位符 | 格式化结果 | 说明 |
---|---|---|---|
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
方法还有lstrip
和rstrip
两个版本,相信从名字大家已经猜出来这两个方法是做什么用的。
1 | s = ' jackfrued@126.com \t\r\n' |
替换操作
如果希望用新的内容替换字符串中指定的内容,可以使用replace
方法,代码如下所示。replace
方法的第一个参数是被替换的内容,第二个参数是替换后的内容,还可以通过第三个参数指定替换的次数。
1 | s = 'hello, world' |
拆分/合并操作
可以使用字符串的split
方法将一个字符串拆分为多个字符串(放在一个列表中),也可以使用字符串的join
方法将列表中的多个字符串连接成一个字符串,代码如下所示。
1 | s = 'I love you' |
需要说明的是,split
方法默认使用空格进行拆分,我们也可以指定其他的字符来拆分字符串,而且还可以指定最大拆分次数来控制拆分的效果,代码如下所示。
1 | s = 'I#love#you#so#much' |
编码/解码操作
Python中除了字符串str
类型外,还有一种表示二进制数据的字节串类型(bytes
)。所谓字节串,就是由零个或多个字节组成的有限序列。通过字符串的encode
方法,我们可以按照某种编码方式将字符串编码为字节串,我们也可以使用字节串的decode
方法,将字节串解码为字符串,代码如下所示。
1 | a = '骆昊' |
注意,如果编码和解码的方式不一致,会导致乱码问题(无法再现原始的内容)或引发UnicodeDecodeError
错误导致程序崩溃。
其他方法
对于字符串类型来说,还有一个常用的操作是对字符串进行匹配检查,即检查字符串是否满足某种特定的模式。例如,一个网站对用户注册信息中用户名和邮箱的检查,就属于模式匹配检查。实现模式匹配检查的工具叫做正则表达式,Python语言通过标准库中的re
模块提供了对正则表达式的支持,我们会在后续的课程中为大家讲解这个知识点。
简单的总结
知道如何表示和操作字符串对程序员来说是非常重要的,因为我们需要处理文本信息,Python中操作字符串可以用拼接、切片等运算符,也可以使用字符串类型的方法。
第11课:常用数据结构之集合
在学习了列表和元组之后,我们再来学习一种容器型的数据类型,它的名字叫集合(set)。说到集合这个词大家一定不会陌生,在数学课本上就有这个概念。通常我们对集合的定义是“把一定范围的、确定的、可以区别的事物当作一个整体来看待”,集合中的各个事物通常称为集合的元素。集合应该满足以下特性:
- 无序性:一个集合中,每个元素的地位都是相同的,元素之间是无序的。
- 互异性:一个集合中,任何两个元素都是不相同的,即元素在集合中只能出现一次。
- 确定性:给定一个集合和一个任意元素,该元素要么属这个集合,要么不属于这个集合,二者必居其一,不允许有模棱两可的情况出现。
Python程序中的集合跟数学上的集合是完全一致的,需要强调的是上面所说的无序性和互异性。无序性说明集合中的元素并不像列中的元素那样一个挨着一个,可以通过索引实现随机访问(随机访问指的是给定一个有效的范围,随机抽取出一个数字,然后通过这个数字可以获取到对应的元素),所以Python中的集合肯定不能够支持索引运算。另外,集合的互异性决定了集合中不能有重复元素,这一点也是集合区别于列表的关键,说得更直白一些就是,Python中的集合类型会对其中的元素做去重处理。Python中的集合一定是支持in
和not in
成员运算的,这样就可以确定一个元素是否属于集合,也就是上面所说的集合的确定性。集合的成员运算在性能上要优于列表的成员运算,这是集合的底层存储特性(哈希存储)决定的,此处我们暂时不做讨论,大家可以先记下这个结论。
创建集合
在Python中,创建集合可以使用{}
字面量语法,{}
中需要至少有一个元素,因为没有元素的{}
并不是空集合而是一个空字典,我们下一节课就会大家介绍字典的知识。当然,也可以使用内置函数set
来创建一个集合,准确的说set
并不是一个函数,而是创建集合对象的构造器,这个知识点我们很快也会讲到,现在不理解跳过它就可以了。要创建空集合可以使用set()
;也可以将其他序列转换成集合,例如:set('hello')
会得到一个包含了4个字符的集合(重复的l
会被去掉)。除了这两种方式,我们还可以使用生成式语法来创建集合,就像我们之前用生成式创建列表那样。要知道集合中有多少个元素,还是使用内置函数len
;使用for
循环可以实现对集合元素的遍历。
1 | # 创建集合的字面量语法(重复元素不会出现在集合中) |
需要提醒大家,集合中的元素必须是hashable
类型。所谓hashable
类型指的是能够计算出哈希码的数据类型,大家可以暂时将哈希码理解为和变量对应的唯一的ID值。通常不可变类型都是hashable
类型,如整数、浮点、字符串、元组等,而可变类型都不是hashable
类型,因为可变类型无法确定唯一的ID值,所以也就不能放到集合中。集合本身也是可变类型,所以集合不能够作为集合中的元素,这一点在使用集合的时候一定要注意。
集合的运算
Python为集合类型提供了非常丰富的运算符,主要包括:成员运算、交集运算、并集运算、差集运算、比较运算(相等性、子集、超集)等。
成员运算
可以通过成员运算in
和not in
检查元素是否在集合中,代码如下所示。
1 | set1 = {11, 12, 13, 14, 15} |
交并差运算
Python中的集合跟数学上的集合一样,可以进行交集、并集、差集等运算,而且可以通过运算符和方法调用两种方式来进行操作,代码如下所示。
1 | set1 = {1, 2, 3, 4, 5, 6, 7} |
通过上面的代码可以看出,对两个集合求交集,&
运算符和intersection
方法的作用是完全相同的,使用运算符的方式更直观而且代码也比较简短。相信大家对交集、并集、差集、对称差这几个概念是比较清楚的,如果没什么印象了可以看看下面的图。

集合的交集、并集、差集运算还可以跟赋值运算一起构成复合赋值运算,如下所示。
1 | set1 = {1, 3, 5, 7} |
比较运算
两个集合可以用==
和!=
进行相等性判断,如果两个集合中的元素完全相同,那么==
比较的结果就是True
,否则就是False
。如果集合A
的任意一个元素都是集合B
的元素,那么集合A
称为集合B
的子集,即对于A
是B
的子集,反过来也可以称B
是A
的超集。如果A
是B
的子集且A
不等于B
,那么A
就是B
的真子集。Python为集合类型提供了判断子集和超集的运算符,其实就是我们非常熟悉的<
和>
运算符,代码如下所示。
1 | set1 = {1, 3, 5} |
集合的方法
Python中的集合是可变类型,我们可以通过集合类型的方法为集合添加或删除元素。
1 | # 创建一个空集合 |
如果要判断两个集合有没有相同的元素可以使用isdisjoint
方法,没有相同元素返回True
,否则返回False
,代码如下所示。
1 | set1 = {'Java', 'Python', 'Go', 'Kotlin'} |
不可变集合
Python中还有一种不可变类型的集合,名字叫frozenset
。set
跟frozenset
的区别就如同list
跟tuple
的区别,frozenset
由于是不可变类型,能够计算出哈希码,因此它可以作为set
中的元素。除了不能添加和删除元素,frozenset
在其他方面跟set
基本是一样的,下面的代码简单的展示了frozenset
的用法。
1 | set1 = frozenset({1, 3, 5, 7}) |
简单的总结
Python中的集合底层使用了哈希存储的方式,对于这一点我们暂时不做介绍,在后面的课程有需要的时候再为大家讲解集合的底层原理,现阶段大家只需要知道集合是一种容器,元素必须是hashable
类型,与列表不同的地方在于集合中的元素没有序、不能用索引运算、不能重复。
第12课:常用数据结构之字典
迄今为止,我们已经为大家介绍了Python中的三种容器型数据类型,但是这些数据类型仍然不足以帮助我们解决所有的问题。例如,我们要保存一个人的信息,包括姓名、年龄、体重、单位地址、家庭住址、本人手机号、紧急联系人手机号等信息,你会发现我们之前学过的列表、元组和集合都不是最理想的选择。
1 | person1 = ['王大锤', 55, 60, '科华北路62号', '中同仁路8号', '13122334455', '13800998877'] |
集合肯定是最不合适的,因为集合有去重特性,如果一个人的年龄和体重相同,那么集合中就会少一项信息;同理,如果这个人的家庭住址和单位地址是相同的,那么集合中又会少一项信息。另一方面,虽然列表和元组可以把一个人的所有信息都保存下来,但是当你想要获取这个人的手机号时,你得先知道他的手机号是列表或元组中的第6个还是第7个元素;当你想获取一个人的家庭住址时,你还得知道家庭住址是列表或元组中的第几项。总之,在遇到上述的场景时,列表、元组、字典都不是最合适的选择,我们还需字典(dictionary)类型,这种数据类型最适合把相关联的信息组装到一起,并且可以帮助我们解决程序中为真实事物建模的问题。
说到字典这个词,大家一定不陌生,读小学的时候每个人基本上都有一本《新华字典》,如下图所示。
Python程序中的字典跟现实生活中的字典很像,它以键值对(键和值的组合)的方式把数据组织到一起,我们可以通过键找到与之对应的值并进行操作。就像《新华字典》中,每个字(键)都有与它对应的解释(值)一样,每个字和它的解释合在一起就是字典中的一个条目,而字典中通常包含了很多个这样的条目。
创建和使用字典
在Python中创建字典可以使用{}
字面量语法,这一点跟上一节课讲的集合是一样的。但是字典的{}
中的元素是以键值对的形式存在的,每个元素由:
分隔的两个值构成,:
前面是键,:
后面是值,代码如下所示。
1 | xinhua = { |
通过上面的代码,相信大家已经看出来了,用字典来保存一个人的信息远远优于使用列表或元组,因为我们可以用:
前面的键来表示条目的含义,而:
后面就是这个条目所对应的值。
当然,如果愿意,我们也可以使用内置函数dict
或者是字典的生成式语法来创建字典,代码如下所示。
1 | # dict函数(构造器)中的每一组参数就是字典中的一组键值对 |
想知道字典中一共有多少组键值对,仍然是使用len
函数;如果想对字典进行遍历,可以用for
循环,但是需要注意,for
循环只是对字典的键进行了遍历,不过没关系,在讲完字典的运算后,我们可以通过字典的键获取到和这个键对应的值。
1 | person = {'name': '王大锤', 'age': 55, 'weight': 60, 'office': '科华北路62号'} |
字典的运算
对于字典类型来说,成员运算和索引运算肯定是最为重要的,前者可以判定指定的键在不在字典中,后者可以通过键获取对应的值或者向字典中加入新的键值对。值得注意的是,字典的索引不同于列表的索引,列表中的元素因为有属于自己有序号,所以列表的索引是一个整数;字典中因为保存的是键值对,所以字典的索引是键值对中的键,通过索引操作可以修改原来的值或者向字典中存入新的键值对。需要特别提醒大家注意的是,字典中的键必须是不可变类型,例如整数(int
)、浮点数(float
)、字符串(str
)、元组(tuple
)等类型的值;显然,列表(list
)和集合(set
)是不能作为字典中的键的,当然字典类型本身也不能再作为字典中的键,因为字典也是可变类型,但是字典可以作为字典中的值。关于可变类型不能作为字典中的键的原因,我们在后面的课程中再为大家详细说明。这里,我们先看看下面的代码,了解一下字典的成员运算和索引运算。
1 | person = {'name': '王大锤', 'age': 55, 'weight': 60, 'office': '科华北路62号'} |
需要注意,在通过索引运算获取字典中的值时,如指定的键没有在字典中,将会引发KeyError
异常。
字典的方法
字典类型的方法基本上都跟字典的键值对操作相关,可以通过下面的例子来了解这些方法的使用。例如,我们要用一个字典来保存学生的信息,我们可以使用学生的学号作为字典中的键,通过学号做索引运算就可以得到对应的学生;我们可以把字典的值也做成一个字典,这样就可以用多组键值对分别存储学生的姓名、性别、年龄、籍贯等信息,代码如下所示。
1 | # 字典中的值又是一个字典(嵌套的字典) |
跟列表一样,从字典中删除元素也可以使用del
关键字,在删除元素的时候如果指定的键索引不到对应的值,一样会引发KeyError
异常,具体的做法如下所示。
1 | person = {'name': '王大锤', 'age': 25, 'sex': True} |
字典的应用
我们通过几个简单的例子来讲解字典的应用。
例子1:输入一段话,统计每个英文字母出现的次数。
1 | sentence = input('请输入一段话: ') |
例子2:在一个字典中保存了股票的代码和价格,找出股价大于100元的股票并创建一个新的字典。
说明:可以用字典的生成式语法来创建这个新字典。
1 | stocks = { |
简单的总结
Python程序中的字典跟现实生活中字典非常像,允许我们以键值对的形式保存数据,再通过键索引对应的值。这是一种非常有利于数据检索的数据类型,底层原理我们在后续的课程中为大家讲解。再次提醒大家注意,字典中的键必须是不可变类型,字典中的值可以是任意类型。
第13课:函数和模块
在讲解本节课的内容之前,我们先来研究一道数学题,请说出下面的方程有多少组正整数解。
你可能已经想到了,这个问题其实等同于将8
个苹果分成四组且每组至少一个苹果有多少种方案,因此该问题还可以进一步等价于在分隔8
个苹果的7
个空隙之间插入三个隔板将苹果分成四组有多少种方案,也就是从7
个空隙选出3
个空隙放入隔板的组合数,所以答案是
根据我们前面学习的知识,可以用循环做累乘的方式来计算阶乘,那么通过下面的Python代码我们就可以计算出组合数
1 | """ |
函数的作用
不知大家是否注意到,上面的代码中我们做了三次求阶乘,虽然m
、n
、m - n
的值各不相同,但是三段代码并没有实质性的区别,属于重复代码。世界级的编程大师Martin Fowler先生曾经说过:“代码有很多种坏味道,重复是最坏的一种!”。要写出高质量的代码首先要解决的就是重复代码的问题。对于上面的代码来说,我们可以将计算阶乘的功能封装到一个称为“函数”的代码块中,在需要计算阶乘的地方,我们只需要“调用函数”就可以了。
定义函数
数学上的函数通常形如y = f(x)
或者z = g(x, y)
这样的形式,在y = f(x)
中,f
是函数的名字,x
是函数的自变量,y
是函数的因变量;而在z = g(x, y)
中,g
是函数名,x
和y
是函数的自变量,z
是函数的因变量。Python中的函数跟这个结构是一致的,每个函数都有自己的名字、自变量和因变量。我们通常把Python中函数的自变量称为函数的参数,而因变量称为函数的返回值。
在Python中可以使用def
关键字来定义函数,和变量一样每个函数也应该有一个漂亮的名字,命名规则跟变量的命名规则是一致的(赶紧想一想我们之前讲过的变量的命名规则)。在函数名后面的圆括号中可以放置传递给函数的参数,就是我们刚才说到的函数的自变量,而函数执行完成后我们会通过return
关键字来返回函数的执行结果,就是我们刚才说的函数的因变量。一个函数要执行的代码块(要做的事情)也是通过缩进的方式来表示的,跟之前分支和循环结构的代码块是一样的。大家不要忘了def
那一行的最后面还有一个:
,之前提醒过大家,那是在英文输入法状态下输入的冒号。
我们可以通过函数对上面的代码进行重构。所谓重构,是在不影响代码执行结果的前提下对代码的结构进行调整。重构之后的代码如下所示。
1 | """ |
说明:事实上,Python标准库的
math
模块中有一个名为factorial
的函数已经实现了求阶乘的功能,我们可以直接使用该函数来计算阶乘。将来我们使用的函数,要么是自定义的函数,要么是Python标准库或者三方库中提供的函数。
函数的参数
参数的默认值
如果函数中没有return
语句,那么函数默认返回代表空值的None
。另外,在定义函数时,函数也可以没有自变量,但是函数名后面的圆括号是必须有的。Python中还允许函数的参数拥有默认值,我们可以把之前讲过的一个例子“CRAPS赌博游戏”中摇色子获得点数的功能封装成函数,代码如下所示。
1 | """ |
我们再来看一个更为简单的例子。
1 | def add(a=0, b=0, c=0): |
注意:带默认值的参数必须放在不带默认值的参数之后,否则将产生
SyntaxError
错误,错误消息是:non-default argument follows default argument
,翻译成中文的意思是“没有默认值的参数放在了带默认值的参数后面”。
可变参数
接下来,我们还可以实现一个对任意多个数求和的add
函数,因为Python语言中的函数可以通过星号表达式语法来支持可变参数。所谓可变参数指的是在调用函数时,可以向函数传入0
个或任意多个参数。将来我们以团队协作的方式开发商业项目时,很有可能要设计函数给其他人使用,但有的时候我们并不知道函数的调用者会向该函数传入多少个参数,这个时候可变参数就可以派上用场。下面的代码演示了用可变参数实现对任意多个数求和的add
函数。
1 | """ |
用模块管理函数
不管用什么样的编程语言来写代码,给变量、函数起名字都是一个让人头疼的问题,因为我们会遇到命名冲突这种尴尬的情况。最简单的场景就是在同一个.py
文件中定义了两个同名的函数,如下所示。
1 | def foo(): |
当然上面的这种情况我们很容易就能避免,但是如果项目是团队协作多人开发的时候,团队中可能有多个程序员都定义了名为foo
的函数,这种情况下怎么解决命名冲突呢?答案其实很简单,Python中每个文件就代表了一个模块(module),我们在不同的模块中可以有同名的函数,在使用函数的时候我们通过import
关键字导入指定的模块再使用完全限定名的调用方式就可以区分到底要使用的是哪个模块中的foo
函数,代码如下所示。
module1.py
1 | def foo(): |
module2.py
1 | def foo(): |
test.py
1 | import module1 |
在导入模块时,还可以使用as
关键字对模块进行别名,这样我们可以使用更为简短的完全限定名。
test.py
1 | import module1 as m1 |
上面的代码我们导入了定义函数的模块,我们也可以使用from...import...
语法从模块中直接导入需要使用的函数,代码如下所示。
test.py
1 | from module1 import foo |
但是,如果我们如果从两个不同的模块中导入了同名的函数,后导入的函数会覆盖掉先前的导入,就像下面的代码中,调用foo
会输出hello, world!
,因为我们先导入了module2
的foo
,后导入了module1
的foo
。如果两个from...import...
反过来写,就是另外一番光景了。
test.py
1 | from module2 import foo |
如果想在上面的代码中同时使用来自两个模块中的foo
函数也是有办法的,大家可能已经猜到了,还是用as
关键字对导入的函数进行别名,代码如下所示。
test.py
1 | from module1 import foo as f1 |
标准库中的模块和函数
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) 会返回8 ;pow(2, 0.5) 会返回1.4142135623730951 。 |
print |
打印输出。 |
range |
构造一个范围序列,例如:range(100) 会产生0 到99 的整数序列。 |
round |
按照指定的精度对数值进行四舍五入,例如:round(1.23456, 4) 会返回1.2346 。 |
sum |
对一个序列中的项从左到右进行求和运算,例如:sum(range(1, 101)) 会返回5050 。 |
type |
返回对象的类型,例如:type(10) 会返回int ;而 type('hello') 会返回str 。 |
简单的总结
函数是对功能相对独立且会重复使用的代码的封装。学会使用定义和使用函数,就能够写出更为优质的代码。当然,Python语言的标准库中已经为我们提供了大量的模块和常用的函数,用好这些模块和函数就能够用更少的代码做更多的事情;如果这些模块和函数不能满足我们的要求,我们就需要自定义函数,然后用模块的概念来管理这些自定义函数。
第14课:函数的应用
接下来我们通过一些案例来为大家讲解函数的应用。
经典小案例
案例1:设计一个生成验证码的函数。
说明:验证码由数字和英文大小写字母构成,长度可以用参数指定。
1 | import random |
可以用下面的代码生成10组随机验证码来测试上面的函数。
1 | for _ in range(10): |
说明:
random
模块的sample
和choices
函数都可以实现随机抽样,sample
实现无放回抽样,这意味着抽样取出的字符是不重复的;choices
实现有放回抽样,这意味着可能会重复选中某些字符。这两个函数的第一个参数代表抽样的总体,而参数k
代表抽样的数量。
案例2:设计一个函数返回给定文件的后缀名。
说明:文件名通常是一个字符串,而文件的后缀名指的是文件名中最后一个
.
后面的部分,也称为文件的扩展名,它是某些操作系统用来标记文件类型的一种机制,例如在Windows系统上,后缀名exe
表示这是一个可执行程序,而后缀名txt
表示这是一个纯文本文件。需要注意的是,在Linux和macOS系统上,文件名可以以.
开头,表示这是一个隐藏文件,像.gitignore
这样的文件名,.
后面并不是后缀名,这个文件没有后缀名或者说后缀名为''
。
1 | def get_suffix(filename, ignore_dot=True): |
可以用下面的代码对上面的函数做一个简单的测验。
1 | print(get_suffix('readme.txt')) # txt |
上面的get_suffix
函数还有一个更为便捷的实现方式,就是直接使用os.path
模块的splitext
函数,这个函数会将文件名拆分成带路径的文件名和扩展名两个部分,然后返回一个二元组,二元组中的第二个元素就是文件的后缀名(包含.
),如果要去掉后缀名中的.
,可以做一个字符串的切片操作,代码如下所示。
1 | from os.path import splitext |
思考:如果要给上面的函数增加一个参数,用来控制文件的后缀名是否包含
.
,应该怎么做?
案例3:写一个判断给定的正整数是不是质数的函数。
1 | def is_prime(num: int) -> bool: |
案例4:写出计算两个正整数最大公约数和最小公倍数的函数。
代码一:
1 | def gcd_and_lcm(x: int, y: int) -> int: |
代码二:
1 | def gcd(x: int, y: int) -> int: |
思考:请比较上面的代码一和代码二,想想哪种做法是更好的选择。
案例5:写出计算一组样本数据描述性统计信息的函数。
1 | import math |
简单的总结
在写代码尤其是开发商业项目的时候,一定要有意识的将相对独立且重复出现的功能封装成函数,这样不管是自己还是团队的其他成员都可以通过调用函数的方式来使用这些功能。
第15课:函数使用进阶
前面我们讲到了关于函数的知识,我们还讲到过Python中常用的数据类型,这些类型的变量都可以作为函数的参数或返回值,用好函数还可以让我们做更多的事情。
关键字参数
下面是一个判断传入的三条边长能否构成三角形的函数,在调用函数传入参数时,我们可以指定参数名,也可以不指定参数名,代码如下所示。
1 | def is_triangle(a, b, c): |
在没有特殊处理的情况下,函数的参数都是位置参数,也就意味着传入参数的时候对号入座即可,如上面代码的第7行所示,传入的参数值1
、2
、3
会依次赋值给参数a
、b
、c
。当然,也可以通过参数名=参数值
的方式传入函数所需的参数,因为指定了参数名,传入参数的顺序可以进行调整,如上面代码的第9行和第11行所示。
调用函数时,如果希望函数的调用者必须以参数名=参数值
的方式传参,可以用命名关键字参数(keyword-only argument)取代位置参数。所谓命名关键字参数,是在函数的参数列表中,写在*
之后的参数,代码如下所示。
1 | def is_triangle(*, a, b, c): |
注意:上面的
is_triangle
函数,参数列表中的*
是一个分隔符,*
前面的参数都是位置参数,而*
后面的参数就是命名关键字参数。
我们之前讲过在函数的参数列表中可以使用可变参数*args
来接收任意数量的参数,但是我们需要看看,*args
是否能够接收带参数名的参数。
1 | def calc(*args): |
执行上面的代码会引发TypeError
错误,错误消息为calc() got an unexpected keyword argument 'a'
,由此可见,*args
并不能处理带参数名的参数。我们在设计函数时,如果既不知道调用者会传入的参数个数,也不知道调用者会不会指定参数名,那么同时使用可变参数和关键字参数。关键字参数会将传入的带参数名的参数组装成一个字典,参数名就是字典中键值对的键,而参数值就是字典中键值对的值,代码如下所示。
1 | def calc(*args, **kwargs): |
提示:不带参数名的参数(位置参数)必须出现在带参数名的参数(关键字参数)之前,否则将会引发异常。例如,执行
calc(1, 2, c=3, d=4, 5)
将会引发SyntaxError
错误,错误消息为positional argument follows keyword argument
,翻译成中文意思是“位置参数出现在关键字参数之后”。
高阶函数的用法
在前面几节课中,我们讲到了面向对象程序设计,在面向对象的世界中,一切皆为对象,所以类和函数也是对象。函数的参数和返回值可以是任意类型的对象,这就意味着函数本身也可以作为函数的参数或返回值,这就是所谓的高阶函数。
如果我们希望上面的calc
函数不仅仅可以做多个参数求和,还可以做多个参数求乘积甚至更多的二元运算,我们就可以使用高阶函数的方式来改写上面的代码,将加法运算从函数中移除掉,具体的做法如下所示。
1 | def calc(*args, init_value, op, **kwargs): |
注意,上面的函数增加了两个参数,其中init_value
代表运算的初始值,op
代表二元运算函数。经过改造的calc
函数不仅仅可以实现多个参数的累加求和,也可以实现多个参数的累乘运算,代码如下所示。
1 | def add(x, y): |
通过对高阶函数的运用,calc
函数不再和加法运算耦合,所以灵活性和通用性会变强,这是一种解耦合的编程技巧,但是最初学者来说可能会稍微有点难以理解。需要注意的是,将函数作为参数和调用函数是有显著的区别的,调用函数需要在函数名后面跟上圆括号,而把函数作为参数时只需要函数名即可。上面的代码也可以不用定义add
和mul
函数,因为Python标准库中的operator
模块提供了代表加法运算的add
和代表乘法运算的mul
函数,我们直接使用即可,代码如下所示。
1 | import operator |
Python内置函数中有不少高阶函数,我们前面提到过的filter
和map
函数就是高阶函数,前者可以实现对序列中元素的过滤,后者可以实现对序列中元素的映射,例如我们要去掉一个整数列表中的奇数,并对所有的偶数求平方得到一个新的列表,就可以直接使用这两个函数来做到,具体的做法是如下所示。
1 | def is_even(num): |
当然,要完成上面代码的功能,也可以使用列表生成式,列表生成式的做法更为简单优雅。
1 | numbers1 = [35, 12, 8, 99, 60, 52] |
Lambda函数
在使用高阶函数的时候,如果作为参数或者返回值的函数本身非常简单,一行代码就能够完成,那么我们可以使用Lambda函数来表示。Python中的Lambda函数是没有的名字函数,所以很多人也把它叫做匿名函数,匿名函数只能有一行代码,代码中的表达式产生的运算结果就是这个匿名函数的返回值。上面代码中的is_even
和square
函数都只有一行代码,我们可以用Lambda函数来替换掉它们,代码如下所示。
1 | numbers1 = [35, 12, 8, 99, 60, 52] |
通过上面的代码可以看出,定义Lambda函数的关键字是lambda
,后面跟函数的参数,如果有多个参数用逗号进行分隔;冒号后面的部分就是函数的执行体,通常是一个表达式,表达式的运算结果就是Lambda函数的返回值,不需要写return
关键字。
如果需要使用加减乘除这种简单的二元函数,也可以用Lambda函数来书写,例如调用上面的calc
函数时,可以通过传入Lambda函数来作为op
参数的参数值。当然,op
参数也可以有默认值,例如我们可以用一个代表加法运算的Lambda函数来作为op
参数的默认值。
1 | def calc(*args, init_value=0, op=lambda x, y: x + y, **kwargs): |
提示:注意上面的代码中的
calc
函数,它同时使用了可变参数、关键字参数、命名关键字参数,其中命名关键字参数要放在可变参数和关键字参数之间,传参时先传入可变参数,关键字参数和命名关键字参数的先后顺序并不重要。
有很多函数在Python中用一行代码就能实现,我们可以用Lambda函数来定义这些函数,调用Lambda函数就跟调用普通函数一样,代码如下所示。
1 | import operator, functools |
提示1:上面使用的
reduce
函数是Python标准库functools
模块中的函数,它可以实现对数据的归约操作,通常情况下,过滤(filter)、映射(map)和归约(reduce)是处理数据中非常关键的三个步骤,而Python的标准库也提供了对这三个操作的支持。提示2:上面使用的
all
函数是Python内置函数,如果传入的序列中所有布尔值都是True
,all
函数就返回True
,否则all
函数就返回False
。
简单的总结
Python中的函数可以使用可变参数*args
和关键字参数**kwargs
来接收任意数量的参数,而且传入参数时可以带上参数名也可以没有参数名,可变参数会被处理成一个元组,而关键字参数会被处理成一个字典。Python中的函数是一等函数,可以赋值给变量,也可以作为函数的参数和返回值,这也就意味着我们可以在Python中使用高阶函数。如果我们要定义的函数非常简单,只有一行代码且不需要函数名,可以使用Lambda函数(匿名函数)。
第16课:函数的高级应用
在上一节课中,我们已经对函数进行了更为深入的研究,还探索了Python中的高阶函数和Lambda函数。在这些知识的基础上,这节课我们为大家分享两个和函数相关的内容,一个是装饰器,一个是函数的递归调用。
装饰器
装饰器是Python中用一个函数装饰另外一个函数或类并为其提供额外功能的语法现象。装饰器本身是一个函数,它的参数是被装饰的函数或类,它的返回值是一个带有装饰功能的函数。很显然,装饰器是一个高阶函数,它的参数和返回值都是函数。下面我们先通过一个简单的例子来说明装饰器的写法和作用,假设已经有名为downlaod
和upload
的两个函数,分别用于文件的上传和下载,下面的代码用休眠一段随机时间的方式模拟了下载和上传需要花费的时间,并没有联网做上传下载。
说明:用Python语言实现联网的上传下载也很简单,继续你的学习,这个环节很快就会来到。
1 | import random |
现在我们希望知道调用download
和upload
函数做文件上传下载到底用了多少时间,这个应该如何实现呢?相信很多小伙伴已经想到了,我们可以在函数开始执行的时候记录一个时间,在函数调用结束后记录一个时间,两个时间相减就可以计算出下载或上传的时间,代码如下所示。
1 | start = time.time() |
通过上面的代码,我们可以得到下载和上传花费的时间,但不知道大家是否注意到,上面记录时间、计算和显示执行时间的代码都是重复代码。有编程经验的人都知道,重复的代码是万恶之源,那么有没有办法在不写重复代码的前提下,用一种简单优雅的方式记录下函数的执行时间呢?在Python中,装饰器就是解决这类问题的最佳选择。我们可以把记录函数执行时间的功能封装到一个装饰器中,在有需要的地方直接使用这个装饰器就可以了,代码如下所示。
1 | import time |
使用上面的装饰器函数有两种方式,第一种方式就是直接调用装饰器函数,传入被装饰的函数并获得返回值,我们可以用这个返回值直接覆盖原来的函数,那么在调用时就已经获得了装饰器提供的额外的功能(记录执行时间),大家可以试试下面的代码就明白了。
1 | download = record_time(download) |
上面的代码中已经没有重复代码了,虽然写装饰器会花费一些心思,但是这是一个一劳永逸的骚操作,如果还有其他的函数也需要记录执行时间,按照上面的代码如法炮制即可。
在Python中,使用装饰器很有更为便捷的语法糖(编程语言中添加的某种语法,这种语法对语言的功能没有影响,但是使用更加方法,代码的可读性也更强,我们将其称之为“语法糖”或“糖衣语法”),可以用@装饰器函数
将装饰器函数直接放在被装饰的函数上,效果跟上面的代码相同,下面是完整的代码。
1 | import random |
上面的代码,我们通过装饰器语法糖为download
和upload
函数添加了装饰器,这样调用download
和upload
函数时,会记录下函数的执行时间。事实上,被装饰后的download
和upload
函数是我们在装饰器record_time
中返回的wrapper
函数,调用它们其实就是在调用wrapper
函数,所以拥有了记录函数执行时间的功能。
如果希望取消装饰器的作用,那么在定义装饰器函数的时候,需要做一些额外的工作。Python标准库functools
模块的wraps
函数也是一个装饰器,我们将它放在wrapper
函数上,这个装饰器可以帮我们保留被装饰之前的函数,这样在需要取消装饰器时,可以通过被装饰函数的__wrapped__
属性获得被装饰之前的函数。
1 | import random |
装饰器函数本身也可以参数化,简单的说就是通过我们的装饰器也是可以通过调用者传入的参数来定制的,这个知识点我们在后面用到它的时候再为大家讲解。
递归调用
Python中允许函数嵌套定义,也允许函数之间相互调用,而且一个函数还可以直接或间接的调用自身。函数自己调用自己称为递归调用,那么递归调用有什么用处呢?现实中,有很多问题的定义本身就是一个递归定义,例如我们之前讲到的阶乘,非负整数N
的阶乘是N
乘以N-1
的阶乘,即
1 | def fac(num): |
上面的代码中,fac
函数中又调用了fac
函数,这就是所谓的递归调用。代码第2行的if
条件叫做递归的收敛条件,简单的说就是什么时候要结束函数的递归调用,在计算阶乘时,如果计算到0
或1
的阶乘,就停止递归调用,直接返回1
;代码第4行的num * fac(num - 1)
是递归公式,也就是阶乘的递归定义。下面,我们简单的分析下,如果用fac(5)
计算5
的阶乘,整个过程会是怎样的。
1 | # 递归调用函数入栈 |
注意,函数调用会通过内存中称为“栈”(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 | def fib(n): |
需要提醒大家,上面计算斐波那契数的代码虽然看起来非常简单明了,但执行性能是比较糟糕的,原因大家可以自己思考一下,更好的做法还是之前讲过的使用循环递推的方式,代码如下所示。
1 | def fib(n): |
简单的总结
装饰器是Python中的特色语法,可以通过装饰器来增强现有的函数,这是一种非常有用的编程技巧。一些复杂的问题用函数递归调用的方式写起来真的很简单,但是函数的递归调用一定要注意收敛条件和递归公式,找到递归公式才有机会使用递归调用,而收敛条件确定了递归什么时候停下来。函数调用通过内存中的栈空间来保存现场和恢复现场,栈空间通常都很小,所以递归如果不能迅速收敛,很可能会引发栈溢出错误,从而导致程序的崩溃。
第17课:面向对象编程入门
面向对象编程是一种非常流行的编程范式(programming paradigm),所谓编程范式就是程序设计的方法论,简单的说就是程序员对程序的认知和理解以及他们编写代码的方式。
在前面的课程中,我们说过“程序是指令的集合”,运行程序时,程序中的语句会变成一条或多条指令,然后由CPU(中央处理器)去执行。为了简化程序的设计,我们又讲到了函数,把相对独立且经常重复使用的代码放置到函数中,在需要使用这些代码的时候调用函数即可。如果一个函数的功能过于复杂和臃肿,我们又可以进一步将函数进一步拆分为多个子函数来降低系统的复杂性。
不知大家是否发现,我们的编程工作其实是写程序的人按照计算机的工作方式通过代码控制机器完成任务。但是,计算机的工作方式与人类正常的思维模式是不同的,如果编程就必须抛弃人类正常的思维方式去迎合计算机,编程的乐趣就少了很多,而“每个人都应该学习编程”的豪言壮语也就只能喊喊口号而已。这里,我想说的并不是我们不能按照计算机的工作方式去编写代码,但是当我们需要开发一个复杂的系统时,这种方式会让代码过于复杂,从而导致开发和维护工作都变得举步维艰。
随着软件复杂性的增加,编写正确可靠的代码会变成了一项极为艰巨的任务,这也是很多人都坚信“软件开发是人类改造世界所有活动中最为复杂的活动”的原因。如何用程序描述复杂系统和解决复杂问题,就成为了所有程序员必须要思考和直面的问题。诞生于上世纪70年代的Smalltalk语言让软件开发者看到了希望,因为它引入了一种新的编程范式叫面向对象编程。在面向对象编程的世界里,程序中的数据和操作数据的函数是一个逻辑上的整体,我们称之为对象,对象可以接收消息,解决问题的方法就是创建对象并向对象发出各种各样的消息;通过消息传递,程序中的多个对象可以协同工作,这样就能构造出复杂的系统并解决现实中的问题。当然,面向对象编程的雏形还可以向前追溯到更早期的Simula语言,但这不是我们现在要讨论的重点。
说明: 今天我们使用的很多高级程序设计语言都支持面向对象编程,但是面向对象编程也不是解决软件开发中所有问题的“银弹”,或者说在软件开发这个行业目前还找不到这种所谓的“银弹”。关于这个问题,大家可以参考IBM360系统之父弗雷德里克·布鲁克斯所发表的论文《没有银弹:软件工程的本质性与附属性工作》或软件工程的经典著作《人月神话》一书。
类和对象
如果要用一句话来概括面向对象编程,我认为下面的说法是相当精辟和准确的。
面向对象编程:把一组数据和处理数据的方法组成对象,把行为相同的对象归纳为类,通过封装隐藏对象的内部细节,通过继承实现类的特化和泛化,通过多态实现基于对象类型的动态分派。
这句话对初学者来说可能不那么容易理解,但是我可以先为大家圈出几个关键词:对象(object)、类(class)、封装(encapsulation)、继承(inheritance)、多态(polymorphism)。
我们先说说类和对象这两个词。在面向对象编程中,类是一个抽象的概念,对象是一个具体的概念。我们把同一类对象的共同特征抽取出来就是一个类,比如我们经常说的人类,这是一个抽象概念,而我们每个人就是人类的这个抽象概念下的实实在在的存在,也就是一个对象。简而言之,类是对象的蓝图和模板,对象是类的实例,是可以接受消息的实体。
在面向对象编程的世界中,一切皆为对象,对象都有属性和行为,每个对象都是独一无二的,而且对象一定属于某个类。对象的属性是对象的静态特征,对象的行为是对象的动态特征。按照上面的说法,如果我们把拥有共同特征的对象的属性和行为都抽取出来,就可以定义出一个类。

定义类
在Python中,可以使用class
关键字加上类名来定义类,通过缩进我们可以确定类的代码块,就如同定义函数那样。在类的代码块中,我们需要写一些函数,我们说过类是一个抽象概念,那么这些函数就是我们对一类对象共同的动态特征的提取。写在类里面的函数我们通常称之为方法,方法就是对象的行为,也就是对象可以接收的消息。方法的第一个参数通常都是self
,它代表了接收这个消息的对象本身。
1 | class Student: |
创建和使用对象
在我们定义好一个类之后,可以使用构造器语法来创建对象,代码如下所示。
1 | stu1 = Student() |
在类的名字后跟上圆括号就是所谓的构造器语法,上面的代码创建了两个学生对象,一个赋值给变量stu1
,一个复制给变量stu2
。当我们用print
函数打印stu1
和stu2
两个变量时,我们会看到输出了对象在内存中的地址(十六进制形式),跟我们用id
函数查看对象标识获得的值是相同的。现在我们可以告诉大家,我们定义的变量其实保存的是一个对象在内存中的逻辑地址(位置),通过这个逻辑地址,我们就可以在内存中找到这个对象。所以stu3 = stu2
这样的赋值语句并没有创建新的对象,只是用一个新的变量保存了已有对象的地址。
接下来,我们尝试给对象发消息,即调用对象的方法。刚才的Student
类中我们定义了study
和play
两个方法,两个方法的第一个参数self
代表了接收消息的学生对象,study
方法的第二个参数是学习的课程名称。Python中,给对象发消息有两种方式,请看下面的代码。
1 | # 通过“类.方法”调用方法,第一个参数是接收消息的对象,第二个参数是学习的课程名称 |
初始化方法
大家可能已经注意到了,刚才我们创建的学生对象只有行为没有属性,如果要给学生对象定义属性,我们可以修改Student
类,为其添加一个名为__init__
的方法。在我们调用Student
类的构造器创建对象时,首先会在内存中获得保存学生对象所需的内存空间,然后通过自动执行__init__
方法,完成对内存的初始化操作,也就是把数据放到内存空间中。所以我们可以通过给Student
类添加__init__
方法的方式为学生对象指定属性,同时完成对属性赋初始值的操作,正因如此,__init__
方法通常也被称为初始化方法。
我们对上面的Student
类稍作修改,给学生对象添加name
(姓名)和age
(年龄)两个属性。
1 | class Student: |
修改刚才创建对象和给对象发消息的代码,重新执行一次,看看程序的执行结果有什么变化。
1 | # 由于初始化方法除了self之外还有两个参数 |
打印对象
上面我们通过__init__
方法在创建对象时为对象绑定了属性并赋予了初始值。在Python中,以两个下划线__
(读作“dunder”)开头和结尾的方法通常都是有特殊用途和意义的方法,我们一般称之为魔术方法或魔法方法。如果我们在打印对象的时候不希望看到对象的地址而是看到我们自定义的信息,可以通过在类中放置__repr__
魔术方法来做到,该方法返回的字符串就是用print
函数打印对象的时候会显示的内容,代码如下所示。
1 | class Student: |
面向对象的支柱
面向对象编程有三大支柱,就是我们之前给大家划重点的时候圈出的三个词:封装、继承和多态。后面两个概念在下一节课中会详细说明,这里我们先说一下什么是封装。我自己对封装的理解是:隐藏一切可以隐藏的实现细节,只向外界暴露简单的调用接口。我们在类中定义的对象方法其实就是一种封装,这种封装可以让我们在创建对象之后,只需要给对象发送一个消息就可以执行方法中的代码,也就是说我们在只知道方法的名字和参数(方法的外部视图),不知道方法内部实现细节(方法的内部视图)的情况下就完成了对方法的使用。
举一个例子,假如要控制一个机器人帮我倒杯水,如果不使用面向对象编程,不做任何的封装,那么就需要向这个机器人发出一系列的指令,如站起来、向左转、向前走5步、拿起面前的水杯、向后转、向前走10步、弯腰、放下水杯、按下出水按钮、等待10秒、松开出水按钮、拿起水杯、向右转、向前走5步、放下水杯等,才能完成这个简单的操作,想想都觉得麻烦。按照面向对象编程的思想,我们可以将倒水的操作封装到机器人的一个方法中,当需要机器人帮我们倒水的时候,只需要向机器人对象发出倒水的消息就可以了,这样做不是更好吗?
在很多场景下,面向对象编程其实就是一个三步走的问题。第一步定义类,第二步创建对象,第三步给对象发消息。当然,有的时候我们是不需要第一步的,因为我们想用的类可能已经存在了。之前我们说过,Python内置的list
、set
、dict
其实都不是函数而是类,如果要创建列表、集合、字典对象,我们就不用自定义类了。当然,有的类并不是Python标准库中直接提供的,它可能来自于第三方的代码,如何安装和使用三方代码在后续课程中会进行讨论。在某些特殊的场景中,我们会用到名为“内置对象”的对象,所谓“内置对象”就是说上面三步走的第一步和第二步都不需要了,因为类已经存在而且对象已然创建过了,直接向对象发消息就可以了,这也就是我们常说的“开箱即用”。
经典案例
案例1:定义一个类描述数字时钟。
1 | import time |
案例2:定义一个类描述平面上的点,要求提供计算到另一个点距离的方法。
1 | class Point(object): |
简单的总结
面向对象编程是一种非常流行的编程范式,除此之外还有指令式编程、函数式编程等编程范式。由于现实世界是由对象构成的,而对象是可以接收消息的实体,所以面向对象编程更符合人类正常的思维习惯。类是抽象的,对象是具体的,有了类就能创建对象,有了对象就可以接收消息,这就是面向对象编程的基础。定义类的过程是一个抽象的过程,找到对象公共的属性属于数据抽象,找到对象公共的方法属于行为抽象。抽象的过程是一个仁者见仁智者见智的过程,对同一类对象进行抽象可能会得到不同的结果,如下图所示。

说明: 本节课的插图来自于 Grady Booc 等撰写的《面向对象分析与设计》一书,该书是讲解面向对象编程的经典著作,有兴趣的读者可以购买和阅读这本书来了解更多的面向对象的相关知识。
第18课:面向对象编程进阶
上一节课我们讲解了Python面向对象编程的基础知识,这一节课我们继续来讨论面向对象编程相关的内容。
可见性和属性装饰器
在很多面向对象编程语言中,对象的属性通常会被设置为私有(private)或受保护(protected)的成员,简单的说就是不允许直接访问这些属性;对象的方法通常都是公开的(public),因为公开的方法是对象能够接受的消息,也是对象暴露给外界的调用接口,这就是所谓的访问可见性。在Python中,可以通过给对象属性名添加前缀下划线的方式来说明属性的访问可见性,例如,可以用__name
表示一个私有属性,_name
表示一个受保护属性,代码如下所示。
1 | class Student: |
上面代码的最后一行会引发AttributeError
(属性错误)异常,异常消息为:'Student' object has no attribute '__name'
。由此可见,以__
开头的属性__name
是私有的,在类的外面无法直接访问,但是类里面的study
方法中可以通过self.__name
访问该属性。
需要提醒大家的是,Python并没有从语法上严格保证私有属性的私密性,它只是给私有的属性和方法换了一个名字来阻挠对它们的访问,事实上如果你知道更换名字的规则仍然可以访问到它们,我们可以对上面的代码稍作修改就可以访问到私有的属性。
1 | class Student: |
Python中有一句名言:“We are all consenting adults here”(大家都是成年人)。Python语言的设计者认为程序员要为自己的行为负责,而不是由Python语言本身来严格限制访问可见性,而大多数的程序员都认为开放比封闭要好,把对象的属性私有化并不是编程语言必须的东西,所以Python并没有从语法上做出最严格的限定。
Python中可以通过property
装饰器为“私有”属性提供读取和修改的方法,之前我们提到过,装饰器通常会放在类、函数或方法的声明之前,通过一个@
符号表示将装饰器应用于类、函数或方法。
1 | class Student: |
在实际项目开发中,我们并不经常使用私有属性,属性装饰器的使用也比较少,所以上面的知识点大家简单了解一下就可以了。
动态属性
Python是一门动态语言,维基百科对动态语言的解释是:“在运行时可以改变其结构的语言,例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化。动态语言非常灵活,目前流行的Python和JavaScript都是动态语言,除此之外如PHP、Ruby等也都属于动态语言,而C、C++等语言则不属于动态语言”。
在Python中,我们可以动态为对象添加属性,这是Python作为动态类型语言的一项特权,代码如下所示。需要提醒大家的是,对象的方法其实本质上也是对象的属性,如果给对象发送一个无法接收的消息,引发的异常仍然是AttributeError
。
1 | class Student: |
如果不希望在使用对象时动态的为对象添加属性,可以使用Python的__slots__
魔法。对于Student
类来说,可以在类中指定__slots__ = ('name', 'age')
,这样Student
类的对象只能有name
和age
属性,如果想动态添加其他属性将会引发异常,代码如下所示。
1 | class Student: |
静态方法和类方法
之前我们在类中定义的方法都是对象方法,换句话说这些方法都是对象可以接收的消息。除了对象方法之外,类中还可以有静态方法和类方法,这两类方法是发给类的消息,二者并没有实质性的区别。在面向对象的世界里,一切皆为对象,我们定义的每一个类其实也是一个对象,而静态方法和类方法就是发送给类对象的消息。那么,什么样的消息会直接发送给类对象呢?
举一个例子,定义一个三角形类,通过传入三条边的长度来构造三角形,并提供计算周长和面积的方法。计算周长和面积肯定是三角形对象的方法,这一点毫无疑问。但是在创建三角形对象时,传入的三条边长未必能构造出三角形,为此我们可以先写一个方法来验证给定的三条边长是否可以构成三角形,这种方法很显然就不是对象方法,因为在调用这个方法时三角形对象还没有创建出来。我们可以把这类方法设计为静态方法或类方法,也就是说这类方法不是发送给三角形对象的消息,而是发送给三角形类的消息,代码如下所示。
1 | class Triangle(object): |
上面的代码使用staticmethod
装饰器声明了is_valid
方法是Triangle
类的静态方法,如果要声明类方法,可以使用classmethod
装饰器。可以直接使用类名.方法名
的方式来调用静态方法和类方法,二者的区别在于,类方法的第一个参数是类对象本身,而静态方法则没有这个参数。简单的总结一下,对象方法、类方法、静态方法都可以通过类名.方法名
的方式来调用,区别在于方法的第一个参数到底是普通对象还是类对象,还是没有接受消息的对象。静态方法通常也可以直接写成一个独立的函数,因为它并没有跟特定的对象绑定。
继承和多态
面向对象的编程语言支持在已有类的基础上创建新类,从而减少重复代码的编写。提供继承信息的类叫做父类(超类、基类),得到继承信息的类叫做子类(派生类、衍生类)。例如,我们定义一个学生类和一个老师类,我们会发现他们有大量的重复代码,而这些重复代码都是老师和学生作为人的公共属性和行为,所以在这种情况下,我们应该先定义人类,再通过继承,从人类派生出老师类和学生类,代码如下所示。
1 | class Person: |
继承的语法是在定义类的时候,在类名后的圆括号中指定当前类的父类。如果定义一个类的时候没有指定它的父类是谁,那么默认的父类是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 | from enum import Enum |
通过上面的代码可以看出,定义枚举类型其实就是定义符号常量,如SPADE
、HEART
等。每个符号常量都有与之对应的值,这样表示黑桃就可以不用数字0
,而是用Suite.SPADE
;同理,表示方块可以不用数字3
, 而是用Suite.DIAMOND
。注意,使用符号常量肯定是优于使用字面常量的,因为能够读懂英文就能理解符号常量的含义,代码的可读性会提升很多。Python中的枚举类型是可迭代类型,简单的说就是可以将枚举类型放到for-in
循环中,依次取出每一个符号常量及其对应的值,如下所示。
1 | for suite in Suite: |
接下来我们可以定义牌类。
1 | class Card: |
可以通过下面的代码来测试下Card
类。
1 | card1 = Card(Suite.SPADE, 5) |
接下来我们定义扑克类。
1 | import random |
可以通过下面的代码来测试下Poker
类。
1 | poker = Poker() |
定义玩家类。
1 | class Player: |
创建四个玩家并将牌发到玩家的手上。
1 | poker = Poker() |
执行上面的代码会在player.arrange()
那里出现异常,因为Player
的arrange
方法使用了列表的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 | class Card: |
说明: 大家可以尝试在上面代码的基础上写一个简单的扑克游戏,如21点游戏(Black Jack),游戏的规则可以自己在网上找一找。
案例2:工资结算系统。
要求:某公司有三种类型的员工,分别是部门经理、程序员和销售员。需要设计一个工资结算系统,根据提供的员工信息来计算员工的月薪。其中,部门经理的月薪是固定15000元;程序员按工作时间(以小时为单位)支付月薪,每小时200元;销售员的月薪由1800元底薪加上销售额5%的提成两部分构成。
通过对上述需求的分析,可以看出部门经理、程序员、销售员都是员工,有相同的属性和行为,那么我们可以先设计一个名为Employee
的父类,再通过继承的方式从这个父类派生出部门经理、程序员和销售员三个子类。很显然,后续的代码不会创建Employee
类的对象,因为我们需要的是具体的员工对象,所以这个类可以设计成专门用于继承的抽象类。Python中没有定义抽象类的关键字,但是可以通过abc
模块中名为ABCMeta
的元类来定义抽象类。关于元类的知识,后面的课程中会有专门的讲解,这里不用太纠结这个概念,记住用法即可。
1 | from abc import ABCMeta, abstractmethod |
在上面的员工类中,有一个名为get_salary
的方法用于结算月薪,但是由于还没有确定是哪一类员工,所以结算月薪虽然是员工的公共行为但这里却没有办法实现。对于暂时无法实现的方法,我们可以使用abstractmethod
装饰器将其声明为抽象方法,所谓抽象方法就是只有声明没有实现的方法,声明这个方法是为了让子类去重写这个方法。接下来的代码展示了如何从员工类派生出部门经理、程序员、销售员这三个子类以及子类如何重写父类的抽象方法。
1 | class Manager(Employee): |
上面的Manager
、Programmer
、Salesman
三个类都继承自Employee
,三个类都分别重写了get_salary
方法。重写就是子类对父类已有的方法重新做出实现。相信大家已经注意到了,三个子类中的get_salary
各不相同,所以这个方法在程序运行时会产生多态行为,多态简单的说就是调用相同的方法,不同的子类对象做不同的事情。
我们通过下面的代码来完成这个工资结算系统,由于程序员和销售员需要分别录入本月的工作时间和销售额,所以在下面的代码中我们使用了Python内置的isinstance
函数来判断员工对象的类型。我们之前讲过的type
函数也能识别对象的类型,但是isinstance
函数更加强大,因为它可以判断出一个对象是不是某个继承结构下的子类型,你可以简答的理解为type
函数是对对象类型的精准匹配,而isinstance
函数是对对象类型的模糊匹配。
1 | emps = [ |
简单的总结
面向对象的编程思想非常的好,也符合人类的正常思维习惯,但是要想灵活运用面向对象编程中的抽象、封装、继承、多态需要长时间的积累和沉淀,这件事情无法一蹴而就,属于“路漫漫其修远兮,吾将上下而求索”的东西。
第20课:Python标准库初探
Python语言最可爱的地方在于它的标准库和三方库实在是太丰富了,日常开发工作中的很多任务都可以通过这些标准库或者三方库直接解决。下面我们先介绍Python标准库中的一些常用模块,后面的课程中再陆陆续续为大家介绍Python常用三方库的用途和用法。
base64 - Base64编解码模块
Base64是一种基于64个可打印字符来表示二进制数据的方法。由于A-Z
、a-z
、0-9
,这里一共是62个字符,另外两个可打印符号通常是+
和/
,=
用于在Base64编码最后进行补位。
关于Base64编码的细节,大家可以参考《Base64笔记》 一文,Python标准库中的base64
模块提供了b64encode
和b64decode
两个函数,专门用于实现Base64的编码和解码,下面演示了在Python的交互式环境中执行这两个函数的效果。
1 | import base64 |
collections - 容器数据类型模块
collections
模块提供了诸多非常好用的数据结构,主要包括:
namedtuple
:命令元组,它是一个类工厂,接受类型的名称和属性列表来创建一个类。deque
:双端队列,是列表的替代实现。Python中的列表底层是基于数组来实现的,而deque
底层是双向链表,因此当你需要在头尾添加和删除元素是,deque
会表现出更好的性能,渐近时间复杂度为 。Counter
:dict
的子类,键是元素,值是元素的计数,它的most_common()
方法可以帮助我们获取出现频率最高的元素。Counter
和dict
的继承关系我认为是值得商榷的,按照CARP原则,Counter
跟dict
的关系应该设计为关联关系更为合理。OrderedDict
:dict
的子类,它记录了键值对插入的顺序,看起来既有字典的行为,也有链表的行为。defaultdict
:类似于字典类型,但是可以通过默认的工厂函数来获得键对应的默认值,相比字典中的setdefault()
方法,这种做法更加高效。
下面是在Python交互式环境中使用namedtuple
创建扑克牌类的例子。
1 | from collections import namedtuple |
下面是使用Counter
类统计列表中出现次数最多的三个元素的例子。
1 | from collections import Counter |
hashlib - 哈希函数模块
哈希函数又称哈希算法或散列函数,是一种为已有的数据创建“数字指纹”(哈希摘要)的方法。哈希函数把数据压缩成摘要,对于相同的输入,哈希函数可以生成相同的摘要(数字指纹),需要注意的是这个过程并不可逆(不能通过摘要计算出输入的内容)。一个优质的哈希函数能够为不同的输入生成不同的摘要,出现哈希冲突(不同的输入产生相同的摘要)的概率极低,MD5 、SHA家族就是这类好的哈希函数。
说明:在2011年的时候,RFC 6151中已经禁止将MD5用作密钥散列消息认证码,这个问题不在我们讨论的范围内。
Python标准库的hashlib
模块提供了对哈希函数的封装,通过使用md5
、sha1
、sha256
等类,我们可以轻松的生成“数字指纹”。举一个简单的例子,用户注册时我们希望在数据库中保存用户的密码,很显然我们不能将用户密码直接保存在数据库中,这样可能会导致用户隐私的泄露,所以在数据库中保存用户密码时,通常都会将密码的“指纹”保存起来,用户登录时通过哈希函数计算密码的“指纹”再进行匹配来判断用户登录是否成功。
1 | import hashlib |
说明:很多网站在下载链接的旁边都提供了哈希摘要,完成文件下载后,我们可以计算该文件的哈希摘要并检查它与网站上提供的哈希摘要是否一致(指纹比对)。如果计算出的哈希摘要与网站提供的并不一致,很有可能是下载出错或该文件在传输过程中已经被篡改,这时候就不应该直接使用这个文件。
heapq - 堆排序模块
heapq
模块实现了堆排序算法,如果希望使用堆排序,尤其是要解决TopK问题(从序列中找到K个最大或最小元素),直接使用该模块即可,代码如下所示。
1 | import heapq |
itertools - 迭代工具模块
itertools
可以帮助我们生成各种各样的迭代器,大家可以看看下面的例子。
1 | import itertools |
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)
:合理地拼接一个或多个路径部分。返回值是path
和paths
所有值的连接,每个非空部分后面都紧跟一个目录分隔符 (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 | import uuid |
简单的总结
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 | file = open('致橡树.txt', 'r', encoding='utf-8') |
说明:《致橡树》 是舒婷老师在1977年3月创建的爱情诗,也是我最喜欢的现代诗之一。
除了使用文件对象的read
方法读取文件之外,还可以使用for-in
循环逐行读取或者用readlines
方法将文件按行读取到一个列表容器中,代码如下所示。
1 | file = open('致橡树.txt', 'r', encoding='utf-8') |
如果要向文件中写入内容,可以在打开文件时使用w
或者a
作为操作模式,前者会截断之前的文本内容写入新的内容,后者是在原来内容的尾部追加新的内容。
1 | file = open('致橡树.txt', 'a', encoding='utf-8') |
异常处理机制
请注意上面的代码,如果open
函数指定的文件并不存在或者无法打开,那么将引发异常状况导致程序崩溃。为了让代码具有健壮性和容错性,我们可以使用Python的异常机制对可能在运行时发生状况的代码进行适当的处理。Python中和异常相关的关键字有五个,分别是try
、except
、else
、finally
和raise
,我们先看看下面的代码,再来为大家介绍这些关键字的用法。
1 | file = None |
在Python中,我们可以将运行时会出现状况的代码放在try
代码块中,在try
后面可以跟上一个或多个except
块来捕获异常并进行相应的处理。例如,在上面的代码中,文件找不到会引发FileNotFoundError
,指定了未知的编码会引发LookupError
,而如果读取文件时无法按指定编码方式解码文件会引发UnicodeDecodeError
,所以我们在try
后面跟上了三个except
分别处理这三种不同的异常状况。在except
后面,我们还可以加上else
代码块,这是try
中的代码没有出现异常时会执行的代码,而且else
中的代码不会再进行异常捕获,也就是说如果遇到异常状况,程序会因异常而终止并报告异常信息。最后我们使用finally
代码块来关闭打开的文件,释放掉程序中获取的外部资源。由于finally
块的代码不论程序正常还是异常都会执行,甚至是调用了sys
模块的exit
函数终止Python程序,finally
块中的代码仍然会被执行(因为exit
函数的本质是引发了SystemExit
异常),因此我们把finally
代码块称为“总是执行代码块”,它最适合用来做释放外部资源的操作。
Python中内置了大量的异常类型,除了上面代码中用到的异常类型以及之前的课程中遇到过的异常类型外,还有许多的异常类型,其继承结构如下所示。
1 | BaseException |
从上面的继承结构可以看出,Python中所有的异常都是BaseException
的子类型,它有四个直接的子类,分别是:SystemExit
、KeyboardInterrupt
、GeneratorExit
和Exception
。其中,SystemExit
表示解释器请求退出,KeyboardInterrupt
是用户中断程序执行(按下Ctrl+c
),GeneratorExit
表示生成器发生异常通知退出,不理解这些异常没有关系,继续学习就好了。值得一提的是Exception
类,它是常规异常类型的父类型,很多的异常都是直接或间接的继承自Exception
类。如果Python内置的异常类型不能满足应用程序的需要,我们可以自定义异常类型,而自定义的异常类型也应该直接或间接继承自Exception
类,当然还可以根据需要重写或添加方法。
在Python中,可以使用raise
关键字来引发异常(抛出异常对象),而调用者可以通过try...except...
结构来捕获并处理异常。例如在函数中,当函数的执行条件不满足时,可以使用抛出异常的方式来告知调用者问题的所在,而调用者可以通过捕获处理异常来使得代码从异常中恢复,定义异常和抛出异常的代码如下所示。
1 | class InputError(ValueError): |
调用求阶乘的函数fac
,通过try...except...
结构捕获输入错误的异常并打印异常对象(显示异常信息),如果输入正确就计算阶乘并结束程序。
1 | flag = True |
上下文语法
对于open
函数返回的文件对象,还可以使用with
上下文语法在文件操作完成后自动执行文件对象的close
方法,这样可以让代码变得更加简单优雅,因为不需要再写finally
代码块来执行关闭文件释放资源的操作。需要提醒大家的是,并不是所有的对象都可以放在with
上下文语法中,只有符合上下文管理器协议(有__enter__
和__exit__
魔术方法)的对象才能使用这种语法,Python标准库中的contextlib
模块也提供了对with
上下文语法的支持,后面再为大家进行讲解。
用with
上下文语法改写后的代码如下所示。
1 | try: |
读写二进制文件
读写二进制文件跟读写文本文件的操作类似,但是需要注意,在使用open
函数打开文件时,如果要进行读操作,操作模式是'rb'
,如果要进行写操作,操作模式是'wb'
。还有一点,读写文本文件时,read
方法的返回值以及write
方法的参数是str
对象(字符串),而读写二进制文件时,read
方法的返回值以及write
方法的参数是bytes-like
对象(字节串)。下面的代码实现了将当前路径下名为guido.jpg
的图片文件复制到吉多.jpg
文件中的操作。
1 | try: |
如果要复制的图片文件很大,一次将文件内容直接读入内存中可能会造成非常大的内存开销,为了减少对内存的占用,可以为read
方法传入size
参数来指定每次读取的字节数,通过循环读取和写入的方式来完成上面的操作,代码如下所示。
1 | try: |
简单的总结
通过读写文件的操作,我们可以实现数据持久化。在Python中可以通过open
函数来获得文件对象,可以通过文件对象的read
和write
方法实现文件读写操作。程序在运行时可能遭遇无法预料的异常状况,可以使用Python的异常机制来处理这些状况。Python的异常机制主要包括try
、except
、else
、finally
和raise
这五个核心关键字。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 | { |
上面是JSON的一个简单例子,大家可能已经注意到了,它跟Python中的字典非常类似而且支持嵌套结构,就好比Python字典中的值可以是另一个字典。我们可以尝试把下面的代码输入浏览器的控制台(对于Chrome浏览器,可以通过“更多工具”菜单找到“开发者工具”子菜单,就可以打开浏览器的控制台),浏览器的控制台提供了一个运行JavaScript代码的交互式环境(类似于Python的交互式环境),下面的代码会帮我们创建出一个JavaScript的对象,我们将其赋值给名为obj
的变量。
1 | let obj = { |

上面的obj
就是JavaScript中的一个对象,我们可以通过obj.name
或obj["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 |
bool (True / False ) |
boolean (true / false ) |
None |
null |
读写JSON格式的数据
在Python中,如果要将字典处理成JSON格式(以字符串形式存在),可以使用json
模块的dumps
函数,代码如下所示。
1 | import json |
运行上面的代码,输出如下所示,可以注意到中文字符都是用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 | import json |
执行上面的代码,会创建data.json
文件,文件的内容跟上面代码的输出是一样的。
json
模块有四个比较重要的函数,分别是:
dump
- 将Python对象按照JSON格式序列化到文件中dumps
- 将Python对象处理成JSON格式的字符串load
- 将文件中的JSON数据反序列化成对象loads
- 将字符串的内容反序列化成Python对象
这里出现了两个概念,一个叫序列化,一个叫反序列化,维基百科 上的解释是:“序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换为可以存储或传输的形式,这样在需要的时候能够恢复到原先的状态,而且通过序列化的数据重新获取字节时,可以利用这些字节来产生原始对象的副本(拷贝)。与这个过程相反的动作,即从一系列字节中提取数据结构的操作,就是反序列化(deserialization)”。
我们可以通过下面的代码,读取上面创建的data.json
文件,将JSON格式的数据还原成Python中的字典。
1 | import json |
包管理工具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 -U
或pip install --upgrade
;如果要删除某个三方库,可以使用pip uninstall
命令。
搜索ujson
三方库。
1 | pip search ujson |
查看已经安装的三方库。
1 | pip list |
更新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,但是要访问接口获取数据,需要绑定验证邮箱或手机,然后还要申请需要使用的接口,如下图所示。

Python通过URL接入网络,我们推荐大家使用requests
三方库,它简单且强大,但需要自行安装。
1 | pip install requests |
获取国内新闻并显示新闻标题和链接。
1 | import requests |
上面的代码通过requests
模块的get
函数向天行数据的国内新闻接口发起了一次请求,如果请求过程没有出现问题,get
函数会返回一个Response
对象,通过该对象的status_code
属性表示HTTP响应状态码,如果不理解没关系,你只需要关注它的值,如果值等于200
或者其他2
字头的值,那么我们的请求是成功的。通过Response
对象的json()
方法可以将返回的JSON格式的数据直接处理成Python字典,非常方便。天行数据国内新闻接口返回的JSON格式的数据(部分)如下图所示。

提示:上面代码中的APIKey需要换成自己在天行数据网站申请的APIKey。天行数据网站上还有提供了很多非常有意思的API接口,例如:垃圾分类、周公解梦等,大家可以仿照上面的代码来调用这些接口。每个接口都有对应的接口文档,文档中有关于如何使用接口的详细说明。
简单的总结
Python中实现序列化和反序列化除了使用json
模块之外,还可以使用pickle
和shelve
模块,但是这两个模块是使用特有的序列化协议来序列化数据,因此序列化后的数据只能被Python识别,关于这两个模块的相关知识,有兴趣的读者可以自己查找网络上的资料。处理JSON格式的数据很显然是程序员必须掌握的一项技能,因为不管是访问网络API接口还是提供网络API接口给他人使用,都需要具备处理JSON格式数据的相关知识。
第23课:用Python读写CSV文件
CSV文件介绍
CSV(Comma Separated Values)全称逗号分隔值文件是一种简单、通用的文件格式,被广泛的应用于应用程序(数据库、电子表格等)数据的导入和导出以及异构系统之间的数据交换。因为CSV是纯文本文件,不管是什么操作系统和编程语言都是可以处理纯文本的,而且很多编程语言中都提供了对读写CSV文件的支持,因此CSV格式在数据处理和数据科学中被广泛应用。
CSV文件有以下特点:
- 纯文本,使用某种字符集(如ASCII 、Unicode 、GB2312 )等);
- 由一条条的记录组成(典型的是每行一条记录);
- 每条记录被分隔符(如逗号、分号、制表符等)分隔为字段(列);
- 每条记录都有同样的字段序列。
CSV文件可以使用文本编辑器或类似于Excel电子表格这类工具打开和编辑,当使用Excel这类电子表格打开CSV文件时,你甚至感觉不到CSV和Excel文件的区别。很多数据库系统都支持将数据导出到CSV文件中,当然也支持从CSV文件中读入数据保存到数据库中,这些内容并不是现在要讨论的重点。
将数据写入CSV文件
现有五个学生三门课程的考试成绩需要保存到一个CSV文件中,要达成这个目标,可以使用Python标准库中的csv
模块,该模块的writer
函数会返回一个csvwriter
对象,通过该对象的writerow
或writerows
方法就可以将数据写入到CSV文件中,具体的代码如下所示。
1 | import csv |
生成的CSV文件的内容。
1 | 姓名,语文,数学,英语 |
需要说明的是上面的writer
函数,除了传入要写入数据的文件对象外,还可以dialect
参数,它表示CSV文件的方言,默认值是excel
。除此之外,还可以通过delimiter
、quotechar
、quoting
参数来指定分隔符(默认是逗号)、包围值的字符(默认是双引号)以及包围的方式。其中,包围值的字符主要用于当字段中有特殊符号时,通过添加包围值的字符可以避免二义性。大家可以尝试将上面第5行代码修改为下面的代码,然后查看生成的CSV文件。
1 | writer = csv.writer(file, delimiter='|', quoting=csv.QUOTE_ALL) |
生成的CSV文件的内容。
1 | "姓名"|"语文"|"数学"|"英语" |
从CSV文件读取数据
如果要读取刚才创建的CSV文件,可以使用下面的代码,通过csv
模块的reader
函数可以创建出csvreader
对象,该对象是一个迭代器,可以通过next
函数或for-in
循环读取到文件中的数据。
1 | import csv |
注意:上面的代码对
csvreader
对象做for
循环时,每次会取出一个列表对象,该列表对象包含了一行中所有的字段。
简单的总结
将来如果大家使用Python做数据分析,很有可能会用到名为pandas
的三方库,它是Python数据分析的神器之一。pandas
中封装了名为read_csv
和to_csv
的函数用来读写CSV文件,其中read_CSV
会将读取到的数据变成一个DataFrame
对象,而DataFrame
就是pandas
库中最重要的类型,它封装了一系列用于数据处理的方法(清洗、转换、聚合等);而to_csv
会将DataFrame
对象中的数据写入CSV文件,完成数据的持久化。read_csv
函数和to_csv
函数远远比原生的csvreader
和csvwriter
强大。
第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文件,可以使用三方库xlrd
和xlwt
,前者用于读Excel文件,后者用于写Excel文件。如果使用较新版本的Excel,即操作xlsx
格式的Excel文件,可以使用openpyxl
库,当然这个库不仅仅可以操作Excel,还可以操作其他基于Office Open XML的电子表格文件。
本章我们先讲解基于xlwt
和xlrd
操作Excel文件,大家可以先使用下面的命令安装这两个三方库以及配合使用的工具模块xlutils
。
1 | pip install xlwt xlrd xlutils |
读Excel文件
例如在当前文件夹下有一个名为“阿里巴巴2020年股票数据.xls”的Excel文件,如果想读取并显示该文件的内容,可以通过如下所示的代码来完成。
1 | import xlrd |
提示:上面代码中使用的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 | import random |
调整单元格样式
在写Excel文件时,我们还可以为单元格设置样式,主要包括字体(Font)、对齐方式(Alignment)、边框(Border)和背景(Background)的设置,xlwt
对这几项设置都封装了对应的类来支持。要设置单元格样式需要首先创建一个XFStyle
对象,再通过该对象的属性对字体、对齐方式、边框等进行设定,例如在上面的例子中,如果希望将表头单元格的背景色修改为黄色,可以按照如下的方式进行操作。
1 | header_style = xlwt.XFStyle() |
如果希望为表头设置指定的字体,可以使用Font
类并添加如下所示的代码。
1 | font = xlwt.Font() |
注意:上面代码中指定的字体名(
font.name
)应当是本地系统有的字体,例如在我的电脑上有名为“华文楷体”的字体。
如果希望表头垂直居中对齐,可以使用下面的代码进行设置。
1 | align = xlwt.Alignment() |
如果希望给表头加上黄色的虚线边框,可以使用下面的代码来设置。
1 | borders = xlwt.Borders() |
如果要调整单元格的宽度(列宽)和表头的高度(行高),可以按照下面的代码进行操作。
1 | # 设置行高为40px |
公式计算
对于前面打开的“阿里巴巴2020年股票数据.xls”文件,如果要统计全年收盘价(Close字段)的平均值以及全年交易量(Volume字段)的总和,可以使用Excel的公式计算即可。我们可以先使用xlrd
读取Excel文件夹,然后通过xlutils
三方库提供的copy
函数将读取到的Excel文件转成Workbook
对象进行写操作,在调用write
方法时,可以将一个Formula
对象写入单元格。
实现公式计算的代码如下所示。
1 | import xlrd |
说明:上面的代码有一些小瑕疵,有兴趣的读者可以自行探索并思考如何解决。
简单的总结
掌握了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文件后,既可以对它进行读操作,又可以对它进行写操作,而且在操作的便捷性上是优于xlwt
和xlrd
的。此外,如果要进行样式编辑和公式计算,使用openpyxl
也远比上一个章节我们讲解的方式更为简单,而且openpyxl
还支持数据透视和插入图表等操作,功能非常强大。有一点需要再次强调,openpyxl
并不支持操作Office 2007以前版本的Excel文件。
读取Excel文件
例如在当前文件夹下有一个名为“阿里巴巴2020年股票数据.xlsx”的Excel文件,如果想读取并显示该文件的内容,可以通过如下所示的代码来完成。
1 | import datetime |
提示:上面代码中使用的Excel文件“阿里巴巴2020年股票数据.xlsx”可以通过后面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。
需要提醒大家一点,openpyxl
获取指定的单元格有两种方式,一种是通过cell
方法,需要注意,该方法的行索引和列索引都是从1
开始的,这是为了照顾用惯了Excel的人的习惯;另一种是通过索引运算,通过指定单元格的坐标,例如C3
、G255
,也可以取得对应的单元格,再通过单元格对象的value
属性,就可以获取到单元格的值。通过上面的代码,相信大家还注意到了,可以通过类似sheet['A2:C5']
或sheet['A2':'C5']
这样的切片操作获取多个单元格,该操作将返回嵌套的元组,相当于获取到了多行多列。
写Excel文件
下面我们使用openpyxl
来进行写Excel操作。
1 | import random |
调整样式和公式计算
在使用openpyxl
操作Excel时,如果要调整单元格的样式,可以直接通过单元格对象(Cell
对象)的属性进行操作。单元格对象的属性包括字体(font
)、对齐(alignment
)、边框(border
)等,具体的可以参考openpyxl
的官方文档 。在使用openpyxl
时,如果需要做公式计算,可以完全按照Excel中的操作方式来进行,具体的代码如下所示。
1 | import openpyxl |
生成统计图表
通过openpyxl
库,可以直接向Excel中插入统计图表,具体的做法跟在Excel中插入图表大体一致。我们可以创建指定类型的图表对象,然后通过该对象的属性对图表进行设置。当然,最为重要的是为图表绑定数据,即横轴代表什么,纵轴代表什么,具体的数值是多少。最后,可以将图表对象添加到表单中,具体的代码如下所示。
1 | from openpyxl import Workbook |
运行上面的代码,打开生成的Excel文件,效果如下图所示。

简单的总结
掌握了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 | from docx import Document |
提示:上面代码第7行中的注释
# type: Doc
是为了在PyCharm中获得代码补全提示,因为如果不清楚对象具体的数据类型,PyCharm无法在后续代码中给出Doc
对象的代码补全提示。
执行上面的代码,打开生成的Word文档,效果如下图所示。
对于一个已经存在的Word文件,我们可以通过下面的代码去遍历它所有的段落并获取对应的内容。
1 | from docx import Document |
提示:如果需要上面代码中的Word文件,可以通过下面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。
读取到的内容如下所示。
1 | 0 |
讲到这里,相信很多读者已经想到了,我们可以把上面的离职证明制作成一个模板文件,把姓名、身份证号、入职和离职日期等信息用占位符代替,这样通过对占位符的替换,就可以根据实际需要写入对应的信息,这样就可以批量的生成Word文档。
按照上面的思路,我们首先编辑一个离职证明的模板文件,如下图所示。

接下来我们读取该文件,将占位符替换为真实信息,就可以生成一个新的Word文档,如下所示。
1 | from docx import Document |
执行上面的代码,会在当前路径下生成三个Word文档,如下图所示。

生成PowerPoint
首先我们需要安装名为python-pptx
的三方库,命令如下所示。
1 | pip install python-pptx |
用Python操作PowerPoint的内容,因为实际应用场景不算很多,我不打算在这里进行赘述,有兴趣的读者可以自行阅读python-pptx
的官方文档 ,下面仅展示一段来自于官方文档的代码。
1 | from pptx import Presentation |
运行上面的代码,生成的PowerPoint文件如下图所示。

简单的总结
用Python程序解决办公自动化的问题真的非常酷,它可以将我们从繁琐乏味的劳动中解放出来。写这类代码就是去做一件一劳永逸的事情,写代码的过程即便不怎么愉快,使用这些代码的时候应该是非常开心的。
第27课:用Python操作PDF文件
PDF是Portable Document Format的缩写,这类文件通常使用.pdf
作为其扩展名。在日常开发工作中,最容易遇到的就是从PDF中读取文本内容以及用已有的内容生成PDF文档这两个任务。
从PDF中提取文本
在Python中,可以使用名为PyPDF2
的三方库来读取PDF文件,可以使用下面的命令来安装它。
1 | pip install PyPDF2 |
PyPDF2
没有办法从PDF文档中提取图像、图表或其他媒体,但它可以提取文本,并将其返回为Python字符串。
1 | import PyPDF2 |
提示:上面代码中使用的PDF文件“test.pdf”以及下面的代码中需要用到的PDF文件,也可以通过下面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。
当然,PyPDF2
并不是什么样的PDF文档都能提取出文字来,这个问题就我所知并没有什么特别好的解决方法,尤其是在提取中文的时候。网上也有很多讲解从PDF中提取文字的文章,推荐大家自行阅读《三大神器助力Python提取pdf文档信息》 一文进行了解。
要从PDF文件中提取文本也可以直接使用三方的命令行工具,具体的做法如下所示。
1 | pip install pdfminer.six |
旋转和叠加页面
上面的代码中通过创建PdfFileReader
对象的方式来读取PDF文档,该对象的getPage
方法可以获得PDF文档的指定页并得到一个PageObject
对象,通过PageObject
对象的rotateClockwise
和rotateCounterClockwise
方法可以实现页面的顺时针和逆时针方向旋转,通过PageObject
对象的addBlankPage
方法可以添加一个新的空白页,代码如下所示。
1 | reader = PyPDF2.PdfReader('XGBoost.pdf') |
加密PDF文件
使用PyPDF2
中的PdfFileWrite
对象可以为PDF文档加密,如果需要给一系列的PDF文档设置统一的访问口令,使用Python程序来处理就会非常的方便。
1 | import PyPDF2 |
批量添加水印
上面提到的PageObject
对象还有一个名为mergePage
的方法,可以两个PDF页面进行叠加,通过这个操作,我们很容易实现给PDF文件添加水印的功能。例如要给上面的“XGBoost.pdf”文件添加一个水印,我们可以先准备好一个提供水印页面的PDF文件,然后将包含水印的PageObject
读取出来,然后再循环遍历“XGBoost.pdf”文件的每个页,获取到PageObject
对象,然后通过mergePage
方法实现水印页和原始页的合并,代码如下所示。
1 | reader1 = PyPDF2.PdfReader('XGBoost.pdf') |
如果愿意,还可以让奇数页和偶数页使用不同的水印,大家可以自己思考下应该怎么做。
创建PDF文件
创建PDF文档需要三方库reportlab
的支持,安装的方法如下所示。
1 | pip install reportlab |
下面通过一个例子为大家展示reportlab
的用法。
1 | from reportlab.lib.pagesizes import A4 |
上面的代码如果不太理解也没有关系,等真正需要用Python创建PDF文档的时候,再好好研读一下reportlab
的官方文档 就可以了。
提示:上面代码中用到的图片和字体,也可以通过下面的百度云盘链接获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。
简单的总结
在学习完上面的内容之后,相信大家已经知道像合并多个PDF文件这样的工作应该如何用Python代码来处理了,赶紧自己动手试一试吧。
第28课:用Python处理图像
入门知识
颜色。如果你有使用颜料画画的经历,那么一定知道混合红、黄、蓝三种颜料可以得到其他的颜色,事实上这三种颜色就是美术中的三原色,它们是不能再分解的基本颜色。在计算机中,我们可以将红、绿、蓝三种色光以不同的比例叠加来组合成其他的颜色,因此这三种颜色就是色光三原色。在计算机系统中,我们通常会将一个颜色表示为一个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) 像素。对于一个由数字序列表示的图像来说,最小的单位就是图像上单一颜色的小方格,这些小方块都有一个明确的位置和被分配的色彩数值,而这些一小方格的颜色和位置决定了该图像最终呈现出来的样子,它们是不可分割的单位,我们通常称之为像素(pixel)。每一个图像都包含了一定量的像素,这些像素决定图像在屏幕上所呈现的大小,大家如果爱好拍照或者自拍,对像素这个词就不会陌生。
用Pillow处理图像
Pillow是由从著名的Python图像处理库PIL发展出来的一个分支,通过Pillow可以实现图像压缩和图像处理等各种操作。可以使用下面的命令来安装Pillow。
1 | pip install pillow |
Pillow中最为重要的是Image
类,可以通过Image
模块的open
函数来读取图像并获得Image
类型的对象。
读取和显示图像
1
2
3
4
5
6
7
8
9
10
11
12from 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()剪裁图像
1
2# 通过Image对象的crop方法指定剪裁区域剪裁图像
image.crop((80, 20, 310, 360)).show()生成缩略图
1
2
3# 通过Image对象的thumbnail方法生成指定尺寸的缩略图
image.thumbnail((128, 128))
image.show()缩放和黏贴图像
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()旋转和翻转
1
2
3
4
5
6
7image = 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()操作像素
1
2
3
4
5for x in range(80, 310):
for y in range(20, 360):
# 通过Image对象的putpixel方法修改图像指定像素点
image.putpixel((x, y), (128, 128, 128))
image.show()滤镜效果
1
2
3
4
5from PIL import ImageFilter
# 使用Image对象的filter方法对图像进行滤镜处理
# ImageFilter模块包含了诸多预设的滤镜也可以自定义滤镜
image.filter(ImageFilter.CONTOUR).show()
使用Pillow绘图
Pillow中有一个名为ImageDraw
的模块,该模块的Draw
函数会返回一个ImageDraw
对象,通过ImageDraw
对象的arc
、line
、rectangle
、ellipse
、polygon
等方法,可以在图像上绘制出圆弧、线条、矩形、椭圆、多边形等形状,也可以通过该对象的text
方法在图像上添加文字。

要绘制如上图所示的图像,完整的代码如下所示。
1 | import random |
注意:上面代码中使用的字体文件需要根据自己准备,可以选择自己喜欢的字体文件并放置在代码目录下。
简单的总结
使用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
对象,通过该对象的login
和send_mail
方法,就能够完成发送邮件的操作。
我们先尝试一下发送一封极为简单的邮件,该邮件不包含附件、图片以及其他超文本内容。发送邮件首先需要接入邮件服务器,我们可以自己架设邮件服务器,这件事情对新手并不友好,但是我们可以选择使用第三方提供的邮件服务。例如,我在www.126.com已经注册了账号,登录成功之后,就可以在设置中开启SMTP服务,这样就相当于获得了邮件服务器,具体的操作如下所示。

用手机扫码上面的二维码可以通过发送短信的方式来获取授权码,短信发送成功后,点击“我已发送”就可以获得授权码。授权码需要妥善保管,因为一旦泄露就会被其他人冒用你的身份来发送邮件。接下来,我们就可以编写发送邮件的代码了,如下所示。
1 | import smtplib |
如果要发送带有附件的邮件,只需要将附件的内容处理成BASE64编码,那么它就和普通的文本内容几乎没有什么区别。BASE64是一种基于64个可打印字符来表示二进制数据的表示方法,常用于某些需要表示、传输、存储二进制数据的场合,电子邮件就是其中之一。对这种编码方式不理解的同学,推荐阅读《Base64笔记》 一文。在之前的内容中,我们也提到过,Python标准库的base64
模块提供了对BASE64编解码的支持。
下面的代码演示了如何发送带附件的邮件。
1 | import smtplib |
为了方便大家用Python实现邮件发送,我将上面的代码封装成了函数,使用的时候大家只需要调整邮件服务器域名、端口、用户名和授权码就可以了。
1 | import smtplib |
发送短信
发送短信也是项目中常见的功能,网站的注册码、验证码、营销信息基本上都是通过短信来发送给用户的。发送短信需要三方平台的支持,下面我们以螺丝帽平台 为例,为大家介绍如何用Python程序发送短信。注册账号和购买短信服务的细节我们不在这里进行赘述,大家可以咨询平台的客服。
接下来,我们可以通过requests
库向平台提供的短信网关发起一个HTTP请求,通过将接收短信的手机号和短信内容作为参数,就可以发送短信,代码如下所示。
1 | import random |
上面请求螺丝帽的短信网关http://sms-api.luosimao.com/v1/send.json
会返回JSON格式的数据,如果返回{'error': 0, 'msg': 'OK'}
就说明短信已经发送成功了,如果error
的值不是0
,可以通过查看官方的开发文档 了解到底哪个环节出了问题。螺丝帽平台常见的错误类型如下图所示。

目前,大多数短信平台都会要求短信内容必须附上签名,下图是我在螺丝帽平台配置的短信签名“【Python小课】”。有些涉及到敏感内容的短信,还需要提前配置短信模板,有兴趣的读者可以自行研究。一般情况下,平台为了防范短信被盗用,还会要求设置“IP白名单”,不清楚如何配置的可以咨询平台客服。
当然国内的短信平台很多,读者可以根据自己的需要进行选择(通常会考虑费用预算、短信达到率、使用的难易程度等指标),如果需要在商业项目中使用短信服务建议购买短信平台提供的套餐服务。
简单的总结
其实,发送邮件和发送短信一样,也可以通过调用三方服务来完成,在实际的商业项目中,建议自己架设邮件服务器或购买三方服务来发送邮件,这个才是比较靠谱的选择。
第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 | """ |
提示: 上面在书写正则表达式时使用了“原始字符串”的写法(在字符串前面加上了
r
),所谓“原始字符串”就是字符串中的每个字符都是它原始的意义,说得更直接一点就是字符串中没有所谓的转义字符啦。因为正则表达式中有很多元字符和需要进行转义的地方,如果不使用原始字符串就需要将反斜杠写作\\
,例如表示数字的\d
得书写成\\d
,这样不仅写起来不方便,阅读的时候也会很吃力。
例子2:从一段文字中提取出国内手机号码。
下面这张图是截止到2017年底,国内三家运营商推出的手机号段。
1 | import re |
说明: 上面匹配国内手机号的正则表达式并不够好,因为像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 | import re |
说明:
re
模块的正则表达式相关函数中都有一个flags
参数,它代表了正则表达式的匹配标记,可以通过该标记来指定匹配时是否忽略大小写、是否进行多行匹配、是否显示调试信息等。如果需要为flags参数指定多个值,可以使用按位或运算符 进行叠加,如flags=re.I | re.M
。
例子4:拆分长字符串
1 | import re |
简单的总结
正则表达式在字符串的处理和匹配上真的非常强大,通过上面的例子相信大家已经感受到了正则表达式的魅力,当然写一个正则表达式对新手来说并不是那么容易,但是很多事情都是熟能生巧,大胆的去尝试就行了,有一个在线的正则表达式测试工具 相信能够在一定程度上帮到大家。
第31课:网络数据采集概述
爬虫(crawler)也经常被称为网络蜘蛛(spider),是按照一定的规则自动浏览网站并获取所需信息的机器人程序(自动化脚本代码),被广泛的应用于互联网搜索引擎和数据采集。使用过互联网和浏览器的人都知道,网页中除了供用户阅读的文字信息之外,还包含一些超链接,网络爬虫正是通过网页中的超链接信息,不断获得网络上其它页面的地址,然后持续的进行数据采集。正因如此,网络数据采集的过程就像一个爬虫或者蜘蛛在网络上漫游,所以才被形象的称为爬虫或者网络蜘蛛。
爬虫的应用领域
在理想的状态下,所有 ICP(Internet Content Provider)都应该为自己的网站提供 API 接口来共享它们允许其他程序获取的数据,在这种情况下就根本不需要爬虫程序。国内比较有名的电商平台(如淘宝、京东等)、社交平台(如微博、微信等)等都提供了自己的 API 接口,但是这类 API 接口通常会对可以抓取的数据以及抓取数据的频率进行限制。对于大多数的公司而言,及时的获取行业数据和竞对数据是企业生存的重要环节之一,然而对大部分企业来说,数据都是其与生俱来的短板。在这种情况下,合理的利用爬虫来获取数据并从中提取出有商业价值的信息对这些企业来说就显得至关重要的。
爬虫的应用领域其实非常广泛,下面我们列举了其中的一部分,有兴趣的读者可以自行探索相关内容。
- 搜索引擎
- 新闻聚合
- 社交应用
- 舆情监控
- 行业数据
爬虫合法性探讨
经常听人说起“爬虫写得好,牢饭吃到饱”,那么编程爬虫程序是否违法呢?关于这个问题,我们可以从以下几个角度进行解读。
- 网络爬虫这个领域目前还属于拓荒阶段,虽然互联网世界已经通过自己的游戏规则建立起了一定的道德规范,即 Robots 协议(全称是“网络爬虫排除标准”),但法律部分还在建立和完善中,也就是说,现在这个领域暂时还是灰色地带。
- “法不禁止即为许可”,如果爬虫就像浏览器一样获取的是前端显示的数据(网页上的公开信息)而不是网站后台的私密敏感信息,就不太担心法律法规的约束,因为目前大数据产业链的发展速度远远超过了法律的完善程度。
- 在爬取网站的时候,需要限制自己的爬虫遵守 Robots 协议,同时控制网络爬虫程序的抓取数据的速度;在使用数据的时候,必须要尊重网站的知识产权(从Web 2.0时代开始,虽然Web上的数据很多都是由用户提供的,但是网站平台是投入了运营成本的,当用户在注册和发布内容时,平台通常就已经获得了对数据的所有权、使用权和分发权)。如果违反了这些规定,在打官司的时候败诉几率相当高。
- 适当的隐匿自己的身份在编写爬虫程序时必要的,而且最好不要被对方举证你的爬虫有破坏别人动产(例如服务器)的行为。
- 不要在公网(如代码托管平台)上去开源或者展示你的爬虫代码,这些行为通常会给自己带来不必要的麻烦。
Robots协议
大多数网站都会定义robots.txt
文件,这是一个君子协议,并不是所有爬虫都必须遵守的游戏规则。下面以淘宝的robots.txt
文件为例,看看淘宝网对爬虫有哪些限制。
1 | User-agent: Baiduspider |
通过上面的文件可以看出,淘宝禁止百度爬虫爬取它任何资源,因此当你在百度搜索“淘宝”的时候,搜索结果下方会出现:“由于该网站的robots.txt
文件存在限制指令(限制搜索引擎抓取),系统无法提供该页面的内容描述”。百度作为一个搜索引擎,至少在表面上遵守了淘宝网的robots.txt
协议,所以用户不能从百度上搜索到淘宝内部的产品信息。
图1. 百度搜索淘宝的结果
下面是豆瓣网的robots.txt
文件,大家可以自行解读,看看它做出了什么样的限制。
1 | User-agent: * |
超文本传输协议(HTTP)
在开始讲解爬虫之前,我们稍微对超文本传输协议(HTTP)做一些回顾,因为我们在网页上看到的内容通常是浏览器执行 HTML (超文本标记语言)得到的结果,而 HTTP 就是传输 HTML 数据的协议。HTTP 和其他很多应用级协议一样是构建在 TCP(传输控制协议)之上的,它利用了 TCP 提供的可靠的传输服务实现了 Web 应用中的数据交换。按照维基百科上的介绍,设计 HTTP 最初的目的是为了提供一种发布和接收 HTML 页面的方法,也就是说,这个协议是浏览器和 Web 服务器之间传输的数据的载体。关于 HTTP 的详细信息以及目前的发展状况,大家可以阅读《HTTP 协议入门》 、《互联网协议入门》 、《图解 HTTPS 协议》 等文章进行了解。
下图是我在四川省网络通信技术重点实验室工作期间用开源协议分析工具 Ethereal(WireShark 的前身)截取的访问百度首页时的 HTTP 请求和响应的报文(协议数据),由于 Ethereal 截取的是经过网络适配器的数据,因此可以清晰的看到从物理链路层到应用层的协议数据。
图2. HTTP请求
HTTP 请求通常是由请求行、请求头、空行、消息体四个部分构成,如果没有数据发给服务器,消息体就不是必须的部分。请求行中包含了请求方法(GET、POST 等,如下表所示)、资源路径和协议版本;请求头由若干键值对构成,包含了浏览器、编码方式、首选语言、缓存策略等信息;请求头的后面是空行和消息体。
图3. HTTP响应
HTTP 响应通常是由响应行、响应头、空行、消息体四个部分构成,其中消息体是服务响应的数据,可能是 HTML 页面,也有可能是JSON或二进制数据等。响应行中包含了协议版本和响应状态码,响应状态码有很多种,常见的如下表所示。
相关工具
下面我们先介绍一些开发爬虫程序的辅助工具,这些工具相信能帮助你事半功倍。
Chrome Developer Tools:谷歌浏览器内置的开发者工具。该工具最常用的几个功能模块是:
- 元素(ELements):用于查看或修改 HTML 元素的属性、CSS 属性、监听事件等。CSS 可以即时修改,即时显示,大大方便了开发者调试页面。
- 控制台(Console):用于执行一次性代码,查看 JavaScript 对象,查看调试日志信息或异常信息。控制台其实就是一个执行 JavaScript 代码的交互式环境。
- 源代码(Sources):用于查看页面的 HTML 文件源代码、JavaScript 源代码、CSS 源代码,此外最重要的是可以调试 JavaScript 源代码,可以给代码添加断点和单步执行。
- 网络(Network):用于 HTTP 请求、HTTP 响应以及与网络连接相关的信息。
- 应用(Application):用于查看浏览器本地存储、后台任务等内容,本地存储主要包括Cookie、Local Storage、Session Storage等。
Postman:功能强大的网页调试与 RESTful 请求工具。Postman可以帮助我们模拟请求,非常方便的定制我们的请求以及查看服务器的响应。
HTTPie:命令行HTTP客户端。
安装。
1
pip install httpie
使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14http --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: 58h4BdKC9lMbuiltwith
库:识别网站所用技术的工具。安装。
1
pip install builtwith
使用。
1
2
3
4
5
6import ssl
import builtwith
ssl._create_default_https_context = ssl._create_unverified_context
print(builtwith.parse('http://www.bootcss.com/'))python-whois
库:查询网站所有者的工具。安装。
1
pip3 install python-whois
使用。
1
2
3import whois
print(whois.whois('https://www.bootcss.com'))
爬虫的基本工作流程
一个基本的爬虫通常分为数据采集(网页下载)、数据处理(网页解析)和数据存储(将有用的信息持久化)三个部分的内容,当然更为高级的爬虫在数据采集和处理时会使用并发编程或分布式技术,这就需要有调度器(安排线程或进程执行对应的任务)、后台管理程序(监控爬虫的工作状态以及检查数据抓取的结果)等的参与。
一般来说,爬虫的工作流程包括以下几个步骤:
- 设定抓取目标(种子页面/起始页面)并获取网页。
- 当服务器无法访问时,按照指定的重试次数尝试重新下载页面。
- 在需要的时候设置用户代理或隐藏真实IP,否则可能无法访问页面。
- 对获取的页面进行必要的解码操作然后抓取出需要的信息。
- 在获取的页面中通过某种方式(如正则表达式)抽取出页面中的链接信息。
- 对链接进行进一步的处理(获取页面并重复上面的动作)。
- 将有用的信息进行持久化以备后续的处理。
第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 | import requests |
说明:上面代码中的变量
resp
是一个Response
对象(requests
库封装的类型),通过该对象的status_code
属性可以获取响应状态码,而该对象的text
属性可以帮我们获取到页面的 HTML 代码。
由于Response
对象的text
是一个字符串,所以我们可以利用之前讲过的正则表达式的知识,从页面的 HTML 代码中提取新闻的标题和链接,代码如下所示。
1 | import re |
除了文本内容,我们也可以使用requests
库通过 URL 获取二进制资源。下面的例子演示了如何获取百度 Logo 并保存到名为baidu.png
的本地文件中。可以在百度的首页上右键点击百度Logo,并通过“复制图片地址”菜单项获取图片的 URL。
1 | import requests |
说明:
Response
对象的content
属性可以获得服务器响应的二进制数据。
requests
库非常好用而且功能上也比较强大和完整,具体的内容我们在使用的过程中为大家一点点剖析。想解锁关于requests
库更多的知识,可以阅读它的官方文档 。
编写爬虫代码
接下来,我们以“豆瓣电影”为例,为大家讲解如何编写爬虫代码。按照上面提供的方法,我们先使用requests
获取到网页的HTML代码,然后将整个代码看成一个长字符串,这样我们就可以使用正则表达式的捕获组从字符串提取我们需要的内容。下面的代码演示了如何从豆瓣电影 获取排前250名的电影的名称。豆瓣电影Top250 的页面结构和对应代码如下图所示,可以看出,每页共展示了25部电影,如果要获取到 Top250 数据,我们共需要访问10个页面,对应的地址是https://movie.douban.com/top250?start=xxx,这里的`xxx`如果为`0`就是第一页,如果`xxx`的值是`100`,那么我们可以访问到第五页。为了代码简单易读,我们只获取电影的标题和评分。
1 | import random |
说明:通过分析豆瓣网的robots协议,我们发现豆瓣网并不拒绝百度爬虫获取它的数据,因此我们也可以将爬虫伪装成百度的爬虫,将
get
函数的headers
参数修改为:headers={'User-Agent': 'BaiduSpider'}
。
使用 IP 代理
让爬虫程序隐匿自己的身份对编写爬虫程序来说是比较重要的,很多网站对爬虫都比较反感的,因为爬虫会耗费掉它们很多的网络带宽并制造很多无效的流量。要隐匿身份通常需要使用商业 IP 代理(如蘑菇代理、芝麻代理、快代理等),让被爬取的网站无法获取爬虫程序来源的真实 IP 地址,也就无法简单的通过 IP 地址对爬虫程序进行封禁。
下面以蘑菇代理 为例,为大家讲解商业 IP 代理的使用方法。首先需要在该网站注册一个账号,注册账号后就可以购买 相应的套餐来获得商业 IP 代理。作为商业用途,建议大家购买不限量套餐,这样可以根据实际需要获取足够多的代理 IP 地址;作为学习用途,可以购买包时套餐或根据自己的需求来决定。蘑菇代理提供了两种接入代理的方式,分别是 API 私密代理和 HTTP 隧道代理,前者是通过请求蘑菇代理的 API 接口获取代理服务器地址,后者是直接使用统一的入口(蘑菇代理提供的域名)进行接入。

下面,我们以HTTP隧道代理为例,为大家讲解接入 IP 代理的方式,大家也可以直接参考蘑菇代理官网提供的代码来为爬虫设置代理。
1 | import requests |
说明:上面的代码需要修改
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 |
|
标签、层叠样式表(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 |
|
对于上面的 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 | from lxml import etree |
CSS 选择器解析
对于熟悉 CSS 选择器和 JavaScript 的开发者来说,通过 CSS 选择器获取页面元素可能是更为简单的选择,因为浏览器中运行的 JavaScript 本身就可以document
对象的querySelector()
和querySelectorAll()
方法基于 CSS 选择器获取页面元素。在 Python 中,我们可以利用三方库beautifulsoup4
或pyquery
来做同样的事情。Beautiful Soup 可以用来解析 HTML 和 XML 文档,修复含有未闭合标签等错误的文档,通过为待解析的页面在内存中创建一棵树结构,实现对从页面中提取数据操作的封装。可以用下面的命令来安装 Beautiful Soup。
1 | pip install beautifulsoup4 |
下面是使用bs4
改写的获取豆瓣电影Top250电影名称的代码。
1 | import bs4 |
关于 BeautifulSoup 更多的知识,可以参考它的官方文档 。
简单的总结
下面我们对三种解析方式做一个简单比较。
解析方式 | 对应的模块 | 速度 | 使用难度 |
---|---|---|---|
正则表达式解析 | re |
快 | 困难 |
XPath 解析 | lxml |
快 | 一般 |
CSS 选择器解析 | bs4 或pyquery |
不确定 | 简单 |
第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 | import random |
说明:上面的代码并没有真正实现联网下载的功能,而是通过
time.sleep()
休眠一段时间来模拟下载文件需要一些时间上的开销,跟实际下载的状况比较类似。
运行上面的代码,可以得到如下所示的运行结果。可以看出,当我们的程序只有一个工作线程时,每个下载任务都需要等待上一个下载任务执行结束才能开始,所以程序执行的总耗时是三个下载任务各自执行时间的总和。
1 | 开始下载Python从入门到住院.pdf. |
事实上,上面的三个下载任务之间并没有逻辑上的因果关系,三者是可以“并发”的,下一个下载任务没有必要等待上一个下载任务结束,为此,我们可以使用多线程编程来改写上面的代码。
1 | import random |
某次的运行结果如下所示。
1 | 开始下载 Python从入门到住院.pdf. |
通过上面的运行结果可以发现,整个程序的执行时间几乎等于耗时最长的一个下载任务的执行时间,这也就意味着,三个下载任务是并发执行的,不存在一个等待另一个的情况,这样做很显然提高了程序的执行效率。简单的说,如果程序中有非常耗时的执行单元,而这些耗时的执行单元之间又没有逻辑上的因果关系,即 B 单元的执行不依赖于 A 单元的执行结果,那么 A 和 B 两个单元就可以放到两个不同的线程中,让他们并发的执行。这样做的好处除了减少程序执行的等待时间,还可以带来更好的用户体验,因为一个单元的阻塞不会造成程序的“假死”,因为程序中还有其他的单元是可以运转的。
使用 Thread 类创建线程对象
通过上面的代码可以看出,直接使用Thread
类的构造器就可以创建线程对象,而线程对象的start()
方法可以启动一个线程。线程启动后会执行target
参数指定的函数,当然前提是获得 CPU 的调度;如果target
指定的线程要执行的目标函数有参数,需要通过args
参数为其进行指定,对于关键字参数,可以通过kwargs
参数进行传入。Thread
类的构造器还有很多其他的参数,我们遇到的时候再为大家进行讲解,目前需要大家掌握的,就是target
、args
和kwargs
。
继承 Thread 类自定义线程
除了上面的代码展示的创建线程的方式外,还可以通过继承Thread
类并重写run()
方法的方式来自定义线程,具体的代码如下所示。
1 | import random |
使用线程池
我们还可以通过线程池的方式将任务放到多个线程中去执行,通过线程池来使用线程应该是多线程编程最理想的选择。事实上,线程的创建和释放都会带来较大的开销,频繁的创建和释放线程通常都不是很好的选择。利用线程池,可以提前准备好若干个线程,在使用的过程中不需要再通过自定义的代码创建和释放线程,而是直接复用线程池中的线程。Python 内置的concurrent.futures
模块提供了对线程池的支持,代码如下所示。
1 | import random |
守护线程
所谓“守护线程”就是在主线程结束的时候,不值得再保留的执行线程。这里的不值得保留指的是守护线程会在其他非守护线程全部运行结束之后被销毁,它守护的是当前进程内所有的非守护线程。简单的说,守护线程会跟随主线程一起挂掉,而主线程的生命周期就是一个进程的生命周期。如果不理解,我们可以看一段简单的代码。
1 | import time |
说明:上面的代码中,我们将
flush
设置为True
,这是因为flush
参数的值如果为False
,而flush
参数设置为True
,强制每次输出都清空输出缓冲区。
上面的代码运行起来之后是不会停止的,因为两个子线程中都有死循环,除非你手动中断代码的执行。但是,如果在创建线程对象时,将名为daemon
的参数设置为True
,这两个线程就会变成守护线程,那么在其他线程结束时,即便有死循环,两个守护线程也会挂掉,不会再继续执行下去,代码如下所示。
1 | import time |
上面的代码,我们在主线程中添加了一行time.sleep(5)
让主线程休眠5秒,在这个过程中,输出Ping
和Pong
的守护线程会持续运转,直到主线程在5秒后结束,这两个守护线程也被销毁,不再继续运行。
思考:如果将上面代码第12行的
daemon=True
去掉,代码会怎样执行?有兴趣的读者可以尝试一下,并看看实际执行的结果跟你想象的是否一致。
资源竞争
在编写多线程代码时,不可避免的会遇到多个线程竞争同一个资源(对象)的情况。在这种情况下,如果没有合理的机制来保护被竞争的资源,那么就有可能出现非预期的状况。下面的代码创建了100
个线程向同一个银行账户(初始余额为0
元)转账,每个线程转账金额为1
元。在正常的情况下,我们的银行账户最终的余额应该是100
元,但是运行下面的代码我们并不能得到100
元这个结果。
1 | import time |
上面代码中的Account
类代表了银行账户,它的deposit
方法代表存款行为,参数money
代表存入的金额,该方法通过time.sleep
函数模拟受理存款需要一段时间。我们通过线程池的方式启动了100
个线程向一个账户转账,但是上面的代码并不能运行出100
这个我们期望的结果,这就是在多个线程竞争一个资源的时候,可能会遇到的数据不一致的问题。注意上面代码的第14
行,当多个线程都执行到这行代码时,它们会在相同的余额上执行加上存入金额的操作,这就会造成“丢失更新”现象,即之前修改数据的成果被后续的修改给覆盖掉了,所以才得不到正确的结果。
要解决上面的问题,可以使用锁机制,通过锁对操作数据的关键代码加以保护。Python 标准库的threading
模块提供了Lock
和RLock
类来支持锁机制,这里我们不去深究二者的区别,建议大家直接使用RLock
。接下来,我们给银行账户添加一个锁对象,通过锁对象来解决刚才存款时发生“丢失更新”的问题,代码如下所示。
1 | import time |
上面代码中,获得锁和释放锁的操作也可以通过上下文语法来实现,使用上下文语法会让代码更加简单优雅,这也是我们推荐大家使用的方式。
1 | import time |
思考:将上面的代码修改为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
参数传入一个函数来指定进程要执行的代码,而args
和kwargs
参数可以指定该函数使用的参数值。
1 | from multiprocessing import Process, current_process |
说明:上面的代码通过
current_process
函数获取当前进程对象,再通过进程对象的pid
属性获取进程ID。在 Python 中,使用os
模块的getpid
函数也可以达到同样的效果。
如果愿意,也可以使用os
模块的fork
函数来创建进程,调用该函数时,操作系统自动把当前进程(父进程)复制一份(子进程),父进程的fork
函数会返回子进程的ID,而子进程中的fork
函数会返回0
,也就是说这个函数调用一次会在父进程和子进程中得到两个不同的返回值。需要注意的是,Windows 系统并不支持fork
函数,如果你使用的是 Linux 或 macOS 系统,可以试试下面的代码。
1 | import os |
简而言之,我们还是推荐大家通过直接使用Process
类、继承Process
类和使用进程池(ProcessPoolExecutor
)这三种方式来创建和使用多进程,这三种方式不同于上面的fork
函数,能够保证代码的兼容性和可移植性。具体的做法跟之前讲过的创建和使用多线程的方式比较接近,此处不再进行赘述。
多进程和多线程的比较
对于爬虫这类 I/O 密集型任务来说,使用多进程并没有什么优势;但是对于计算密集型任务来说,多进程相比多线程,在效率上会有显著的提升,我们可以通过下面的代码来加以证明。下面的代码会通过多线程和多进程两种方式来判断一组大整数是不是质数,很显然这是一个计算密集型任务,我们将任务分别放到多个线程和多个进程中来加速代码的执行,让我们看看多线程和多进程的代码具体表现有何不同。
我们先实现一个多线程的版本,代码如下所示。
1 | import concurrent.futures |
假设上面的代码保存在名为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 | import concurrent.futures |
提示:运行上面的代码时,可以通过操作系统的任务管理器(资源监视器)来查看是否启动了多个 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 | import os |
综上所述,多进程可以突破 GIL 的限制,充分利用 CPU 多核特性,对于计算密集型任务,这一点是相当重要的。常见的计算密集型任务包括科学计算、图像处理、音视频编解码等,如果这些计算密集型任务本身是可以并行的,那么使用多进程应该是更好的选择。
进程间通信
在讲解进程间通信之前,先给大家一个任务:启动两个进程,一个输出“Ping”,一个输出“Pong”,两个进程输出的“Ping”和“Pong”加起来一共有50个时,就结束程序。听起来是不是非常简单,但是实际编写代码时,由于多个进程之间不能够像多个线程之间直接通过共享内存的方式交换数据,所以下面的代码是达不到我们想要的结果的。
1 | from multiprocessing import Process |
上面的代码看起来没毛病,但是最后的结果是“Ping”和“Pong”各输出了50个。再次提醒大家,当我们在程序中创建进程的时候,子进程会复制父进程及其所有的数据结构,每个子进程有自己独立的内存空间,这也就意味着两个子进程中各有一个counter
变量,它们都会从0
加到50
,所以结果就可想而知了。要解决这个问题比较简单的办法是使用multiprocessing
模块中的Queue
类,它是可以被多个进程共享的队列,底层是通过操作系统底层的管道和信号量(semaphore)机制来实现的,代码如下所示。
1 | import time |
提示:
multiprocessing.Queue
对象的get
方法默认在队列为空时是会阻塞的,直到获取到数据才会返回。如果不希望该方法阻塞以及需要指定阻塞的超时时间,可以通过指定block
和timeout
参数进行设定。
上面的代码通过Queue
类的get
和put
方法让三个进程(p1
、p2
和主进程)实现了数据的共享,这就是所谓的进程间的通信,通过这种方式,当Queue
中取出的值已经大于等于50
时,p1
和p2
就会跳出while
循环,从而终止进程的执行。代码第22行的循环是为了等待p1
和p2
两个进程中的一个结束,这时候主进程还需要向Queue
中放置一个大于等于50
的值,这样另一个尚未结束的进程也会因为读到这个大于等于50
的值而终止。
进程间通信的方式还有很多,比如使用套接字也可以实现两个进程的通信,甚至于这两个进程并不在同一台主机上,有兴趣的读者可以自行了解。
简单的总结
在 Python 中,我们还可以通过subprocess
模块的call
函数执行其他的命令来创建子进程,相当于就是在我们的程序中调用其他程序,这里我们暂不探讨这些知识,有兴趣的读者可以自行研究。
对于Python开发者来说,以下情况需要考虑使用多线程:
- 程序需要维护许多共享的状态(尤其是可变状态),Python 中的列表、字典、集合都是线程安全的(多个线程同时操作同一个列表、字典或集合,不会引发错误和数据问题),所以使用线程而不是进程维护共享状态的代价相对较小。
- 程序会花费大量时间在 I/O 操作上,没有太多并行计算的需求且不需占用太多的内存。
那么在遇到下列情况时,应该考虑使用多进程:
- 程序执行计算密集型任务(如:音视频编解码、数据压缩、科学计算等)。
- 程序的输入可以并行的分成块,并且可以将运算结果合并。
- 程序在内存使用方面没有任何限制且不强依赖于 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 | def fib(max_count): |
上面我们编写了一个生成斐波那契数列的生成器,调用上面的fib
函数并不是执行该函数获得返回值,因为fib
函数中有一个特殊的关键字yield
。这个关键字使得fib
函数跟普通的函数有些区别,调用该函数会得到一个生成器对象,我们可以通过下面的代码来验证这一点。
1 | gen_obj = fib(20) |
输出:
1 | <generator object fib at 0x106daee40> |
我们可以使用内置函数next
从生成器对象中获取斐波那契数列的值,也可以通过for-in
循环对生成器能够提供的值进行遍历,代码如下所示。
1 | for value in gen_obj: |
生成器经过预激活,就是一个协程,它可以跟其他子程序协作。
1 | def calc_average(): |
上面的main
函数首先通过生成器对象的send
方法发送一个None
值来将其激活为协程,也可以通过next(obj)
达到同样的效果。接下来,协程对象会接收main
函数发送的数据并产出(yield
)数据的平均值。通过上面的例子,不知道大家是否看出两段子程序是怎么“协作”的。
异步函数
Python 3.5版本中,引入了两个非常有意思的元素,一个叫async
,一个叫await
,它们在Python 3.7版本中成为了正式的关键字。通过这两个关键字,可以简化协程代码的编写,可以用更为简单的方式让多个子程序很好的协作起来。我们通过一个例子来加以说明,请大家先看看下面的代码。
1 | import time |
上面的代码每次执行都会依次输出1
到9
的数字,每个间隔1
秒钟,整个代码需要执行大概需要9
秒多的时间,这一点我相信大家都能看懂。不知道大家是否意识到,这段代码就是以同步和阻塞的方式执行的,同步可以从代码的输出看出来,而阻塞是指在调用display
函数发生休眠时,整个代码的其他部分都不能继续执行,必须等待休眠结束。
接下来,我们尝试用异步的方式改写上面的代码,让display
函数以异步的方式运转。
1 | import asyncio |
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 的利用率。而且我们还会注意到,数字并不是按照从1
到9
的顺序打印输出的,这正是我们想要的结果,说明它们是异步执行的。对于爬虫这样的 I/O 密集型任务来说,这种协作式并发在很多场景下是比使用多线程更好的选择,因为这种做法减少了管理和维护多个线程以及多个线程切换所带来的开销。
aiohttp库
我们之前使用的requests
三方库并不支持异步 I/O,如果希望使用异步 I/O 的方式来加速爬虫代码的执行,我们可以安装和使用名为aiohttp
的三方库。
安装aiohttp
。
1 | pip install aiohttp |
下面的代码使用aiohttp
抓取了10
个网站的首页并解析出它们的标题。
1 | import asyncio |
输出:
1 | 京东(JD.COM)-正品低价、品质保障、配送及时、轻松购物! |
从上面的输出可以看出,网站首页标题的输出顺序跟它们的 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 | """ |
在 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 | """ |
执行如下所示的命令。
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 的方式实现网络资源的获取和写文件操作,我们首先得安装三方库aiohttp
和aiofile
,命令如下所示。
1 | pip install aiohttp aiofile |
aiohttp
的用法在之前的课程中已经做过简要介绍,aiofile
模块中的async_open
函数跟 Python 内置函数open
的用法大致相同,只不过它支持异步操作。下面是异步 I/O 版本的爬虫代码。
1 | """ |
执行如下所示的命令。
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 | from selenium import webdriver |
如果不愿意使用 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 |
解决这个问题的办法有三种:
将下载的 ChromeDriver 放到已有的 PATH 环境变量下,建议直接跟 Python 解释器放在同一个目录,因为之前安装 Python 的时候我们已经将 Python 解释器的路径放到 PATH 环境变量中了。
将 ChromeDriver 放到项目虚拟环境下的
bin
文件夹中(Windows 系统对应的目录是Scripts
),这样 ChromeDriver 就跟虚拟环境下的 Python 解释器在同一个位置,肯定是能够找到的。修改上面的代码,在创建 Chrome 对象时,通过
service
参数配置Service
对象,并通过创建Service
对象的executable_path
参数指定 ChromeDriver 所在的位置,如下所示:1
2
3
4
5from 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_element
和find_elements
方法来获取页面元素,Selenium 支持多种获取元素的方式,包括:CSS 选择器、XPath、元素名字(标签名)、元素 ID、类名等,前者可以获取单个页面元素(WebElement
对象),后者可以获取多个页面元素构成的列表。获取到WebElement
对象以后,可以通过send_keys
来模拟用户输入行为,可以通过click
来模拟用户点击操作,代码如下所示。
1 | from selenium import webdriver |
如果要执行一个系列动作,例如模拟拖拽操作,可以创建ActionChains
对象,有兴趣的读者可以自行研究。
隐式等待和显式等待
这里还有一个细节需要大家知道,网页上的元素可能是动态生成的,在我们使用find_element
或find_elements
方法获取的时候,可能还没有完成渲染,这时会引发NoSuchElementException
错误。为了解决这个问题,我们可以使用隐式等待的方式,通过设置等待时间让浏览器完成对页面元素的渲染。除此之外,我们还可以使用显示等待,通过创建WebDriverWait
对象,并设置等待时间和条件,当条件没有满足时,我们可以先等待再尝试进行后续的操作,具体的代码如下所示。
1 | from selenium import webdriver |
上面设置的等待条件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 | # 执行JavaScript代码 |
Selenium反爬的破解
有一些网站专门针对 Selenium 设置了反爬措施,因为使用 Selenium 驱动的浏览器,在控制台中可以看到如下所示的webdriver
属性值为true
,如果要绕过这项检查,可以在加载页面之前,先通过执行 JavaScript 代码将其修改为undefined
。

另一方面,我们还可以将浏览器窗口上的“Chrome正受到自动测试软件的控制”隐藏掉,完整的代码如下所示。
1 | # 创建Chrome参数对象 |
无头浏览器
很多时候,我们在爬取数据时并不需要看到浏览器窗口,只要有 Chrome 浏览器以及对应的驱动程序,我们的爬虫就能够运转起来。如果不想看到浏览器窗口,我们可以通过下面的方式设置使用无头浏览器。
1 | options = webdriver.ChromeOptions() |
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 | import os |
运行上面的代码,检查指定的目录下是否下载了根据关键词搜索到的图片。
第39课:爬虫框架Scrapy简介
当你写了很多个爬虫程序之后,你会发现每次写爬虫程序时,都需要将页面获取、页面解析、爬虫调度、异常处理、反爬应对这些代码从头至尾实现一遍,这里面有很多工作其实都是简单乏味的重复劳动。那么,有没有什么办法可以提升我们编写爬虫代码的效率呢?答案是肯定的,那就是利用爬虫框架,而在所有的爬虫框架中,Scrapy 应该是最流行、最强大的框架。
Scrapy 概述
Scrapy 是基于 Python 的一个非常流行的网络爬虫框架,可以用来抓取 Web 站点并从页面中提取结构化的数据。下图展示了 Scrapy 的基本架构,其中包含了主要组件和系统的数据处理流程(图中带数字的红色箭头)。
Scrapy的组件
我们先来说说 Scrapy 中的组件。
- Scrapy 引擎(Engine):用来控制整个系统的数据处理流程。
- 调度器(Scheduler):调度器从引擎接受请求并排序列入队列,并在引擎发出请求后返还给它们。
- 下载器(Downloader):下载器的主要职责是抓取网页并将网页内容返还给蜘蛛(Spiders)。
- 蜘蛛程序(Spiders):蜘蛛是用户自定义的用来解析网页并抓取特定URL的类,每个蜘蛛都能处理一个域名或一组域名,简单的说就是用来定义特定网站的抓取和解析规则的模块。
- 数据管道(Item Pipeline):管道的主要责任是负责处理有蜘蛛从网页中抽取的数据条目,它的主要任务是清理、验证和存储数据。当页面被蜘蛛解析后,将被发送到数据管道,并经过几个特定的次序处理数据。每个数据管道组件都是一个 Python 类,它们获取了数据条目并执行对数据条目进行处理的方法,同时还需要确定是否需要在数据管道中继续执行下一步或是直接丢弃掉不处理。数据管道通常执行的任务有:清理 HTML 数据、验证解析到的数据(检查条目是否包含必要的字段)、检查是不是重复数据(如果重复就丢弃)、将解析到的数据存储到数据库(关系型数据库或 NoSQL 数据库)中。
- 中间件(Middlewares):中间件是介于引擎和其他组件之间的一个钩子框架,主要是为了提供自定义的代码来拓展 Scrapy 的功能,包括下载器中间件和蜘蛛中间件。
数据处理流程
Scrapy 的整个数据处理流程由引擎进行控制,通常的运转流程包括以下的步骤:
引擎询问蜘蛛需要处理哪个网站,并让蜘蛛将第一个需要处理的 URL 交给它。
引擎让调度器将需要处理的 URL 放在队列中。
引擎从调度那获取接下来进行爬取的页面。
调度将下一个爬取的 URL 返回给引擎,引擎将它通过下载中间件发送到下载器。
当网页被下载器下载完成以后,响应内容通过下载中间件被发送到引擎;如果下载失败了,引擎会通知调度器记录这个 URL,待会再重新下载。
引擎收到下载器的响应并将它通过蜘蛛中间件发送到蜘蛛进行处理。
蜘蛛处理响应并返回爬取到的数据条目,此外还要将需要跟进的新的 URL 发送给引擎。
引擎将抓取到的数据条目送入数据管道,把新的 URL 发送给调度器放入队列中。
上述操作中的第2步到第8步会一直重复直到调度器中没有需要请求的 URL,爬虫就停止工作。
安装和使用Scrapy
可以使用 Python 的包管理工具pip
来安装 Scrapy。
1 | pip install scrapy |
在命令行中使用scrapy
命令创建名为demo
的项目。
1 | scrapy startproject demo |
项目的目录结构如下图所示。
1 | demo |
切换到demo
目录,用下面的命令创建名为douban
的蜘蛛程序。
1 | scrapy genspider douban movie.douban.com |
一个简单的例子
接下来,我们实现一个爬取豆瓣电影 Top250 电影标题、评分和金句的爬虫。
在
items.py
的Item
类中定义字段,这些字段用来保存数据,方便后续的操作。1
2
3
4
5
6
7import scrapy
class DoubanItem(scrapy.Item):
title = scrapy.Field()
score = scrapy.Field()
motto = scrapy.Field()修改
spiders
文件夹中名为douban.py
的文件,它是蜘蛛程序的核心,需要我们添加解析页面的代码。在这里,我们可以通过对Response
对象的解析,获取电影的信息,代码如下所示。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21import 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 或正则表达式进行页面解析,对应的方法分别是
xpath
和re
。如果还要生成后续爬取的请求,我们可以用
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
26import 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
24import 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如果希望完成爬虫数据的持久化,可以在数据管道中处理蜘蛛程序产生的
Item
对象。例如,我们可以通过前面讲到的openpyxl
操作 Excel 文件,将数据写入 Excel 文件中,代码如下所示。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import 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_item
和close_spider
都是回调方法(钩子函数), 简单的说就是 Scrapy 框架会自动去调用的方法。当蜘蛛程序产生一个Item
对象交给引擎时,引擎会将该Item
对象交给数据管道,这时我们配置好的数据管道的parse_item
方法就会被执行,所以我们可以在该方法中获取数据并完成数据的持久化操作。另一个方法close_spider
是在爬虫结束运行前会自动执行的方法,在上面的代码中,我们在这个地方进行了保存 Excel 文件的操作,相信这段代码大家是很容易读懂的。总而言之,数据管道可以帮助我们完成以下操作:
- 清理 HTML 数据,验证爬取的数据。
- 丢弃重复的不必要的内容。
- 将爬取的结果进行持久化操作。
修改
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概述
关系型数据库概述
数据持久化 - 将数据保存到能够长久保存数据的存储介质中,在掉电的情况下数据也不会丢失。
数据库发展史 - 网状数据库、层次数据库、关系数据库、NoSQL 数据库、NewSQL 数据库。
1970年,IBM的研究员E.F.Codd在Communication of the ACM上发表了名为A Relational Model of Data for Large Shared Data Banks的论文,提出了关系模型的概念,奠定了关系模型的理论基础。后来Codd又陆续发表多篇文章,论述了范式理论和衡量关系系统的12条标准,用数学理论奠定了关系数据库的基础。
关系数据库特点。
理论基础:关系代数(关系运算、集合论、一阶谓词逻辑)。
具体表象:用二维表(有行和列)组织数据。
编程语言:结构化查询语言(SQL)。
ER模型(实体关系模型)和概念模型图。
ER模型,全称为实体关系模型(Entity-Relationship Model),由美籍华裔计算机科学家陈品山先生提出,是概念数据模型的高层描述方式,如下图所示。
- 实体 - 矩形框
- 属性 - 椭圆框
- 关系 - 菱形框
- 重数 - 1:1(一对一) / 1:N(一对多) / M:N(多对多)
实际项目开发中,我们可以利用数据库建模工具(如:PowerDesigner)来绘制概念数据模型(其本质就是 ER 模型),然后再设置好目标数据库系统,将概念模型转换成物理模型,最终生成创建二维表的 SQL(很多工具都可以根据我们设计的物理模型图以及设定的目标数据库来导出 SQL 或直接生成数据表)。
关系数据库产品。
- 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 环境
通过官方网站 提供的下载链接 下载“MySQL社区版服务器”安装程序,如下图所示,建议大家下载离线安装版的MySQL Installer。
运行 Installer,按照下面的步骤进行安装。
- 选择自定义安装。
- 选择需要安装的组件。
- 如果缺少依赖项,需要先安装依赖项。
- 准备开始安装。
- 安装完成。
- 准备执行配置向导。
执行安装后的配置向导。
- 配置服务器类型和网络。
配置认证方法(保护密码的方式)。
配置用户和角色。
配置Windows服务名以及是否开机自启。
配置日志。
配置高级选项。
应用配置。
可以在 Windows 系统的“服务”窗口中启动或停止 MySQL。
配置 PATH 环境变量,以便在命令行提示符窗口使用 MySQL 客户端工具。
打开 Windows 的“系统”窗口并点击“高级系统设置”。
在“系统属性”的“高级”窗口,点击“环境变量”按钮。
修改PATH环境变量,将MySQL安装路径下的
bin
文件夹的路径配置到PATH环境变量中。配置完成后,可以尝试在“命令提示符”下使用 MySQL 的命令行工具。
Linux 环境
下面以 CentOS 7.x 环境为例,演示如何安装 MySQL 5.7.x,如果需要在其他 Linux 系统下安装其他版本的 MySQL,请读者自行在网络上查找对应的安装教程。
安装 MySQL。
可以在 MySQL 官方网站 下载安装文件。首先在下载页面中选择平台和版本,然后找到对应的下载链接,直接下载包含所有安装文件的归档文件,解归档之后通过包管理工具进行安装。
1
2wget 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
2yum update
yum install -y libaio libaio-devel接下来可以按照如下所示的顺序用 RPM(Redhat Package Manager)工具安装 MySQL。
1
2
3
4
5
6rpm -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
配置 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 服务器进行性能调优和安全管控。
启动 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
使用 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
3set 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 | show databases; |
- 查看所有字符集
1 | show character set; |
- 查看所有的排序规则
1 | show collation; |
- 查看所有的引擎
1 | show engines; |
- 查看所有日志文件
1 | show binary logs; |
- 查看数据库下所有表
1 | show tables; |
获取帮助
在 MySQL 命令行工具中,可以使用help
命令或?
来获取帮助,如下所示。
查看
show
命令的帮助。1
? show
查看有哪些帮助内容。
1
? contents
获取函数的帮助。
1
? functions
获取数据类型的帮助。
1
? data types
其他命令
新建/重建服务器连接 -
connect
/resetconnection
。清空当前输入 -
\c
。在输入错误时,可以及时使用\c
清空当前输入并重新开始。修改终止符(定界符)-
delimiter
。默认的终止符是;
,可以使用该命令修改成其他的字符,例如修改为$
符号,可以用delimiter $
命令。打开系统默认编辑器 -
edit
。编辑完成保存关闭之后,命令行会自动执行编辑的内容。查看服务器状态 -
status
。修改默认提示符 -
prompt
。执行系统命令 -
system
。可以将系统命令跟在system
命令的后面执行,system
命令也可以缩写为\!
。执行 SQL 文件 -
source
。source
命令后面跟 SQL 文件路径。重定向输出 -
tee
/notee
。可以将命令的输出重定向到指定的文件中。切换数据库 -
use
。显示警告信息 -
warnings
。退出命令行 -
quit
或exit
。
第41课:SQL详解之DDL
我们通常可以将 SQL 分为四类,分别是 DDL(数据定义语言)、DML(数据操作语言)、DQL(数据查询语言)和 DCL(数据控制语言)。DDL 主要用于创建、删除、修改数据库中的对象,比如创建、删除和修改二维表,核心的关键字包括create
、drop
和alter
;DML 主要负责数据的插入、删除和更新,关键词包括insert
、delete
和update
;DQL 负责数据查询,最重要的一个关键词是select
;DCL 通常用于授予和召回权限,核心关键词是grant
和revoke
。
说明:SQL 是不区分大小写的语言,有人会建议将关键字大写,其他部分小写。为了书写和识别方便,下面的 SQL 我都是使用小写字母进行书写的。 如果公司的 SQL 编程规范有强制规定,那么就按照公司的要求来,个人的喜好不应该凌驾于公司的编程规范之上,这一点对职业人来说应该是常识。
建库建表
下面我们来实现一个非常简单的学校选课系统的数据库。我们将数据库命名为school
,四个关键的实体分别是学院、老师、学生和课程,其中,学生跟学院是从属关系,这个关系从数量上来讲是多对一关系,因为一个学院可以有多名学生,而一个学生通常只属于一个学院;同理,老师跟学院的从属关系也是多对一关系。一名老师可以讲授多门课程,一门课程如果只有一个授课老师的话,那么课程跟老师也是多对一关系;如果允许多个老师合作讲授一门课程,那么课程和老师就是多对多关系。简单起见,我们将课程和老师设计为多对一关系。学生和课程是典型的多对多关系,因为一个学生可以选择多门课程,一门课程也可以被多个学生选择,而关系型数据库需要借助中间表才能维持维持两个实体的多对多关系。最终,我们的学校选课系统一共有五张表,分别是学院表(tb_college
)、学生表(tb_student
)、教师表(tb_teacher
)、课程表(tb_course
)和选课记录表(tb_record
),其中选课记录表就是维持学生跟课程多对多关系的中间表。
1 | -- 如果存在名为school的数据库就删除它 |
上面的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
38You 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
31Name: '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 update
和on 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 | insert into `tb_college` |
在插入数据时,要注意主键是不能重复的,如果插入的数据与表中已有记录主键相同,那么insert
操作将会产生 Duplicated Entry 的报错信息。再次提醒大家,如果insert
操作省略了某些列,那么这些列要么有默认值,要么允许为null
,否则也将产生错误。在业务系统中,为了让insert
操作不影响其他操作(主要是后面要讲的select
操作)的性能,可以在insert
和into
之间加一个low_priority
来降低insert
操作的优先级,这个做法也适用于下面要讲的delete
和update
操作。
假如有一张名为tb_temp
的表中有a
和b
两个列,分别保存了学院的名称和学院的介绍,我们也可以通过查询操作获得tb_temp
表的数据并插入到学院表中,如下所示,其中的select
就是我们之前提到的 DQL,在下一课中会详细讲解。
1 | insert into `tb_college` |
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 | use `school`; |
注意:上面的
insert
语句使用了批处理的方式来插入数据,这种做法插入数据的效率比较高。
第43课:SQL详解之DQL
接下来,我们利用之前创建的学校选课系统数据库,为大家讲解 DQL 的应用。无论对于开发人员还是数据分析师,DQL 都是非常重要的,它关系着我们能否从关系数据库中获取我们需要的数据。建议大家把上上一节课中建库建表的 DDL 以及 上一节课中插入数据的 DML 重新执行一次,确保表和数据跟没有问题再执行下面的操作。
1 | use `school`; |
上面的 DQL 有几个地方需要加以说明:
MySQL目前的版本不支持全外连接,上面我们通过
union
操作,将左外连接和右外连接的结果求并集实现全外连接的效果。大家可以通过下面的图来加深对连表操作的认识。MySQL 中支持多种类型的运算符,包括:算术运算符(
+
、-
、*
、/
、%
)、比较运算符(=
、<>
、<=>
、<
、<=
、>
、>=
、BETWEEN...AND..
.、IN
、IS NULL
、IS NOT NULL
、LIKE
、RLIKE
、REGEXP
)、逻辑运算符(NOT
、AND
、OR
、XOR
)和位运算符(&
、|
、^
、~
、>>
、<<
),我们可以在 DML 中使用这些运算符处理数据。在查询数据时,可以在
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 | drop user if exists 'wangdachui'@'%'; |
此时,如果我们使用 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 | *************************** 1. row *************************** |
在上面的 SQL 执行计划中,有几项值得我们关注:
select_type
:查询的类型。SIMPLE
:简单 SELECT,不需要使用 UNION 操作或子查询。PRIMARY
:如果查询包含子查询,最外层的 SELECT 被标记为 PRIMARY。UNION
:UNION 操作中第二个或后面的 SELECT 语句。SUBQUERY
:子查询中的第一个 SELECT。DERIVED
:派生表的 SELECT 子查询。
table
:查询对应的表。type
:MySQL 在表中找到满足条件的行的方式,也称为访问类型,包括:ALL
(全表扫描)、index
(索引全扫描,只遍历索引树)、range
(索引范围扫描)、ref
(非唯一索引扫描)、eq_ref
(唯一索引扫描)、const
/system
(常量级查询)、NULL
(不需要访问表或索引)。在所有的访问类型中,很显然 ALL 是性能最差的,它代表的全表扫描是指要扫描表中的每一行才能找到匹配的行。possible_keys
:MySQL 可以选择的索引,但是有可能不会使用。key
:MySQL 真正使用的索引,如果为NULL
就表示没有使用索引。key_len
:使用的索引的长度,在不影响查询的情况下肯定是长度越短越好。rows
:执行查询需要扫描的行数,这是一个预估值。extra
:关于查询额外的信息。Using filesort
:MySQL 无法利用索引完成排序操作。Using index
:只使用索引的信息而不需要进一步查表来获取更多的信息。Using temporary
:MySQL 需要使用临时表来存储结果集,常用于分组和排序。Impossible where
:where
子句会导致没有符合条件的行。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 | *************************** 1. row *************************** |
可以注意到,在对学生姓名创建索引后,刚才的查询已经不是全表扫描而是基于索引的查询,而且扫描的行只有唯一的一行,这显然大大的提升了查询的性能。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 | *************************** 1. row *************************** |
不知道大家是否注意到,这一次扫描的行变成了2行,因为学生表中有两个姓“林”的学生,我们只用姓名的第一个字作为索引的话,在查询时通过索引就会找到这两行。
如果要删除索引,可以使用下面的SQL。
1 | alter table tb_student drop index idx_student_name; |
或者
1 | drop index idx_student_name on tb_student; |
在创建索引时,我们还可以使用复合索引、函数索引(MySQL 5.7 开始支持),用好复合索引实现索引覆盖可以减少不必要的排序和回表操作,这样就会让查询的性能成倍的提升,有兴趣的读者可以自行研究。
我们简单的为大家总结一下索引的设计原则:
- 最适合索引的列是出现在WHERE子句和连接子句中的列。
- 索引列的基数越大(取值多、重复值少),索引的效果就越好。
- 使用前缀索引可以减少索引占用的空间,内存中可以缓存更多的索引。
- 索引不是越多越好,虽然索引加速了读操作(查询),但是写操作(增、删、改)都会变得更慢,因为数据的变化会导致索引的更新,就如同书籍章节的增删需要更新目录一样。
- 使用 InnoDB 存储引擎时,表的普通索引都会保存主键的值,所以主键要尽可能选择较短的数据类型,这样可以有效的减少索引占用的空间,提升索引的缓存效果。
最后,还有一点需要说明,InnoDB 使用的 B-tree 索引,数值类型的列除了等值判断时索引会生效之外,使用>
、<
、>=
、<=
、BETWEEN...AND...
、<>
时,索引仍然生效;对于字符串类型的列,如果使用不以通配符开头的模糊查询,索引也是起作用的,但是其他的情况会导致索引失效,这就意味着很有可能会做全表查询。
第46课:视图、函数和过程
为了讲解视图、函数和过程,我们首先用下面的 DDL 和 DML 创建名为 hrs 的数据库并为其二维表添加如下所示的数据。
1 | -- 创建名为hrs的数据库并指定默认的字符集 |
视图
视图是关系型数据库中将一组查询指令构成的结果集组合成可查询的数据表的对象。简单的说,视图就是虚拟的表,但与数据表不同的是,数据表是一种实体结构,而视图是一种虚拟结构,你也可以将视图理解为保存在数据库中被赋予名字的 SQL 语句。
使用视图可以获得以下好处:
- 可以将实体数据表隐藏起来,让外部程序无法得知实际的数据结构,让访问者可以使用表的组成部分而不是整个表,降低数据库被攻击的风险。
- 在大多数的情况下视图是只读的(更新视图的操作通常都有诸多的限制),外部程序无法直接透过视图修改数据。
- 重用 SQL 语句,将高度复杂的查询包装在视图表中,直接访问该视图即可取出需要的数据;也可以将视图视为数据表进行连接查询。
- 视图可以返回与实体数据表不同格式的数据,在创建视图的时候可以对数据进行格式化处理。
创建视图。
1 | create view `vw_emp_simple` |
提示:因为视图不包含数据,所以每次使用视图时,都必须执行查询以获得数据,如果你使用了连接查询、嵌套查询创建了较为复杂的视图,你可能会发现查询性能下降得很厉害。因此,在使用复杂的视图前,应该进行测试以确保其性能能够满足应用的需求。
有了上面的视图,我们就可以使用之前讲过的 DCL, 限制某些用户只能从视图中获取员工信息,这样员工表中的工资(sal
)、补贴(comm
)等敏感字段便不会暴露给用户。下面的代码演示了如何从视图中获取数据。
1 | select * from `vw_emp_simple`; |
查询结果:
1 | +------+-----------+--------------+-----+ |
既然视图是一张虚拟的表,那么视图的中的数据可以更新吗?视图的可更新性要视具体情况而定,以下类型的视图是不能更新的:
- 使用了聚合函数(
SUM
、MIN
、MAX
、AVG
、COUNT
等)、DISTINCT
、GROUP BY
、HAVING
、UNION
或者UNION ALL
的视图。 SELECT
中包含了子查询的视图。FROM
子句中包含了一个不能更新的视图的视图。WHERE
子句的子查询引用了FROM
子句中的表的视图。
删除视图。
1 | drop view if exists `vw_emp_simple`; |
说明:如果希望更新视图,可以先用上面的命令删除视图,也可以通过
create or replace view
来更新视图。
视图的规则和限制。
- 视图可以嵌套,可以利用从其他视图中检索的数据来构造一个新的视图。视图也可以和表一起使用。
- 创建视图时可以使用
order by
子句,但如果从视图中检索数据时也使用了order by
,那么该视图中原先的order by
会被覆盖。 - 视图无法使用索引,也不会激发触发器(实际开发中因为性能等各方面的考虑,通常不建议使用触发器,所以我们也不对这个概念进行介绍)的执行。
函数
MySQL 中的函数跟 Python 中的函数大同小异,因为函数都是用来封装功能上相对独立且会被重复使用的代码的。如果非要找出一些差别来,那么 MySQL 中的函数是可以执行 SQL 语句的。下面的例子,我们通过自定义函数实现了截断超长字符串的功能。
1 | delimiter $$ |
说明1:函数声明后面的
no sql
是声明函数体并没有使用 SQL 语句;如果函数体中需要通过 SQL 读取数据,需要声明为reads sql data
。说明2:定义函数前后的
delimiter
命令是为了修改终止符(定界符),因为函数体中的语句都是用;
表示结束,如果不重新定义定界符,那么遇到的;
的时候代码就会被截断执行,显然这不是我们想要的效果。
在查询中调用自定义函数。
1 | select fn_truncate_string('和我在成都的街头走一走,直到所有的灯都熄灭了也不停留', 10) as short_string; |
1 | +--------------------------------------+ |
过程
过程(又称存储过程)是事先编译好存储在数据库中的一组 SQL 的集合,调用过程可以简化应用程序开发人员的工作,减少与数据库服务器之间的通信,对于提升数据操作的性能也是有帮助的。其实迄今为止,我们使用的 SQL 语句都是针对一个或多个表的单条语句,但在实际开发中经常会遇到某个操作需要多条 SQL 语句才能完成的情况。例如,电商网站在受理用户订单时,需要做以下一系列的处理。
- 通过查询来核对库存中是否有对应的物品以及库存是否充足。
- 如果库存有物品,需要锁定库存以确保这些物品不再卖给别人, 并且要减少可用的物品数量以反映正确的库存量。
- 如果库存不足,可能需要进一步与供应商进行交互或者至少产生一条系统提示消息。
- 不管受理订单是否成功,都需要产生流水记录,而且需要给对应的用户产生一条通知信息。
我们可以通过过程将复杂的操作封装起来,这样不仅有助于保证数据的一致性,而且将来如果业务发生了变动,只需要调整和修改过程即可。对于调用过程的用户来说,过程并没有暴露数据表的细节,而且执行过程比一条条的执行一组 SQL 要快得多。
下面的过程实现 hrs 数据库中员工工资的普调,具体的规则是:10
部门的员工薪资上浮300
, 20
部门的员工薪资上浮800
,30
部门的员工薪资上浮500
。
1 | delimiter $$ |
说明:上面的过程代码中使用了
start transaction
来开启事务环境,关于事务,在本课的最后有一个简单的介绍。为了确定代码中是否发生异常,从而提交或回滚事务,上面的过程中定义了一个名为flag
的变量和一个异常处理器,如果发生了异常,flag
将会被赋值为0
,后面的分支结构会根据flag
的值来决定是执行commit
,还是执行rollback
。
调用过程。
1 | call sp_upgrade_salary(); |
删除过程。
1 | drop procedure if exists sp_upgrade_salary; |
在过程中,我们可以定义变量、条件,可以使用分支和循环语句,可以通过游标操作查询结果,还可以使用事件调度器,这些内容我们暂时不在此处进行介绍。虽然我们说了很多过程的好处,但是在实际开发中,如果频繁的使用过程并将大量复杂的运算放到过程中,会给据库服务器造成巨大的压力,而数据库往往都是性能瓶颈所在,使用过程无疑是雪上加霜的操作。所以,对于互联网产品开发,我们一般建议让数据库只做好存储,复杂的运算和处理交给应用服务器上的程序去完成,如果应用服务器变得不堪重负了,我们可以比较容易的部署多台应用服务器来分摊这些压力。
如果大家对上面讲到的视图、函数、过程包括我们没有讲到的触发器这些知识有兴趣,建议大家阅读 MySQL 的入门读物《MySQL必知必会》 进行一般性了解即可,因为这些知识点在大家将来的工作中未必用得上,学了也可能仅仅是为了应付面试而已。
其他内容
范式理论
范式理论是设计关系型数据库中二维表的指导思想。
- 第一范式:数据表的每个列的值域都是由原子值组成的,不能够再分割。
- 第二范式:数据表里的所有数据都要和该数据表的键(主键与候选键)有完全依赖关系。
- 第三范式:所有非键属性都只和候选键有相关性,也就是说非键属性之间应该是独立无关的。
说明:实际工作中,出于效率的考虑,我们在设计表时很有可能做出反范式设计,即故意降低方式级别,增加冗余数据来获得更好的操作性能。
数据完整性
实体完整性 - 每个实体都是独一无二的
- 主键(
primary key
) / 唯一约束(unique
)
- 主键(
引用完整性(参照完整性)- 关系中不允许引用不存在的实体
- 外键(
foreign key
)
- 外键(
域(domain)完整性 - 数据是有效的
数据类型及长度
非空约束(
not null
)默认值约束(
default
)检查约束(
check
)说明:在 MySQL 8.x 以前,检查约束并不起作用。
数据一致性
事务:一系列对数据库进行读/写的操作,这些操作要么全都成功,要么全都失败。
事务的 ACID 特性
- 原子性:事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行
- 一致性:事务应确保数据库的状态从一个一致状态转变为另一个一致状态
- 隔离性:多个事务并发执行时,一个事务的执行不应影响其他事务的执行
- 持久性:已被提交的事务对数据库的修改应该永久保存在数据库中
MySQL 中的事务操作
开启事务环境
1
start transaction
提交事务
1
commit
回滚事务
1
rollback
查看事务隔离级别
1
show variables like 'transaction_isolation';
1
2
3
4
5+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+可以看出,MySQL 默认的事务隔离级别是
REPEATABLE-READ
。修改(当前会话)事务隔离级别
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数组两种,如下所示。
- JSON 对象
1 | {"name": "骆昊", "tel": "13122335566", "QQ": "957658"} |
- JSON 数组
1 | [1, 2, 3] |
1 | [{"name": "骆昊", "tel": "13122335566"}, {"name": "王大锤", "QQ": "123456"}] |
哪些地方需要用到JSON类型呢?举一个简单的例子,现在很多产品的用户登录都支持多种方式,例如手机号、微信、QQ、新浪微博等,但是一般情况下我们又不会要求用户提供所有的这些信息,那么用传统的设计方式,就需要设计多个列来对应多种登录方式,可能还需要允许这些列存在空值,这显然不是很好的选择;另一方面,如果产品又增加了一种登录方式,那么就必然要修改之前的表结构,这就更让人痛苦了。但是,有了 JSON 类型,刚才的问题就迎刃而解了,我们可以做出如下所示的设计。
1 | create table `tb_test` |
如果要查询用户的手机和微信号,可以用如下所示的 SQL 语句。
1 | select |
1 | +---------+-------------+-----------+ |
因为支持 JSON 类型,MySQL 也提供了配套的处理 JSON 数据的函数,就像上面用到的json_extract
和json_unquote
。当然,上面的 SQL 还有更为便捷的写法,如下所示。
1 | select |
再举个例子,如果我们的产品要实现用户画像功能(给用户打标签),然后基于用户画像给用户推荐平台的服务或消费品之类的东西,我们也可以使用 JSON 类型来保存用户画像数据,示意代码如下所示。
创建画像标签表。
1 | create table `tb_tags` |
为用户打标签。
1 | create table `tb_users_tags` |
接下来,我们通过一组查询来了解 JSON 类型的巧妙之处。
查询爱看电影(有
10
这个标签)的用户ID。1
select `user_id` from `tb_users_tags` where 10 member of (`user_tags`->'$');
查询爱看电影(有
10
这个标签)的80后(有2
这个标签)用户ID。1
select `user_id` from `tb_users_tags` where json_contains(`user_tags`->'$', '[2, 10]');
查询爱看电影或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 <用户排序的列名>) |
上面语法中,窗口函数的位置可以放以下两种函数:
- 专用窗口函数,包括:
lead
、lag
、first_value
、last_value
、rank
、dense_rank
和row_number
等。 - 聚合函数,包括:
sum
、avg
、max
、min
和count
等。
下面为大家举几个使用窗口函数的简单例子,我们直接使用上一课创建的 hrs 数据库。
例子1:查询按月薪从高到低排在第4到第6名的员工的姓名和月薪。
1 | select * from ( |
上面使用的函数row_number()
可以为每条记录生成一个行号,在实际工作中可以根据需要将其替换为rank()
或dense_rank()
函数,三者的区别可以参考官方文档或阅读《通俗易懂的学会:SQL窗口函数》 进行了解。在MySQL 8以前的版本,我们可以通过下面的方式来完成类似的操作。
1 | select `rank`, `ename`, `sal` from ( |
例子2:查询每个部门月薪最高的两名的员工的姓名和部门名称。
1 | select `ename`, `sal`, `dname` |
说明:在MySQL 8以前的版本,我们可以通过下面的方式来完成类似的操作。
1 | select `ename`, `sal`, `dname` from `tb_emp` as `t1` |
第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 的步骤如下所示:
- 创建连接。MySQL 服务器启动后,提供了基于 TCP (传输控制协议)的网络服务。我们可以通过
pymysql
模块的connect
函数连接 MySQL 服务器。在调用connect
函数时,需要指定主机(host
)、端口(port
)、用户名(user
)、口令(password
)、数据库(database
)、字符集(charset
)等参数,该函数会返回一个Connection
对象。 - 获取游标。连接 MySQL 服务器成功后,接下来要做的就是向数据库服务器发送 SQL 语句,MySQL 会执行接收到的 SQL 并将执行结果通过网络返回。要实现这项操作,需要先通过连接对象的
cursor
方法获取游标(Cursor
)对象。 - 发出 SQL。通过游标对象的
execute
方法,我们可以向数据库发出 SQL 语句。 - 如果执行
insert
、delete
或update
操作,需要根据实际情况提交或回滚事务。因为创建连接时,默认开启了事务环境,在操作完成后,需要使用连接对象的commit
或rollback
方法,实现事务的提交或回滚,rollback
方法通常会放在异常捕获代码块except
中。如果执行select
操作,需要通过游标对象抓取查询的结果,对应的方法有三个,分别是:fetchone
、fetchmany
和fetchall
。其中fetchone
方法会抓取到一条记录,并以元组或字典的方式返回;fetchmany
和fetchall
方法会抓取到多条记录,以嵌套元组或列表装字典的方式返回。 - 关闭连接。在完成持久化操作后,请不要忘记关闭连接,释放外部资源。我们通常会在
finally
代码块中使用连接对象的close
方法来关闭连接。
代码实操
下面,我们通过代码实操的方式为大家演示上面说的五个步骤。
插入数据
1 | import pymysql |
说明:上面的
127.0.0.1
称为回环地址,它代表的是本机。下面的guest
是我提前创建好的用户,该用户拥有对hrs
数据库的insert
、delete
、update
和select
权限。我们不建议大家在项目中直接使用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 | import pymysql |
说明:如果不希望每次 SQL 操作之后手动提交或回滚事务,可以
connect
函数中加一个名为autocommit
的参数并将它的值设置为True
,表示每次执行 SQL 成功后自动提交。但是我们建议大家手动提交或回滚,这样可以根据实际业务需要来构造事务环境。如果不愿意捕获异常并进行处理,可以在try
代码块后直接跟finally
块,省略except
意味着发生异常时,代码会直接崩溃并将异常栈显示在终端中。
更新数据
1 | import pymysql |
查询数据
- 查询部门表的数据。
1 | import pymysql |
说明:上面的代码中,我们通过构造一个
while
循环实现了逐行抓取查询结果的操作。这种方式特别适合查询结果有非常多行的场景。因为如果使用fetchall
一次性将所有记录抓取到一个嵌套元组中,会造成非常大的内存开销,这在很多场景下并不是一个好主意。如果不愿意使用while
循环,还可以考虑使用iter
函数构造一个迭代器来逐行抓取数据,有兴趣的读者可以自行研究。
- 分页查询员工表的数据。
1 | import pymysql |
案例讲解
下面我们为大家讲解一个将数据库表数据导出到 Excel 文件的例子,我们需要先安装openpyxl
三方库,命令如下所示。
1 | pip install openpyxl |
接下来,我们通过下面的代码实现了将数据库hrs
中所有员工的编号、姓名、职位、月薪、补贴和部门名称导出到一个 Excel 文件中。
1 | import openpyxl |
大家可以参考上面的例子,试一试把 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.