本节书摘来自华章出版社《数据结构与算法:Python语言描述》一书中的第2章,第2.5节,作者 裘宗燕,更多章节内容可以访问云栖社区“华章计算机”公众号查看
2.5类定义实例:学校人事管理系统中的类
作为本章内容的总结,现在考虑一个综合性的实例:为一个学校的人员管理系统定义所需的表示人员信息的类,它们都是数据抽象。
2.5.1问题分析和设计
学校里有两大类人员,即学生和教职工,他们都是需要在系统里表示的对象。分析这两类人员需要记录的信息,可以看到这里有一些值得注意的情况:与两类人员有关的信息中存在一些公共部分,又有各自的特殊情况:
首先,作为人员信息,无论学生或教职工都有姓名、性别、年龄等公共信息。另外,为了便于学校管理,学生应该有一个学号,教职工也应该有一个职工号。
作为学生应该有学习记录,包括属于哪个院系、注册时间,特别是学习期间已经学过的各门课程及其成绩等。
教职工应该有入职时间、院系、职位和工资等信息。
由于两类人员的信息既有共性又有特殊性,特别适合采用面向对象的类继承机制处理。这里的考虑是首先定义一个公共的人员类,提供记录和查询人员基本信息的功能。然后从这个公共类分别派生出表示学生的类和表示教职工的类。
显然,表示人员或者其特殊情况(学生或教职工)都是数据表示问题,应该采用抽象数据类型的思想对其进行分析和设计。在开始具体的类定义(编程)之前,下面首先考虑如何设计出几个合适的抽象数据类型(ADT)。
基本人员ADT的设计
首先考虑一般人员ADT的定义。为建立这个ADT的具体对象,需要提供一组基本信息,包括有关人员的姓名、性别、出生年月日和一个人员编号(学号或职工号,这里要求提供,具体人员类ADT可以考虑具体的生成规则)。ADT的解析操作包括提取人员的编号、姓名、性别、出生年月日和年龄。还应允许人员改名,为此定义一个变动操作。由于人员记录可能需要排序,为此要有一个对象之间的“小于”运算符。还需要为输出定义一些辅助操作。根据这些考虑,可以给出下面抽象数据类型定义:
为管理一个学校的人员,还需考虑人数统计。这件事可以通过类里的数据属性和类方法完成,在下面的实现中可以考虑这个问题。
学生ADT的设计
每个学生属于一个院系,入学时确定。另外,学生的学号编制应该按一套规则自动生成,不需要人为选择。新的解析操作包括查看学生所属院系和入学时间(年),查看学生成绩单。变动操作应包括设置选课记录和课程成绩(是变动操作)等。
根据上述分析,可以给出下面抽象数据类型定义。这里借用了Python面向对象机制中类继承的形式,Student(Person) 表示继承Person抽象数据类型中除构造函数之外的其他操作。可以不引进这种缩写,但需要把Person ADT的定义体重抄一遍。
实现这个ADT时还需要实现一个生成学号的内部函数。具体技术后面考虑。
教职工ADT的设计
与学生的情况相对应,教职工ADT应有取得其院系、工资、入职时间等的解析函数,以及设置这些数据的变动操作:
完成了这些设计之后,下面可以进一步考虑其程序实现了。
2.5.2人事记录类的实现
这里的考虑是定义几个类,实现前面设计的各个ADT。在定义这些类之前先定义两个异常类,以便在定义人事类的操作中遇到异常情况时引发特殊的异常,使用这些类的程序部分可以正确地捕捉和处理。
人们在定义自己的特殊异常类时,多数时候都采用最简单的方式:只是简单选择一个合适的Python标准异常类作为基类,派生时不定义任何方法或数据属性。针对准备实现的类定义,这里派生两个专用的异常类:
class PersonTypeError(TypeError):
pass
class PersonValueError(ValueError):
pass
两个类的体都只有一个pass语句,只是为了填补语法上的缺位。在下面操作中遇到参数的类型或者值不满足需要时就引发这两个异常。
此外,由于人事类定义需要处理一些与时间有关的数据,直接采用Python标准库的有关功能类是最合适的。引进datatime标准库包:
import datetime
下面讨论几个人事管理类的实现。
公共人员类的实现
首先考虑基本人员类的定义,将这个类命名为Person。
为了统计在程序运行中建立的人员对象的个数,需要为Person类引进一个数据属性_num,每当创建这个类的对象就将其值加一。Person类的__init__方法里完成这一工作。下面是Person类的开始部分:
class Person:
_num = 0
def __init__(self, name, sex, birthday, ident):
if not (isinstance(name, str) and
sex in ("女", "男")):
raise PersonValueError(name, sex)
try:
birth = datetime.date(*birthday) # 生成一个日期对象
except:
raise PersonValueError("Wrong date:", birthday)
self._name = name
self._sex = sex
self._birthday = birth
self._id = ident
Person._num += 1 # 实例计数
__init__方法的主要工作是检查参数合法性,设置对象的数据属性。这些检查非常重要,只有通过细致检查,才能保证建立起的人员对象都是合法对象,使用这些对象的程序可以依赖于它们的合法性。对人员的名字,这里只要求它是一个字符串。对于性别,要求实参是两个汉字字符串之一,用运算符in检查。
最麻烦的问题是出生日期的检查。朴素的考虑可能是要求实参为一个三元组,三个元素都是整数,分别表示年、月、日。但是不难想到,并非任意三个整数都构成合法的年月日数据,例如 (2015, 23, 48) 就不是。可以自己实现完整的检查,但是很麻烦。上面的函数定义利用了标准库包datetime里的date类,其构造函数要求三个参数,如果实参不是合法日期值就会引发异常。在调用date构造日期对象时使用了拆分实参的描述方式。在上面方法定义里,try语句的异常处理器没有给定异常名,这说明它将捕捉构造date对象时发生的所有异常,处理器的体说明在这种情况下引发PersonValueError。后面几个语句都很简单,最后一个语句完成生成实例的计数工作。
Person类的其他方法都非常简单:
def id(self): return self._id
def name(self): return self._name
def sex(self): return self._sex
def birthday(self): return self._birthday
def age(self): return (datetime.date.today().year -
self._birthday.year)
def set_name(self, name): # 修改名字
if not isinstance(name, str):
raise PersonValueError("set_name", name)
self._name = name
def __lt__(self, another):
if not isinstance(another, Person):
raise PersonTypeError(another)
return self._id < another._id
这里有几个小问题需要做一点解释:①标准库date类的today方法返回函数调用时刻的日期,这也是一个date对象。date对象的year属性记录其年份,上面定义的age方法利用这些功能计算出两个年份之差,得到这个人的年龄。②实现小于运算的方法要求另一个参数也是Person,然后根据两个人员记录的_id域的大小确定记录的大小关系。其余情况都非常简单,无须赘述。
在这个类里还需要定义一个类方法,以便取得类中的人员计数值。另外定义了两个与输出有关的方法,它们都很简单:
@classmethod
def num(cls): return Person._num
def __str__(self):
return " ".join((self._id, self._name,
self._sex, str(self._birthday)))
def details(self):
return ", ".join(("编号: " + self._id,
"姓名: " + self._name,
"性别: " + self._sex,
"出生日期: " + str(self._birthday)))
这里的想法是让__str__提供对象的基本信息,details方法提供完整细节。请注意,字符串的join方法要求参数是可迭代对象,这里先做出一个元组。
至此Person类的基本定义就完成了。下面是使用这个类的几个语句:
p1 = Person("谢雨洁", "女", (1995, 7, 30), "1201510111")
p2 = Person("汪力强", "男", (1990, 2, 17), "1201380324")
p3 = Person("张子玉", "女", (1974, 10, 16), "0197401032")
p4 = Person("李国栋", "男", (1962, 5, 24), "0196212018")
plist2 = [p1, p2, p3, p4]
for p in plist2:
print(p)
print("\nAfter sorting:")
plist2.sort()
for p in plist2:
print(p.details())
print("People created:", Person.num(), "\n")
由于定义了__str__方法,因此可以直接用print输出Person对象。后几个语句还展示了可以对人员对象的表排序(表的sort方法里使用了“小于”运算符),以及通过Person类名调用类方法num的情况。
总而言之,这个类实现了前面ADT要求的功能。
学生类的实现
现在考虑学生类Student的实现。在这里需要关注几件事:①Student对象也是Person对象,因此,在建立Student对象时,应该调用Person类的初始化函数,建立起表示Person对象的那些数据属性。②这里希望Student类实现一种学号生成方式。为了保证学号的唯一性,最简单的技术就是用一个计数变量,每次生成学号时将其加一。这个变量应该是Student类内部的数据,但又不属于任何Student实例对象,因此应该用类的数据属性表示。③学号生成函数只在Student类的内部使用,但并不依赖于Student的具体实例。根据这些情况,该函数似乎应该定义为静态方法。但是,这个函数并不是独立的,它依赖于Student类中的数据属性。根据前面的讨论,应该将其定义为类方法,在其中实现所需的学号生成规则。
基于上面考虑,Student类的初始化函数定义如下:
class Student(Person):
_id_num = 0
@classmethod
def _id_gen(cls): # 实现学号生成规则
cls._id_num += 1
year = datetime.date.today().year
return "1{:04}{:05}".format(year, cls._id_num)
def __init__(self, name, sex, birthday, department):
Person.__init__(self, name, sex, birthday,
Student._id_gen())
self._department = department
self._enroll_date = datetime.date.today()
self._courses = {} # 一个空字典
这里的学号用一个数字字符串表示,利用str的format方法构造学号:规定学生学号的首位为1,以便与职工号区分;把学生的入学年份用4位十进制数字的形式编码在学号里;最后是5位的序号。学生对象里还要记录学生的院系和入学报道日期,最后用一个字典记录课程学习成绩,初始时设置为空字典。
其他方法都很容易考虑,下面只给出与选课和成绩有关的三个方法:
def set_course(self, course_name):
self._courses[course_name] = None
def set_score(self, course_name, score):
if course_name not in self._courses:
raise PersonValueError("No this course selected:",
course_name)
self._courses[course_name] = score
def scores(self): return [(cname, self._courses[cname])
for cname in self._courses]
这里假定了必须先选课,最后才能设定课程成绩。最后一个方法给出所有成绩的列表,其中用了一个表描述式,非常方便。
继续考虑可以发现一个问题:虽然Person类的details方法仍然可用,但Student对象包含的信息更多,原方法不能展示这些新属性。为了满足Student类的实际需要,必须修改details方法的行为,也就是说,需要定义一个同名的新方法,覆盖基类中已有定义的details方法。在定义这种新方法时,应该维持原方法的参数形式,并提供类似的行为,以保证派生类的对象能用在要求基类对象的环境中(“替换原理”)。此外,在新方法里,经常需要首先完成基类同名方法所做的工作。这件事可以通过在新方法里调用基类的同名方法实现。下面是新的details方法的定义:
def details(self):
return ", ".join((Person.details(self),
"入学日期: " + str(self._enroll_date),
"院系: " + self._department,
"课程记录: " + str(self.scores())))
当然,并不是每个派生类的覆盖方法都需要重复基类方法的工作,是否调用基类被覆盖的方法,应根据需要确定。如果需要,必须通过基类名去调用。
其余方法都非常简单,这里不再给出。
教职工类的实现
下面将教职工类命名为Staff,也定义为Person类的子类,继承Person类的基本定义。教职工记录对象也应包含Person类对象的所有数据属性,以便可以对它们调用Person类里定义的方法。在这些数据属性的基础上,Staff类还需要为其实例扩充一些数据属性,定义一组方法。
首先,Staff类也需要为教职工对象实现一个职工号生成函数,同样定义为类方法,基于Staff类的数据属性完成工作。这里假定职工号的首字符为0,其中编码了具体教职工的出生年份,在加上一个内定的序号。其余方法的定义都比较自然,下面是Staff类的重要部分,一些简单的方法没有给出,读者很容易自己补全:
class Staff(Person):
_id_num = 0
@classmethod
def _id_gen(cls, birthday): # 实现职工号生成规则
cls._id_num += 1
birth_year = datetime.date(*birthday).year
return "0{:04}{:05}".format(birth_year, cls._id_num)
def __init__(self, name, sex, birthday, entry_date=None):
super().__init__(name, sex, birthday,
Staff._id_gen(birthday))
if entry_date:
try:
self._entry_date = datetime.date(*entry_date)
except:
raise PersonValueError("Wrong date:",
entry_date)
else:
self._entry_date = datetime.date.today()
self._salary = 1720 # 默认设为最低工资, 可修改
self._department = "未定" # 需要另行设定
self._position = "未定" # 需要另行设定
def set_salary(self, amount):
if not type(amount) is int:
raise TypeError
self._salary = amount
def set_position(self, position):
self._position = position
def set_department(self, department):
self._department = department
def details(self):
return ", ".join((super().details(),
"入职日期: " + str(self._entry_date),
"院系: " + self._department,
"职位: " + self._position,
"工资: " + str(self._salary)))
请注意:在这里定义初始化方法和details方法时,都用super() 表示要求调用基类的方法,也是为了展示super函数的使用技术。在使用super() 时不需要提供self参数(请与Student中的情况对比)。实际上,在做复杂的面向对象程序时,人们更提倡采用super函数描述这种调用,而不是直呼基类的名字。直接写基类名将造成类定义代码之间更密切的关联,对程序的修改不利。
下面是一些使用这个类的语句:
p1 = Staff("张子玉", "女", (1974, 10, 16))
p2 = Staff("李国栋", "男", (1962, 5, 24))
print(p1)
print(p2)
p1.set_department("数学")
p1.set_position("副教授")
p1.set_salary(8400)
print(p1.details())
print(p2.details())
2.5.3讨论
本节通过几个大学人事信息类的定义,总结了利用Python面向对象的继承机制,以及在已有类的基础上定义派生类时可能遇到的各种问题,展示了Python面向对象编程的许多机制。在上面的类定义里,还介绍了类的数据属性和类方法的使用。
在面向对象编程领域,定义派生类主要有两种用途:
1)定义基类对象中的一类特殊个体,它们具有与基类对象类似的行为,可以作为基类对象使用(替换原理),但通常还有一些自己的特殊功能。为满足这种需要,从基类派生将能直接共享基类定义的操作,通过调用基类的初始化方法,建立派生类对象中与基类对象相同的部分。派生类对象继承基类的方法属性,可以用重新定义的方式覆盖原有方法,也可以定义新方法。在上面实例中,Student和Staff类都是公共人事类Person的派生类,其对象都是特殊的Person对象。
2)只是为了重用基类已有的功能,而将一个类定义为派生类。实际中有时也有这种需要,主要是为了代码的重用,这也是面向对象中继承机制的一类用途。
在定义一个类时,有时需要保存一些与整个类有关但并不特定于具体实例对象的信息,或者需要一些与整个类有关的功能。这些就需要通过类的数据属性和类方法实现。类的数据属性通过类层面的赋值语句定义,类方法需要用特殊前缀 @classmethod描述。在上面的实例里,多次使用了这方面的功能,如做对象计数,或为对象生成唯一编号等。这些在实际程序中都经常遇到。
基类和派生类是相对的。例如,为建立一个学校的人事系统,可能还需要从Staff出发派生出更具体的教职工类,如教师类、职员类等,或从学生类出发派生出具体的本科生类、硕士生类、博士生类等。它们又需要有一些特殊的行为。本章后面的习题提出了一些这方面的问题,供读者参考。
本章总结
随着计算机科学技术和软件领域的发展,人们逐渐认识到,数据的抽象和计算过程的抽象同样重要。以建立数据抽象为目标的抽象数据类型的思想逐渐发展起来,对程序和软件系统的设计以及编程语言的发展都产生了广泛而深远的影响。新的编程语言都为建立数据抽象设置了专门的结构或机制。所有设计优良的复杂软件系统在其设计和实现的许多方面都会反映并实践着抽象数据类型的思想。掌握抽象数据类型的基本思想和实践技术,是从简单编程走向复杂的实际应用开发历程中的重要一步。
抽象数据类型的基本思想是抽象定义与数据表示和数据操作的实现分离。定义抽象数据类型,首先要描述好这一类型的对象与外界的接口,通过一组操作(函数)描述。这样的接口定义在程序中划出了一条明晰的分界:一边是抽象数据类型的实现,可以采用适合具体需要的任何技术;另一边是使用这个抽象数据类型的其他程序部分,它们只需要相对于给定的操作接口定义,完全不必考虑有关功能是如何实现的。这种分离能很好地支持程序的模块化组织,是分解和实现大型复杂系统的最重要基础技术。
Python语言里专门用于支持数据抽象的机制是类(class)及其相关结构。解释器处理完一个类定义后生成一个类对象。类对象也是一种复合对象,具有类定义里描述的所有数据属性和函数属性,约束到给定的类名,就像函数定义将生成的函数对象约束于函数名一样。类对象的最重要功能就是可以通过调用的形式生成该类的实例对象。如果类中定义了名字为__init__的初始化函数,生成实例对象时就会自动调用它;如果没定义这个函数,生成的将是一个空对象。人们通常用初始化函数为实例对象建立数据属性,设置实例对象的初始状态。这样生成的实例对象可以通过方法调用的形式,使用其所属类中定义的各个实例函数。在类里还可以定义静态方法和类方法。
面向对象编程的另一个重要机制是继承,用于支持基于已有的一个或几个类定义新类。这样定义的新类称为派生类(或子类),被继承的类称为基类(或父类)。派生类可以利用基类的所有机制,可以重新定义基类中已有的方法,改变自己的实例对象的行为,或者通过定义新方法的方式扩充新实例对象的行为。在方法调用时,Python采用动态约束规则,根据调用对象的类型确定应该调用的函数。面向对象的观点把派生类看作基类的特殊情况,派生类的对象也看作基类的对象。如果在定义新类时不指明基类,Python就认为基类是object。这样,一个程序里的类定义形成了一种层次结构,其中最高层的类是object,其他类都是object的派生类。对复杂的程序,开发者可以通过恰当的类层次结构设计,把程序中的各种数据组织好,以利于程序的开发和维护。
综上所述,基于Python的类机制,不仅可以定义出一个具体的抽象数据类型,而且可以定义出一组相关的具有层次关系的抽象数据类型。在定义新类型时,可以通过继承的方式尽可能利用已有定义的功能,提高工作效率。良好设计的类层次结构还使开发者可以把所需操作定义在适当的抽象层次上,使操作尽可能地通用化。
Python的异常处理机制是完全基于类和对象的概念构造起来的。系统定义了一组异常类,形成了一套标准的异常类层次结构。引发一个异常就是生成相应异常类的一个对象。Python解释器的异常查找机制设法找到与异常匹配的处理器,匹配条件就是发生的异常对象属于处理器描述的异常类。在这里应该注意,派生类的对象也是基类的对象。因此,捕捉基类异常的处理器也能捕捉属于派生类的异常。如果用户需要定义自己的异常,只需要选择一个系统异常类,基于它定义一个派生类。
练习
一般练习
- 复习下面概念:抽象数据类型,接口,实现,过程抽象和数据抽象,类型,内置类型和用户定义类型,表示,不变类型和可变类型,不变对象和可变对象,类,类定义和类对象,类对象名字空间,类的属性(数据属性和函数属性),类的实例(实例对象,对象),方法,实例方法,self参数,方法和函数,函数isinstance,初始化方法,实例的属性和属性赋值,静态方法,类方法,实例变量和私有变量,Python的特殊方法名,继承,基类,派生类,方法覆盖,替换原理,类层次结构,类object,函数issubclass,类方法查找,静态约束和动态约束,函数super,Python标准异常,异常类层次结构,Exception异常,Python异常的传播和捕捉。
- 请列举出数据类型的三类操作,说明它们的意义和作用。
3.为什么需要初始化函数?其重要意义和作用是什么? - 设法说明在实际中某些类型应该定义为不变类型,另一些类型应该定义为可变类型。请各举出两个例子。
- 请简要说明在定义一个数据类型时应该考虑哪些问题?
- 请检查本章给出的Date抽象数据类型,讨论其中操作的语义说明里有哪些不精确之处,设法做些修改,消除描述中的歧义性。
- 请解释并比较类定义中的三类方法:实例方法、静态方法和类方法。
- 列出Python编程中有关类属性命名的约定。
- 请通过实例比较类作用域与函数作用域的差异。
- 试比较本章采用元组实现有理数和采用类实现有理数的技术,讨论这两种不同方式各自的优点和缺点。
编程练习 - 定义一个表示时间的类Time,它提供下面操作:
a)Time(hours、minutes、seconds)创建一个时间对象;
b)t.hours()、t.minutes()、t.seconds()分别返回时间对象t的小时、分钟和秒值;
c)为Time对象定义加法和减法操作(用运算符+和-);
d)定义时间对象的等于和小于关系运算(用运算符==和<)。
注意:Time类的对象可以采用不同的内部表示方式。例如,可以给每个对象定义三个数据属性hours、minutes和seconds,基于这种表示实现操作。也可以用一个属性seconds,构造对象时算出参数相对于基准时间0点0分0秒的秒值,同样可以实现所有操作。请从各方面权衡利弊,选择合适的设计。
上面情况表现出“抽象数据类型”的抽象性,其内部实现与使用良好隔离,换一种实现方式(或改变一些操作的实现技术)可以不影响使用它的代码。
- 请定义一个类,实现本章描述的Date抽象数据类型。
- 扩充本章给出的有理数类,加入一些功能:
a)其他运算符的定义;
b)各种比较和判断运算符的定义;
c)转换到整数(取整)和浮点数的方法;
d)给初始化函数加入从浮点数构造有理数的功能(Python标准库浮点数类型的as_integer_ratio()函数可以用在这里)。
对应运算符的特殊函数名请查看语言手册3.3.7节(Emulating numeric types)。
- 本章2.2.2节中有理数类的实现有一个缺点:每次调__init__都会对两个参数做一遍彻底检查。但是,在有理数运算函数中构造结果时,其中一些检查并不必要,浪费了时间。请查阅Python手册中与类有关的机制,特别是名字为__new_的特殊方法等,修改有关设计,使得到的实现能完成工作但又能避免不必要的检查。
- 请基于2.5节的工作继续扩充,为该学校人事系统定义研究生类、教师类和职员类。