转载自:
https://www.liaoxuefeng.com/wiki/1016959663602400
前言
中级篇_上涉及内容如下:
- 模块
- 面向对象编程
- 面向对象高级编程
- 错误、调试和测试
- IO编程
1. 模块
为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就相对较少,很多编程语言都采用这种组织代码的方式。在Python中,一个.py文件就称之为一个模块(Module)。
使用模块有什么好处?
最大的好处是大大提高了代码的可维护性。其次,编写代码不必从零开始。当一个模块编写完毕,就可以被其他地方引用。我们在编写程序的时候,也经常引用其他模块,包括Python内置的模块和来自第三方的模块。
使用模块还可以避免函数名和变量名冲突。相同名字的函数和变量完全可以分别存在不同的模块中,因此,我们自己在编写模块时,不必考虑名字会与其他模块冲突。但是也要注意,尽量不要与内置函数名字冲突。
如果不同的人编写的模块名相同怎么办?为了避免模块名冲突,Python又引入了按目录来组织模块的方法,称为包(Package)。
举个例子,一个abc.py
的文件就是一个名字叫abc
的模块,一个xyz.py
的文件就是一个名字叫xyz
的模块。
现在,假设我们的abc
和xyz
这两个模块名字与其他模块冲突了,于是我们可以通过包来组织模块,避免冲突。方法是选择一个顶层包名,比如mycompany
,按照如下目录存放:
引入了包以后,只要顶层的包名不与别人冲突,那所有模块都不会与别人冲突。现在,abc.py
模块的名字就变成了mycompany.abc
,类似的,xyz.py
的模块名变成了mycompany.xyz
。
请注意,每一个包目录下面都会有一个__init__.py
的文件,这个文件是必须存在的,否则,Python就把这个目录当成普通目录,而不是一个包。__init__.py
可以是空文件,也可以有Python代码,因为__init__.py
本身就是一个模块,而它的模块名就是mycompany
。
类似的,可以有多级目录,组成多级层次的包结构。比如如下的目录结构:
文件www.py
的模块名就是mycompany.web.www
,两个文件utils.py
的模块名分别是mycompany.utils
和mycompany.web.utils
。
自己创建模块时要注意命名,不能和Python自带的模块名称冲突。例如,系统自带了sys模块,自己的模块就不可命名为sys.py,否则将无法导入系统自带的sys模块。
小结
模块是一组Python代码的集合,可以使用其他模块,也可以被其他模块使用。
创建自己的模块时,要注意:
- 模块名要遵循Python变量命名规范,不要使用中文、特殊字符;
- 模块名不要和系统模块名冲突,最好先查看系统是否已存在该模块,检查方法是在Python交互环境执行
import abc
,若成功则说明系统存在此模块。
1. 使用模块
Python本身就内置了很多非常有用的模块,只要安装完毕,这些模块就可以立刻使用。
我们以内建的sys
模块为例,编写一个hello
的模块:
1 | #!/usr/bin/env python3 |
解析:
第1行和第2行是标准注释,第1行注释可以让这个hello.py
文件直接在Unix/Linux/Mac上运行,第2行注释表示.py文件本身使用标准UTF-8编码;
第4行是一个字符串,表示模块的文档注释,任何模块代码的第一个字符串都被视为模块的文档注释;
第6行使用__author__
变量把作者写进去,这样当你公开源代码后别人就可以瞻仰你的大名;
以上就是Python模块的标准文件模板,当然也可以全部删掉不写,但是,按标准办事肯定没错。
后面开始就是真正的代码部分。
使用sys
模块的第一步,就是导入该模块:
1 | import sys |
导入sys
模块后,我们就有了变量sys
指向该模块,利用sys
这个变量,就可以访问sys
模块的所有功能。
sys
模块有一个argv
变量,用list存储了命令行的所有参数。argv
至少有一个元素,因为第一个参数永远是该.py文件的名称,例如:
运行python3 hello.py
获得的sys.argv
就是['hello.py']
;
运行python3 hello.py Michael
获得的sys.argv
就是['hello.py', 'Michael]
。
最后,注意到这两行代码:
1 | if __name__=='__main__': |
当我们在命令行运行hello
模块文件时,Python解释器把一个特殊变量__name__
置为__main__
,而如果在其他地方导入该hello
模块时,if
判断将失败,因此,这种if
测试可以让一个模块通过命令行运行时执行一些额外的代码,最常见的就是运行测试。
我们可以用命令行运行hello.py
看看效果:
1 | $ python3 hello.py |
如果启动Python交互环境,再导入hello
模块:
1 | $ python3 |
导入时,没有打印Hello, word!
,因为没有执行test()
函数。
调用hello.test()
时,才能打印出Hello, word!
:
1 | >>> hello.test() |
作用域
在一个模块中,我们可能会定义很多函数和变量,但有的函数和变量我们希望给别人使用,有的函数和变量我们希望仅仅在模块内部使用。在Python中,是通过_
前缀来实现的。
正常的函数和变量名是公开的(public),可以被直接引用,比如:abc
,x123
,PI
等;
类似__xxx__
这样的变量是特殊变量,可以被直接引用,但是有特殊用途,比如上面的__author__
,__name__
就是特殊变量,hello
模块定义的文档注释也可以用特殊变量__doc__
访问,我们自己的变量一般不要用这种变量名;
类似_xxx
和__xxx
这样的函数或变量就是非公开的(private),不应该被直接引用,比如_abc
,__abc
等;
之所以我们说,private函数和变量“不应该”被直接引用,而不是“不能”被直接引用,是因为Python并没有一种方法可以完全限制访问private函数或变量,但是,从编程习惯上不应该引用private函数或变量。
private函数或变量不应该被别人引用,那它们有什么用呢?请看例子:
1 | def _private_1(name): |
我们在模块里公开greeting()
函数,而把内部逻辑用private函数隐藏起来了,这样,调用greeting()
函数不用关心内部的private函数细节,这也是一种非常有用的代码封装和抽象的方法,即:
外部不需要引用的函数全部定义成private,只有外部需要引用的函数才定义为public。
2. 安装第三方模块
推荐直接使用Anaconda,这是一个基于Python的数据处理和科学计算平台,它已经内置了许多非常有用的第三方库,我们装上Anaconda,就相当于把数十个第三方模块自动安装好了,非常简单易用。
可以从Anaconda官网下载GUI安装包,安装包有500~600M,所以需要耐心等待下载。下载后直接安装,Anaconda会把系统Path中的python指向自己自带的Python,并且,Anaconda安装的第三方模块会安装在Anaconda自己的路径下,不影响系统已安装的Python目录。
安装好Anaconda后,重新打开命令行窗口,输入python,可以看到Anaconda的信息:
可以尝试直接import numpy
等已安装的第三方模块。
模块搜索路径
当我们试图加载一个模块时,Python会在指定的路径下搜索对应的.py文件,如果找不到,就会报错:
1 | >>> import mymodule |
默认情况下,Python解释器会搜索当前目录、所有已安装的内置模块和第三方模块,搜索路径存放在sys
模块的path
变量中:
1 | >>> import sys |
如果我们要添加自己的搜索目录,有两种方法:
一是直接修改sys.path
,添加要搜索的目录:
1 | >>> import sys |
这种方法是在运行时修改,运行结束后失效。
第二种方法是设置环境变量PYTHONPATH
,该环境变量的内容会被自动添加到模块搜索路径中。设置方式与设置Path环境变量类似。注意只需要添加你自己的搜索路径,Python自己本身的搜索路径不受影响。
2. 面向对象编程
面向对象编程——Object Oriented Programming,简称OOP,是一种程序设计思想。OOP把对象作为程序的基本单元,一个对象包含了数据和操作数据的函数。
面向对象的程序设计把计算机程序视为一组对象的集合,而每个对象都可以接收其他对象发过来的消息,并处理这些消息,计算机程序的执行就是一系列消息在各个对象之间传递。
在Python中,所有数据类型都可以视为对象,当然也可以自定义对象。自定义的对象数据类型就是面向对象中的类(Class)的概念。
面向对象的设计思想是抽象出Class,根据Class创建Instance。
面向对象的抽象程度又比函数要高,因为一个Class既包含数据,又包含操作数据的方法。
1. 类和实例
面向对象最重要的概念就是类(Class)和实例(Instance),必须牢记类是抽象的模板,比如Student类,而实例是根据类创建出来的一个个具体的“对象”,每个对象都拥有相同的方法,但各自的数据可能不同。
以Student类为例,在Python中,定义类是通过class
关键字:
1 | class Student(object): |
class
后面紧接着是类名,即Student
,类名通常是大写开头的单词,紧接着是(object)
,表示该类是从哪个类继承下来的,继承的概念我们后面再讲,通常,如果没有合适的继承类,就使用object
类,这是所有类最终都会继承的类。
定义好了Student
类,就可以根据Student
类创建出Student
的实例,创建实例是通过类名+()实现的:
1 | >>> bart = Student() |
可以看到,变量bart
指向的就是一个Student
的实例,后面的0x10a67a590
是内存地址,每个object的地址都不一样,而Student
本身则是一个类。
可以自由地给一个实例变量绑定属性,比如,给实例bart
绑定一个name
属性:
1 | >>> bart.name = 'Bart Simpson' |
由于类可以起到模板的作用,因此,可以在创建实例的时候,把一些我们认为必须绑定的属性强制填写进去。通过定义一个特殊的__init__
方法,在创建实例的时候,就把name
,score
等属性绑上去:
1 | class Student(object): |
注意:特殊方法“init”前后分别有两个下划线!!!
注意到__init__
方法的第一个参数永远是self
,表示创建的实例本身,因此,在__init__
方法内部,就可以把各种属性绑定到self
,因为self
就指向创建的实例本身。
有了__init__
方法,在创建实例的时候,就不能传入空的参数了,必须传入与__init__
方法匹配的参数,但self
不需要传,Python解释器自己会把实例变量传进去:
1 | >>> bart = Student('Bart Simpson', 59) |
和普通的函数相比,在类中定义的函数只有一点不同,就是第一个参数永远是实例变量self
,并且,调用时,不用传递该参数。除此之外,类的方法和普通函数没有什么区别,所以,你仍然可以用默认参数、可变参数、关键字参数和命名关键字参数。
数据封装
面向对象编程的一个重要特点就是数据封装。在上面的Student
类中,每个实例就拥有各自的name
和score
这些数据。我们可以通过函数来访问这些数据,比如打印一个学生的成绩:
1 | >>> def print_score(std): |
但是,既然Student
实例本身就拥有这些数据,要访问这些数据,就没有必要从外面的函数去访问,可以直接在Student
类的内部定义访问数据的函数,这样,就把“数据”给封装起来了。这些封装数据的函数是和Student
类本身是关联起来的,我们称之为类的方法:
1 | class Student(object): |
要定义一个方法,除了第一个参数是self
外,其他和普通函数一样。要调用一个方法,只需要在实例变量上直接调用,除了self
不用传递,其他参数正常传入:
1 | >>> bart.print_score() |
这样一来,我们从外部看Student
类,就只需要知道,创建实例需要给出name
和score
,而如何打印,都是在Student
类的内部定义的,这些数据和逻辑被“封装”起来了,调用很容易,但却不用知道内部实现的细节。
封装的另一个好处是可以给Student
类增加新的方法,比如get_grade
:
1 | class Student(object): |
同样的,get_grade
方法可以直接在实例变量上调用,不需要知道内部实现细节。
2. 访问限制
在Class内部,可以有属性和方法,而外部代码可以通过直接调用实例变量的方法来操作数据,这样,就隐藏了内部的复杂逻辑。
但是,从前面Student类的定义来看,外部代码还是可以自由地修改一个实例的name
、score
属性:
1 | >>> bart = Student('Bart Simpson', 59) |
如果要让内部属性不被外部访问,可以把属性的名称前加上两个下划线__
,在Python中,实例的变量名如果以__
开头,就变成了一个私有变量(private),只有内部可以访问,外部不能访问,所以,我们把Student类改一改:
1 | class Student(object): |
改完后,对于外部代码来说,没什么变动,但是已经无法从外部访问实例变量.__name
和实例变量.__score
了:
1 | >>> bart = Student('Bart Simpson', 59) |
这样就确保了外部代码不能随意修改对象内部的状态,这样通过访问限制的保护,代码更加健壮。
但是如果外部代码要获取name和score怎么办?可以给Student类增加get_name
和get_score
这样的方法:
1 | class Student(object): |
如果又要允许外部代码修改score怎么办?可以再给Student类增加set_score
方法:
1 | class Student(object): |
你也许会问,原先那种直接通过bart.score = 99
也可以修改啊,为什么要定义一个方法大费周折?因为在方法中,可以对参数做检查,避免传入无效的参数:
1 | class Student(object): |
需要注意的是,在Python中,变量名类似__xxx__
的,也就是以双下划线开头,并且以双下划线结尾的,是特殊变量,特殊变量是可以直接访问的,不是private变量,所以,不能用__name__
、__score__
这样的变量名。
有些时候,你会看到以一个下划线开头的实例变量名,比如_name
,这样的实例变量外部是可以访问的,但是,按照约定俗成的规定,当你看到这样的变量时,意思就是,“虽然我可以被访问,但是,请把我视为私有变量,不要随意访问”。
双下划线开头的实例变量是不是一定不能从外部访问呢?其实也不是。不能直接访问__name
是因为Python解释器对外把__name
变量改成了_Student__name
,所以,仍然可以通过_Student__name
来访问__name
变量:
1 | >>> bart._Student__name |
但是强烈建议你不要这么干,因为不同版本的Python解释器可能会把__name
改成不同的变量名。
总的来说就是,Python本身没有任何机制阻止你干坏事,一切全靠自觉。
最后注意下面的这种_错误写法_:
1 | >>> bart = Student('Bart Simpson', 59) |
表面上看,外部代码“成功”地设置了__name
变量,但实际上这个__name
变量和class内部的__name
变量_不是_一个变量!内部的__name
变量已经被Python解释器自动改成了_Student__name
,而外部代码给bart
新增了一个__name
变量。不信试试:
1 | >>> bart.get_name() # get_name()内部返回self.__name |
3. 继承和多态
在OOP程序设计中,当我们定义一个class的时候,可以从某个现有的class继承,新的class称为子类(Subclass),而被继承的class称为基类、父类或超类(Base class、Super class)。
比如,我们已经编写了一个名为Animal
的class,有一个run()
方法可以直接打印:
1 | class Animal(object): |
当我们需要编写Dog
和Cat
类时,就可以直接从Animal
类继承:
1 | class Dog(Animal): |
对于Dog
来说,Animal
就是它的父类,对于Animal
来说,Dog
就是它的子类。Cat
和Dog
类似。
继承有什么好处?最大的好处是子类获得了父类的全部功能。由于Animial
实现了run()
方法,因此,Dog
和Cat
作为它的子类,什么事也没干,就自动拥有了run()
方法:
1 | dog = Dog() |
运行结果如下:
1 | Animal is running... |
当然,也可以对子类增加一些方法,比如Dog类:
1 | class Dog(Animal): |
继承的第二个好处需要我们对代码做一点改进。你看到了,无论是Dog
还是Cat
,它们run()
的时候,显示的都是Animal is running...
,符合逻辑的做法是分别显示Dog is running...
和Cat is running...
,因此,对Dog
和Cat
类改进如下:
1 | class Dog(Animal): |
再次运行,结果如下:
1 | Dog is running... |
当子类和父类都存在相同的run()
方法时,我们说,子类的run()
覆盖了父类的run()
,在代码运行的时候,总是会调用子类的run()
。这样,我们就获得了继承的另一个好处:多态。
要理解什么是多态,我们首先要对数据类型再作一点说明。当我们定义一个class的时候,我们实际上就定义了一种数据类型。我们定义的数据类型和Python自带的数据类型,比如str、list、dict没什么两样:
1 | a = list() # a是list类型 |
判断一个变量是否是某个类型可以用isinstance()
判断:
1 | >>> isinstance(a, list) |
看来a
、b
、c
确实对应着list
、Animal
、Dog
这3种类型。
但是等等,试试:
1 | >>> isinstance(c, Animal) |
看来c
不仅仅是Dog
,c
还是Animal
!
不过仔细想想,这是有道理的,因为Dog
是从Animal
继承下来的,当我们创建了一个Dog
的实例c
时,我们认为c
的数据类型是Dog
没错,但c
同时也是Animal
也没错,Dog
本来就是Animal
的一种!
所以,在继承关系中,如果一个实例的数据类型是某个子类,那它的数据类型也可以被看做是父类。但是,反过来就不行:
1 | >>> b = Animal() |
Dog
可以看成Animal
,但Animal
不可以看成Dog
。
要理解多态的好处,我们还需要再编写一个函数,这个函数接受一个Animal
类型的变量:
1 | def run_twice(animal): |
当我们传入Animal
的实例时,run_twice()
就打印出:
1 | >>> run_twice(Animal()) |
当我们传入Dog
的实例时,run_twice()
就打印出:
1 | >>> run_twice(Dog()) |
当我们传入Cat
的实例时,run_twice()
就打印出:
1 | >>> run_twice(Cat()) |
看上去没啥意思,但是仔细想想,现在,如果我们再定义一个Tortoise
类型,也从Animal
派生:
1 | class Tortoise(Animal): |
当我们调用run_twice()
时,传入Tortoise
的实例:
1 | >>> run_twice(Tortoise()) |
你会发现,新增一个Animal
的子类,不必对run_twice()
做任何修改,实际上,任何依赖Animal
作为参数的函数或者方法都可以不加修改地正常运行,原因就在于多态。
多态的好处就是,当我们需要传入Dog
、Cat
、Tortoise
……时,我们只需要接收Animal
类型就可以了,因为Dog
、Cat
、Tortoise
……都是Animal
类型,然后,按照Animal
类型进行操作即可。由于Animal
类型有run()
方法,因此,传入的任意类型,只要是Animal
类或者子类,就会自动调用实际类型的run()
方法,这就是多态的意思:
对于一个变量,我们只需要知道它是Animal
类型,无需确切地知道它的子类型,就可以放心地调用run()
方法,而具体调用的run()
方法是作用在Animal
、Dog
、Cat
还是Tortoise
对象上,由运行时该对象的确切类型决定,这就是多态真正的威力:调用方只管调用,不管细节,而当我们新增一种Animal
的子类时,只要确保run()
方法编写正确,不用管原来的代码是如何调用的。这就是著名的“开闭”原则:
对扩展开放:允许新增Animal
子类;
对修改封闭:不需要修改依赖Animal
类型的run_twice()
等函数。
静态语言 vs 动态语言
对于静态语言(例如Java)来说,如果需要传入Animal
类型,则传入的对象必须是Animal
类型或者它的子类,否则,将无法调用run()
方法。
对于Python这样的动态语言来说,则不一定需要传入Animal
类型。我们只需要保证传入的对象有一个run()
方法就可以了:
1 | class Timer(object): |
这就是动态语言的“鸭子类型”,它并不要求严格的继承体系,一个对象只要“看起来像鸭子,走起路来像鸭子”,那它就可以被看做是鸭子。
Python的“file-like object“就是一种鸭子类型。对真正的文件对象,它有一个read()
方法,返回其内容。但是,许多对象,只要有read()
方法,都被视为“file-like object“。许多函数接收的参数就是“file-like object“,你不一定要传入真正的文件对象,完全可以传入任何实现了read()
方法的对象。
小结
继承可以把父类的所有功能都直接拿过来,这样就不必重零做起,子类只需要新增自己特有的方法,也可以把父类不适合的方法覆盖重写。
动态语言的鸭子类型特点决定了继承不像静态语言那样是必须的。
4. 获取对象信息
当我们拿到一个对象的引用时,如何知道这个对象是什么类型、有哪些方法呢?
1. 使用type()
首先,我们来判断对象类型,使用type()
函数:
基本类型都可以用type()
判断:
1 | >>> type(123) |
如果一个变量指向函数或者类,也可以用type()
判断:
1 | >>> type(abs) |
但是type()
函数返回的是什么类型呢?它返回对应的Class类型。如果我们要在if
语句中判断,就需要比较两个变量的type类型是否相同:
1 | >>> type(123)==type(456) |
判断基本数据类型可以直接写int
,str
等,但如果要判断一个对象是否是函数怎么办?可以使用types
模块中定义的常量:
1 | >>> import types |
2. 使用isinstance()
对于class的继承关系来说,使用type()
就很不方便。我们要判断class的类型,可以使用isinstance()
函数。
我们回顾上次的例子,如果继承关系是:
1 | object -> Animal -> Dog -> Husky |
那么,isinstance()
就可以告诉我们,一个对象是否是某种类型。先创建3种类型的对象:
1 | >>> a = Animal() |
然后,判断:
1 | >>> isinstance(h, Husky) |
没有问题,因为h
变量指向的就是Husky对象。
再判断:
1 | >>> isinstance(h, Dog) |
h
虽然自身是Husky类型,但由于Husky是从Dog继承下来的,所以,h
也还是Dog类型。换句话说,isinstance()
判断的是一个对象是否是该类型本身,或者位于该类型的父继承链上。
因此,我们可以确信,h
还是Animal类型:
1 | >>> isinstance(h, Animal) |
同理,实际类型是Dog的d
也是Animal类型:
1 | >>> isinstance(d, Dog) and isinstance(d, Animal) |
但是,d
不是Husky类型:
1 | >>> isinstance(d, Husky) |
能用type()
判断的基本类型也可以用isinstance()
判断:
1 | >>> isinstance('a', str) |
并且还可以判断一个变量是否是某些类型中的一种,比如下面的代码就可以判断是否是list或者tuple:
1 | >>> isinstance([1, 2, 3], (list, tuple)) |
总是优先使用isinstance()判断类型,可以将指定类型及其子类“一网打尽”。
3. 使用dir()
如果要获得一个对象的所有属性和方法,可以使用dir()
函数,它返回一个包含字符串的list,比如,获得一个str对象的所有属性和方法:
1 | >>> dir('ABC') |
类似__xxx__
的属性和方法在Python中都是有特殊用途的,比如__len__
方法返回长度。在Python中,如果你调用len()
函数试图获取一个对象的长度,实际上,在len()
函数内部,它自动去调用该对象的__len__()
方法,所以,下面的代码是等价的:
1 | >>> len('ABC') |
我们自己写的类,如果也想用len(myObj)
的话,就自己写一个__len__()
方法:
1 | >>> class MyDog(object): |
剩下的都是普通属性或方法,比如lower()
返回小写的字符串:
1 | >>> 'ABC'.lower() |
仅仅把属性和方法列出来是不够的,配合getattr()
、setattr()
以及hasattr()
,我们可以直接操作一个对象的状态:
1 | >>> class MyObject(object): |
紧接着,可以测试该对象的属性:
1 | >>> hasattr(obj, 'x') # 有属性'x'吗? |
如果试图获取不存在的属性,会抛出AttributeError的错误:
1 | >>> getattr(obj, 'z') # 获取属性'z' |
可以传入一个default参数,如果属性不存在,就返回默认值:
1 | >>> getattr(obj, 'z', 404) # 获取属性'z',如果不存在,返回默认值404 |
也可以获得对象的方法:
1 | >>> hasattr(obj, 'power') # 有属性'power'吗? |
小结
通过内置的一系列函数,我们可以对任意一个Python对象进行剖析,拿到其内部的数据。要注意的是,只有在不知道对象信息的时候,我们才会去获取对象信息。
如果可以直接写:
1 | sum = obj.x + obj.y |
就不要写:
1 | sum = getattr(obj, 'x') + getattr(obj, 'y') |
一个正确的用法的例子如下:
1 | ef readImage(fp): |
假设我们希望从文件流fp中读取图像,我们首先要判断该fp对象是否存在read方法,如果存在,则该对象是一个流,如果不存在,则无法读取。hasattr()
就派上了用场。
请注意,在Python这类动态语言中,根据鸭子类型,有read()
方法,不代表该fp对象就是一个文件流,它也可能是网络流,也可能是内存中的一个字节流,但只要read()
方法返回的是有效的图像数据,就不影响读取图像的功能。
5. 实例属性和类属性
由于Python是动态语言,根据类创建的实例可以任意绑定属性。
给实例绑定属性的方法是通过实例变量,或者通过self
变量:
1 | class Student(object): |
但是,如果Student
类本身需要绑定一个属性呢?可以直接在class中定义属性,这种属性是类属性,归Student
类所有:
1 | class Student(object): |
当我们定义了一个类属性后,这个属性虽然归类所有,但类的所有实例都可以访问到。来测试一下:
1 | >>> class Student(object): |
从上面的例子可以看出,在编写程序的时候,千万不要对实例属性和类属性使用相同的名字,因为相同名称的实例属性将屏蔽掉类属性,但是当你删除实例属性后,再使用相同的名称,访问到的将是类属性。
小结
实例属性属于各个实例所有,互不干扰;
类属性属于类所有,所有实例共享一个属性;
不要对实例属性和类属性使用相同的名字,否则将产生难以发现的错误。
3. 面向对象高级编程
1. 使用 slots
正常情况下,当我们定义了一个class,创建了一个class的实例后,我们可以给该实例绑定任何属性和方法,这就是动态语言的灵活性。先定义class:
1 | class Student(object): |
然后,尝试给实例绑定一个属性:
1 | >>> s = Student() |
还可以尝试给实例绑定一个方法:
1 | >>> def set_age(self, age): # 定义一个函数作为实例方法 |
但是,给一个实例绑定的方法,对另一个实例是不起作用的:
1 | >>> s2 = Student() # 创建新的实例 |
为了给所有实例都绑定方法,可以给class绑定方法:
1 | >>> def set_score(self, score): |
给class绑定方法后,所有实例均可调用:
1 | >>> s.set_score(100) |
通常情况下,上面的set_score
方法可以直接定义在class中,但动态绑定允许我们在程序运行的过程中动态给class加上功能,这在静态语言中很难实现。
使用slots
但是,如果我们想要限制实例的属性怎么办?比如,只允许对Student实例添加name
和age
属性。
为了达到限制的目的,Python允许在定义class的时候,定义一个特殊的__slots__
变量,来限制该class实例能添加的属性:
1 | class Student(object): |
然后,我们试试:
1 | >>> s = Student() # 创建新的实例 |
由于'score'
没有被放到__slots__
中,所以不能绑定score
属性,试图绑定score
将得到AttributeError
的错误。
使用__slots__
要注意,__slots__
定义的属性仅对当前类实例起作用,对继承的子类是不起作用的:
1 | >>> class GraduateStudent(Student): |
除非在子类中也定义__slots__
,这样,子类实例允许定义的属性就是自身的__slots__
加上父类的__slots__
。
2. 使用@property
在绑定属性时,如果我们直接把属性暴露出去,虽然写起来很简单,但是,没办法检查参数,导致可以把成绩随便改:
1 | s = Student() |
这显然不合逻辑。为了限制score的范围,可以通过一个set_score()
方法来设置成绩,再通过一个get_score()
来获取成绩,这样,在set_score()
方法里,就可以检查参数:
1 | class Student(object): |
现在,对任意的Student实例进行操作,就不能随心所欲地设置score了:
1 | >>> s = Student() |
但是,上面的调用方法又略显复杂,没有直接用属性这么直接简单。
有没有既能检查参数,又可以用类似属性这样简单的方式来访问类的变量呢?对于追求完美的Python程序员来说,这是必须要做到的!
还记得装饰器(decorator)可以给函数动态加上功能吗?对于类的方法,装饰器一样起作用。Python内置的@property
装饰器就是负责把一个方法变成属性调用的:
1 | class Student(object): |
@property
的实现比较复杂,我们先考察如何使用。把一个getter方法变成属性,只需要加上@property
就可以了,此时,@property
本身又创建了另一个装饰器@score.setter
,负责把一个setter方法变成属性赋值,于是,我们就拥有一个可控的属性操作:
1 | >>> s = Student() |
注意到这个神奇的@property
,我们在对实例属性操作的时候,就知道该属性很可能不是直接暴露的,而是通过getter和setter方法来实现的。
还可以定义只读属性,只定义getter方法,不定义setter方法就是一个只读属性:
1 | class Student(object): |
面的birth
是可读写属性,而age
就是一个_只读_属性,因为age
可以根据birth
和当前时间计算出来。
小结@property
广泛应用在类的定义中,可以让调用者写出简短的代码,同时保证对参数进行必要的检查,这样,程序运行时就减少了出错的可能性。
3. 多重继承
通过多重继承,一个子类就可以同时获得多个父类的所有功能。
MixIn
在设计类的继承关系时,通常,主线都是单一继承下来的,例如,Ostrich
继承自Bird
。但是,如果需要“混入”额外的功能,通过多重继承就可以实现,比如,让Ostrich
除了继承自Bird
外,再同时继承Runnable
。这种设计通常称之为MixIn。
为了更好地看出继承关系,我们把Runnable
和Flyable
改为RunnableMixIn
和FlyableMixIn
。类似的,你还可以定义出肉食动物CarnivorousMixIn
和植食动物HerbivoresMixIn
,让某个动物同时拥有好几个MixIn:
1 | class Dog(Mammal, RunnableMixIn, CarnivorousMixIn): |
MixIn的目的就是给一个类增加多个功能,这样,在设计类的时候,我们优先考虑通过多重继承来组合多个MixIn的功能,而不是设计多层次的复杂的继承关系。
Python自带的很多库也使用了MixIn。举个例子,Python自带了TCPServer
和UDPServer
这两类网络服务,而要同时服务多个用户就必须使用多进程或多线程模型,这两种模型由ForkingMixIn
和ThreadingMixIn
提供。通过组合,我们就可以创造出合适的服务来。
比如,编写一个多进程模式的TCP服务,定义如下:
1 | class MyTCPServer(TCPServer, ForkingMixIn): |
编写一个多线程模式的UDP服务,定义如下:
1 | class MyUDPServer(UDPServer, ThreadingMixIn): |
如果你打算搞一个更先进的协程模型,可以编写一个CoroutineMixIn
:
1 | class MyTCPServer(TCPServer, CoroutineMixIn): |
这样一来,我们不需要复杂而庞大的继承链,只要选择组合不同的类的功能,就可以快速构造出所需的子类。
小结
由于Python允许使用多重继承,因此,MixIn就是一种常见的设计。
只允许单一继承的语言(如Java)不能使用MixIn的设计。
4. 定制类
看到类似__slots__
这种形如__xxx__
的变量或者函数名就要注意,这些在Python中是有特殊用途的。
__slots__
我们已经知道怎么用了,__len__()
方法我们也知道是为了能让class作用于len()
函数。
除此之外,Python的class中还有许多这样有特殊用途的函数,可以帮助我们定制类。
__str__
我们先定义一个Student
类,打印一个实例:
1 | >>> class Student(object): |
打印出一堆<__main__.Student object at 0x109afb190>
,不好看。
怎么才能打印得好看呢?只需要定义好__str__()
方法,返回一个好看的字符串就可以了:
1 | >>> class Student(object): |
这样打印出来的实例,不但好看,而且容易看出实例内部重要的数据。
但是细心的朋友会发现直接敲变量不用print
,打印出来的实例还是不好看:
1 | >>> s = Student('Michael') |
这是因为直接显示变量调用的不是__str__()
,而是__repr__()
,两者的区别是__str__()
返回用户看到的字符串,而__repr__()
返回程序开发者看到的字符串,也就是说,__repr__()
是为调试服务的。
解决办法是再定义一个__repr__()
。但是通常__str__()
和__repr__()
代码都是一样的,所以,有个偷懒的写法:
1 | class Student(object): |
__iter__
如果一个类想被用于for ... in
循环,类似list或tuple那样,就必须实现一个__iter__()
方法,该方法返回一个迭代对象,然后,Python的for循环就会不断调用该迭代对象的__next__()
方法拿到循环的下一个值,直到遇到StopIteration
错误时退出循环。
我们以斐波那契数列为例,写一个Fib类,可以作用于for循环:
1 | class Fib(object): |
现在,试试把Fib实例作用于for循环:
1 | >>> for n in Fib(): |
__getitem__
Fib实例虽然能作用于for循环,看起来和list有点像,但是,把它当成list来使用还是不行,比如,取第5个元素:
1 | >>> Fib()[5] |
要表现得像list那样按照下标取出元素,需要实现__getitem__()
方法:
1 | class Fib(object): |
现在,就可以按下标访问数列的任意一项了:
1 | >>> f = Fib() |
但是list有个神奇的切片方法:
1 | >>> list(range(100))[5:10] |
对于Fib却报错。原因是__getitem__()
传入的参数可能是一个int,也可能是一个切片对象slice
,所以要做判断:
1 | class Fib(object): |
现在试试Fib的切片:
1 | >>> f = Fib() |
但是没有对step参数作处理:
1 | >>> f[:10:2] |
也没有对负数作处理,所以,要正确实现一个__getitem__()
还是有很多工作要做的。
此外,如果把对象看成dict
,__getitem__()
的参数也可能是一个可以作key的object,例如str
。
与之对应的是__setitem__()
方法,把对象视作list或dict来对集合赋值。最后,还有一个__delitem__()
方法,用于删除某个元素。
总之,通过上面的方法,我们自己定义的类表现得和Python自带的list、tuple、dict没什么区别,这完全归功于动态语言的“鸭子类型”,不需要强制继承某个接口。
__getattr__
正常情况下,当我们调用类的方法或属性时,如果不存在,就会报错。比如定义Student
类:
1 | class Student(object): |
调用name
属性,没问题,但是,调用不存在的score
属性,就有问题了:
1 | >>> s = Student() |
错误信息很清楚地告诉我们,没有找到score
这个attribute。
要避免这个错误,除了可以加上一个score
属性外,Python还有另一个机制,那就是写一个__getattr__()
方法,动态返回一个属性。修改如下:
1 | class Student(object): |
当调用不存在的属性时,比如score
,Python解释器会试图调用__getattr__(self, 'score')
来尝试获得属性,这样,我们就有机会返回score
的值:
1 | >>> s = Student() |
返回函数也是完全可以的:
1 | class Student(object): |
只是调用方式要变为:
1 | >>> s.age() |
注意,只有在没有找到属性的情况下,才调用__getattr__
,已有的属性,比如name
,不会在__getattr__
中查找。
此外,注意到任意调用如s.abc
都会返回None
,这是因为我们定义的__getattr__
默认返回就是None
。要让class只响应特定的几个属性,我们就要按照约定,抛出AttributeError
的错误:
1 | class Student(object): |
这实际上可以把一个类的所有属性和方法调用全部动态化处理了,不需要任何特殊手段。
这种完全动态调用的特性有什么实际作用呢?作用就是,可以针对完全动态的情况作调用。
举个例子:
现在很多网站都搞REST API,比如新浪微博、豆瓣啥的,调用API的URL类似:
如果要写SDK,给每个URL对应的API都写一个方法,那得累死,而且,API一旦改动,SDK也要改。
利用完全动态的__getattr__
,我们可以写出一个链式调用:
1 | class Chain(object): |
试试:
1 | >>> Chain().status.user.timeline.list |
这样,无论API怎么变,SDK都可以根据URL实现完全动态的调用,而且,不随API的增加而改变!
还有些REST API会把参数放到URL中,比如GitHub的API:
1 | GET /users/:user/repos |
调用时,需要把:user
替换为实际用户名。如果我们能写出这样的链式调用:
1 | Chain().users('michael').repos |
就可以非常方便地调用API了
__call__
一个对象实例可以有自己的属性和方法,当我们调用实例方法时,我们用instance.method()
来调用。能不能直接在实例本身上调用呢?在Python中,答案是肯定的。
任何类,只需要定义一个__call__()
方法,就可以直接对实例进行调用。请看示例:
1 | class Student(object): |
调用方式如下:
1 | >>> s = Student('Michael') |
__call__()
还可以定义参数。对实例进行直接调用就好比对一个函数进行调用一样,所以你完全可以把对象看成函数,把函数看成对象,因为这两者之间本来就没啥根本的区别。
如果你把对象看成函数,那么函数本身其实也可以在运行期动态创建出来,因为类的实例都是运行期创建出来的,这么一来,我们就模糊了对象和函数的界限。
那么,怎么判断一个变量是对象还是函数呢?其实,更多的时候,我们需要判断一个对象是否能被调用,能被调用的对象就是一个Callable
对象,比如函数和我们上面定义的带有__call__()
的类实例:
1 | >>> callable(Student()) |
通过callable()
函数,我们就可以判断一个对象是否是“可调用”对象。
5. 使用枚举类
当我们需要定义常量时,一个办法是用大写变量通过整数来定义,例如月份:
1 | JAN = 1 |
好处是简单,缺点是类型是int
,并且仍然是变量。
更好的方法是为这样的枚举类型定义一个class类型,然后,每个常量都是class的一个唯一实例。Python提供了Enum
类来实现这个功能:
1 | from enum import Enum |
这样我们就获得了Month
类型的枚举类,可以直接使用Month.Jan
来引用一个常量,或者枚举它的所有成员:
1 | for name, member in Month.__members__.items(): |
value
属性则是自动赋给成员的int
常量,默认从1
开始计数。
如果需要更精确地控制枚举类型,可以从Enum
派生出自定义类:
1 | from enum import Enum, unique |
@unique
装饰器可以帮助我们检查保证没有重复值。
访问这些枚举类型可以有若干种方法:
1 | >>> day1 = Weekday.Mon |
可见,既可以用成员名称引用枚举常量,又可以直接根据value的值获得枚举常量。
小结
Enum
可以把一组相关常量定义在一个class中,且class不可变,而且成员可以直接比较。
6. 使用元类
type()
动态语言和静态语言最大的不同,就是函数和类的定义,不是编译时定义的,而是运行时动态创建的。
比方说我们要定义一个Hello
的class,就写一个hello.py
模块:
1 | class Hello(object): |
当Python解释器载入hello
模块时,就会依次执行该模块的所有语句,执行结果就是动态创建出一个Hello
的class对象,测试如下:
1 | >>> from hello import Hello |
type()
函数可以查看一个类型或变量的类型,Hello
是一个class,它的类型就是type
,而h
是一个实例,它的类型就是class Hello
。
我们说class的定义是运行时动态创建的,而创建class的方法就是使用type()
函数。
type()
函数既可以返回一个对象的类型,又可以创建出新的类型,比如,我们可以通过type()
函数创建出Hello
类,而无需通过class Hello(object)...
的定义:
1 | >>> def fn(self, name='world'): # 先定义函数 |
要创建一个class对象,type()
函数依次传入3个参数:
- class的名称;
- 继承的父类集合,注意Python支持多重继承,如果只有一个父类,别忘了tuple的单元素写法;
- class的方法名称与函数绑定,这里我们把函数
fn
绑定到方法名hello
上。
通过type()
函数创建的类和直接写class是完全一样的,因为Python解释器遇到class定义时,仅仅是扫描一下class定义的语法,然后调用type()
函数创建出class。
正常情况下,我们都用class Xxx...
来定义类,但是,type()
函数也允许我们动态创建出类来,也就是说,动态语言本身支持运行期动态创建类,这和静态语言有非常大的不同,要在静态语言运行期创建类,必须构造源代码字符串再调用编译器,或者借助一些工具生成字节码实现,本质上都是动态编译,会非常复杂。
metaclass
除了使用type()
动态创建类以外,要控制类的创建行为,还可以使用metaclass。
metaclass,直译为元类,简单的解释就是:
当我们定义了类以后,就可以根据这个类创建出实例,所以:先定义类,然后创建实例。
但是如果我们想创建出类呢?那就必须根据metaclass创建出类,所以:先定义metaclass,然后创建类。
连接起来就是:先定义metaclass,就可以创建类,最后创建实例。
所以,metaclass允许你创建类或者修改类。换句话说,你可以把类看成是metaclass创建出来的“实例”。
metaclass是Python面向对象里最难理解,也是最难使用的魔术代码。正常情况下,你不会碰到需要使用metaclass的情况,所以,以下内容看不懂也没关系,因为基本上你不会用到。
我们先看一个简单的例子,这个metaclass可以给我们自定义的MyList增加一个add
方法:
定义ListMetaclass
,按照默认习惯,metaclass的类名总是以Metaclass结尾,以便清楚地表示这是一个metaclass:
1 | # metaclass是类的模板,所以必须从`type`类型派生: |
有了ListMetaclass,我们在定义类的时候还要指示使用ListMetaclass来定制类,传入关键字参数metaclass
:
1 | class MyList(list, metaclass=ListMetaclass): |
当我们传入关键字参数metaclass
时,魔术就生效了,它指示Python解释器在创建MyList
时,要通过ListMetaclass.__new__()
来创建,在此,我们可以修改类的定义,比如,加上新的方法,然后,返回修改后的定义。
__new__()
方法接收到的参数依次是:
当前准备创建的类的对象;
类的名字;
类继承的父类集合;
类的方法集合。
测试一下MyList
是否可以调用add()
方法:
1 | >>> L = MyList() |
而普通的list
没有add()
方法:
1 | >>> L2 = list() |
动态修改有什么意义?直接在MyList
定义中写上add()
方法不是更简单吗?正常情况下,确实应该直接写,通过metaclass修改纯属变态。
但是,总会遇到需要通过metaclass修改类定义的。ORM就是一个典型的例子。
ORM全称“Object Relational Mapping”,即对象-关系映射,就是把关系数据库的一行映射为一个对象,也就是一个类对应一个表,这样,写代码更简单,不用直接操作SQL语句。
要编写一个ORM框架,所有的类都只能动态定义,因为只有使用者才能根据表的结构定义出对应的类来。
让我们来尝试编写一个ORM框架。
编写底层模块的第一步,就是先把调用接口写出来。比如,使用者如果使用这个ORM框架,想定义一个User
类来操作对应的数据库表User
,我们期待他写出这样的代码:
1 | class User(Model): |
其中,父类Model
和属性类型StringField
、IntegerField
是由ORM框架提供的,剩下的魔术方法比如save()
全部由metaclass自动完成。虽然metaclass的编写会比较复杂,但ORM的使用者用起来却异常简单。
现在,我们就按上面的接口来实现该ORM。
首先来定义Field
类,它负责保存数据库表的字段名和字段类型:
1 | class Field(object): |
在Field
的基础上,进一步定义各种类型的Field
,比如StringField
,IntegerField
等等:
1 | class StringField(Field): |
下一步,就是编写最复杂的ModelMetaclass
了:
1 | class ModelMetaclass(type): |
以及基类Model
:
1 | class Model(dict, metaclass=ModelMetaclass): |
当用户定义一个class User(Model)
时,Python解释器首先在当前类User
的定义中查找metaclass
,如果没有找到,就继续在父类Model
中查找metaclass
,找到了,就使用Model
中定义的metaclass
的ModelMetaclass
来创建User
类,也就是说,metaclass可以隐式地继承到子类,但子类自己却感觉不到。
在ModelMetaclass
中,一共做了几件事情:
排除掉对
Model
类的修改;在当前类(比如
User
)中查找定义的类的所有属性,如果找到一个Field属性,就把它保存到一个__mappings__
的dict中,同时从类属性中删除该Field属性,否则,容易造成运行时错误(实例的属性会遮盖类的同名属性);把表名保存到
__table__
中,这里简化为表名默认为类名。
在Model
类中,就可以定义各种操作数据库的方法,比如save()
,delete()
,find()
,update
等等。
我们实现了save()
方法,把一个实例保存到数据库中。因为有表名,属性到字段的映射和属性值的集合,就可以构造出INSERT
语句。
编写代码试试:
1 | u = User(id=12345, name='Michael', email='test@orm.org', password='my-pwd') |
输出如下:
1 | Found model: User |
可以看到,save()
方法已经打印出了可执行的SQL语句,以及参数列表,只需要真正连接到数据库,执行该SQL语句,就可以完成真正的功能。
不到100行代码,我们就通过metaclass实现了一个精简的ORM框架,是不是非常简单?
4. 错误、调试和测试
Python内置了一套异常处理机制,来帮助我们进行错误处理。
我们需要跟踪程序的执行,查看变量的值是否正确,这个过程称为调试。Python的pdb可以让我们以单步方式执行代码。
编写测试也很重要。有了良好的测试,就可以在程序修改后反复运行,确保程序输出符合我们编写的测试。
1. 错误处理
在程序运行的过程中,如果发生了错误,可以事先约定返回一个错误代码,这样,就可以知道是否有错,以及出错的原因。在操作系统提供的调用中,返回错误码非常常见。比如打开文件的函数open()
,成功时返回文件描述符(就是一个整数),出错时返回-1
。
用错误码来表示是否出错十分不便,因为函数本身应该返回的正常结果和错误码混在一起,造成调用者必须用大量的代码来判断是否出错:
1 | def foo(): |
一旦出错,还要一级一级上报,直到某个函数可以处理该错误(比如,给用户输出一个错误信息)。
所以高级语言通常都内置了一套try...except...finally...
的错误处理机制,Python也不例外。
1. try
用一个例子来看看try
的机制:
1 | try: |
当我们认为某些代码可能会出错时,就可以用try
来运行这段代码,如果执行出错,则后续代码不会继续执行,而是直接跳转至错误处理代码,即except
语句块,执行完except
后,如果有finally
语句块,则执行finally
语句块,至此,执行完毕。
上面的代码在计算10 / 0
时会产生一个除法运算错误:
1 | try... |
从输出可以看到,当错误发生时,后续语句print('result:', r)
不会被执行,except
由于捕获到ZeroDivisionError
,因此被执行。最后,finally
语句被执行。然后,程序继续按照流程往下走。
如果把除数0
改成2
,则执行结果如下:
1 | try... |
由于没有错误发生,所以except
语句块不会被执行,但是finally
如果有,则一定会被执行(可以没有finally
语句)。
可以猜测,错误应该有很多种类,如果发生了不同类型的错误,应该由不同的except
语句块处理。没错,可以有多个except
来捕获不同类型的错误:
1 | try: |
int()
函数可能会抛出ValueError
,所以我们用一个except
捕获ValueError
,用另一个except
捕获ZeroDivisionError
。
此外,如果没有错误发生,可以在except
语句块后面加一个else
,当没有错误发生时,会自动执行else
语句:
1 | try: |
Python的错误其实也是class,所有的错误类型都继承自BaseException
,所以在使用except
时需要注意的是,它不但捕获该类型的错误,还把其子类也“一网打尽”。比如:
1 | try: |
第二个except
永远也捕获不到UnicodeError
,因为UnicodeError
是ValueError
的子类,如果有,也被第一个except
给捕获了。
Python所有的错误都是从BaseException
类派生的,常见的错误类型和继承关系看这里:
https://docs.python.org/3/library/exceptions.html#exception-hierarchy
使用try...except
捕获错误还有一个巨大的好处,就是可以跨越多层调用,比如函数main()
调用bar()
,bar()
调用foo()
,结果foo()
出错了,这时,只要main()
捕获到了,就可以处理:
1 | def foo(s): |
也就是说,不需要在每个可能出错的地方去捕获错误,只要在合适的层次去捕获错误就可以了。这样一来,就大大减少了写try...except...finally
的麻烦。
2. 调用栈
如果错误没有被捕获,它就会一直往上抛,最后被Python解释器捕获,打印一个错误信息,然后程序退出。来看看err.py
:
1 | # err.py: |
执行,结果如下:
1 | $ python3 err.py |
出错并不可怕,可怕的是不知道哪里出错了。解读错误信息是定位错误的关键。我们从上往下可以看到整个错误的调用函数链:
错误信息第1行:
1 | Traceback (most recent call last): |
告诉我们这是错误的跟踪信息。
第2~3行:
1 | File "err.py", line 11, in <module> |
调用main()
出错了,在代码文件err.py
的第11行代码,但原因是第9行:
1 | File "err.py", line 9, in main |
调用bar('0')
出错了,在代码文件err.py
的第9行代码,但原因是第6行:
1 | File "err.py", line 6, in bar |
原因是return foo(s) * 2
这个语句出错了,但这还不是最终原因,继续往下看:
1 | File "err.py", line 3, in foo |
原因是return 10 / int(s)
这个语句出错了,这是错误产生的源头,因为下面打印了:
1 | ZeroDivisionError: integer division or modulo by zero |
根据错误类型ZeroDivisionError
,我们判断,int(s)
本身并没有出错,但是int(s)
返回0
,在计算10 / 0
时出错,至此,找到错误源头。
出错的时候,一定要分析错误的调用栈信息,才能定位错误的位置。
3. 记录错误
如果不捕获错误,自然可以让Python解释器来打印出错误堆栈,但程序也被结束了。既然我们能捕获错误,就可以把错误堆栈打印出来,然后分析错误原因,同时,让程序继续执行下去。
Python内置的logging
模块可以非常容易地记录错误信息:
1 | # err_logging.py |
同样是出错,但程序打印完错误信息后会继续执行,并正常退出:
1 | $ python3 err_logging.py |
通过配置,logging
还可以把错误记录到日志文件里,方便事后排查。
4. 抛出错误
因为错误是class,捕获一个错误就是捕获到该class的一个实例。因此,错误并不是凭空产生的,而是有意创建并抛出的。Python的内置函数会抛出很多类型的错误,我们自己编写的函数也可以抛出错误。
如果要抛出错误,首先根据需要,可以定义一个错误的class,选择好继承关系,然后,用raise
语句抛出一个错误的实例:
1 | # err_raise.py |
执行,可以最后跟踪到我们自己定义的错误:
1 | $ python3 err_raise.py |
只有在必要的时候才定义我们自己的错误类型。如果可以选择Python已有的内置的错误类型(比如ValueError
,TypeError
),尽量使用Python内置的错误类型。
最后,我们来看另一种错误处理的方式:
1 | # err_reraise.py |
在bar()
函数中,我们明明已经捕获了错误,但是,打印一个ValueError!
后,又把错误通过raise
语句抛出去了,这不有病么?
其实这种错误处理方式不但没病,而且相当常见。捕获错误目的只是记录一下,便于后续追踪。但是,由于当前函数不知道应该怎么处理该错误,所以,最恰当的方式是继续往上抛,让顶层调用者去处理。好比一个员工处理不了一个问题时,就把问题抛给他的老板,如果他的老板也处理不了,就一直往上抛,最终会抛给CEO去处理。
raise
语句如果不带参数,就会把当前错误原样抛出。此外,在except
中raise
一个Error,还可以把一种类型的错误转化成另一种类型:
1 | try: |
只要是合理的转换逻辑就可以,但是,决不应该把一个IOError
转换成毫不相干的ValueError
。
5. 小结
Python内置的try...except...finally
用来处理错误十分方便。出错时,会分析错误信息并定位错误发生的代码位置才是最关键的。
程序也可以主动抛出错误,让调用者来处理相应的错误。但是,应该在文档中写清楚可能会抛出哪些错误,以及错误产生的原因。
2. 调试
程序能一次写完并正常运行的概率很小,基本不超过1%。总会有各种各样的bug需要修正。有的bug很简单,看看错误信息就知道,有的bug很复杂,我们需要知道出错时,哪些变量的值是正确的,哪些变量的值是错误的,因此,需要一整套调试程序的手段来修复bug。
第一种方法简单直接粗暴有效,就是用print()
把可能有问题的变量打印出来看看:
1 | def foo(s): |
执行后在输出中查找打印的变量值:
1 | $ python err.py |
用print()
最大的坏处是将来还得删掉它,想想程序里到处都是print()
,运行结果也会包含很多垃圾信息。所以,我们又有第二种方法。
1. 断言
凡是用print()
来辅助查看的地方,都可以用断言(assert)来替代:
1 | def foo(s): |
assert
的意思是,表达式n != 0
应该是True
,否则,根据程序运行的逻辑,后面的代码肯定会出错。
如果断言失败,assert
语句本身就会抛出AssertionError
:
1 | $ python err.py |
程序中如果到处充斥着assert
,和print()
相比也好不到哪去。不过,启动Python解释器时可以用-O
参数来关闭assert
:
1 | # 英文大写字母 O |
关闭后,你可以把所有的assert
语句当成pass
来看。
2. logging
把print()
替换为logging
是第3种方式,和assert
比,logging
不会抛出错误,而且可以输出到文件:
1 | import logging |
logging.info()
就可以输出一段文本。运行,发现除了ZeroDivisionError
,没有任何信息。怎么回事?
别急,在import logging
之后添加一行配置再试试:
1 | import logging |
看到输出了:
1 | $ python err.py |
这就是logging
的好处,它允许你指定记录信息的级别,有debug
,info
,warning
,error
等几个级别,当我们指定level=INFO
时,logging.debug
就不起作用了。同理,指定level=WARNING
后,debug
和info
就不起作用了。这样一来,你可以放心地输出不同级别的信息,也不用删除,最后统一控制输出哪个级别的信息。
logging
的另一个好处是通过简单的配置,一条语句可以同时输出到不同的地方,比如console和文件。
3. pdb
第4种方式是启动Python的调试器pdb,让程序以单步方式运行,可以随时查看运行状态。我们先准备好程序:
1 | # err.py |
然后启动:
1 | $ python -m pdb err.py |
以参数-m pdb
启动后,pdb定位到下一步要执行的代码-> s = '0'
。输入命令l
来查看代码:
1 | (Pdb) l |
输入命令n
可以单步执行代码:
1 | (Pdb) n |
任何时候都可以输入命令p 变量名
来查看变量:
1 | (Pdb) p s |
输入命令q
结束调试,退出程序:
1 | (Pdb) q |
这种通过pdb在命令行调试的方法理论上是万能的,但实在是太麻烦了,如果有一千行代码,要运行到第999行得敲多少命令啊。还好,我们还有另一种调试方法。
4. pdb.set_trace()
这个方法也是用pdb,但是不需要单步执行,我们只需要import pdb
,然后,在可能出错的地方放一个pdb.set_trace()
,就可以设置一个断点:
1 | # err.py |
运行代码,程序会自动在pdb.set_trace()
暂停并进入pdb调试环境,可以用命令p
查看变量,或者用命令c
继续运行:
1 | $ python err.py |
这个方式比直接启动pdb单步调试效率要高很多,但也高不到哪去。
5. IDE
如果要比较爽地设置断点、单步执行,就需要一个支持调试功能的IDE。目前比较好的Python IDE有:
Visual Studio Code:https://code.visualstudio.com/,需要安装Python插件。
PyCharm:http://www.jetbrains.com/pycharm/
另外,Eclipse加上pydev插件也可以调试Python程序。
6. 小结
写程序最痛苦的事情莫过于调试,程序往往会以你意想不到的流程来运行,你期待执行的语句其实根本没有执行,这时候,就需要调试了。
虽然用IDE调试起来比较方便,但是最后你会发现,logging才是终极武器。
3. 单元测试
如果你听说过“测试驱动开发”(TDD:Test-Driven Development),单元测试就不陌生。
单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。
比如对函数abs()
,我们可以编写出以下几个测试用例:
输入正数,比如
1
、1.2
、0.99
,期待返回值与输入相同;输入负数,比如
-1
、-1.2
、-0.99
,期待返回值与输入相反;输入
0
,期待返回0
;输入非数值类型,比如
None
、[]
、{}
,期待抛出TypeError
。
把上面的测试用例放到一个测试模块里,就是一个完整的单元测试。
如果单元测试通过,说明我们测试的这个函数能够正常工作。如果单元测试不通过,要么函数有bug,要么测试条件输入不正确,总之,需要修复使单元测试能够通过。
单元测试通过后有什么意义呢?如果我们对abs()
函数代码做了修改,只需要再跑一遍单元测试,如果通过,说明我们的修改不会对abs()
函数原有的行为造成影响,如果测试不通过,说明我们的修改与原有行为不一致,要么修改代码,要么修改测试。
种以测试为驱动的开发模式最大的好处就是确保一个程序模块的行为符合我们设计的测试用例。在将来修改的时候,可以极大程度地保证该模块行为仍然是正确的。
我们来编写一个Dict
类,这个类的行为和dict
一致,但是可以通过属性来访问,用起来就像下面这样:
1 | >>> d = Dict(a=1, b=2) |
mydict.py
代码如下:
1 | class Dict(dict): |
为了编写单元测试,我们需要引入Python自带的unittest
模块,编写mydict_test.py
如下:
1 | import unittest |
编写单元测试时,我们需要编写一个测试类,从unittest.TestCase
继承。
以test
开头的方法就是测试方法,不以test
开头的方法不被认为是测试方法,测试的时候不会被执行。
对每一类测试都需要编写一个test_xxx()
方法。由于unittest.TestCase
提供了很多内置的条件判断,我们只需要调用这些方法就可以断言输出是否是我们所期望的。最常用的断言就是assertEqual()
:
1 | self.assertEqual(abs(-1), 1) # 断言函数返回的结果与1相等 |
另一种重要的断言就是期待抛出指定类型的Error,比如通过d['empty']
访问不存在的key时,断言会抛出KeyError
:
1 | with self.assertRaises(KeyError): |
而通过d.empty
访问不存在的key时,我们期待抛出AttributeError
:
1 | with self.assertRaises(AttributeError): |
1. 运行单元测试
一旦编写好单元测试,我们就可以运行单元测试。最简单的运行方式是在mydict_test.py
的最后加上两行代码:
1 | if __name__ == '__main__': |
这样就可以把mydict_test.py
当做正常的python脚本运行:
1 | $ python mydict_test.py |
另一种方法是在命令行通过参数-m unittest
直接运行单元测试:
1 | $ python -m unittest mydict_test |
这是推荐的做法,因为这样可以一次批量运行很多单元测试,并且,有很多工具可以自动来运行这些单元测试。
2. setUp与tearDown
可以在单元测试中编写两个特殊的setUp()
和tearDown()
方法。这两个方法会分别在每调用一个测试方法的前后分别被执行。
setUp()
和tearDown()
方法有什么用呢?设想你的测试需要启动一个数据库,这时,就可以在setUp()
方法中连接数据库,在tearDown()
方法中关闭数据库,这样,不必在每个测试方法中重复相同的代码:
1 | class TestDict(unittest.TestCase): |
可以再次运行测试看看每个测试方法调用前后是否会打印出setUp...
和tearDown...
。
3. 小结
单元测试可以有效地测试某个程序模块的行为,是未来重构代码的信心保证。
单元测试的测试用例要覆盖常用的输入组合、边界条件和异常。
单元测试代码要非常简单,如果测试代码太复杂,那么测试代码本身就可能有bug。
单元测试通过了并不意味着程序就没有bug了,但是不通过程序肯定有bug。
4. 文档测试
如果你经常阅读Python的官方文档,可以看到很多文档都有示例代码。比如re模块就带了很多示例代码:
1 | >>> import re |
可以把这些示例代码在Python的交互式环境下输入并执行,结果与文档中的示例代码显示的一致。
这些代码与其他说明可以写在注释中,然后,由一些工具来自动生成文档。既然这些代码本身就可以粘贴出来直接运行,那么,可不可以自动执行写在注释中的这些代码呢?
答案是肯定的。
当我们编写注释时,如果写上这样的注释:
1 | def abs(n): |
无疑更明确地告诉函数的调用者该函数的期望输入和输出。
并且,Python内置的“文档测试”(doctest)模块可以直接提取注释中的代码并执行测试。
doctest严格按照Python交互式命令行的输入和输出来判断测试结果是否正确。只有测试异常的时候,可以用...
表示中间一大段烦人的输出。
让我们用doctest来测试上次编写的Dict
类:
1 | mydict2.py |
运行python mydict2.py
:
1 | $ python mydict2.py |
什么输出也没有。这说明我们编写的doctest运行都是正确的。如果程序有问题,比如把__getattr__()
方法注释掉,再运行就会报错
注意到最后3行代码。当模块正常导入时,doctest不会被执行。只有在命令行直接运行时,才执行doctest。所以,不必担心doctest会在非测试环境下执行。
小结
doctest非常有用,不但可以用来测试,还可以直接作为示例代码。通过某些文档生成工具,就可以自动把包含doctest的注释提取出来。用户看文档的时候,同时也看到了doctest。
5. IO编程
1. 文件读写
读写文件是最常见的IO操作。Python内置了读写文件的函数,用法和C是兼容的。
读写文件前,我们先必须了解一下,在磁盘上读写文件的功能都是由操作系统提供的,现代操作系统不允许普通的程序直接操作磁盘,所以,读写文件就是请求操作系统打开一个文件对象(通常称为文件描述符),然后,通过操作系统提供的接口从这个文件对象中读取数据(读文件),或者把数据写入这个文件对象(写文件)。
1. 读文件
要以读文件的模式打开一个文件对象,使用Python内置的open()
函数,传入文件名和标示符:
1 | >>> f = open('/Users/michael/test.txt', 'r') |
标示符’r’表示读,这样,我们就成功地打开了一个文件。
如果文件不存在,open()
函数就会抛出一个IOError
的错误,并且给出错误码和详细的信息告诉你文件不存在:
1 | >>> f=open('/Users/michael/notfound.txt', 'r') |
如果文件打开成功,接下来,调用read()
方法可以一次读取文件的全部内容,Python把内容读到内存,用一个str
对象表示:
1 | >>> f.read() |
最后一步是调用close()
方法关闭文件。文件使用完毕后必须关闭,因为文件对象会占用操作系统的资源,并且操作系统同一时间能打开的文件数量也是有限的:
1 | >>> f.close() |
由于文件读写时都有可能产生IOError
,一旦出错,后面的f.close()
就不会调用。所以,为了保证无论是否出错都能正确地关闭文件,我们可以使用try ... finally
来实现:
1 | try: |
但是每次都这么写实在太繁琐,所以,Python引入了with
语句来自动帮我们调用close()
方法:
1 | with open('/path/to/file', 'r') as f: |
这和前面的try ... finally
是一样的,但是代码更佳简洁,并且不必调用f.close()
方法。
调用read()
会一次性读取文件的全部内容,如果文件有10G,内存就爆了,所以,要保险起见,可以反复调用read(size)
方法,每次最多读取size个字节的内容。另外,调用readline()
可以每次读取一行内容,调用readlines()
一次读取所有内容并按行返回list
。因此,要根据需要决定怎么调用。
如果文件很小,read()
一次性读取最方便;如果不能确定文件大小,反复调用read(size)
比较保险;如果是配置文件,调用readlines()
最方便:
1 | for line in f.readlines(): |
2. file-like Object
像open()
函数返回的这种有个read()
方法的对象,在Python中统称为file-like Object。除了file外,还可以是内存的字节流,网络流,自定义流等等。file-like Object不要求从特定类继承,只要写个read()
方法就行。
StringIO
就是在内存中创建的file-like Object,常用作临时缓冲。
3. 二进制文件
前面讲的默认都是读取文本文件,并且是UTF-8编码的文本文件。要读取二进制文件,比如图片、视频等等,用'rb'
模式打开文件即可:
1 | >>> f = open('/Users/michael/test.jpg', 'rb') |
4. 字符编码
要读取非UTF-8编码的文本文件,需要给open()
函数传入encoding
参数,例如,读取GBK编码的文件:
1 | >>> f = open('/Users/michael/gbk.txt', 'r', encoding='gbk') |
遇到有些编码不规范的文件,你可能会遇到UnicodeDecodeError
,因为在文本文件中可能夹杂了一些非法编码的字符。遇到这种情况,open()
函数还接收一个errors
参数,表示如果遇到编码错误后如何处理。最简单的方式是直接忽略:
1 | >>> f = open('/Users/michael/gbk.txt', 'r', encoding='gbk', errors='ignore') |
5. 写文件
写文件和读文件是一样的,唯一区别是调用open()
函数时,传入标识符'w'
或者'wb'
表示写文本文件或写二进制文件:
1 | >>> f = open('/Users/michael/test.txt', 'w') |
你可以反复调用write()
来写入文件,但是务必要调用f.close()
来关闭文件。当我们写文件时,操作系统往往不会立刻把数据写入磁盘,而是放到内存缓存起来,空闲的时候再慢慢写入。只有调用close()
方法时,操作系统才保证把没有写入的数据全部写入磁盘。忘记调用close()
的后果是数据可能只写了一部分到磁盘,剩下的丢失了。所以,还是用with
语句来得保险:
1 | with open('/Users/michael/test.txt', 'w') as f: |
要写入特定编码的文本文件,请给open()
函数传入encoding
参数,将字符串自动转换成指定编码。
以'w'
模式写入文件时,如果文件已存在,会直接覆盖(相当于删掉后新写入一个文件)。如果我们希望追加到文件末尾怎么办?可以传入'a'
以追加(append)模式写入。
所有模式的定义及含义可以参考Python的官方文档。
6. 小结
在Python中,文件读写是通过open()
函数打开的文件对象完成的。使用with
语句操作文件IO是个好习惯。
2. StringIO和BytesIO()
1. StringIO
很多时候,数据读写不一定是文件,也可以在内存中读写。
StringIO顾名思义就是在内存中读写str。
要把str写入StringIO,我们需要先创建一个StringIO,然后,像文件一样写入即可:
1 | >>> from io import StringIO |
getvalue()
方法用于获得写入后的str。
要读取StringIO,可以用一个str初始化StringIO,然后,像读文件一样读取:
1 | >>> from io import StringIO |
2. BytesIO()
StringIO操作的只能是str,如果要操作二进制数据,就需要使用BytesIO。
BytesIO实现了在内存中读写bytes,我们创建一个BytesIO,然后写入一些bytes:
1 | >>> from io import BytesIO |
请注意,写入的不是str,而是经过UTF-8编码的bytes。
和StringIO类似,可以用一个bytes初始化BytesIO,然后,像读文件一样读取:
1 | >>> from io import BytesIO |
3. 小结
StringIO和BytesIO是在内存中操作str和bytes的方法,使得和读写文件具有一致的接口。
3. 操作文件和目录
如果我们要操作文件、目录,可以在命令行下面输入操作系统提供的各种命令来完成。比如dir
、cp
等命令。
如果要在Python程序中执行这些目录和文件的操作怎么办?其实操作系统提供的命令只是简单地调用了操作系统提供的接口函数,Python内置的os
模块也可以直接调用操作系统提供的接口函数。
打开Python交互式命令行,我们来看看如何使用os
模块的基本功能:
1 | >>> import os |
如果是posix
,说明系统是Linux
、Unix
或Mac OS X
,如果是nt
,就是Windows
系统。
要获取详细的系统信息,可以调用uname()
函数:
1 | >>> os.uname() |
注意uname()
函数在Windows上不提供,也就是说,os
模块的某些函数是跟操作系统相关的。
1. 环境变量
在操作系统中定义的环境变量,全部保存在os.environ
这个变量中,可以直接查看:
1 | >>> os.environ |
要获取某个环境变量的值,可以调用os.environ.get('key')
:
1 | >>> os.environ.get('PATH') |
2. 操作文件和目录
操作文件和目录的函数一部分放在os
模块中,一部分放在os.path
模块中,这一点要注意一下。查看、创建和删除目录可以这么调用:
1 | # 查看当前目录的绝对路径: |
把两个路径合成一个时,不要直接拼字符串,而要通过os.path.join()
函数,这样可以正确处理不同操作系统的路径分隔符。在Linux/Unix/Mac下,os.path.join()
返回这样的字符串:
1 | part-1/part-2 |
而Windows下会返回这样的字符串:
1 | part-1\part-2 |
同样的道理,要拆分路径时,也不要直接去拆字符串,而要通过os.path.split()
函数,这样可以把一个路径拆分为两部分,后一部分总是最后级别的目录或文件名:
1 | >>> os.path.split('/Users/michael/testdir/file.txt') |
os.path.splitext()
可以直接让你得到文件扩展名,很多时候非常方便:
1 | >>> os.path.splitext('/path/to/file.txt') |
这些合并、拆分路径的函数并不要求目录和文件要真实存在,它们只对字符串进行操作。
文件操作使用下面的函数。假定当前目录下有一个test.txt
文件:
1 | # 对文件重命名: |
但是复制文件的函数居然在os
模块中不存在!原因是复制文件并非由操作系统提供的系统调用。理论上讲,我们通过上一节的读写文件可以完成文件复制,只不过要多写很多代码。
幸运的是shutil
模块提供了copyfile()
的函数,你还可以在shutil
模块中找到很多实用函数,它们可以看做是os
模块的补充。
最后看看如何利用Python的特性来过滤文件。比如我们要列出当前目录下的所有目录,只需要一行代码:
1 | >>> [x for x in os.listdir('.') if os.path.isdir(x)] |
要列出所有的.py
文件,也只需一行代码:
1 | >>> [x for x in os.listdir('.') if os.path.isfile(x) and os.path.splitext(x)[1]=='.py'] |
3. 小结
Python的os
模块封装了操作系统的目录和文件操作,要注意这些函数有的在os
模块中,有的在os.path
模块中。
4. 序列化
在程序运行的过程中,所有的变量都是在内存中,比如,定义一个dict:
1 | d = dict(name='Bob', age=20, score=88) |
可以随时修改变量,比如把name
改成'Bill'
,但是一旦程序结束,变量所占用的内存就被操作系统全部回收。如果没有把修改后的'Bill'
存储到磁盘上,下次重新运行程序,变量又被初始化为'Bob'
。
我们把变量从内存中变成可存储或传输的过程称之为序列化,在Python中叫pickling,在其他语言中也被称之为serialization,marshalling,flattening等等,都是一个意思。
序列化之后,就可以把序列化后的内容写入磁盘,或者通过网络传输到别的机器上。
反过来,把变量内容从序列化的对象重新读到内存里称之为反序列化,即unpickling。
Python提供了pickle
模块来实现序列化。
首先,我们尝试把一个对象序列化并写入文件:
1 | >>> import pickle |
pickle.dumps()
方法把任意对象序列化成一个bytes
,然后,就可以把这个bytes
写入文件。或者用另一个方法pickle.dump()
直接把对象序列化后写入一个file-like Object:
1 | >>> f = open('dump.txt', 'wb') |
看看写入的dump.txt
文件,一堆乱七八糟的内容,这些都是Python保存的对象内部信息。
当我们要把对象从磁盘读到内存时,可以先把内容读到一个bytes
,然后用pickle.loads()
方法反序列化出对象,也可以直接用pickle.load()
方法从一个file-like Object
中直接反序列化出对象。我们打开另一个Python命令行来反序列化刚才保存的对象:
1 | >>> f = open('dump.txt', 'rb') |
变量的内容又回来了!
当然,这个变量和原来的变量是完全不相干的对象,它们只是内容相同而已。
Pickle的问题和所有其他编程语言特有的序列化问题一样,就是它只能用于Python,并且可能不同版本的Python彼此都不兼容,因此,只能用Pickle保存那些不重要的数据,不能成功地反序列化也没关系。
1. JSON
如果我们要在不同的编程语言之间传递对象,就必须把对象序列化为标准格式,比如XML,但更好的方法是序列化为JSON,因为JSON表示出来就是一个字符串,可以被所有语言读取,也可以方便地存储到磁盘或者通过网络传输。JSON不仅是标准格式,并且比XML更快,而且可以直接在Web页面中读取,非常方便。
JSON表示的对象就是标准的JavaScript语言的对象,JSON和Python内置的数据类型对应如下:
Python内置的json
模块提供了非常完善的Python对象到JSON格式的转换。我们先看看如何把Python对象变成一个JSON:
1 | >>> import json |
dumps()
方法返回一个str
,内容就是标准的JSON。类似的,dump()
方法可以直接把JSON写入一个file-like Object
。
要把JSON反序列化为Python对象,用loads()
或者对应的load()
方法,前者把JSON的字符串反序列化,后者从file-like Object
中读取字符串并反序列化:
1 | >>> json_str = '{"age": 20, "score": 88, "name": "Bob"}' |
由于JSON标准规定JSON编码是UTF-8,所以我们总是能正确地在Python的str
与JSON的字符串之间转换。
2. JSON进阶
Python的dict
对象可以直接序列化为JSON的{}
,不过,很多时候,我们更喜欢用class
表示对象,比如定义Student
类,然后序列化:
1 | import json |
运行代码,毫不留情地得到一个TypeError
:
1 | Traceback (most recent call last): |
错误的原因是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 | def student2dict(std): |
这样,Student
实例首先被student2dict()
函数转换成dict
,然后再被顺利序列化为JSON:
1 | >>> print(json.dumps(s, default=student2dict)) |
不过,下次如果遇到一个Teacher
类的实例,照样无法序列化为JSON。我们可以偷个懒,把任意class
的实例变为dict
:
1 | print(json.dumps(s, default=lambda obj: obj.__dict__)) |
因为通常class
的实例都有一个__dict__
属性,它就是一个dict
,用来存储实例变量。也有少数例外,比如定义了__slots__
的class。
同样的道理,如果我们要把JSON反序列化为一个Student
对象实例,loads()
方法首先转换出一个dict
对象,然后,我们传入的object_hook
函数负责把dict
转换为Student
实例:
1 | def dict2student(d): |
运行结果如下:
1 | >>> json_str = '{"age": 20, "score": 88, "name": "Bob"}' |
打印出的是反序列化的Student
实例对象。
3. 小结
Python语言特定的序列化模块是pickle
,但如果要把序列化搞得更通用、更符合Web标准,就可以使用json
模块。
json
模块的dumps()
和loads()
函数是定义得非常好的接口的典范。当我们使用时,只需要传入一个必须的参数。但是,当默认的序列化或反序列机制不满足我们的要求时,我们又可以传入更多的参数来定制序列化或反序列化的规则,既做到了接口简单易用,又做到了充分的扩展性和灵活性。