读书笔记:数据结构与算法-Python语言描述【第2章:抽象数据类型和Python类】

第2章:抽象数据类型和Python类

2.1 抽象数据类型

抽象数据类型(Abstract Data Type,ADT)是计算机领域中被广泛接受的一种思想和方法,也是一种用于设计和实现程序模块的有效技术。ADT的基本思想是抽象,或者说是数据抽象。(数据抽象,与函数定义实现的计算抽象或称计算抽象,所相对应。)

2.1.1 数据类型与数据构造。

类型(数据类型),是程序设计领域最重要的基本概念之一,在程序里描述的、通过计算机去处理的数据,通常都分属于不同的类型,如整形或浮点型等等。每个类型包含一集的合法数据对象,并规定了对这些对象的合法操作。各种编程语言都有类型的概念,每种语言都提供了一组内置数据类型,为每个内置类型提供了一批操作。

以Python为例,它提供的基本类型包括逻辑类型bool,数值类型int和float等、字符串类型str,还有一些组合数据类型。但是,无论编程语言提供了多少内置类型,在处理较为复杂的问题时,程序员或早或晚都会遇到一些情况,此时各种内置类型都不能满足或者不能适合于自己的需要。

在这种情况下,编程语言提供的组合类型是可以解决一些问题的,如Python所提供的list,tuple,set,dict等结构。编程时可以利用它们把一组相关的数据组织在一起,构成一个数据对象,作为一个整体存储、传递和处理。

举个例子,假设程序需要处理有理数,最简单朴素的想法就是用两个整数分别表示一个有理数的分子和分母。在此基础上实现所需要的运算和操作,在这种安排下,把有理数3/5存入变量可能写成:

a1 = 3
b1 = 5

而利用Python函数可返回多对象元组和多项赋值的机制,加法函数可以如下定义:

def rational_plus(a1,b1,a2,b2):
    num = a1*b2 + b1*a2
    den = b1*b2
    return num,den

下面是一个简单的函数使用实例:

In [2]: a2,b2 = rational_plus(a1,b1,7,10)

In [3]: a2,b2
Out[3]: (65, 50)

不难想到的是,如果真的这样去写程序,很快就会遇到非常麻烦的管理问题:这里用每两个单独的变量来为一个有理数赋值,那么,编程者需要时时刻刻记住哪两个变量记录的是一个有理数的分子和分母,操作时不能混淆不同的有理数;如果需要换一个有理数参与变量,也会遇到成对变量名的替换问题。而程序比较复杂时,做这类问题就很容易出错,一旦真的出错,确定错在哪里并改正也极其费时费力。

一种简单改进是利用编程语言的数据组合机制,把相关的多项简单数据组合在一起,还是看有理数的例子,可以考虑用一个Python元组(tuple)而非两个单独的变量来表示一个有理数,约定其中第0项表示分子,第1项表示分母,这样就可以写:

##用元组而非两个独立变量来表示一个有理数:

r1 = (3,5) 
r2 = (7,10)  #r1和r2分别表示了两个不同的有理数

def rational_plus(r1,r2):
    num = r1[0]*r2[1] + r2[0]*r1[1]
    den = r1[1]*r2[1]
    return num,den

尝试使用该函数:

In [6]: r3 = rational_plus(r1,r2)

In [7]: r3
Out[7]: (65, 50)

现在的情况显然好了很多,许多管理问题得到了缓解,这就是数据构造和组织的作用。

但是,如果进一步考虑,就会发现这样做仍然有多方面的缺陷,例如:

  1. 这里使用的不是特殊的“有理数”而是普通的元组,因此不能将其与其他元组区别表示。例如,(3,5)表示平面上X坐标为3,Y坐标为5的点。从概念上说,把一个有理数和一个格点相加是非常荒谬的,但是Python编程语言,包括上面定义的函数rational_plus都不会认为这样做是错误的。
  2. 与有理数相关的操作并没有绑定于有理数的二元组,由于Python不需要说明函数参数的类型,这个问题更加严重。
  3. 在为有理数定义运算(函数)时,需要直接按位置去的元素。对有理数这样结构简单,在操作中只需要区分位置0,1的对象还算比较容易处理,给思维带来的负担也可以忍受。但是如果需要处理的数据对象更复杂,比如包含了十几个甚至几十个不同成员,在为这种组合数据对象定义操作的时候,记住每个成员在对象里的位置并正确适用,就变成一件非常麻烦的事情了。更不要提修改数据的时候。

以上是原书中的说法,概括一下就是两个问题:第一,没有把“有理数”这一特殊的类型,和其他的意义完全不同的元组类型区分开来;第二,由于前一项的缘故,所以关于有理数的操作也没有根据数据类型进行特化,而完全是从元组中读取下标的操作,并不方便和直观。

2.1.2 抽象数据类型的概念

造成前一节中揭示出的变成缺陷的最重要问题之一,就是数据的表示完全暴露,以及对象使用和操作实现对具体表示的依赖性。要克服这些缺点,就要把对象的使用与其具体实现隔离开来。

理想的情况是:在编程中使用一种对象时,只需要考虑“如何使用”,而不需要(最好是根本不能)去关注和触及对象的内部表示,这样的数据对象就是一种抽象单元。一组这样的对象构成一个抽象的数据类型,为程序里的使用提供了一整套功能。

抽象数据类型的基本想法,是把数据定义为抽象的对象集合,只为它们定义可用的合法操作,并不暴露其内部实现的具体细节,不论是其数据的表示细节还是操作的实现细节。当然,要使用一种对象,首先需要能够早这种对象,然后能操作它们。抽象数据类型的操作应该满足这些要求,一个数据类型的操作通常可以分为以下三类:

  1. 构造操作:这类操作基于一些已知信息,产生出这种类型的一个新对象。
  2. 解析操作:这种操作从一个对象取得有用的信息,其结果反映了被操作对象的某方面特性,但结果并不是本类型的对象。
  3. 变动操作:这类操作修改被操作对象的内部状态。

当然,一个抽象数据类型还应该有一个名字,用于代表这个类型。

其实,编程语言的一个内置类型,就可以看做是一个抽象数据类型。Python的字符串类型是一个典型实例:字符串对象有一种内部表示形式(无需对外公布),人们用Python编程序时并不依赖于实际表示(甚至不知道其具体表示方式);str提供了一组操作供编程使用,每个操作都有明确的抽象意义,不依赖于内部的具体实现技术。

作为数据类型,特别是很复杂的数据类型,有一个很重要的性质被称为变动性,表示该类型的对象,在创建之后是否允许变化。如果某个类型只提供上面的第1和第2类操作,那么该类型的对象在创建之后就不会变化,永远处于一个固定的状态。这样的类型被称为不变数据类型,这种类型的对象则被称为不变对象。对于这种类型,在程序里只能(基于其他信息或已有对象)构造新对象或者取得已有对象的特性,不能修改已经建立的对象。如果一个类型提供了第3类操作,对该类型的对象执行这种操作后,虽然对象依旧,但是它的内部状态已经改变,这样的类型就称为可变操作类型,其对象称为可变对象。下面经常把不变数据类型和可变数据类型分别简称为不变类型和可变类型。

2.1.3 抽象数据类型的描述

定义一个抽象数据类型,目的是要定义一类计算对象,它们具有某些特定的功能,可以在计算中使用,这类对象的功能体现为一组可以对它们使用的操作,当然,还需要为这一抽象数据类型确定一个类型名。

下面为抽象数据类型引进一种描述方式,其形式体现了抽象数据类型的主要特点。

在后面介绍各种数据结构的时候,有关章节也经常是先给出一个抽象数据类型的描述,写出这种描述的过程本身也很有意义,因为它能够帮助开发者理清对希望定义的数据类型的想法,清晰第表达出各方面的形势要求(如操作的名字,参数的个数和类型等等)和功能要求(希望这个操作完成什么样的计算,产生什么样的效果)。

现在考虑一个简单地有理数抽象数据类型,有下面描述:

ADT Rational: #定义有理数的抽象数据类型
    Rational(int num,int num) #构造有理数num/den
    +(Rational r1, Rational r2) #求出表示r1+r2的有理数
    -(Rational r1, Rational r2) #求出表示r1-r2的有理数
    *(Rational r1, Rational r2) #求出表示r1*r2的有理数
    /(Rational r1, Rational r2) #求出表示r1/r2的有理数
    num(Rational r1) #取得有理数r1的分子
    den(Rational r1) #取得有理数r1的分母

这里用特殊名字ADT表示这是一个抽象数据类型的描述,随它之后给出被定义类型的名字。ADT定义的主要部分描述了一组操作,每个操作的描述由两个部分组成:首先是用标识符或者特殊符号的形式给出的操作名和操作的参数表,随后用类似Python注释的形式给出操作的功能描述。另请注意,在描述操作的参数时,可以考虑在参数名前写一个类型名,表示这个参数应该具有的类型,也可以省略,通过文字叙述说明。

具体到上面的抽象数据类型,其名字是Rational,其中共提供了7个操作。第一个操作以Rational作为名字,这种形式表示它是一个最基本的构造操作,从其他类型的参数出发构造本类型的对象。随后的几个算术运算也是构造操作,它们基于Rational类型的对象生成Rational类型的新对象。最后两个是解析操作,取得有理数对象的性质(成分)。

使用抽象数据类型的思想和技术,不但可以描述有理数一类的数学类型,也可以描述实际应用中所需的各种类型,比如,下面描述了一个表示日期的抽象数据类型:

ADT Date:#定义日期对象的抽象数据类型
    Date(int year, int month, int day) #构造表示year/month/day的对象
    difference(Date d1, Date d2) #求出d1和d2的时间差
    plus(Date d, int n) #计算出日期d之后n天的日期
    num_day(int year, int n) #计算出year年第n天的日期
    adjust(Date d, int n) #将日期d调整n天(n为带符号参数)

在这个描述里,同样用注释的形式给出了每个操作的解释。注意,上面这个类型里出现了一个第三类操作adjust。举例说明其用途:假设在一个实际应用中建立了一个表示开会日期的对象,随后这个对象被系统的许多地方(体现为具体的功能模块)共享,如会务、交通、餐饮、住宿等方面的管理子系统。后来出现了一些情况,导致会议的会期需要修改。这时存在两种修改方案:其一是用adjust操作去修改那个日期对象,由于对象共享,这样修改的结果会被各有关机构职介看到;第二个方案是另行构造一个表示新会期的对象,然后重新给各部门发一轮通知,要求它们都用新日期对象替换原来的对象。显然这两种方案都能解决问题,但是基于它们的工作细节却大不相同。

上面看了两个抽象数据类型的例子,现在总结其中的一些情况:

  • 一个ADT描述,由一个头部和按一定格式给出的一组操作构成。
  • ADT的头部给出类型名,最前面是表示抽象数据类型的关键词ADT。
  • 操作的形式描述给出操作的名字、参数的类型和参数名。在ADT描述中,参数名主要用在解释这个操作的地方(上面借用了Python的注释形式)
  • 各操作实际功能用自然语言描述,这是一种非形式的说明主要是为了帮助理解这些操作需要(能够)做什么,以便正确地实现和使用它们。

在抽象数据类型的描述中,其他方面都比较清晰和严格,用自然语言形式给出的功能描述则不然,自然语言有天然的非精确性和歧义性,用它写的描述很难精确无误。这种描述的意义需要人去理解,误解是造成错误的最重要根源之一。

举例说,仔细考虑上面有关日期的ADT,会发现一些说的不够清楚的地方。例如,“求出d1和d2的日期差”是什么意思?是否包含两端(或者一端)的日期?对整数n,“调整n天”的确切含义是什么?这些可能需要进一步解释。

ADT是一种思想,也是一种组织程序的技术,主要包括:

  1. 围绕着一类数据定义程序模块,如上面的Rational和Date都是这样。
  2. 模块的接口和实现分离。上面只给出了模块的接口规范,包括模块名、模块提供的各个操作的名字和参数。每个操作还有非形式化的语义说明。
  3. 在需要实现时,从所用的编程语言里选择一套合适的机制,采用合理的技术,实现这种ADT的功能。包括具体的数据表示和操作。

2.2 Python的类

Python语言里没有直接的ADT定义,本节介绍最常用也是最自然的一种技术:利用class定义(类定义)实现抽象数据类型。

2.2.1 有理数类

类(class)定义机制用于定义程序里需要的类型,定义好的一个类就像是一个系统内部类型,可以产生该类型的对象(也称为该类的实例)。实例对象具有这个类所描述的行为。实际上,Python语言把内置类型都看做类。

在介绍Python类定义的详细情况以前,这里先给出一个类定义的实例。下面代码是一个简化的有理数类的一部分:

class Ratioanal0:
    def __init__(self, num, den=1):
        self.num = num
        self.den = den

    def plus(self, another):
        den = self.den * another.den
        num = (self.num * another.den + self.den * another.num)
        return Ratioanal0(num, den)

    def print0(self):
        print(str(self.num)+'/'+str(self.den))

下面对这段代码做些解释:

  • class是关键字,表示从这里开始一个类定义。class之后是给定的类名和一个表示类头部结束的冒号。这部分(这一行)称为类定义的头部,随后是类定义的体部分,形式上就是一个语句组。定义一个类,通常是为了创建该类的实例,称为该类的实例对象,简称这个类的对象。例如,上面有理数类的名字是Rational0,定义它就是为了在程序里创建和使用Rational0(一种有理数)对象。
  • 类的体部分通常主要是一批函数定义,所定义的函数称为这个类的方法。最常见的方法是操作本类的实例对象的方法,称为实例方法。这种方法总是从本类的对象出发去调用,其参数表里第一个参数就表示实际使用时的调用对象,通常以self作为参数名。例如,Rational0类里就定义了三个实例方法,下面讨论中简单说“方法”时,指的就总是实例方法,其他情况在后面介绍。
  • 在一个类里,通常都会定义一个名为__init__的方法,称为初始化方法,其工作是构造本类的新对象。创建实例对象采用函数调用的描述形式,以类名作为函数名,这时系统将会建立一个该类的新对象,并自动对这个对象执行__init方法,例如,下面语句:

    r1 = Rational0(3,5)

    就是要求创建一个值为3/5的有理数对象,并把这个新对象赋给r1变量。调用式应给出除了self之外的其他实际参数,Rational0类的__init__方法要求两个实参,上面语句中是3和5。求值表达式时Python先建立一个新对象,然后再把这个新对象作为__init__方法的self参数去执行方法体。
    在Rational0类的__init__方法里有两个语句,要求用实参的值给self.num和self.den赋值。在实例方法的体重,self.fname形式的写法表示本类实例对象的属性,其中fname称为属性名。与Python变量的情况类似,程序里不需要说明对象有哪些属性,赋值时就会创建。上面初始化方法要求给Rational0对象的两个属性赋值,创建本类对象时就会为它建立相应的属性并赋予相应的值。

  • 类里的其他实例方法也应该以self作为第一个参数,对它们的调用需要从本类的实例出发,用圆点形式描述,如果写:

    r2 = r1.plus(Rational0(7,15))

    赋值右边的表达式调用方法plus,r1的值被称为plus方法的调用对象,方法中self参数将约束于该对象。调用中的实参表达式Rational0(7,15)创建另一个有理数对象,它将作为plus方法的第二个实参约束到形参another。

  • 上面类定义的print0方法只有一个self参数,其调用形式就应该是r1.print0(),不要求其他实参,该方法以字符串形式输出对象r1的内容。

在定义了类Rational0之后,如果送给Python系统下面的语句:

In [13]: r1 = Rational0(3,5)

In [14]: r2 = r1.plus(Rational0(7,15))

In [15]: r2.print0()
80/75

解释器将输出80/75,容易看到这个结果没有化简,虽然正确却不是最合适的形式。如果程序里用这样的有理数对象做复杂计算,计算结果的分子和分母都会变得越来越大,虽然Python支持任意大的整数,得到的结果应该是正确的,但是存储大的整数需要大的空间,计算也更费时间。所以,实现有理数的计算时,应该考虑化简。

下面将考虑一个更加完整合理的有理数实现,顺便介绍类定义的更多情况。

2.2.2 类定义进阶

如前所述,类定义的一类重要作用就是支持创建抽象的数据类型。在建立这种抽象的时候,人们不希望暴露其实现的内部细节。例如,对于有理数类,不希望暴露这种对象内部是用两个整数分别表示分子和分母。对更复杂的抽象,信息隐藏的意义可能会更重要。Python语言没有专门服务于这种需求的机制,所以只能依靠一些编程约定。

首先,人们约定,在一个类的定义里,由下划线_开头的属性名(和函数名)都当做内部使用的名字,不应该在这个类之外使用,另外,Python对类定义里以两个下划线开头(但不以两个下划线结尾)的名字做了特殊处理,使得在类定义之外不能直接用这个名字访问。这是另一种保护方式,下面定义更好的有理数类时将遵循这些约定。

上节最后说到有理数的化简问题,在建立有理数的时候,应该考虑约去其分子和分母的最大公约数,避免无意义的资源浪费。为了完成化简,需要定义一个求最大公约数的函数gcd。这里出现了一个问题:应该在哪里定义这个函数。稍加分析就会发现,现在出现了两个新情况:首先,gcd的参数应该是两个整数,它们不属于被定义的有理数类型,另外,gcd的计算并不依赖于任何有理数类的对象,因此其参数表中似乎不应该以表示有理数的self作为第一个参数,但是在另一方面,这个gcd是为有理数类的实现而需要使用的一种辅助功能。根据信息局部化的原则,局部使用的功能不应该被定义为全局函数。综合这两点情况,gcd应该是在有理数类里定义的一个非实例方法。

Python把在类里定义的这种方法称为静态方法(与实例方法不同),描述时需要在函数定义的头部行之前加入修饰符@staticmethod。静态方法的参数表中不应有self参数,在其他方面没有任何性质。对于静态方法,可以从其定义所在类的名字出发通过圆点形式调用,也可以从该类的对象出发通过圆点形式调用。本质上说,静态方法就是在类里面定义的普通函数,但也是该类的局部函数。

还有一个问题也需要考虑:前面简单有理数类的初始化方法每页检查参数,既没有检查参数的类型是否合适(显然,两个实参都应该是整数),也没有检查分母是否为0。此外,人们传送给初始化方法的实参可能有正有负,内部表示应该标准化,例如,保证所有有理数的内部的分母为正,用分子的正负表示有理数的正负。这些检查和变换都应该在有理数类的初始化方法里完成,保证构造出的有理数都是合法合规的对象。

考虑了上面的这些问题以后,可以给出下面的有理数类定义(部分):

class Rational:
    @staticmethod
    def _gcd(m, n):
        if n==0:
            m, n = n, m
        while m != 0:
            m, n = n % m, m
        return n

    def __init__(self, num, den=1):
        if not isinstance(num, int) or not isinstance(den, int): ##isinstance:判断对象是否是一个已知的类型,本例用来判断分子和分母是否为int
            raise TypeError #使用raise抛出异常
        if den ==0:
            raise ZeroDivisionError
        sign = 1
        if num < 0:
            num, sign = -num, -sign
        if den < 0:
            den, sign = -den, -sign
        g = Rational._gcd(num, den)
        self._num = sign * (num//g)
        self._den = den//g

在这个类里定义了一个局部使用的求最大公约数的静态方法_gcd,在初始化方法里使用。初始化函数在开始处检查参数的类型和分母的值,不满足要求抛出适当的异常。随后的if语句提取有理数的符号,几个检查之后sign值为1表示是正数,-1表示负数。最后用化简后的分子分母设置有理数的数据属性。

下面考虑Rational类的其他方法。首先,在上面定义中把有理数对象的两个属性都当作内部属性,不应该在类之外去引用它们。但实际计算中有时需要提取有理数的分子或者分母,为了满足这种需要,应该定义一对解析操作:

    def num(self):return self._num
    def den(self):return self._den

现在考虑有理数的运算。在前面的简单有理数类里定义了名字为plus的方法,对于有理数这种数学类型,人们可能更希望用运算符(+,-,,/等)描述计算过程,写出形式更自然的计算表达式。Python语言也支持这种想法,它为所有算术运算符规定了特殊方法名。Python的所有特殊的名字都以两个下划线开始,并以两个下划线结束。例如,与+运算符对应的名字是__add__,与对应的名字是__mul__。下面是实现有理数运算的几个方法定义,其他运算不难类似地实现:

    def __add__(self,another): #相加
        den = self._den * another.den()
        num = (self._num * another.den() + self._den * another.num())
        return Rational(num,den)

    def __mul__(self,another): #相减
        return Rational(self._num * another.num(), self._den * another.den())

    def __floordiv__(self,another):
        if another.num() == 0: #self除以another
            raise ZeroDivisionError
        return Rational(self.num * another.den(), self.den * another.num())

    #.....
    #其他运算符可以类似定义:
    #-:__sub--, /:__truediv__,%:__mod__等

这里有几个问题很值得提出:首先,通过在每个方法最后用Rational(…,…)构造新对象,所有构造出的对象都保证能够化简为最简形式,不需要在每个新建立有理数的地方考虑化简问题。这种方法值得提倡。

另外,上面定义除法时用的是整除运算符“//”,在除法方法的开始检查除数并可能抛出异常,也是常规的做法。按照Python的惯例,普通除法“/”的结果应该是浮点数,对应的方法是__truediv__,如果需要可以另行定义,实现从有理数到浮点数的转换。

还请注意一个情况:算术运算都要求另一个参数也是有理数对象。如果希望检查这个条件,可以在方法定义的开始加一个条件语句,用内置语句isinstance(another)检查。另外,由于another是另一个有理数对象,上面方法定义中没有直接去访问其成分,而是通过解析函数。

有理数对象经常需要比较相等和不等,有些类的对象需要比较大小。Python为各组关系运算符提供了特殊方法名,下面是有理数相等、小于运算的方法定义:

    def __eq__(self,another):
        return self._num * another.den() == self._den * another.num()

    def __lt__(self,another):
        return self._num * another.den() < self._den * another.num()

    #其他比较运算符可以类似定义:
    #!=:__ne__,<=:__le__,>:__gt__,>=:__ge__

为了便于输出等目的,人们经常在类里定义一个把该类的对象转换到字符串的方法。为了保证系统的str类型转换函数能够正常使用,这个方法应该采用特殊名字__str__,内置函数str将调用它。下面是有理数类字符串的转换方法:

    def __str__(self):
        return str(self._num) + '/' + str(self._den)

    def print0(self):
        print (self._num, '/', self._den)

至此一个简单的有理数类已经基本完成了。

在程序定义好一个类之后,就可以像使用Python系统里的类型一样使用它。首先是创建类的对象,形式是采用类名的函数调用式,前面已经说明:

five = Rational(5)

x = Rational(3,5)

如果一个变量的值是这个类的对象,就可以用圆点记法调用该类的实例方法:

In [58]: x.print0()
3 / 5

由于有理数类定义了str转换函数,可以直接用标准函数print输出:

print "Two thirds are", Rational(2,3)
Two thirds are 2/3

总而言之,用类机制定义的类型,与Python系统内部类型没有什么差别,地位和用法相同。

2.2.3 本书采用的ADT描述形式

本书后面章节主要采用Python的面向对象技术和类结构定义各种数据类型,为了更好地与之对应,这里对ADT的描述做一点改动。后面使用的ADT描述将模仿Python的类定义的形式,也认为ADT的描述是一个类型,因此:

  • ADT的基本创建函数将以self为第一个参数,表示被操作对象。其他参数表示为正确创建对象时需要提供的其他信息。
  • 在ADT描述中的每个操作也都以self作为第一个参数,表示被操作对象。
  • 定义二元运算时也采用同样的形式,其参数表将包括self和另一个同类型对象,操作返回的是运算生成的结果对象。
  • 虽然Python函数定义的参数表里没有描述参数类型的机制,但是为了提供更多信息,在下面写ADT定义时,有时还是采用写参数类型的形式,用于说明操作对具体参数的类型要求,在很多情况下,这样写可以省略一些文字说明。

按照这种方式描述的有理数对象如下:

ADT Rational #定义有理数的抽象数据类型
    Rational(self,int num,int den) #构造有理数num/den
    +(self,Rational r2) #求出本对象加r2的结果
    -(self,Rational r2) #求出本对象减r2的结果
    *(self,Rational r2) #求出本对象乘以r2的结果
    /(self,Rational r2) #求出本对象除以r2的结果
    num(self) #取得本对象的分子
    den(self) #取得本对象的分母

2.3 类的定义与使用

基本上都是看完一遍了解下就行的东西,略过,不过有一个类方法要说一下。

类里定义的另一类方法称为类方法,定义形式是在def行前加上@classmethod,这种方法必须有一个表示其调用类的参数,习惯用cls作为参数名。还可以有任意多个其他参数。类方法也是类对象的属性,可以以属性访问的形式调用。在类方法执行时,调用它的类将会自动约束到方法的cls参数,可以通过这个参数访问该类的其他属性。人们通常用类方法实现与本类的所有对象有关的操作。和self表示对象不同,cls表示的是这个类本身。

举个例子,假设所定义的类需要维护一个计数器,记录程序运行中创建的该类的实例对象的个数,可以采用下面的定义:

class Countable:
    counter = 0

    def __init__(self):
        Countable.counter += 1

    @classmethod
    def get_count(cls):
        return Countable.counter

运行效果如下:

x = Countable()

y = Countable()

z = Countable()

print (Countable.get_count)
>

print (Countable.get_count())
3

上面程序片段在运行时输出整数3,说明到执行print语句为止已经创建了3个Countable对象。

2.4 Python异常

2.5 类定义实例:学校人事管理系统中的类

现在考虑一个综合性的实例:为一个学校的人员管理系统定义所需要的表示人员信息的类,它们都是数据抽象。

2.5.1 问题分析和设计

首先考虑基本人员ADT的设计:

ADT Person: #定义人员抽象数据类型
    Person(self,strname,strsex,tuple birthday,str ident) #构造人员对象
    id(self) #取得人员编号
    name(self) #取得姓名
    sex(self) #性别
    birthday(self) #出生年月日
    age(self) #年龄
    set_name(self,str name) #修改姓名
    <(self,Person another) #基于人员编号比较两个记录
    details(self) #给出人员记录里保存着的数据详情

然后是学生的数据:

ADT Student(Person): #定义学生ADT,注意这里继承了Person基类
    Student(self,strname,strsex,tuple birthday,str department) #构造学生对象
    department(self) #学生所属院系
    en_year(self) #学生入学年度
    scores(self) #学生成绩单
    set_course(self,str course_name) #设置选课
    set_score(self,str course_name,int score) #设置课程成绩

教职工的ADT:

ADT Staff(Person): #定义教职工ADT,继承Person基类
    Staff(self,strname,strswex,tuple birthday,tuple entry_date) #构造教职工对象
    department(self) #所属院系
    salary(self) #工资额
    entry_date(self) #入职时间
    position(self) #职位
    set_salary(self,int amount) #设置工资额
    ...

完成这些设计之后,进一步考虑程序实施

2.5.2 人事记录类的实现

这里的考虑是定义几个类,实现前面设计的各个ADT。在定义这些类之前先定义两个异常类,以便在定义人事类的操作中遇到异常情况时引发特殊的异常,使用这些类的程序部分可以正确地捕捉和处理。

人们在定义自己的特殊异常类时,多数时候都采用最简单的方式:只是简单选择一个合适的Python标准异常类作为基类,派生时不定义任何方法或数据属性。

这里派生两个专用的异常类:

class PersonTypeError(TypeError):
    pass

class PersonValueError(ValueError):
    pass

两个类的体都只有一个pass语句,只是为了填补语法上的缺位。在下面操作中遇到参数的类型或者值不满足需要时就引发这两个异常。

此外,由于人事类定义需要处理一些与时间有关的数据,直接采用Python标准库的有关功能类是最合适的,引进datatime标准库包:

import datetime

公共人员类的实现

首先考虑基本人员类的定义,将这个类定义为Person。为了统计在程序运行中建立的人员对象的个数,需要为Person类引进一个数据属性_num,每当创建这个类的对象就将其值加1。Person类的__init__方法里完成这一工作。

下面是Person类的开始部分:

class Person:
    _num = 0

    def __init__(self, name, sex, birthday, ident):
        if not (isinstance(name, str)) and sex in ('女','男'):
            raise PersonTypeError(name, sex)

        try:
            birth = datetime.date(*birthday) #生成一个日期对象
        except: #try/except的异常处理系统,except指当出现异常时的处理
            raise PersonValueError('Wrong date:', birthday)

        self._name = name
        self._sex = sex
        self._birthday = birthday
        self._id = ident
        Person._num += 1 #为实例计数

__init__方法的主要工作是检查参数合法性,设置对象的数据属性。生日检查的方法比较麻烦,引入datetime库处理。

其他方法的定义都比较简单:

    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() - self._birthday.year)

    def set_name(self, name):
        if not isinstance(name, str):
            raise PersonTypeError('set_name', name)
        self._name = name

    def __lt__(self, another):
        if not isinstance(another, Person):
            raise PersonTypeError(another)
        return self._id < another._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)))

至此Person类的基本定义完成,下面是使用这个类的几个语句:

In [89]: p1 = Person('谢雨洁', '女', (1995, 7, 30), '1201510111')

In [90]: p2 = Person('汪力强', '男', (1990, 2, 17), '1201380324')

In [91]: p3 = Person('软软软', '女', (1992, 1, 11), '1111111111')

In [92]: plist2 = [p1,p2,p3]

In [93]: for p in plist2:print(p)
1201510111 谢雨洁 女 (1995, 7, 30)
1201380324 汪力强 男 (1990, 2, 17)
1111111111 软软软 女 (1992, 1, 11)

In [94]: for p in plist2:print(p.details())
编号:1201510111, 姓名:谢雨洁, 性别:女, 出生日期:(1995, 7, 30)
编号:1201380324, 姓名:汪力强, 性别:男, 出生日期:(1990, 2, 17)
编号:1111111111, 姓名:软软软, 性别:女, 出生日期:(1992, 1, 11)

In [95]: print 'People created:' , Person.num() 
People created: 3

In [96]: plist2.sort()

In [97]: for p in plist2:print(p.details())
编号:1111111111, 姓名:软软软, 性别:女, 出生日期:(1992, 1, 11)
编号:1201380324, 姓名:汪力强, 性别:男, 出生日期:(1990, 2, 17)
编号:1201510111, 姓名:谢雨洁, 性别:女, 出生日期:(1995, 7, 30)

可以看到,这个表ADT实现了前面ADT的要求。

学生表的实现

考虑学生表的实现需要关注几件事:1.,Student对象也是Person对象,因此,建立Student对象时,应该调用Person类的初始化函数,建立起表示Person对象的那些数据属性。2.这里希望Student类实现一种学号生成方式,为了保证学号的唯一性,最简单的技术就是用一个计数变量,每次生成学号将其加1,这个变量是Student类的内部数据,但又不属于任何Student实例对象,因此应该用类的数据属性表示。3.学号生成函数只在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 = () #一个空字典

其他的方法都很容易考虑,下面只给出与选课和成绩有关的方法:

    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())))

其余方法都非常简单,这里不再给出。

教职员部分没有新的尝试,只要注意super()的用法就好,不赘述。

你可能感兴趣的:(读书笔记:数据结构与算法-Python语言描述【第2章:抽象数据类型和Python类】)