Python进阶
高级特性
切片 :
对这种经常取指定索引范围的操作,用循环十分繁琐,因此,Python提供了切片(Slice)操作符,能大大简化这种操作
取前3个元素,用一行代码就可以完成切片:
1 |
|
L[0:3]
表示,从索引0
开始取,直到索引3
为止,但不包括索引3
。即索引0
,1
,2
,正好是3个元素。
如果第一个索引是
0
,还可以省略什么都不写,只写
[:]
就可以原样复制一个list
tuple也是一种list,唯一区别是tuple不可变。因此,tuple也可以用切片操作,只是操作的结果仍是tuple:
1 |
|
前10个数,每两个取一个:
1 |
|
迭代
如果给定一个list
或tuple
,我们可以通过for
循环来遍历这个list
或tuple
,这种遍历我们称为迭代(Iteration)
Python的for
循环抽象程度要高于C的for
循环,因为Python的for
循环不仅可以用在list
或tuple
上,还可以作用在其他可迭代对象上。
list
这种数据类型虽然有下标,但很多其他数据类型是没有下标的,但是,只要是可迭代对象,无论有无下标,都可以迭代,比如dict
就可以迭代:
1 |
|
判断一个对象是可迭代对象呢?方法是通过collections.abc
模块的Iterable
类型判断
1 |
|
列表生成式
列表生成式即List Comprehensions,是Python内置的非常简单却强大的可以用来创建list的生成式
生成[1x1, 2x2, 3x3, ..., 10x10]
1 |
|
写列表生成式时,把要生成的元素x * x
放到前面,后面跟for
循环,就可以把list创建出来
if形式
for循环后面还可以加上if判断,这样我们就可以筛选出仅偶数的平方:
1 |
|
if else 写法
因为for
前面的部分是一个表达式,它必须根据x
计算出一个结果。因此,考察表达式:x if x % 2 == 0
,它无法根据x
计算出结果,因为缺少else
,必须加上else
:
1 |
|
两重循环
还可以使用两层循环,可以生成全排列:
1 |
|
特殊技巧
运用列表生成式,可以写出非常简洁的代码。
列出当前目录下的所有文件和目录名
1 |
|
列表生成式也可以使用两个变量来生成list:
1 |
|
把一个list中所有的字符串变成小写:
1 |
|
生成器
如果列表元素可以按照某种算法推算出来,那我们是否可以在循环的过程中不断推算出后续的元素呢?这样就不必创建完整的list,从而节省大量的空间。在Python中,这种一边循环一边计算的机制,称为生成器:generator。
要创建一个generator,有很多种方法。
第一种方法把一个列表生成式的[]
改成()
1 |
|
创建L
和g
的区别仅在于最外层的[]
和()
,L
是一个list,而g
是一个generator。
可以通过next()
函数获得generator的下一个返回值:
1 |
|
使用for
循环迭代,因为generator也是可迭代对象:
1 |
|
第二种方法(在函数定义中包含yield
关键字
把fib
函数变成generator函数,只需要把print(b)
改为yield b
就可以了:
1 |
|
这就是定义generator的另一种方法。如果一个函数定义中包含yield
关键字,那么这个函数就不再是一个普通函数,而是一个generator函数,调用一个generator函数将返回一个generator:
1 |
|
这里,最难理解的就是generator函数和普通函数的执行流程不一样。普通函数是顺序执行,遇到return
语句或者最后一行函数语句就返回。而变成generator的函数,在每次调用next()
的时候执行,遇到yield
语句返回,再次执行时从上次返回的yield
语句处继续执行。
举个简单的例子,定义一个generator函数,依次返回数字1,3,5
注意
调用generator函数会创建一个generator对象,多次调用generator函数会创建多个相互独立的generator。
不能每次都这样写
1
next(odd())
函数式编程
高阶函数
变量可以指向函数
比如abs(-10)
是函数调用,而abs
是函数本身
要获得函数调用结果,我们可以把结果赋值给变量:
1 |
|
如果把函数本身赋值给变量
1 |
|
成功!说明变量f现在已经指向了abs函数本身。直接调用abs()函数和调用变量f()完全相同。
函数名也是变量(要点)
那么函数名是什么呢?函数名其实就是指向函数的变量!对于abs()
这个函数,完全可以把函数名abs
看成变量,它指向一个可以计算绝对值的函数!
如果把abs
指向其他对象,会有什么情况发生?
1 |
|
把abs
指向10
后,就无法通过abs(-10)
调用该函数了!因为abs
这个变量已经不指向求绝对值函数而是指向一个整数10
!
当然实际代码绝对不能这么写,这里是为了说明函数名也是变量。要恢复abs
函数,请重启Python交互环境。
注:由于abs
函数实际上是定义在import builtins
模块中的,所以要让修改abs
变量的指向在其它模块也生效,要用import builtins; builtins.abs = 10
。
传入函数
既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数。
一个最简单的高阶函数:
1 |
|
当我们调用add(-5, 6, abs)
时,参数x
,y
和f
分别接收-5
,6
和abs
,根据函数定义,我们可以推导计算过程为:
1 |
|
返回函数
如果不需要立刻求和,而是在后面的代码中,根据需要再计算怎么办?可以不返回求和的结果,而是返回求和的函数:
1 |
|
当我们调用lazy_sum()
时,每次调用都会返回一个新的函数,即使传入相同的参数
1 |
|
f1()
和f2()
的调用结果互不影响。
闭包
注意到返回的函数在其定义内部引用了局部变量args
,所以,当一个函数返回了一个函数后,其内部的局部变量还被新函数引用,所以,闭包用起来简单,实现起来可不容易。
另一个需要注意的问题是,返回的函数并没有立刻执行,而是直到调用了f()
才执行。我们来看一个例子:
1 |
|
在上面的例子中,每次循环,都创建了一个新的函数,然后,把创建的3个函数都返回了。
你可能认为调用f1()
,f2()
和f3()
结果应该是1
,4
,9
,但实际结果是:
1 |
|
全部都是9
!原因就在于返回的函数引用了变量i
,但它并非立刻执行。等到3个函数都返回时,它们所引用的变量i
已经变成了3
,因此最终结果为9
。
返回闭包时牢记一点:返回函数不要引用任何循环变量,或者后续会发生变化的变量。
nonlocal
使用闭包,就是内层函数引用了外层函数的局部变量。如果只是读外层变量的值,我们会发现返回的闭包函数调用一切正常:
1 |
|
但是,如果对外层变量赋值,由于Python解释器会把x
当作函数fn()
的局部变量,它会报错:
1 |
|
匿名函数
有些时候,不需要显式地定义函数,直接传入匿名函数更方便。
1 |
|
通过对比可以看出,匿名函数lambda x: x * x
实际上就是:
1 |
|
关键字lambda
表示匿名函数,冒号前面的x
表示函数参数。
匿名函数有个限制,就是只能有一个表达式,不用写return
,返回值就是该表达式的结果。
用匿名函数有个好处,因为函数没有名字,不必担心函数名冲突。此外,匿名函数也是一个函数对象,也可以把匿名函数赋值给一个变量,再利用变量来调用该函数
装饰器
在面向对象(OOP)的设计模式中,decorator被称为装饰模式。OOP的装饰模式需要通过继承和组合来实现,而Python除了能支持OOP的decorator外,直接从语法层次支持decorator。Python的decorator可以用函数实现,也可以用类实现。
decorator可以增强函数的功能,定义起来虽然有点复杂,但使用起来非常灵活和方便。
本质上,decorator就是一个返回函数的高阶函数
1 |
|
偏函数
1 |
|
functools.partial
就是帮助我们创建一个偏函数的,不需要我们自己定义int2()
,可以直接使用下面的代码创建一个新的函数int2
:
1 |
|
简单总结functools.partial
的作用就是,把一个函数的某些参数给固定住(也就是设置默认值),返回一个新的函数,调用这个新函数会更简单
创建偏函数时,实际上可以接收函数对象、*args
和**kw
这3个参数,当传入:
1 |
|
实际上固定了int()函数的关键字参数base
,也就是:
1 |
|
相当于:
1 |
|
模块
为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就相对较少,很多编程语言都采用这种组织代码的方式。在Python中,一个.py文件就称之为一个模块(Module)。
模块是一组Python代码的集合,可以使用其他模块,也可以被其他模块使用。
创建自己的模块时,要注意:
- 模块名要遵循Python变量命名规范,不要使用中文、特殊字符;
- 模块名不要和系统模块名冲突,最好先查看系统是否已存在该模块,检查方法是在Python交互环境执行
import abc
,若成功则说明系统存在此模块。
简介
最大的好处是大大提高了代码的可维护性。其次,编写代码不必从零开始。当一个模块编写完毕,就可以被其他地方引用。我们在编写程序的时候,也经常引用其他模块,包括Python内置的模块和来自第三方的模块。
使用模块还可以避免函数名和变量名冲突。相同名字的函数和变量完全可以分别存在不同的模块中,因此,我们自己在编写模块时,不必考虑名字会与其他模块冲突。但是也要注意,尽量不要与内置函数名字冲突。点这里查看Python的所有内置函数。
你也许还想到,如果不同的人编写的模块名相同怎么办?为了避免模块名冲突,Python又引入了按目录来组织模块的方法,称为包(Package)。
包
引入了包以后,只要顶层的包名不与别人冲突,那所有模块都不会与别人冲突。现在,abc.py
模块的名字就变成了mycompany.abc
,类似的,xyz.py
的模块名变成了mycompany.xyz
。
请注意,每一个包目录下面都会有一个__init__.py
的文件,这个文件是必须存在的,否则,Python就把这个目录当成普通目录,而不是一个包。__init__.py
可以是空文件,也可以有Python代码,因为__init__.py
本身就是一个模块,而它的模块名就是mycompany
。
类似的,可以有多级目录,组成多级层次的包结构。比如如下的目录结构:
1 |
|
文件www.py
的模块名就是mycompany.web.www
,两个文件utils.py
的模块名分别是mycompany.utils
和mycompany.web.utils
。
自己创建模块时要注意命名,不能和Python自带的模块名称冲突。例如,系统自带了sys模块,自己的模块就不可命名为sys.py,否则将无法导入系统自带的sys模块
创建方法
标准模块
编写一个hello
的模块:
1 |
|
第1行和第2行是标准注释,第1行注释可以让这个hello.py
文件直接在Unix/Linux/Mac上运行,第2行注释表示.py文件本身使用标准UTF-8编码;
第4行是一个字符串,表示模块的文档注释,任何模块代码的第一个字符串都被视为模块的文档注释;
第6行使用__author__
变量把作者写进去,这样当你公开源代码后别人就可以瞻仰你的大名;
以上就是Python模块的标准文件模板,当然也可以全部删掉不写,但是,按标准办事肯定没错。
作用域
正常的函数和变量名是公开的(public),可以被直接引用,比如:abc
,x123
,PI
等;
类似__xxx__
这样的变量是特殊变量,可以被直接引用,但是有特殊用途,比如上面的__author__
,__name__
就是特殊变量,hello
模块定义的文档注释也可以用特殊变量__doc__
访问,我们自己的变量一般不要用这种变量名;
类似_xxx
和__xxx
这样的函数或变量就是非公开的(private),不应该被直接引用,比如_abc
,__abc
等;
之所以我们说,private函数和变量“不应该”被直接引用,而不是“不能”被直接引用,是因为Python并没有一种方法可以完全限制访问private函数或变量,但是,从编程习惯上不应该引用private函数或变量。
private函数或变量不应该被别人引用,那它们有什么用呢?请看例子:
1 |
|
我们在模块里公开greeting()
函数,而把内部逻辑用private函数隐藏起来了,这样,调用greeting()
函数不用关心内部的private函数细节,这也是一种非常有用的代码封装和抽象的方法,即:
外部不需要引用的函数全部定义成private,只有外部需要引用的函数才定义为public
使用方法
使用sys
模块的第一步,就是导入该模块:
1 |
|
导入sys
模块后,我们就有了变量sys
指向该模块,利用sys
这个变量,就可以访问sys
模块的所有功能。
sys
模块有一个argv
变量,用list存储了命令行的所有参数。argv
至少有一个元素,因为第一个参数永远是该.py文件的名称,例如:
运行python3 hello.py
获得的sys.argv
就是['hello.py']
;
最后,注意到这两行代码:
1 |
|
当我们在命令行运行hello
模块文件时,Python解释器把一个特殊变量__name__
置为__main__
,而如果在其他地方导入该hello
模块时,if
判断将失败,因此,这种if
测试可以让一个模块通过命令行运行时执行一些额外的代码,最常见的就是运行测试。
调用hello.test()
时,才能打印出Hello, word!
:
1 |
|
第三方模块
一般来说,第三方库都会在Python官方的pypi.python.org网站注册,要安装一个第三方库,必须先知道该库的名称,可以在官网或者pypi上搜索,比如Pillow的名称叫Pillow,因此,安装Pillow的命令就是:
1 |
|
模块搜索路径
当我们试图加载一个模块时,Python会在指定的路径下搜索对应的.py文件,如果找不到,就会报错:
1 |
|
默认情况下,Python解释器会搜索当前目录、所有已安装的内置模块和第三方模块,搜索路径存放在sys
模块的path
变量中:
1 |
|
如果我们要添加自己的搜索目录,有两种方法:
一是直接修改sys.path
,添加要搜索的目录:
1 |
|
这种方法是在运行时修改,运行结束后失效。
第二种方法是设置环境变量PYTHONPATH
,该环境变量的内容会被自动添加到模块搜索路径中。设置方式与设置Path环境变量类似。注意只需要添加你自己的搜索路径,Python自己本身的搜索路径不受影响。
面向对象编程
OOP思想
面向对象编程——Object Oriented Programming,简称OOP,是一种程序设计思想。OOP把对象作为程序的基本单元,一个对象包含了数据和操作数据的函数。
面向过程的程序设计把计算机程序视为一系列的命令集合,即一组函数的顺序执行。为了简化程序设计,面向过程把函数继续切分为子函数,即把大块函数通过切割成小块函数来降低系统的复杂度。
而面向对象的程序设计把计算机程序视为一组对象的集合,而每个对象都可以接收其他对象发过来的消息,并处理这些消息,计算机程序的执行就是一系列消息在各个对象之间传递。
在Python中,所有数据类型都可以视为对象,当然也可以自定义对象。自定义的对象数据类型就是面向对象中的类(Class)的概念。
OOP思想与面向过程思想对比
面向过程思想
假设我们要处理学生的成绩表,为了表示一个学生的成绩,面向过程的程序可以用一个dict表示:
1 |
|
而处理学生成绩可以通过函数实现,比如打印学生的成绩:
1 |
|
OOP
如果采用面向对象的程序设计思想,我们首选思考的不是程序的执行流程,而是Student
这种数据类型应该被视为一个对象,这个对象拥有name
和score
这两个属性(Property)。如果要打印一个学生的成绩,首先必须创建出这个学生对应的对象,然后,给对象发一个print_score
消息,让对象自己把自己的数据打印出来。
1 |
|
给对象发消息实际上就是调用对象对应的关联函数,我们称之为对象的方法(Method)。面向对象的程序写出来就像这样:
1 |
|
类与实例
面向对象最重要的概念就是类(Class)和实例(Instance),必须牢记类是抽象的模板,比如Student类,而实例是根据类创建出来的一个个具体的“对象”,每个对象都拥有相同的方法,但各自的数据可能不同
类
在Python中,定义类是通过class
关键字:
1 |
|
class
后面紧接着是类名,即Student
,类名通常是大写开头的单词,紧接着是(object)
,表示该类是从哪个类继承下来的,继承的概念我们后面再讲,通常,如果没有合适的继承类,就使用object
类,这是所有类最终都会继承的类。
实例
定义好了Student
类,就可以根据Student
类创建出Student
的实例,创建实例是通过类名+()实现的:
1 |
|
可以看到,变量bart
指向的就是一个Student
的实例,后面的0x10a67a590
是内存地址,每个object的地址都不一样,而Student
本身则是一个类。
可以自由地给一个实例变量绑定属性,比如,给实例bart
绑定一个name
属性:
1 |
|
函数引用
初始化Student对象
1 |
|
访问限制
如果要让内部属性不被外部访问,可以把属性的名称前加上两个下划线__
,在Python中,实例的变量名如果以__
开头,就变成了一个私有变量(private),只有内部可以访问,外部不能访问
1 |
|
改完后,对于外部代码来说,没什么变动,但是已经无法从外部访问实例变量.__name
和实例变量.__score
了:
1 |
|
双下划线开头的实例变量是不是一定不能从外部访问呢?其实也不是。不能直接访问__name
是因为Python解释器对外把__name
变量改成了_Student__name
,所以,仍然可以通过_Student__name
来访问__name
变量:
1 |
|
子类
继承
1 |
|
当我们需要编写Cat
类时,就可以直接从Animal
类继承:
1 |
|
当然,也可以对子类增加一些方法,比如Dog类:
1 |
|
你会发现,新增一个Animal
的子类,不必对run_twice()
做任何修改,实际上,任何依赖Animal
作为参数的函数或者方法都可以不加修改地正常运行,原因就在于多态。
传入的任意类型,只要是Animal
类或者子类,就会自动调用实际类型的run()
方法,这就是多态的意思:
多态
对于一个变量,我们只需要知道它是Animal
类型,无需确切地知道它的子类型,就可以放心地调用run()
方法,而具体调用的run()
方法是作用在Animal
、Dog
、Cat
还是Tortoise
对象上,由运行时该对象的确切类型决定,这就是多态真正的威力:调用方只管调用,不管细节,而当我们新增一种Animal
的子类时,只要确保run()
方法编写正确,不用管原来的代码是如何调用的。
这就是著名的“开闭”原则:
对扩展开放:允许新增
Animal
子类;对修改封闭:不需要修改依赖
Animal
类型的run_twice()
等函数。
继承还可以一级一级地继承下来,就好比从爷爷到爸爸、再到儿子这样的关系。而任何类,最终都可以追溯到根类object,这些继承关系看上去就像一颗倒着的树。比如如下的继承树:
静态语言 vs 动态语言
对于静态语言(例如Java)来说,如果需要传入Animal
类型,则传入的对象必须是Animal
类型或者它的子类,否则,将无法调用run()
方法。
对于Python这样的动态语言来说,则不一定需要传入Animal
类型。我们只需要保证传入的对象有一个run()
方法就可以了:
1 |
|
这就是动态语言的“鸭子类型”,它并不要求严格的继承体系,一个对象只要“看起来像鸭子,走起路来像鸭子”,那它就可以被看做是鸭子。
Python的“file-like object“就是一种鸭子类型。对真正的文件对象,它有一个read()
方法,返回其内容。但是,许多对象,只要有read()
方法,都被视为“file-like object“。许多函数接收的参数就是“file-like object“,你不一定要传入真正的文件对象,完全可以传入任何实现了read()
方法的对象。
获取对象信息
使用type
首先,我们来判断对象类型,使用type()
函数:
基本类型都可以用type()
判断:
1 |
|
判断基本数据类型可以直接写int
,str
等,但如果要判断一个对象是否是函数怎么办?可以使用types
模块中定义的常量:
1 |
|
使用isinstance()
对于class的继承关系来说,使用type()
就很不方便。我们要判断class的类型,可以使用isinstance()
函数。
能用type()
判断的基本类型也可以用isinstance()
判断:
1 |
|
使用dir()
如果要获得一个对象的所有属性和方法,可以使用dir()
函数,它返回一个包含字符串的list,比如,获得一个str对象的所有属性和方法:
1 |
|
剩下的都是普通属性或方法,比如lower()
返回小写的字符串:
1 |
|
getatttr()
等
仅仅把属性和方法列出来是不够的,配合getattr()
、setattr()
以及hasattr()
,我们可以直接操作一个对象的状态:
1 |
|
一个正确的用法的例子如下:
1 |
|
假设我们希望从文件流fp中读取图像,我们首先要判断该fp对象是否存在read方法,如果存在,则该对象是一个流,如果不存在,则无法读取。hasattr()
就派上了用场。
请注意,在Python这类动态语言中,根据鸭子类型,有read()
方法,不代表该fp对象就是一个文件流,它也可能是网络流,也可能是内存中的一个字节流,但只要read()
方法返回的是有效的图像数据,就不影响读取图像的功能。
实例属性与类属性
由于Python是动态语言,根据类创建的实例可以任意绑定属性。
给实例绑定属性的方法是通过实例变量,或者通过self
变量:
1 |
|
但是,如果Student
类本身需要绑定一个属性呢?可以直接在class中定义属性,这种属性是类属性,归Student
类所有:
1 |
|
当我们定义了一个类属性后,这个属性虽然归类所有,但类的所有实例都可以访问到。来测试一下:
1 |
|
从上面的例子可以看出,在编写程序的时候,千万不要对实例属性和类属性使用相同的名字,因为相同名称的实例属性将屏蔽掉类属性,但是当你删除实例属性后,再使用相同的名称,访问到的将是类属性。
错误、调试和测试错误处理
在程序运行的过程中,如果发生了错误,可以事先约定返回一个错误代码,这样,就可以知道是否有错,以及出错的原因。在操作系统提供的调用中,返回错误码非常常见。比如打开文件的函数open()
,成功时返回文件描述符(就是一个整数),出错时返回-1
。
用错误码来表示是否出错十分不便,因为函数本身应该返回的正常结果和错误码混在一起,造成调用者必须用大量的代码来判断是否出错:
1 |
|
try...except...finally...
的错误处理机制
try
让我们用一个例子来看看try
的机制:
1 |
|
当我们认为某些代码可能会出错时,就可以用try
来运行这段代码,如果执行出错,则后续代码不会继续执行,而是直接跳转至错误处理代码,即except
语句块,执行完except
后,如果有finally
语句块,则执行finally
语句块,至此,执行完毕。
上面的代码在计算10 / 0
时会产生一个除法运算错误:
1 |
|
如果把除数0
改成2
,则执行结果如下:
1 |
|
错误应该有很多种类,如果发生了不同类型的错误,应该由不同的except
语句块处理。没错,可以有多个except
来捕获不同类型的错误:
1 |
|
Python所有的错误都是从BaseException
类派生的,常见的错误类型和继承关系看这里:
https://docs.python.org/3/library/exceptions.html#exception-hierarchy
使用try...except
捕获错误还有一个巨大的好处,就是可以跨越多层调用,比如函数main()
调用bar()
,bar()
调用foo()
,结果foo()
出错了,这时,只要main()
捕获到了,就可以处理:
1 |
|
调用栈
如果错误没有被捕获,它就会一直往上抛,最后被Python解释器捕获,打印一个错误信息,然后程序退出。来看看err.py
:
1 |
|
执行,结果如下:
1 |
|
解读错误信息是定位错误的关键。我们从上往下可以看到整个错误的调用函数链:
错误信息第1行:
1 |
|
告诉我们这是错误的跟踪信息。
第2~3行:
1 |
|
调用main()
出错了,在代码文件err.py
的第11行代码,但原因是第9行:
1 |
|
调用bar('0')
出错了,在代码文件err.py
的第9行代码,但原因是第6行:
1 |
|
原因是return foo(s) * 2
这个语句出错了,但这还不是最终原因,继续往下看:
1 |
|
原因是return 10 / int(s)
这个语句出错了,这是错误产生的源头,因为下面打印了:
1 |
|
根据错误类型ZeroDivisionError
,我们判断,int(s)
本身并没有出错,但是int(s)
返回0
,在计算10 / 0
时出错,至此,找到错误源头
记录错误
Python内置的logging
模块可以非常容易地记录错误信息:
1 |
|
同样是出错,但程序打印完错误信息后会继续执行,并正常退出
通过配置,logging
还可以把错误记录到日志文件里,方便事后排查
抛出错误
因为错误是class,捕获一个错误就是捕获到该class的一个实例。因此,错误并不是凭空产生的,而是有意创建并抛出的。Python的内置函数会抛出很多类型的错误,我们自己编写的函数也可以抛出错误。
如果要抛出错误,首先根据需要,可以定义一个错误的class,选择好继承关系,然后,用raise
语句抛出一个错误的实例:
1 |
|
执行,可以最后跟踪到我们自己定义的错误:
1 |
|
调试
断言assert
凡是用print()
来辅助查看的地方,都可以用断言(assert)来替代:
1 |
|
assert
的意思是,表达式n != 0
应该是True
,否则,根据程序运行的逻辑,后面的代码肯定会出错。
断言的开关“-O”是英文大写字母O,不是数字0
如果断言失败,assert
语句本身就会抛出AssertionError
:
1 |
|
程序中如果到处充斥着assert
,和print()
相比也好不到哪去。不过,启动Python解释器时可以用-O
参数来关闭assert
:
1 |
|
logging
把print()
替换为logging
是第3种方式,和assert
比,logging
不会抛出错误,而且可以输出到文件:
1 |
|
logging.info()
就可以输出一段文本。运行,发现除了ZeroDivisionError
,没有任何信息。怎么回事?
别急,在import logging
之后添加一行配置再试试:
1 |
|
这就是logging
的好处,它允许你指定记录信息的级别,有debug
,info
,warning
,error
等几个级别,当我们指定level=INFO
时,logging.debug
就不起作用了。同理,指定level=WARNING
后,debug
和info
就不起作用了。这样一来,你可以放心地输出不同级别的信息,也不用删除,最后统一控制输出哪个级别的信息。
logging
的另一个好处是通过简单的配置,一条语句可以同时输出到不同的地方,比如console和文件。
pbd
启动Python的调试器pdb,让程序以单步方式运行,可以随时查看运行状态。我们先准备好程序:
1 |
|
然后启动:
1 |
|
以参数-m pdb
启动后,pdb定位到下一步要执行的代码-> s = '0'
。输入命令l
来查看代码:
1 |
|
输入命令n
可以单步执行代码:
1 |
|
任何时候都可以输入命令p 变量名
来查看变量:
1 |
|
输入命令q
结束调试,退出程序:
1 |
|
这种通过pdb在命令行调试的方法理论上是万能的,但实在是太麻烦了,如果有一千行代码,要运行到第999行得敲多少命令啊。还好,我们还有另一种调试方法。
pdb.set_trace()
这个方法也是用pdb,但是不需要单步执行,我们只需要import pdb
,然后,在可能出错的地方放一个pdb.set_trace()
,就可以设置一个断点:
1 |
|
运行代码,程序会自动在pdb.set_trace()
暂停并进入pdb调试环境,可以用命令p
查看变量,或者用命令c
继续运行
测试
单元测试
单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作
如果单元测试通过,说明我们测试的这个函数能够正常工作。如果单元测试不通过,要么函数有bug,要么测试条件输入不正确,总之,需要修复使单元测试能够通过。
单元测试通过后有什么意义呢?如果我们对abs()
函数代码做了修改,只需要再跑一遍单元测试,如果通过,说明我们的修改不会对abs()
函数原有的行为造成影响,如果测试不通过,说明我们的修改与原有行为不一致,要么修改代码,要么修改测试。
这种以测试为驱动的开发模式最大的好处就是确保一个程序模块的行为符合我们设计的测试用例。在将来修改的时候,可以极大程度地保证该模块行为仍然是正确的。
为了编写单元测试,我们需要引入Python自带的unittest
模块,编写mydict_test.py
如下:
1 |
|
运行单元测试
一旦编写好单元测试,我们就可以运行单元测试。最简单的运行方式是在mydict_test.py
的最后加上两行代码:
1 |
|
这样就可以把mydict_test.py
当做正常的python脚本运行:
1 |
|
另一种方法是在命令行通过参数-m unittest
直接运行单元测试:
1 |
|
这是推荐的做法,因为这样可以一次批量运行很多单元测试,并且,有很多工具可以自动来运行这些单元测试
文档测试
当我们编写注释时,如果写上这样的注释:
1 |
|
无疑更明确地告诉函数的调用者该函数的期望输入和输出。
并且,Python内置的“文档测试”(doctest)模块可以直接提取注释中的代码并执行测试。
IO编程
IO在计算机中指Input/Output,也就是输入和输出。由于程序和运行时数据是在内存中驻留,由CPU这个超快的计算核心来执行,涉及到数据交换的地方,通常是磁盘、网络等,就需要IO接口
IO编程中,Stream(流)是一个很重要的概念,可以把流想象成一个水管,数据就是水管里的水,但是只能单向流动。Input Stream就是数据从外面(磁盘、网络)流进内存,Output Stream就是数据从内存流到外面去。对于浏览网页来说,浏览器和新浪服务器之间至少需要建立两根水管,才可以既能发数据,又能收数据。
由于CPU和内存的速度远远高于外设的速度,所以,在IO编程中,就存在速度严重不匹配的问题
第一种是CPU等着,也就是程序暂停执行后续代码,等100M的数据在10秒后写入磁盘,再接着往下执行,这种模式称为同步IO;
另一种方法是CPU不等待,只是告诉磁盘,“您老慢慢写,不着急,我接着干别的事去了”,于是,后续代码可以立刻接着执行,这种模式称为异步IO。
同步和异步的区别就在于是否等待IO执行的结果
文件读写
读写文件前,我们先必须了解一下,在磁盘上读写文件的功能都是由操作系统提供的,现代操作系统不允许普通的程序直接操作磁盘,所以,读写文件就是请求操作系统打开一个文件对象(通常称为文件描述符),然后,通过操作系统提供的接口从这个文件对象中读取数据(读文件),或者把数据写入这个文件对象(写文件)。
读文件
要以读文件的模式打开一个文件对象,使用Python内置的open()
函数,传入文件名和标示符:
1 |
|
标示符’r’表示读,这样,我们就成功地打开了一个文件。
如果文件不存在,open()
函数就会抛出一个IOError
的错误,并且给出错误码和详细的信息告诉你文件不存在:
1 |
|
如果文件打开成功,接下来,调用read()
方法可以一次读取文件的全部内容,Python把内容读到内存,用一个str
对象表示:
1 |
|
最后一步是调用close()
方法关闭文件。文件使用完毕后必须关闭,因为文件对象会占用操作系统的资源,并且操作系统同一时间能打开的文件数量也是有限的:
1 |
|
由于文件读写时都有可能产生IOError
,一旦出错,后面的f.close()
就不会调用。所以,为了保证无论是否出错都能正确地关闭文件,我们可以使用try ... finally
来实现:
1 |
|
但是每次都这么写实在太繁琐,所以,Python引入了with
语句来自动帮我们调用close()
方法:
1 |
|
这和前面的try ... finally
是一样的,但是代码更佳简洁,并且不必调用f.close()
方法。
调用read()
会一次性读取文件的全部内容,如果文件有10G,内存就爆了,所以,要保险起见,可以反复调用read(size)
方法,每次最多读取size个字节的内容。另外,调用readline()
可以每次读取一行内容,调用readlines()
一次读取所有内容并按行返回list
。因此,要根据需要决定怎么调用。
如果文件很小,read()
一次性读取最方便;如果不能确定文件大小,反复调用read(size)
比较保险;如果是配置文件,调用readlines()
最方便:
1 |
|
file-like Object
像open()
函数返回的这种有个read()
方法的对象,在Python中统称为file-like Object。除了file外,还可以是内存的字节流,网络流,自定义流等等。file-like Object不要求从特定类继承,只要写个read()
方法就行。
StringIO
就是在内存中创建的file-like Object,常用作临时缓冲。
二进制文件rb
前面讲的默认都是读取文本文件,并且是UTF-8编码的文本文件。要读取二进制文件,比如图片、视频等等,用'rb'
模式打开文件即可:
1 |
|
字符编码
要读取非UTF-8编码的文本文件,需要给open()
函数传入encoding
参数,例如,读取GBK编码的文件:
1 |
|
遇到有些编码不规范的文件,你可能会遇到UnicodeDecodeError
,因为在文本文件中可能夹杂了一些非法编码的字符。遇到这种情况,open()
函数还接收一个errors
参数,表示如果遇到编码错误后如何处理。最简单的方式是直接忽略:
1 |
|
写文件
写文件和读文件是一样的,唯一区别是调用open()
函数时,传入标识符'w'
或者'wb'
表示写文本文件或写二进制文件:
1 |
|
你可以反复调用write()
来写入文件,但是务必要调用f.close()
来关闭文件。当我们写文件时,操作系统往往不会立刻把数据写入磁盘,而是放到内存缓存起来,空闲的时候再慢慢写入。只有调用close()
方法时,操作系统才保证把没有写入的数据全部写入磁盘。忘记调用close()
的后果是数据可能只写了一部分到磁盘,剩下的丢失了。所以,还是用with
语句来得保险:
1 |
|
要写入特定编码的文本文件,请给open()
函数传入encoding
参数,将字符串自动转换成指定编码。
细心的童鞋会发现,以'w'
模式写入文件时,如果文件已存在,会直接覆盖(相当于删掉后新写入一个文件)。如果我们希望追加到文件末尾怎么办?可以传入'a'
以追加(append)模式写入。
StringIO 和ByteIO
StringIO
很多时候,数据读写不一定是文件,也可以在内存中读写。
StringIO顾名思义就是在内存中读写str。
要把str写入StringIO,我们需要先创建一个StringIO,然后,像文件一样写入即可:
1 |
|
getvalue()
方法用于获得写入后的str。
要读取StringIO,可以用一个str初始化StringIO,然后,像读文件一样读取:
1 |
|
BytesIO
StringIO操作的只能是str,如果要操作二进制数据,就需要使用BytesIO。
BytesIO实现了在内存中读写bytes,我们创建一个BytesIO,然后写入一些bytes:
1 |
|
请注意,写入的不是str,而是经过UTF-8编码的bytes。
和StringIO类似,可以用一个bytes初始化BytesIO,然后,像读文件一样读取:
1 |
|
操作文件和目录
初始化os指令
打开Python交互式命令行,我们来看看如何使用os
模块的基本功能:
1 |
|
如果是posix
,说明系统是Linux
、Unix
或Mac OS X
,如果是nt
,就是Windows
系统。
要获取详细的系统信息,可以调用uname()
函数
环境变量
在操作系统中定义的环境变量,全部保存在os.environ
这个变量中,可以直接查看:
1 |
|
要获取某个环境变量的值,可以调用os.environ.get('key')
:
1 |
|
操作文件和目录
操作文件和目录的函数一部分放在os
模块中,一部分放在os.path
模块中,这一点要注意一下。查看、创建和删除目录可以这么调用:
1 |
|
把两个路径合成一个时,不要直接拼字符串,而要通过os.path.join()
函数,这样可以正确处理不同操作系统的路径分隔符
同样的道理,要拆分路径时,也不要直接去拆字符串,而要通过os.path.split()
函数,这样可以把一个路径拆分为两部分,后一部分总是最后级别的目录或文件名:
1 |
|
os.path.splitext()
可以直接让你得到文件扩展名,很多时候非常方便:
1 |
|
文件操作使用下面的函数。假定当前目录下有一个test.txt
文件:
1 |
|
列出当前目录下的所有目录,只需要一行代码:
1 |
|
要列出所有的.py
文件,也只需一行代码:
1 |
|
序列化
我们把变量从内存中变成可存储或传输的过程称之为序列化,在Python中叫pickling,在其他语言中也被称之为serialization,marshalling,flattening等等,都是一个意思。
序列化之后,就可以把序列化后的内容写入磁盘,或者通过网络传输到别的机器上。
反过来,把变量内容从序列化的对象重新读到内存里称之为反序列化,即unpickling。
Python提供了pickle
模块来实现序列化。
pickle
首先,我们尝试把一个对象序列化并写入文件:
1 |
|
pickle.dumps()
方法把任意对象序列化成一个bytes
,然后,就可以把这个bytes
写入文件。或者用另一个方法pickle.dump()
直接把对象序列化后写入一个file-like Object:
1 |
|
看看写入的dump.txt
文件,一堆乱七八糟的内容,这些都是Python保存的对象内部信息。
当我们要把对象从磁盘读到内存时,可以先把内容读到一个bytes
,然后用pickle.loads()
方法反序列化出对象,也可以直接用pickle.load()
方法从一个file-like Object
中直接反序列化出对象。我们打开另一个Python命令行来反序列化刚才保存的对象:
1 |
|
Pickle的问题和所有其他编程语言特有的序列化问题一样,就是它只能用于Python,并且可能不同版本的Python彼此都不兼容,因此,只能用Pickle保存那些不重要的数据,不能成功地反序列化也没关系。
JSON
如果我们要在不同的编程语言之间传递对象,就必须把对象序列化为标准格式,比如XML,但更好的方法是序列化为JSON,因为JSON表示出来就是一个字符串,可以被所有语言读取,也可以方便地存储到磁盘或者通过网络传输。JSON不仅是标准格式,并且比XML更快,而且可以直接在Web页面中读取,非常方便。
JSON表示的对象就是标准的JavaScript语言的对象,JSON和Python内置的数据类型对应如下:
JSON类型 | Python类型 |
---|---|
{} | dict |
[] | list |
“string” | str |
1234.56 | int或float |
true/false | True/False |
null | None |
Python内置的json
模块提供了非常完善的Python对象到JSON格式的转换。我们先看看如何把Python对象变成一个JSON:
1 |
|
dumps()
方法返回一个str
,内容就是标准的JSON。类似的,dump()
方法可以直接把JSON写入一个file-like Object
。
要把JSON反序列化为Python对象,用loads()
或者对应的load()
方法,前者把JSON的字符串反序列化,后者从file-like Object
中读取字符串并反序列化:
1 |
|
由于JSON标准规定JSON编码是UTF-8,所以我们总是能正确地在Python的str
与JSON的字符串之间转换。
JSON进阶
Python的dict
对象可以直接序列化为JSON的{}
,不过,很多时候,我们更喜欢用class
表示对象,比如定义Student
类,然后序列化:
1 |
|
运行代码,毫不留情地得到一个TypeError
:
1 |
|
错误的原因是Student
对象不是一个可序列化为JSON的对象。
如果连class
的实例对象都无法序列化为JSON,这肯定不合理!
别急,我们仔细看看dumps()
方法的参数列表,可以发现,除了第一个必须的obj
参数外,dumps()
方法还提供了一大堆的可选参数:
https://docs.python.org/3/library/json.html#json.dumps
这些可选参数就是让我们来定制JSON序列化。前面的代码之所以无法把Student
类实例序列化为JSON,是因为默认情况下,dumps()
方法不知道如何将Student
实例变为一个JSON的{}
对象。
可选参数default
就是把任意一个对象变成一个可序列为JSON的对象,我们只需要为Student
专门写一个转换函数,再把函数传进去即可:
1 |
|
这样,Student
实例首先被student2dict()
函数转换成dict
,然后再被顺利序列化为JSON:
1 |
|
不过,下次如果遇到一个Teacher
类的实例,照样无法序列化为JSON。我们可以偷个懒,把任意class
的实例变为dict
:
1 |
|
进阶(可选)
高级特性(已完成)
函数式编程(已完成)
面向对象高级编程
IO编程
venv
浏览
粗略看下,知道有什么就好,需要用的时候再学。
§ 常用内建模块
§ 正则表达式
其他
§ 图形界面
之后的暂时不用学习,需要的时候再来。
编码类型
ASCIL
计算机是美国人发明的,因此,最早只有127个字符被编码到计算机里,也就是大小写英文字母、数字和一些符号,这个编码表被称为ASCII
编码,比如大写字母A
的编码是65
,小写字母z
的编码是122
。
但是要处理中文显然一个字节是不够的,至少需要两个字节,而且还不能和ASCII编码冲突,所以,中国制定了GB2312
编码,用来把中文编进去。
你可以想得到的是,全世界有上百种语言,日本把日文编到Shift_JIS
里,韩国把韩文编到Euc-kr
里,各国有各国的标准,就会不可避免地出现冲突,结果就是,在多语言混合的文本中,显示出来会有乱码。
Unicode
Unicode 字符集应运而生。Unicode把所有语言都统一到一套编码里,这样就不会再有乱码问题了。
Unicode标准也在不断发展,但最常用的是UCS-16编码,用两个字节表示一个字符(如果要用到非常偏僻的字符,就需要4个字节)。现代操作系统和大多数编程语言都直接支持Unicode。
现在,捋一捋ASCII编码和Unicode编码的区别:ASCII编码是1个字节,而Unicode编码通常是2个字节。
字母A
用ASCII编码是十进制的65
,二进制的01000001
;
字符0
用ASCII编码是十进制的48
,二进制的00110000
,注意字符'0'
和整数0
是不同的;
汉字中
已经超出了ASCII编码的范围,用Unicode编码是十进制的20013
,二进制的01001110 00101101
。
你可以猜测,如果把ASCII编码的A
用Unicode编码,只需要在前面补0就可以,因此,A
的Unicode编码是00000000 01000001
。
新的问题又出现了:如果统一成Unicode编码,乱码问题从此消失了。但是,如果你写的文本基本上全部是英文的话,用Unicode编码比ASCII编码需要多一倍的存储空间,在存储和传输上就十分不划算。
UTF-8
把一个Unicode字符根据不同的数字大小编码成1-6个字节,常用的英文字母被编码成1个字节,汉字通常是3个字节,只有很生僻的字符才会被编码成4-6个字节。如果你要传输的文本包含大量英文字符,用UTF-8编码就能节省空间:
字符 | ASCII | Unicode | UTF-8 |
---|---|---|---|
A | 01000001 | 00000000 01000001 | 01000001 |
中 | x | 01001110 00101101 | 11100100 10111000 10101101 |
从上面的表格还可以发现,UTF-8编码有一个额外的好处,就是ASCII编码实际上可以被看成是UTF-8编码的一部分,所以,大量只支持ASCII编码的历史遗留软件可以在UTF-8编码下继续工作。
总结
搞清楚了ASCII、Unicode和UTF-8的关系,我们就可以总结一下现在计算机系统通用的字符编码工作方式:
在计算机内存中,统一使用Unicode编码,当需要保存到硬盘或者需要传输的时候,就转换为UTF-8编码。
用记事本编辑的时候,从文件读取的UTF-8字符被转换为Unicode字符到内存里,编辑完成后,保存的时候再把Unicode转换为UTF-8保存到文件
浏览网页的时候,服务器会把动态生成的Unicode内容转换为UTF-8再传输到浏览器:
所以你看到很多网页的源码上会有类似<meta charset="UTF-8" />
的信息,表示该网页正是用的UTF-8编码。
Errors
SyntaxError
如果遇到SyntaxError
,表示输入的Python代码有语法错误,最常见的一种语法错误是使用了中文标点,例如使用了中文括号(
和)
,或者使用了中文引号“
和”
:
1 |
|
出错时,务必阅读错误原因。对于上述SyntaxError
,解释器会明确指出错误原因是无法识别的字符“
:invalid character '“'
。