Python 面向对象编程之类和对象
前面我们提到过,我们知道 面向对象的编程概述。面向过程是具体化的,流程化的,解决一个问题,你需要一步一步的分析,一步一步的实现,这就是面向过程的设计。而面向对象呢?其实,面向对象是模型化的,你只需抽象出一个类,这是一个封闭的盒子,在这里你拥有数据也拥有解决问题的方法。
一切皆对象,面向对象的程序设计把计算机程序视为一组对象的集合,而每个对象都可以接收其他对象发过来的消息,并处理这些消息,计算机程序的执行就是一系列消息在各个对象之间传递。
面向对象初识
Python 从设计之初就已经是一门面向对象的语言,在 Python 中所有数据类型都可以视为对象,当然也可以支持自定义对象。自定义的对象数据类型就是面向对象中的类(Class)的概念。
我们以一个例子来说明面向过程和面向对象在程序流程上的不同之处:
假设我们要处理学生的成绩表 >>>>
–> 面向过程:
为了表示一个学生的成绩,面向过程的程序可以用一个 dict
表示:
1 | std1 = { 'name': 'Michael', 'score': 98 } |
而处理学生成绩可以通过函数实现,比如打印学生的成绩:
1 | def print_score(std): |
–> 面向对象:
而如果采用面向对象的设计思想,我们首选思考的不是程序的执行流程,而是 Student
这种数据类型应该被视为一个对象,这个对象拥有 name
和 score
这两个属性(Property)。如果要打印一个学生的成绩,首先必须创建出这个学生对应的对象,然后,给对象发一个 print_score
消息,让对象自己把自己的数据打印出来。
1 | class Student(object): |
注意,以上代码仅是为了演示面向对象的编程思想,暂时先不需要深究。
给对象发消息实际上就是调用对象对应的关联函数,我们称之为对象的方法(Method)。面向对象的程序写出来就像这样:
1 | bart = Student('Bart Simpson', 59) |
上面,我们定义了 Class——Student,是指学生这个概念;而实例(Instance)则是一个个具体的 Student,比如:Bart Simpson
和 Lisa Simpson
是两个具体的 Student。
类和实例
面向对象最重要的概念就是类(Class)和实例(Instance)。
必须牢记类是抽象的模板,类仅仅充当图纸的作用,本身并不能直接拿来用,而只有根据图纸造出的实际物品(对象)才能直接使用。
比如 Student 类,实例(张三、李四)是根据类实例化出来的一个个具体的 “对象”,每个对象都拥有相同的方法(打印成绩),但各自的数据可能不同(姓名、成绩)。
因此,Python 程序中类的使用顺序是这样的:
- 创建(定义)类,也就是制作图纸的过程;
- 创建类的实例对象(根据图纸造出实际的物品),通过实例对象实现特定的功能。
类
Python 中,定义类是通过 class
关键字,其基本语法格式如下:
1 | class 类名(object): |
说明:
- 类名:符合标识符命名规范,推荐使用代表该类功能的单词组合(首字母大写,其它字母小写);
- 继承:
(object)
表示该类是从哪个类继承下来的,通常,如果没有合适的继承类,就使用object
类,这是所有类最终都会继承的类(也称为顶级父类); - 冒号:表示告诉 Python 解释器,下面要开始设计类的内部功能了,也就是编写类属性和类方法
- 类属性/类方法:无论是类属性还是类方法,对于类来说,它们都不是必需的,可以有也可以没有。另外,Python 类中属性和方法所在的位置是任意的,即它们之间并没有固定的前后次序。
关于继承,这里先不要深究~~~这里给出是为了保证类定义的完整性。
以 Student 类为例:
1 | class Student(object): |
和函数一样,我们也可以为类定义说明文档,其要放到类头之后,类体之前的位置。
其次,可以看到,学生类(Student)中包含了一个名为 add 的类属性。注意,根据定义属性位置的不同,在各个类方法之外定义的变量称为类属性或类变量(如 add 属性),而在类方法中定义的属性称为实例属性(或实例变量),区别和用法后面会进行说明。
同时,学生类(Student)中还包含一个 __init__()
类方法和一个 print_score
方法。
[1] >>>> 空类
Python 中支持创建一个没有任何类属性和类方法的空类:
1 | class Empty: |
但在实际应用中,很少会创建空类,因为空类没有任何实际意义。
[2] >>>> 类的构造方法
上面定义的 Student 类中,我们手动添加过一个 __init__(self)
方法,事实上,该方法是一个特殊的类方法,称为类的构造方法(构造函数)。
特殊在哪里??? >>>>
构造方法是用于后续创建类的对象(类实例化为对象)时使用的,每当创建一个类的实例对象时,Python 解释器都会自动调用它。
1)–> 隐式构造函数
事实上,定义类时,即使不手动为类添加任何构造方法,Python 也会自动为类添加一个仅包含 self 参数的构造方法(默认构造方法)。并且,在创建类对象(类实例化为对象)时,会自动调用默认构造方法。
2)–> 显示创建构造函数
手动(显示)添加构造方法的语法格式如下:
1 | def __init__(self,...): |
构造函数说明:
- 此方法的方法名中,开头和结尾各有 2 个下划线,且中间不能有空格。前面已经说过,Python 中很多这种以双下划线开头、双下划线结尾的方法,都具有特殊的意义;
__init__()
方法可以包含多个参数,但必须包含一个名为self
的参数,且必须作为第一个参数。
再贴出包含手动创建构造函数的类定义,重新认识一下:
1 | class Student(object): |
验证 >>>> 创建类的实例对象时,是否会自动调用构造函数:
1 | class Student(object): |
运行代码可看到如下结果:
1 | 类的构造函数被调用了! |
可以发现,创建类的实例对象时,确实隐式调用了我们手动创建的 __init__()
构造方法。
3)–> 多参数显示构造方法
在 __init__()
构造方法中,除了 self 参数外,还可以自定义一些参数,如下:
1 | class Student(object): |
由于在创建对象时会隐式调用类的构造方法,如果构造函数中又定义有多个参数时,必须手动传递参数。但注意,self 不需要手动传递参数:
1 | zhang_san = Student("zhangsan", 100) # 创建一个学生对象 >>> 张三(zhang_san) |
输出如下:
1 | zhangsan Score: 100 |
self 方法参数
不知道你发现没有,在前面类中定义方法的过程中,无论是定义类的构造方法,还是在类中定义一般方法,都要求将 self
参数作为方法的第一个参数。你可以返回去 Check 一下前面定义好的 Student 类。
那么,self 到底在其中扮演着什么样的角色呢?
[1] >>>> self 由来
事实上,Python 类中关于方法的定义规范是,无论是构造方法还是实例方法,最少要包含一个参数,但并没有规定该参数的具体名称。
也就是说,无论是定义构造方法还是实例方法中,都至少要包含一个参数,该参数名称是任意的(符合标识符命名)。
之所以将其命名为 self
,只是程序员之间约定俗成的一种习惯,遵守这个约定,可以使我们编写的代码具有更好的可读性,大家一看到 self 参数,就知道它的作用。
那么 >>>> 为什么要至少包含一个参数,self 参数到底有什么作用呢?
[2] >>>> 方法中第一个参数 self 的作用
我们把类比作是造房子的图纸,那么类实例化后的对象对应的是根据图纸建成的真正可以住的房子(对象创建)。
思考一下 >>>> 根据一张图纸(类),我们可以设计出成千上万的房子(类对象),每个房子长相都是类似的(都有相同的类变量和类方法),但它们都有各自的主人,那么如何对它们进行区分呢?
self 参数来了,它就相当于每个房子的门钥匙(对象的引用),可以保证每个房子的主人在使用房子时仅能进入自己的房子(每个类对象只能调用自己的类变量和类方法)。
类比到 Python 实际场景 >>>>
Python 中,同一个类可以实例化多个类对象,当使用某个对象调用类的方法时,该对象会自动把自身的引用作为第一个参数传给该方法,这样 Python 解释器就明白当前正在调用的类方法属于哪一个对象了。也就是说,对象调用类方法时,Python 会自动绑定类方法的第一个参数使其指向调用该方法的对象。
你可以简单为 >>>>
Python 解释器为了区分不同对象对构造方法或实例方法的拥有权,会自动绑定这些方法的第一个 self 参数到当前调用对象,self 就是一个指向当前调用对象的引用。
这样,你就可以理解上面为什么在调用实例方法和构造方法时,不需要手动为第一个 self 参数传值了。
最后给出一个示例来理解上述说明:
1 | class Student(object): |
好了,我们已经学会如何定义一个类,但要 想使用定义好的类,必须创建该类的实例对象。
类的对象
创建类实例对象的过程,又称为类的实例化过程,可以将类实例化成一个个具体的对象。
其语法格式如下:
1 | 类名(参数) |
说明:定义类时,如果没有手动添加 __init__()
构造方法,又或者添加的 __init__()
中仅有一个 self
参数,则创建类对象时的参数可以省略不写。
创建了名为 Language 的类,并对其进行了实例化:
1 | class Language : |
上面的程序中,由于构造方法除 self
参数外,还包含 2
个参数,且这 2
个参数没有设置默认参数,因此在实例化类对象时,需要传入相应的 name
值和 add
值(self
参数是特殊参数,不需要手动传值,Python 会自动传给它值)。
类实例化对象的使用 >>>>
一般情况下,类的使用,实际上是对类实例化后对象的使用。
上面我们已经完成了类的实例化,即获得了一个个的具体的对象。实例化后的类对象可以执行以下两种操作:
- 访问或修改类对象具有的实例变量,甚至可以添加新的实例变量或者删除已有的实例变量;
- 访问类对象的方法,包括调用现有的方法,甚至给类对象动态添加方法。
1)–> 访问类对象的变量或方法
使用已实例化好的类对象,访问类中实例变量(方法)的语法格式如下:
1 | 类对象名.变量名(方法名) |
给出代码演示,如何通过上面 language 对象调用类中的实例变量和实例方法:
1 | # 将该 Language 对象赋给 language 变量 |
2)–> 为类对象动态增加或删除实例变量
1 | # 为 language 对象增加一个 money 实例变量 |
3)–> 为类对象动态增加实例方法
以本节开头的 Language 类为例,由于其内部只包含一个 say()
方法,因此该类实例化出的 language 对象也只包含一个 say()
方法。Python 中允许为类对象动态增加方法,我们还可以为 language 对象动态添加其它方法。
需要注意的是,为 language 对象动态增加的方法,Python 不会自动将调用者自动绑定到第一个参数(即使将第一个参数命名为 self 也没用),函数调用是需要手动绑定。例如如下代码:
1 | # 先定义一个函数 |
有没有不用手动给 self 传值的动态增加方法呢? >>>> 需要借助 types
模块下的 MethodType
:
1 | def study(self,content): |
由于使用 MethodType
包装 study()
函数时,已经将该函数的 self 参数绑定为 language
,因此后续再使用 study()
函数时,就不用再给 self 参数绑定值了。
类属性和类方法详解
对于前面提到的类属性和类方法,看官老爷可能会有一种疑惑:怎么一会儿叫实例属性和实例方法,一会儿叫类属性和类方法,到底是不是一回事儿???
至少可以达成一个共识 >>>> 无论是类属性还是类方法,都无法像普通变量或者函数那样,在类的外部直接使用它们。我们可以将类看做一个独立的空间,则类属性其实就是在类体中定义的变量,类方法是在类体中定义的函数。
关于上述差别,和前面函数部分讲解的变量作用域是类似的,Python 中根据类体中变量定义的位置,以及变量和方法定义方式的不同,类属性和类方法的叫法以及使用也是有差别的。
类属性 && 实例属性
在类体中,根据变量定义位置以及方式的不同,类属性可细分为以下 3 种类型:
- 类体中、所有函数之外:此范围定义的变量,称为 类属性或类变量;
- 类体中,所有函数内部:以
self.varName
的方式定义的变量,称为 实例属性或实例变量; - 类体中,所有函数内部:以
varName = VarName
的方式定义的变量,称为 局部变量。
那么,类变量、实例变量以及局部变量之间有哪些不同呢?
[1] >>>> 类变量(类属性)
类变量指的是在类中,但在各个类方法外定义的变量。实例如下:
1 | class Language : |
上面代码中,name
和 add
就属于类变量。
1)–> 类变量访问方式
类变量的访问方式有 2 种,既可以使用类名直接访问,也可以使用类的实例化对象访问。
类名访问示例(可访问,可修改) >>>>
1 | # 使用类名直接访问 |
类对象访问示例(可访问,不可修改) >>>> 不推荐!!!后续会说明
1 | lang = Language() |
需要注意的是,通过类对象对类变量进行赋值,其本质将不再是修改类变量的值,而是在给该对象动态增加新的实例变量。
2)–> 类变量在所有类的实例化对象中作为公用资源存在
注意,类变量(类属性)为所有实例化对象共有,通过类名修改类变量的值,会影响所有的实例化对象。
3)–> 动态增加和删除类变量
类似于类对象中动态增加和删除实例变量操作,可以通过类名为类和类对象动态增加或删除类变量:
1 | Language.test = 10.2 |
总结:使用类名的方式进行类变量(类属性)的访问、修改以及删除,并且类变量(类属性)为所有实例化对象共有。
[2] >>>> 实例变量(实例属性)
实例变量指的是在任意类方法内部,以 self.变量名
的方式定义的变量,其特点是只作用于调用方法的对象。
另外,实例变量只能通过对象名访问,无法通过类名访问。
示例如下:
1 | class Language : |
说明:Language 类中,name
、add
以及 catalog
都是实例变量。其中,由于 __init__()
函数在创建类对象时会自动调用,而 say()
方法需要类对象手动调用。因此,Language 类的类对象都会包含 name
和 add
实例变量,而只有调用了 say()
方法的类对象,才包含 catalog
实例变量。
通过 __init__()
构造函数中实例变量(属性)的特性,你应该受到启发 >>>>
1)–> 实例变量为所有类的实例化对象绑定通用属性
有些人可能会问,既然是通用属性,那将这些通用属性定义为类变量(类属性)不就行了?
你没有考虑到的是,一旦通用属性被定义为类变量后,即被所有类的实例化对象所共享,导致该通用属性在所有对象中的取值都相同,这就失去其意义了。
也就是说,实例变量为类的实例化对象所独占。通过某个对象修改实例变量的值,不会影响类的其它实例化对象。
2)–> 动态增加和删除实例变量
前面讲过,通过类对象可以访问类变量,但无法修改类变量的值。这是因为,通过类对象修改类变量的值,不是在给“类变量赋值”,而是定义新的实例变量。
示例如下:
1 | class Language : |
3)–> 实例变量和类变量同名问题
类中,实例变量和类变量可以同名,但这种情况下使用类对象将无法调用类变量,它会首选实例变量!!!
明白前面说的:为什么不推荐 “类变量使用对象名调用” 的原因了吧。
[3] >>>> 局部变量
除了实例变量,类方法中还可以定义局部变量。和前者不同,局部变量直接以 变量名=值
的方式进行定义,例如:
1 | class Book: |
通常情况下,定义局部变量是为了所在类方法功能的实现。需要注意的一点是,局部变量只能用于所在函数中,函数执行完成后,局部变量也会被销毁。
类方法 && 静态方法 && 实例方法
和类属性一样,类方法也可以进行更细致的划分,具体可分为类方法、实例方法和静态方法:
- 类方法:采用 @classmethod 修饰的方法;
- 静态方法:采用 @staticmethod 修饰的方法;
- 实例方法:不用任何修饰的方法。
其中 @classmethod 和 @staticmethod 都是函数装饰器,后续章节会对其做详细介绍。
[1] >>>> 实例方法
通常情况下,在类中定义的方法默认都是实例方法,无需使用任何方法修饰符(使用最多)。
前面章节中,我们定义的类方法均为实例方法。甚至,类的构造方法(函数)理论上也属于实例方法,只不过它比较特殊。
1 | class Language : |
实例方法最大的特点就是,它最少也要包含一个 self
参数,用于绑定调用此方法的实例对象(Python 会自动完成绑定)。
并且,实例方法支持如下两种调用方式:
- 类对象调用(绑定方法调用);
- 类名调用(非绑定方法调用)。
两种方式的 调用差别在于:和前面使用类对象调用实例方法不同,通过类名直接调用实例方法时,Python 并不会自动给 self 参数传值(需要手动传递 self 对象引用)。
也就是说如果想用类调用实例方法,不能像如下这样:
1 | class Language : |
如上使用(不为 self 传入对象引用),会报出如下错误:
1 | Traceback (most recent call last): |
考虑一下,这也是合理的。self
参数需要的是方法的实际调用者(是类对象),而这里只提供了类名,当然无法自动传值。
故,采用类名调用实例方法时,必须手动为 self 参数传值:
1 | class Language : |
需要注意的是,上面的报错信息只是让我们手动为 self
参数传值,但并没有规定必须传一个该类的对象,其实完全可以任意传入一个参数,例如:
1 | class Language : |
可见,"zhang_san"
这个字符串传给了 info()
方法的 self
参数,但这样的使用会引发程序异常,需要注意。
[2] >>>> 类方法
Python 类方法和实例方法相似,它最少也要包含一个参数,只不过类方法中通常将其命名为 cls
,Python 会自动将 类本身绑定给 cls
参数。也就是说,在调用类方法时,无需显式为 cls 参数传参。
和
self
一样,cls
参数的命名也不是规定的(可以随意命名),只是 Python 程序员约定俗称的习惯而已。
和实例方法最大的不同在于,类方法需要使用 @classmethod
修饰符进行修饰,例如:
1 | class Language : |
需要注意的是,如果没有 @classmethod
,则 Python 解释器会将 info()
方法认定为实例方法,而不是类方法。
类方法调用 >>>>
类方法推荐使用类名直接调用,当然也可以使用实例对象来调用(不推荐):
1 | # 使用类名直接调用: |
[3] >>>> 静态方法
静态方法,其实就是前面学习的函数,和函数唯一的区别是,静态方法定义在类这个空间(类命名空间)中,而函数则定义在程序所在的空间(全局命名空间)中。
静态方法没有类似 self
、cls
这样的特殊参数,因此 Python 解释器不会对它包含的参数做任何类或对象的绑定。也正因为如此,类的静态方法中无法调用任何类属性和类方法。
静态方法需要使用 @staticmethod
修饰,例如:
1 | class Language : |
静态方法的调用 >>>>
既可以使用类名,也可以使用类对象进行静态方法的调用,例如:
1 | # 使用类名直接调用: |
在实际编程中,几乎不会用到类方法和静态方法,因为我们完全可以使用函数代替它们实现想要的功能,但在一些特殊的场景中(例如工厂模式中),使用类方法和静态方法也是很不错的选择。
类命名空间
前面提到过,Python 类体中的代码位于独立的命名空间(称为类命名空间)中。换句话说,所有用 class
关键字修饰的代码块,都可以看做是位于独立的命名空间中。
和类命名空间相对的是 全局命名空间,即整个 Python 程序默认都位于全局命名空间中。而类体则独立位于 类命名空间中。
事实上,类是由多个类属性和类方法构成,而类属性其实就是定义在类这个独立空间中的变量,而类方法其实就是定义在类空间中的函数,和定义在全局命名空间中的变量和函数相比,并没有明显的不同。
程序实例:
1 | # 全局空间定义变量 |
类命名空间中编写可执行程序 >>>>
Python 还允许直接在类命名空间中编写可执行程序(例如输出语句、分支语句、循环等等),例如:
1 | class Language: |
运行结果为:
1 | 正在执行类空间中的代码 |
类的封装特性(访问限制)
这一小节,我们来看面向对象四大特性之一的:封装。
我们知道,类(Class)的内部可以定义类属性和类方法,而外部代码可以直接通过 “类对象.属性名” 或 “类对象.方法名(参数)” 的方式来操作相应数据和类内部方法(看作是一个简单封装)。
1 | class Student(object): |
事实上,更多时候对于设计一个良好封装的类时,我们需要刻意地将一些属性和方法隐藏在类的内部,这样在使用此类时,将无法直接以 “类对象.属性名”(或 “类对象.方法名(参数)” 的形式调用这些属性(或方法)。
那么,如何将不想直接暴露给用户的属性和方法隐藏在类的内部呢?!! >>>>
Python 中的访问限制
和其它面向对象的编程语言(如 C++、Java)不同,Python 类中的变量和函数,不是公有的(类似 public 属性),就是私有的(类似 private),这两种属性的区别如下:
- public:公有属性的类变量和类函数,在类的外部、类内部以及子类中,都可以正常访问;
- private:私有属性的类变量和类函数,只能在本类内部使用,类的外部以及子类都无法使用。
但是,Python 并没有提供 public
、private
这些修饰符。为了实现类的封装,Python 采取了下面的方法:
- 默认情况下,Python 类中的变量和方法都是公有(public)的,它们的名称前都没有下划线
(_)
; - 如果类中的变量和函数,其名称以双下划线
(__)
开头,则该变量(函数)为私有变量(私有函数),其属性等同于 private。
所以,我们把 Student
类改一改:
1 | class Student(object): |
改完之后,对于外部代码来说,没什么变动,但是已经无法从外部访问 实例变量.__name
&& 实例变量.__score
&& 实例变量.__print_score
了:
1 | bart = Student('Bart Simpson', 59) |
私有变量或方法命名问题
除此之外,还可以定义以单下划线 (_)
开头的类属性或者类方法(例如 _name
、_display(self)
),这种类属性和类方法通常也被视为私有属性和私有方法。虽然它们也能通过类对象正常访问,但是按照约定俗成的规定,当你看到这样的变量或方法时,意思就是,“虽然我可以被访问,但是,请把我视为私有,不要随意访问”。
注意,Python 类中还有以双下划线 (__)
开头和结尾的类方法(例如类的构造函数 __init__(self)
),这些都是 Python 内部定义的,用于 Python 内部调用。我们自己定义类属性或者类方法时,不要使用这种格式。·
这样就确保了外部代码不能随意修改对象内部的状态,这种通过访问限制的保护,会使得代码更加健壮。
那么,如何访问这些被规定了访问限制的私有属性呢 >>>>
你需要使用 Python 中的描述符协议,或者使用类定义时暴露出来的 未隐藏的类方法 来间接操作这些隐藏的属性和方法(本质上都是通过 get && set 方法进行访问)。
Python 中的描述符
先来认识一下 Python 中描述符的概念:
本质上,描述符就是一个类,只不过它定义了另一个类中属性的访问方式。换句话说,一个类可以将属性管理全权委托给描述符类。
描述符是 Python 中复杂属性访问的基础,它在内部被用于实现
property
、@property 装饰器
和super
类型等。这里先不容深究这些概念~~~后文会给出相关说明。
描述符类基于以下 3 个特殊方法(换句话说,这 3 个方法组成了描述符协议):
__get__(self, obj, value)
:在读取属性时将调用这一方法(本节后续用getter
表示);__set__(self, obj, type=None)
:在设置属性时将调用这一方法(本节后续用setter
表示);__delete__(self, obj)
:对属性调用 del 时将调用这一方法。
其中,实现了 setter
和 getter
方法的描述符类被称为 数据描述符;反之,如果只实现了 getter
方法,则称为 非数据描述符。
实际上,在每次进行属性访问时,描述符协议中的方法都由类对象的特殊方法 __getattribute__()
调用(注意不要和 __getattr__()
弄混)。也就是说,每次使用 类对象.属性(或者 getattr(类对象,属性值))的调用方式时,都会隐式地调用 __getattribute__()
,它会按照下列顺序查找该属性:
- 验证该属性是否为类实例对象的数据描述符;
- 如果不是,就查看该属性是否能在类实例对象的
__dict__
中找到; - 最后,查看该属性是否为类实例对象的非数据描述符。
为了表达清楚,这里举个例子:
1 | # 描述符类 |
可以看出,如果一个类的某个属性有数据描述符,那么每次查找这个属性时,都会调用描述符的 __get__()
方法,并返回它的值;同样,每次在对该属性赋值时,也会调用 __set__()
方法。
注意,虽然上面例子中没有使用 __del__()
方法,但也很容易理解,当每次使用 del 类对象.属性(或者 delattr(类对象,属性))
语句时,都会调用该方法。
除了使用描述符类来对类属性进行封装外,还可以使用 property()函数 或者 @property 装饰器:
Python property() 函数
前面,我们一直在用 类对象.属性
的方式访问类中定义的属性,其实这种做法是欠妥的,因为它破坏了类的封装原则。正常情况下,类包含的属性应该是隐藏的,只允许通过类提供的公共方法(未隐藏的类方法)来间接实现对类属性的访问和操作。
因此,在不破坏类封装原则的基础上,为了能够有效操作类中的属性,类中应定义用于读(或写)类属性的多个 getter && setter 方法,这样就可以通过这些暴露出来的公共方法来操作隐藏的类属性了。
例如,上面的 Student 类中,如果外部代码要访问、修改以及删除 name
怎么办?方法如下:
1 | class Student(object): |
看官老爷可能会觉得,这种操作类属性的方式比较麻烦,更习惯使用 类对象.属性
这种方式。
property() 函数登场 >>>>
庆幸的是,Python 中提供了 property()
函数,让开发者依旧使用 类对象.属性
的方式操作类中的属性。其语法格式如下:
1 | 属性名 = property(fget=None, fset=None, fdel=None, doc=None) |
说明,fget
参数用于指定获取该属性值的类方法,fset
参数用于指定设置该属性值的方法,fdel
参数用于指定删除该属性值的方法,最后的 doc 是一个文档字符串,用于说明此函数的作用。
注意,在使用
property()
函数时,以上 4 个参数可以仅指定第 1 个、或者前 2 个、或者前 3 个,当前也可以全部指定。也就是说,property()
函数中参数的指定并不是完全随意的。
例如,修改上面的程序,为 name
属性配置 property()
函数:
1 | class Student(object): |
注意,由于 getname()
方法中需要返回 name
属性,如果使用 self.name
的话,其本身又被调用 getname()
,这将会先入无限死循环。
当然,property()
函数也可以少传入几个参数。以上面的程序为例,我们可以修改 property()
函数如下所示:
1 | name = property(getname, setname) |
这意味着,name
是一个可读写的属性,但不能删除。因为 property()
函数中并没有为 name
配置用于函数该属性的方法。也就是说,即便 Student 类中设计有 delname()
函数,这种情况下也不能用来删除 name
属性。但你仍然可以使用 delname
函数。
Python @property 装饰器
Python 中,既要保护类的封装特性,又要让开发者可以使用 对象.属性
的方式操作操作类属性,除了使用 property()
函数,Python 还提供了 @property
装饰器。
通过 @property 装饰器,可以直接通过方法名来访问方法,不需要在方法名后添加一对 ()
小括号。
@property 的语法格式如下:
1 |
|
例如,定义一个矩形类,并定义用 @property
修饰的方法操作类中的 area
私有属性,代码如下:
1 | class Rect: |
上面程序中,使用 @property 修饰了 area()
方法,这样就使得该方法变成了 area
私有属性的 getter
方法。
你可以将其简单看作是为 area
私有属性添加一个不带下划线的同名 getter
方法,以提供 对象.属性
方式进行私有属性的读取。如果只包含该方法,area
私有属性只具有读属性。
也就是说,在使用 Rect
类时,无法对 area
属性重新赋值,即运行如下代码会报错:
1 | rect.area = 90 |
如果想要为 area
私有属性添加修改以及删除操作,就需要使用 setter 装饰器 && deleter 装饰器
添加不带下划线的同名 getter && deleter
方法:
1 |
|
完整代码样例如下:
1 | class Rect: |
这样 area
私有属性就有了 getter && setter && deleter
方法,该属性就变成了具有读、写、删除功能的属性了。
访问限制优点
使用上述的封装机制,保证了类内部数据结构的完整性。因为使用类的用户无法直接看到类中的数据结构,只能使用类允许公开的数据和方法,很好地避免了外部对内部数据的影响,提高了程序的可维护性。
并且由于用户只能借助暴露出来的类方法来访问数据,这时只需要在这些暴露的方法中加入适当的控制逻辑,即可轻松避免用户对类中属性或方法的不合理操作,有助于提高程序的健壮性。
例如,你可以通过下面形式来提高程序的健壮性:
1 | class Student(object): |
深入了解访问限制底层原理
事实上,Python 封装特性的实现纯属“投机取巧”,之所以类对象无法直接调用以双下划线开头命名的类属性和类方法,是因为其底层实现时,Python 偷偷改变了它们的名称。
我们定义了一个 Student 类,定义如下:
1 | class Student(object): |
在这个类中,有一个 __print_score()
方法,由于其是私有方法,且该类没有提供任何调用该方法的“接口”,因此在目前看来,此方法根本无法在类外部调用。也就是说,如下调用 __print_score()
方法是不可行的:
1 | zhangsan = Student("zhang_san", 99) |
那么,是不是类似 __print_score()
这种的私有方法,真的没有方法调用吗?
事实上,对于以双下划线开头命名的类属性或类方法,Python 在底层实现时,将它们的名称都偷偷改成了 _类名__属性(方法)名
的格式。
以 Student 类中的 __print_score()
为例,Python 在底层将其方法名偷偷改成了_Student__print_score
。例如在 Student 类的基础上,执行如下代码:
1 | class Student(object): |
原来如此~~~,再尝试一下私有的类属性的访问:
1 | print(zhangsan._Student__name) |
类的继承特性
继承特性,经常用于创建和现有类功能类似的新类,又或是新类只需要在现有类基础上添加一些成员(属性和方法),但又不想直接将现有类代码复制给新类。也就是说,通过使用继承这种机制,可以轻松实现类的重复使用,提高其复用性。
在 OOP 程序设计中,当我们定义一个 class 的时候,可以从某个现有的 class 继承,新的 class 称为 子类(Subclass),而被继承的 class 称为 基类、父类或超类(Base class、Super class)。有读者可能还听说过 “派生” 这个词汇,它和继承是一个意思,只是观察角度不同而已。换句话说,继承是相对子类来说的,即子类继承自父类;而派生是相对于父类来说的,即父类派生出子类。
子类继承父类时,只需在定义子类时,将父类(可以是多个)放在子类之后的圆括号里即可。语法格式如下:
1 | class 类名(父类1, 父类2, ...): |
注意,如果类中没有显式指定继承自哪个类,则默认继承 object 类(object 类是 Python 中所有类的父类,顶级父类)。另外,Python 的继承是多继承机制(和 C++ 一样),即一个子类可以同时拥有多个直接父类。
假设已经编写了一个名为 Animal
的类,定义有一个 run()
实例方法可以直接打印:
1 | class Animal(object): |
当我们需要编写 Dog
和 Cat
类时,要求新类不仅具有 run()
方法,还具有叫声方法 call()
。此时,笨方法是将 run()
方法直接复制到新类中并且添加表示叫声的方法 call()
,这样就没有复用之前已经定义好的 Animal
类。
事实上,更简单的方法,就是使用类的继承机制。此时可以直接从 Animal
类继承:
1 | class Animal(object): |
对于 Dog
来说,Animal
就是它的父类,对于 Animal
来说,Dog
就是它的子类。Cat
和 Dog
类似。
[1] >>>> 子类继承父类所有的属性以及方法
继承有什么好处???
继承最大的好处是子类获得了父类的全部功能,即具有父类全部的属性和方法(即便该属性或方法是私有(private)的)。
上面由于 Animial
类中已经实现了 run()
方法,因此 Dog
和 Cat
作为它的子类,即使什么事也没干,但自动拥有了 run()
方法:
1 | class Animal(object): |
[2] >>>> 子类重写(覆盖)父类中方法
继承第二个好处是在子类中可以重写父类中方法。
我们知道,子类继承了父类,那么子类就拥有了父类所有的类属性和类方法。通常情况下,子类会在此基础上,扩展一些新的类属性和类方法。
能会遇到这样一种情况,即子类从父类继承得来的类方法中,大部分是适合子类使用的,但有个别的类方法,并不能直接照搬父类的,如果不对这部分类方法进行修改,子类对象无法使用。针对这种情况,我们就需要在子类中重复父类的方法。
上面 Animal 的例子,无论是 Dog
还是 Cat
,它们 run()
的时候,显示的都是 Animal is running...
,然而符合逻辑的做法是分别显示 Dog is running...
和 Cat is running...
,因此:
1 | class Animal(object): |
事实上,如果我们在子类中重写(覆盖)了从父类继承来的类方法,那么当在类的外部通过子类对象调用该方法时,Python 总是会执行子类中重写的方法。
子类中调用父类中被重写的方法 >>>>
这就产生一个新的问题,即如果想调用父类中被重写的这个方法,该怎么办呢?
我们知道,Python 中的类可以看做是一个独立空间,而类方法其实就是出于该空间中的一个函数。而如果想要全局空间或者其它类的独立空间中,调用类空间中的函数,只需要通过类名调用该函数即可(要注意此时为未绑定方法调用,注意给 self 传参)。
[3] >>>> Python 中的多继承
大部分面向对象的编程语言,都只支持单继承,即子类有且只能有一个父类。而 Python 却支持多继承(C++也支持多继承)。和单继承相比,多继承容易让代码逻辑复杂、思路混乱,一直备受争议,中小型项目中较少使用,后来的 Java、C#、PHP 等干脆取消了多继承。
使用多继承经常需要面临的问题是,多个父类中包含同名的类方法。
对于这种情况,Python 的处置措施是:根据子类继承多个父类时这些父类的前后次序决定,即排在前面父类中的类方法会覆盖排在后面父类中的同名类方法。
举个样例:
1 | class People: |
推荐:尽管 Python 在语法上支持多继承,但逼不得已,建议大家不要使用多继承。
深入理解 Python 中的 MRO
我们知道,Python 类是支持(多)继承的,一个类的方法和属性可能定义在当前类,也可能定义在基类。当调用类方法或类属性时,就需要对当前类以及它的基类进行搜索,以确定方法或属性的位置,而搜索的顺序就称为方法解析顺序(MRO)。
方法解析顺序(Method Resolution Order,MRO)。对于 只支持单继承的编程语言来说,MRO 很简单,就是从当前类开始,逐个搜索它的父类(思考一下上面说到的 “子类重写(覆盖)父类中方法”);而对于 Python,它支持多继承,MRO 相对会复杂一些。
实际上,Python 发展至今,经历了以下 3 种 MRO 算法,分别是:
- 从左往右,采用深度优先搜索(Deep-first search, DFS)的算法,称为旧式类的 MRO;
- 自 Python 2.2 版本开始,新式类 MRO 在采用深度优先搜索算法的基础上,对其做了优化;
- 自 Python 2.3 版本,对新式类采用了 C3 算法。由于 Python3.X 仅支持新式类,所以我们只使用 C3 算法。
为什么 MRO 弃用了前两种算法,而选择最终的 C3 算法呢??? >>>> 前两种算法都存在一定的问题。
旧式类 MRO 算法
在使用旧式类的 MRO 算法时,以 【程序一】 为例:
1 | class A: |
通过分析可以想到,此程序中的 4 个类是一个“菱形”继承的关系,当使用 D 类对象访问 method()
方法时,根据 深度优先算法,搜索顺序为 D->B->A->C->A
。
因此,使用旧式类的 MRO 算法最先搜索得到的是基类 A
中的 method()
方法,即在 Python<2.2 版本中,此程序的运行结果为:
1 | CommonA |
但是,这个结果显然不是想要的,我们希望搜索到的是 C
类中的 method()
方法。
新式类 MRO 算法
Python 2.2 版本中推出了新的计算新式类 MRO 的方法,它仍然采用从左至右的 深度优先遍历,但是如果遍历中出现重复的类,只保留最后一个。
【程序一】 中,通过深度优先遍历,其搜索顺序为 D->B->A->C->A
,由于此顺序中有两个 A
,因此仅保留后一个,简化后得到最终的搜索顺序为 D->B->C->A
。
新式类可以直接通过
类名.__mro__
的方式获取类的 MRO,也可以通过类名.mro()
的形式,旧式类是没有__mro__
属性和mro()
方法的。
可以看到,这种 MRO 方式已经能够解决“菱形”继承的问题,但是可能会违反单调性原则。单调性原则是指在类存在多继承时,子类不能改变基类的 MRO 搜索顺序,否则会导致程序发生异常。
例如,分析如下程序 【程序二】:
1 | class X(object): |
通过进行深度遍历,得到搜索顺序为 C->A->X->object->Y->object->B->Y->object->X->object
,再进行简化(相同取后者),得到 C->A->B->Y->X->object
。
下面来分析这样的搜索顺序是否合理,我们来看下各个类中的 MRO:
- 对于 A,其搜索顺序为
A->X->Y->object
; - 对于 B,其搜索顺序为
B->Y->X->object
; - 对于 C,其搜索顺序为
C->A->B->Y->X->object
。
可以看到,A 和 C 中,X、Y 的搜索顺序是相反的,也就是说,当 A 被继承时,它本身的搜索顺序发生了改变,这违反了单调性原则。
MRO C3
为解决 Python 2.2 中 MRO 所存在的问题,Python 2.3 采用了 C3 方法来确定方法解析顺序。多数情况下,如果某人提到 Python 中的 MRO,指的都是 C3 算法。
在 Python 2.3 及后续版本中,运行 【程序一】,得到如下结果:
1 | CommonC |
运行 【程序二】,会产生如下异常:
1 | Traceback (most recent call last): |
由此可见,C3 可以有效解决前面 2 种算法的问题。
那么,C3 算法是怎样实现的呢???
C3 实现方法 >>>>
以 【程序一】 为主:
1 | class A: |
C3 把各个类的 MRO 记为如下等式:
- 类 A:L[A] = merge(A , object)
- 类 B:L[B] = [B] + merge(L[A] , [A])
- 类 C:L[C] = [C] + merge(L[A] , [A])
- 类 D:L[D] = [D] + merge(L[B] , L[C] , [B] , [C])
注意,以类 A 等式为例,其中 merge 包含的 A 称为
L[A]
的头,剩余元素(这里仅有一个object
)称为尾。对于类 B 等式,其中包含两个列表,L[A] && [A],分别有头和尾。
这里的关键在于 merge,它的运算方式如下:
- 检查第一个列表的头元素(如
L[A]
的头),记作 H; - 若 H 未出现在 merge 中其它列表的尾部,则将其输出,并将其从所有列表中删除,然后回到步骤 1;否则,取出下一个列表的头部记作 H,继续该步骤。
重复上述步骤,直至列表为空或者不能再找出可以输出的元素。如果是前一种情况,则算法结束;如果是后一种情况,Python 会抛出异常。
由此,可以计算出类 A && B && C && D 的 MRO,其计算过程为:
1 | L[A] = merge(A , object) |
你可以在 Python 交互式环境下通过 类名.mro()
验证一下如上推理:
1 | A.mro() |
继承升阶
我们知道,Python 中内置有一个 object 类,它是所有内置类型的共同祖先,也是所有没有显式指定父类的类(包括用户自定义的)的共同祖先。
因此在实际编程过程中,如果想实现与某个 Python 内置类型具有类似行为的类时,最好的方法就是将这个内置类型子类化。
内置类型子类化,其实就是自定义一个新类,使其继承有类似行为的内置类,通过重定义这个新类实现指定的功能。
举个例子,如下所示创建了一个名为 newDict 的类,其中 newDictError
是自定义的异常类:
1 | class newDictError(ValueError): |
可以看到,newDict
是 Python 中 dict
类型的子类,所以其大部分行为都和 dict
内置类相同。唯一不同之处在于,newDict
不允许字典中多个键对应相同的值。如果用户试图添加具有相同值的新元素,则会引发 newDictError
异常,并给出提示信息。
由于目前尚未学习如何处理异常,因此这里没有
newDictError
做任何处理,异常处理会在后续章节做详细讲解。
另外,如果查看现有代码你会发现,其实很多类都是对 Python 内置类的部分实现,它们作为子类的速度更快,代码更整洁。
其实,除了 Python 中常用的基本内置类型,collections
模块中还额外提供了很多有用的容器,这些容器可以满足大部分情况。
Super() 使用
我们知道,Python 中子类会继承父类所有的类属性和类方法。严格来说,类的构造方法其实就是实例方法,因此毫无疑问,父类的构造方法,子类同样会继承。
但我们知道,Python 是一门支持多继承的面向对象编程语言,如果子类继承的多个父类中包含同名的类实例方法,则子类对象在调用该方法时,会优先选择排在最前面的父类中的实例方法。显然,构造方法也是如此。
1 | class People: |
Person 类同时继承 People 和 Animal,其中 People 在前。这意味着,在创建 per
对象时,其将会调用从 People 继承来的构造函数。因此我们看到,上面程序在创建 per
对象的同时,还要给 name
属性进行赋值。
场景引入
但如果去掉上述代码中最后一行的注释符,运行此行代码,Python 解释器会报如下错误:
1 | Traceback (most recent call last): |
这是因为,从 Animal 类中继承的 display()
方法中,需要用到 food
属性的值,但由于 People 类的构造方法“遮蔽”了Animal 类的构造方法,使得在创建 per
对象时,Animal 类的构造方法未得到执行,所以程序出错。
反过来也是如此,如果将代码改为如下形式:
1 | class Person(Animal, People) |
则在创建 per
对象时,会给 food
属性传值。这意味着,per.display()
能顺序执行,但 per.say()
将会报错。
怎么办???
针对这种情况,正确的做法是定义 Person 类自己的构造方法(等同于重写第一个直接父类的构造方法)。
但需要注意,如果在子类中定义构造方法,则必须在该方法中调用父类的构造方法。
子类中调用父类构造函数
也就是说,当某个类具有多继承时,为了保证可以正常使用继承至其父类中的各种方法,一般需要在子类中定义构造方法,并且必须在该方法中调用父类的构造方法。
在子类中的构造方法中,调用父类构造方法的方式有两种,分别是:
- 类可以看做一个独立空间,在类的外部调用其中的实例方法,可以向调用普通函数那样,只不过需要使用非绑定方式进行调用
类名.方法名(参数)
。同理前面子类中调用父类中被重写的方法说明; - 使用 super() 函数,但注意如果涉及多继承时,该函数只能调用第一个直接父类的构造方法。
也就是说,涉及到多继承时,在子类构造函数中,调用第一个父类构造方法的方式有以上 2 种(super() 函数有使用限制),而调用其它父类构造方法的方式只能使用未绑定方法。
super()
函数的使用语法格式如下:
1 | # Python 2.X && Python 3.X |
例如,对于上面的程序可以尝试如下修改:
1 | class People: |
可以看到,Person 类自定义的构造方法中,调用 People 类构造方法,可以使用 super()
函数,也可以使用未绑定方法。但是调用 Animal 类的构造方法,只能使用未绑定方法。
注意,这里 super()
方法不仅可以调用第一个直接父类中的构造函数,还可以调用其第一个父类中的重写方法(或其它方法),例如:
1 | class Person(People, Animal): |
Super 注意事项
Python 中,由于基类不会在子类 __init__()
中被隐式地调用,需要程序员显式调用它们。这种情况下,当程序中包含多重继承的类层次结构时,使用 super 是非常危险的,往往会在类的初始化过程中出现问题。
[1] >>>> 混用 super 与显式类调用
分析如下程序,C 类使用了 __init__()
方法调用它的基类,会造成 B 类被调用了 2 次:
1 | class A: |
出现以上这种情况的原因在于,C 的实例调用 A.__init__(self)
,使得 super(A,self).__init__()
调用了 B.__init__()
方法。换句话说,super
应该被用到整个类的层次结构中。
但是,有时这种层次结构的一部分位于第三方代码中,我们无法确定外部包的这些代码中是否使用 super()
,因此,当需要对某个第三方类进行子类化时,最好查看其内部代码以及 MRO 中其他类的内部代码。
[2] >>>> 不同种类参数
使用 super 的另一个问题是初始化过程中的参数传递。如果没有相同的签名(参数),一个类怎么能调用其基类的 __init__()
代码呢?这会导致下列问题:
1 | class commonBase: |
可以看到,base1
&& base2
需要不同的参数传入,使用形式一还是形式二传入呢?都是会导致问题的!!!
一种解决方法是使用 *args
和 **kwargs
包装的参数和关键字参数,这样即使不使用它们,所有的构造函数也会传递所有参数,如下所示:
1 | class commonBase: |
不过,这是一种很糟糕的解决方法,由于任何参数都可以传入,所有构造函数都可以接受任何类型的参数,这会导致代码变得脆弱。另一种解决方法是在 MyClass 中显式地使用特定类的 __init__()
调用,但这无疑会导致第一种错误。
如果想要避免程序中出现以上的这些问题,这里给出几点建议:
- 尽可能避免使用多继承,可以使用一些设计模式来替代它;
- super 的使用必须一致,即在类的层次结构中,要么全部使用 super,要么全不用。混用 super 和传统调用是一种混乱的写法;
- 如果代码需要兼容 Python 2.x,在 Python 3.x 中应该显式地继承自 object。在 Python 2.x 中,没有指定任何祖先地类都被认定为旧式类。
- 调用父类时应提前查看类的层次结构,也就是使用类的
__mro__
属性或者mro()
方法查看有关类的 MRO。
前面,我们介绍了类的封装和继承特性,下面来看其多态性:
类的多态特性
我们都知道,Python 是弱类型语言,其最明显的特征是在使用变量时,无需为其指定具体的数据类型。这会导致一种情况,即同一变量可能会被先后赋值不同的类对象,例如:
1 | class Language: |
可以看到,a
可以被先后赋值为 Language
类和 IPython
类的对象,但这并不是多态。
类的多态特性,需要满足以下两个前提条件:
- 继承:多态一定是发生在子类和父类之间;
- 重写:子类重写了父类的方法。
对上面代码的改写:
1 | class CLanguage: |
CPython 和 CLinux 都继承自 CLanguage 类,且各自都重写了父类的 say()
方法。从运行结果可以看出,同一变量 a
在执行同一个 say()
方法时,由于 a
实际表示不同的类实例对象,因此 a.say()
调用的并不是同一个类中的 say()
方法,这就是多态。
要理解多态的好处,我们还需要再编写一个函数,这个函数接受一个 CLanguage
类型的变量:
1 | class WhoSay: |
此程序中,通过给 WhoSay 类中的 say()
函数添加一个 cLanguage
参数,其内部利用传入的 cLanguage
调用 say()
方法。这意味着,当调用 WhoSay 类中的 say()
方法时,我们传给 cLanguage
参数的是哪个类的实例对象,它就会调用那个类中的 say()
方法。
开闭原则 >>>>
对于一个变量,我们只需要知道它是 cLanguage
类型,无需确切地知道它的子类型,就可以放心地调用 say()
方法,而具体调用的 say()
方法是作用在 CLanguage
、CPython
还是 CLinux
对象上,由运行时该对象的确切类型决定,这就是多态真正的威力。
这样,调用方只管调用,不管细节,而当我们新增一种 CLanguage
的子类时,只要确保 say()
方法编写正确,不用管原来的代码是如何调用的。这就是著名的“开闭”原则:
对扩展开放:允许新增 CLanguage
子类;
对修改封闭:不需要修改依赖 CLanguage
类型的 say()
等函数。
静态语言 vs 动态语言
对于静态语言(例如 Java)来说,如果需要传入 CLanguage
类型,则传入的对象必须是 CLanguage
类型或者它的子类,否则,将无法调用 say()
方法。
对于 Python 这样的动态语言来说,则不一定需要传入 CLanguage
类型。我们只需要保证传入的对象有一个 say()
方法就可以了:
1 | class Timer(object): |
例如,Python 的 file-like object
类型。对真正的文件对象,它有一个 read()
方法,返回其内容。
但是对于许多对象,只要有 read()
方法,都被视为 file-like object
。许多函数接收的参数就是 file-like object
,你不一定要传入真正的文件对象,完全可以传入任何实现了 read()
方法的对象。
枚举类
一些具有特殊含义的类,其实例化对象的个数往往是固定的,比如用一个类表示月份,则该类的实例对象最多有 12
个;再比如用一个类表示季节,则该类的实例化对象最多有 4
个。
针对这种特殊的类,Python 3.4 中新增加了 Enum 枚举类。 也就是说,对于这些实例化对象个数固定的类,可以用枚举类来定义。
[1] >>>> 枚举类的定义和属性访问
例如,下面程序演示了如何定义一个枚举类:
1 | from enum import Enum |
如果想将一个类定义为枚举类,只需要令其继承自 enum 模块中的 Enum 类即可。例如在上面程序中,Color 类继承自 Enum 类,则证明这是一个枚举类。
在 Color 枚举类中,red、green、blue 都是该类的成员(可以理解为是类变量)。注意,枚举类的每个成员都由 2 部分组成,分别为 name 和 value,其中 name 属性值为该枚举值的变量名(如 red
),value 代表该枚举值的序号(序号通常从 1
开始)。
和普通类的用法不同,枚举类不能用来实例化对象,但这并不妨碍我们访问枚举类中的成员。访问枚举类成员的方式有多种,例如以 Color 枚举类为例,在其基础上添加如下代码:
1 | from enum import Enum |
[2] >>>> 枚举类不支持外部修改
枚举类中各个成员的值,不能在类的外部做任何修改,也就是说,下面语法的做法是错误的:
1 | Color.red = 4 |
[3] >>>> 枚举类成员比较
枚举类成员之间不支持比较大小,但可以用 ==
或者 is
进行比较是否相等,例如:
1 | print(Color.red == Color.green) |
[4] >>>> 枚举类成员遍历
遍历枚举类中所有成员的支持两种方式:
1 | for color in Color: |
除此之外,该枚举类还提供了一个 __members__
属性,该属性是一个包含枚举类中所有成员的字典,通过遍历该属性,也可以访问枚举类中的各个成员。例如:
1 | for name,member in Color.__members__.items(): |
[5] >>>> @unique 装饰器
Python 枚举类中各个成员必须保证 name
互不相同(唯一Key),但 value
可以相同,举个例子:
1 | from enum import Enum |
可以看到,Color 枚举类中 red 和 green 具有相同的值(都是 1
),Python 允许这种情况的发生,它会将 green 当做是 red 的别名,因此当访问 green 成员时,最终输出的是 red。
在实际编程过程中,如果想避免发生这种情况,可以借助 @unique
装饰器,这样当枚举类中出现相同值的成员时,程序会报 ValueError
错误。例如:
1 | from enum import Enum,unique |
[6] >>>> Enum() 函数创建枚举类
除了通过继承 Enum 类的方法创建枚举类,还可以使用 Enum() 函数快速创建枚举类。例如:
1 | from enum import Enum |
实战搜索引擎
要想实现一个搜索引擎,首先要了解什么是搜索引擎。
简单地理解,搜索引擎是一个系统,它可以帮助用户去互联网上搜集与其检索内容相关的信息。
通常,一个搜索引擎由搜索器、索引器、检索器以及用户接口组成,其中各个部分的含义如下:
- 搜索器:其实就是我们常说的爬虫、它能够从互联网中搜集大量的信息,并将之传递给索引器;
- 索引器:理解搜索器搜索到的信息,并从中抽取出索引项,存储到内部的数据库中,等待检索;
- 检索器:根据用户查询的内容,在已经建立好的索引库中快速检索出与之相关的信息,并做相关度评价,以此进行排序;
- 用户接口:其作用就是提供给用户输入查询内容的窗口(例如百度、谷歌的搜索框),并将检索好的内容反馈给用户。
由于爬虫知识不是重点,这里不再做深入介绍,我们假设搜索样本就存在于本地磁盘中的文件。为了方便,这里只提供五个用于检索的文件,各文件存放的内容分别如下:
1 | # 1.txt |
下面,根据以上知识,我们先实现一个最基本的搜索引擎:
1 | class SearchEngineBase: |
以上代码仅是建立了搜索引擎的一个基本框架,它可以作为基类被其他类继承,那么继承自此类的类将分别代表不同的搜索引擎,它们应该各自实现基类中的 process_corpus() 和 searcher() 方法。
整个代码的运行过程是这样的,首先将各个检索文件中包含的内容连同该文件所在的路径一起传递给索引器,索引器会以该文件的路径建立索引,等待用户检索。
在 SearchEngineBase 类的基础上,下面实现了一个基本可以工作的搜索引擎:
1 | class SimpleEngine(SearchEngineBase): |
运行结果如下:
1 | C语言中文网 |
可以看到,用户搜索与 “Python 官方文档” 有关的内容,最终检索到了 1.txt、3.txt和 4.txt 文件中包含与之相关的内容。由此,只需要短短十来行代码就可以实现一个基础的搜索引擎。
install_url
to use ShareThis. Please set it in _config.yml
.