13.13 用特殊方法定制类
我们已在本章前面部分讲解了方法的两个重要方面:首先,方法必须在调用前被绑定(到它们相应类的某个实例中);其次,有两个特殊方法可以分别作为构造器和解构器的功能,分别名为init()和del()。
事实上,init()和del()只是可自定义特殊方法集中的一部分。它们中的一些有预定义的默认行为,而其他一些则没有,留到需要的时候去实现。这些特殊方法是Python中用来扩充类的强有力的方式。它们可以实现:
模拟标准类型
重载操作符
特殊方法允许类通过重载标准操作符+, *,甚至包括分段下标及映射操作操作[]来模拟标准类型。如同其他很多保留标识符,这些方法都是以双下划线(__)开始及结尾的。表13.4列出了所有特殊方法及其他的描述。
基本的定制和对象(值)比较特殊方法在大多数类中都可以被实现,且没有同任何特定的类型模型绑定。延后设置,也就是所谓的富比较(Rich Comparison),在Python2.1中加入。属性组帮助管理你的类的实例属性。这同样独立于模型。还有一个,getattribute(),它仅用在新式类中,我们将在后面的章节中对它进行描述。
特殊方法中数值类型部分可以用来模拟很多数值操作,包括那些标准(一元和二进制)操作符、类型转换、基本表示法及压缩。还有用来模拟序列和映射类型的特殊方法。实现这些类型的特殊方法将会重载操作符,以使它们可以处理你的class类型的实例。
另外,除操作符*truediv()和*floordiv()在Python 2.2中加入,用来支持Python除操作符中待定的更改——可查看5.5.3节。基本上,如果解释器启用新的除法,不管是通过一个开关来启动Python,还是通过“fromfutureimport division”,单斜线除操作(/)表示的将是”真”除法,意思是它将总是返回一个浮点值,不管操作数是否为浮点型或者整型(复数除法保持不变)。双斜线除操作(//)将提供大家熟悉的浮点除法,从标准编译型语言像C/C++及Java过来的工程师一定对此非常熟悉。同样,这些方法只能处理实现了这些方法并且启用了新的除操作的类的那些符号。
表格中,在它们的名字中,用星号通配符标注的数值二进制操作符则表示这些方法有多个版本,在名字上有些许不同。星号可代表在字符串中没有额外的字符,或者一个简单的“r”指明是一个右结合操作。没有“r”,操作则发生在对于selfOP obj的格式;“r”的出现表明格式obj OP self。比如,add(self, obj)是针对self+obj的调用,而radd(self, obj)则针对obj+self来调用。
增量赋值,起于Python 2.0,介绍了“原位”操作符。一个“i”代替星号的位置,表示左结合操作与赋值的结合,相当是在self=self OP obj。举例,iadd(self, obj)相当于self=self+obj的调用。
随着Python 2.2中新式类的引入,有一些更多的方法增加了重载功能。然而,在本章开始部分提到过,我们仅关注经典类和新式类都适应的核心部分,本章的后续部分,我们介绍新式类的高级特性。
13.13.1 简单定制(RoundFloat2)
我们的第一个例子很普通。在某种程度上,它基于我们前面所看到的从Python类型中派生出的派生类RoundFloat。这个例子很简单。事实上,我们甚至不想去派生任何东西(当然,除object外)……我们也不想采用与floats有关的所有“好东西”。不,这次,我们想创建一个苗条的例子,这样你可以对类定制的工作方式有一个更好的理解。这种类的前提与其他类是一样的:我们只要一个类来保存浮点型,四舍五入,保留两位小数位。
这个类仅接收一个浮点值——它断言了传递给构造器的参数类型必须为一个浮点型——并且将其保存为实例属性值。让我们来试试,创建这个类的一个实例:
你已看到,它因输入非法,而“噎住”,但如果输入正确时,就没有任何输出了。可是,当把这个对象转存在交互式解释器中时,看一下发生了什么。我们得到一些信息,却不是我们要找的。(我们想看到数值,对吧?)调用print语句同样没有明显的帮助。
不幸的是,print(使用str())和真正的字符串对象表示(使用repr())都没能显示更多有关我们对象的信息。一个好的办法是,去实现str()和repr()二者之一,或者两者都实现,这样我们就能“看到”我们的对象是个什么样子了。换句话说,当你想显示你的对象,实际上是想看到有意义的东西,而不仅仅是通常的Python对象字符串(<object object at id>)。让我们来添加一个str()方法,以覆盖默认的行为:
现在我们得到下面的:
我们还有一些问题……一个问题是仅仅在解释器中转储(dump)对象时,仍然显示的是默认对象符号,但这样做也算不错。如果我们想修复它,只需要覆盖repr()。因为字符串表示法也是Python对象,我们可以让repr()和str()的输出一致。
为了完成这些,只要把str()的代码复制给repr()。这是一个简单的例子,所以它没有真正对我们造成负面影响,但作为程序员,你知道那不是最好的办法。如果str()中存在bug,那么我们会将bug也复制给repr()了。
最好的方案,在str()中的代码也是一个对象,同所有对象一样,引用可以指向它们,所以,我们可以仅仅让repr()作为str()的一个别名:
在带参数5.5964的第二个例子中,我们看到它舍入值刚好为5.6,但我们还是想显示带两位小数的数。来一个更妙的方法吧,看下面:
这里就同时具备str()和repr()的输出了:
例13.2 基本定制(roundFloat2.py)
在本章开始部分,最初的RoundFloat例子,我们没有担心所有细致对象的显示问题;原因是str()和repr()作为float类的一部分已经为我们定义好了。我们所要做的就是去继承它们。增强版本“手册”中需要另外的工作。你发现派生是多么的有益了吗?我们甚至不需要知道解释器在继承树上要执行多少步才能找到一个已声明的你正在使用却没有考虑过的方法。我们将在例13.2中列出这个类的全部代码。
现在开始一个稍复杂的例子。
13.13.2 数值定制(Time60)
作为第一个实际的例子,我们可以想象需要创建一个简单的应用,用来操作时间,精确到小时和分。我们将要创建的这个类可用来跟踪职员工作时间,ISP用户在线时间,数据库总的运行时间(不包括备份及升级时的停机时间),在扑克比赛中玩家总时间,等等。
在Time60类中,我们将整型的小时和分钟作为输入传给构造器。
1.显示
同样,如前面的例子所示,在显示我们的实例的时候,我们需要一个有意义的输出,那么就要覆盖str()(如果有必要的话,repr()也要覆盖)。我们都习惯看小时和分,用冒号分隔开的格式,比如,“4:30”,表示4个小时,加半个小时(4个小时又30分钟):
用此类,可以实例化一些对象。在下面的例子中,我们启动一个工时表来跟踪对应构造器的计费小时数:
输出不错,正是我们想看到的。下一步干什么呢?可考虑与我们的对象进行交互。比如在时间片的应用中,有必要把Time60的实例放到一起让我们的对象执行所有有意义的操作。我们更喜欢像这样的:
2. 加法
Python的重载操作符很简单。像加号(+),我们只需要重载add()方法,如果合适,还可以用radd()及iadd()。稍后有更多有关这方面的描述。实现add()听起来不难——只要把分和小时加在一块。大多数复杂性源于我们怎么处理这个新的总数。如果我们想看到“21:45”,就必须认识到这是另一个Time60对象,我们没有修改mon或tue,所以,我们的方法就应当创建另一个对象并填入计算出来的总数。
实现add()特殊方法时,首先计算出个别的总数,然后调用类构造器返回一个新的对象:
和正常情况下一样,新的对象通过调用类来创建。唯一的不同点在于,在类中,你一般不直接调用类名,而是使用self的class属性,即实例化self的那个类,并调用它。由于self.class与Time60相同,所以调用self.class()与调用Time60()是一回事。
不管怎样,这是一个更面向对象的方式。另一个原因是,如果我们在创建一个新对象时,处处使用真实的类名,然后,决定将其改为别的名字,这时,我们就不得不非常小心地执行全局搜索并替换。如果靠使用self.class,就不需要做任何事情,只需要直接改为你想要的类名。
好了,我们现在来使用加号重载,“增加” Time60对象:
哎哟,我们忘记添加一个别名repr给str了,这很容易修复。你可能会问,“当我们试着在重载情况下使用一个操作符,却没有定义相对应的特殊方法时还有很多需要优化和重要改良的地方,会发生什么事呢?”答案是一个TypeError异常:
3. 原位加法
有了增量赋值(在Python 2.0中引入),我们也许还有希望覆盖“原位”操作,比如,iadd()。这是用来支持像mon+= tue这样的操作符,并把正确的结果赋给mon。重载一个i*()方法的唯一秘密是它必须返回self。把下面的片段加到我们例子中,以修复上面的repr()问题,并支持增量赋值:
下面是结果输出:
注意,使用id()内建函数是用来确定一下,在原位加的前后,我们确实是修改了原来的对象,而没有创建一个新的对象。对一个具有巨大潜能的类来说,这是很好的开始。在例13.3中给出了Time60的类的完全定义。
例13.3 中级定制(time60.py)
例13.4 随机序列迭代器(randSeq.py)
4. 进一步优化
现在暂时告一段落,但在这个类中,还有很多需要优化和改良的地方。比如,如果我们不传入两个分离的参数,而传入一个2元组给构造器作为参数,是不是更好些呢?如果是像“10:30”这样的字符串的话,结果会怎样?
答案是肯定的,你可以这样做,在Python中很容易做到,但不是像很多其他面向对象语言一样通过重载构造器来实现。Python不允许用多个签名重载可调用对象。所以实现这个功能的唯一的方式是使用单一的构造器,并由isinstance()和(可能的)type()内建函数执行自省功能。
能支持多种形式的输入,能够执行其他操作像减法等,可以让我们的应用更健壮、灵活。当然这些是可选的,就像“如虎添翼”,但我们首先应该担心的是两个中等程度的缺点:首先当比十分钟还少时,这种格式并不是我们所希望的。其次不支持60进制(sexagesimal [1])(基数60,以60为分母)的操作:
显示wed结果是“12:05”,把thu和fri加起来结果会是”19:15”。修改这些缺陷,实现上面的改进建议可以实际性地提高你编写定制类技能。这方面的更新,更详细的描述在本章的练习13-20中。
我们希望你现在对于操作符重载、为什么要使用操作符重载及如何使用特殊方法来实现它已有了一个更好的理解了。接下来为选看章节内容,让我们来了解更多复杂的类定制的情况。
13.13.3 迭代器(RandSeq和AnyIter)
1. RandSeq
我们正式介绍迭代器是在第8章,但在全书中都在用它。它可以一次一个的遍历序列(或者是类似序列对象)中的项。在第8章中,我们描述了如何利用一个类中的iter()和next()方法,来创建一个迭代器。我们在此展示两个例子。
第一个例子是RandSeq (RANDom SEQuence的缩写)。我们给我们的类传入一个初始序列,然后让用户通过next()去迭代(无穷)。
init()方法执行前述的赋值操作。iter()仅返回self,这就是如何将一个对象声明为迭代器的方式,最后,调用next()来得到迭代器中连续的值。这个迭代器唯一的亮点是它没有终点。
这个例子展示了一些我们可以用定制类迭代器来做的与众不同的事情。一个是无穷迭代。因为我们无损地读取一个序列,所以它是不会越界的。每次用户调用next()时,它会得到下一个迭代值,但我们的对象永远不会引发Stoplteration异常。我们来运行它,将会看到下面的输出:
例13.5 任意项的迭代器(anylter.py)
2. AnyIter
在第二个例子中,我们的确创建了一个迭代器对象,我们传给next()方法一个参数,控制返回条目的数目,而不是去一次一个地迭代每个项。下面是我们的代码(ANY number of items ITERator)(笔者这里的注释是告诉读者类“Anylter”是如何命名的,译者注):
和RandSeq类的代码一样,类Anylter很容易领会。我们在上面描述了基本的操作…它同其他迭代器一样工作,只是用户可以请求一次返回N个迭代的项,而不仅是一个项。
我们给出一个迭代器和一个安全标识符(safe)来创建这个对象。如果这个标识符(safe)为真(True),我们将在遍历完这个迭代器前,返回所获取的任意条目,但如果这个标识符为假(False),则在用户请求过多条目时,将会引发一个异常。错综复杂的核心在于next(),特别是它如何退出的(14~21行)。
在next()的最后一部分中,我们创建用于返回的一个列表项,并且调用对象的next()方法来获得每一项条目。如果我们遍历完列表,得到一个Stoplteration异常,这时则检查安全标识符(safe)。如果不安全(即,self.safe=False),则将异常抛还给调用者(raise);否则,退出(break)并返回(return)已经保存过的所有项
上面程序的运行没有问题,因为迭代器正好符合项的个数。当情况出现偏差,会发生什么呢?让我们首先试试“不安全(unsafe)”的模式,这也就是紧随其后创建我们的迭代器:
因为超出了项的支持量,所以出现了Stoplteration异常,并且这个异常还被重新引发回调用者(第20行)。如果我们使用“安全(safe)”模式重建迭代器,再次运行一次同一个例子的话,我们就可以在项失控出现前得到迭代器所得到的元素:
13.13.4 *多类型定制(NumStr)
现在创建另一个新类,NumStr,由一个数字-字符对组成,相应地,记为n和s,数值类型使用整型(integer)。尽管这组顺序对的“合适的”记号是(n,s),但我们选用[n::s]来表示它,有点不同。暂不管记号,这两个数据元素只要我们模型考虑好了,就是一个整体。可以创建我们的新类了,叫做NumStr,有下面的特征:
1.初始化
类应当对数字和字符串进行初始化;如果其中一个(或两)没有初始化,则使用0和空字符串,也就是,n=0且s=‘’,作为默认。
2.加法
我们定义加法操作符,功能是把数字加起来,把字符连在一起;要点部分是字符串要按顺序相连。比如,NumStr1=[nl::sl]且NumStr2=[n2::s2]。则NumStr1+NumStr2表示[n1+n2::s1+s2],其中,+代表数字相加及字符相连接。
3.乘法
类似的,定义乘法操作符的功能为数字相乘、字符累积相连,也就是NumStrl NumStr2=[nln::sl*n]。
- False值
当数字的数值为0且字符串为空时,也就是当NumStr=[0::“”]时,这个实体即有一个false值。
5.比较
比较一对NumStr对象,比如,[n1::s1] vs.[n2::s2],我们可以发现九种不同的组合(即nl>n2和s1<s2、n1==n2和s1>s2等)。对数字和字符串,我们一般按照标准的数值和字典顺序的进行比较,即,如果obj1<obj2,普通比较cmp(obj1,obj2)的返回值是一个小于0的整型,当obj1>obj2时,比较的返回值大于0,当两个对象有相同的值时,比较的返回值等于0。
我们的类的解决方案是把这些值相加,然后返回结果。有趣的是cmp()不会总是返回-1、0或1。上面提到过,它是一个小于、等于或大于0的整数。
为了能够正确的比较对象,我们需要让cmp()在(n1>n2)且(s1>s2)时返回-1,在(nl<n2)且(s1<s2)时返回-1,而当数值和字符串都一样时,或是两个比较的结果正相反时(即(n1<n2)且(s1>s2),或相反)返回0,反之亦然。
例13.6 多类型类定制(numstr.py)
根据上面的特征,我们列出numstr.py的代码,执行一些例子:
6.逐行解释
1 ~ 7行
脚本的开始部分为构造器init(),通过调用NumStr()时传入的值来设置实例,完成自身初始化。如果有参数缺失,属性则使用false值,即默认的0或空字符,这取决于参数情况。
一个值得注意的偏好是命名属性时,使用双下划线。我们在下一节中会看到,这是在信息隐藏时强加一个级别——尽管不够成熟。程序员导入一个模块时,就不能直接访问到这些数据元素。我们正试着执行一种OO设计中的封装特性,只有通过存取函数才能访问。如果这种语法让你感觉有点怪异,不舒服的话,你可以从实例属性中删除所有双下划线,程序同样可以良好地运行。
所有的由双下划线(__)开始的属性都被“混淆”(mangled)了,导致这些名字在程序运行时很难被访问到。但是它们并没有用一种难于被逆向工程的方法来“混淆”。事实上,“混淆”属性的方式已众所周知,很容易被发现。这里主要是为了防止这些属性在被外部模块导入时,由于被意外使用而造成的名字冲突。我们将名字改成含有类名的新标识符,这样做,可以确保这些属性不会被无意“访问”。更多信息,请参见13.14节中关于私有成员的内容。
9 ~ 12行
我们把顺序对的字符串表示形式确定为“[num::‘str’]”,这样不论我们的实例用str()还是包含在print语句中时候,我们都可以用str()来提供这种表示方式。我们想强调一点,第二个元素是一个字符串,如果用户看到由引号标记的字符串时,会更加直观。要做到这点,我们使用“reprO”表示法对代码进行转换,把“%s”替换成“%r”。这相当于调用repr()或者使用单反引号来给出字符串的可求值版本——可求值版本的确要有引号:
如果在self.__string中没有调用repr()(去掉单反引号或使用“%s”)将导致字符串引号丢失:
现在对实例再次调用print,结果:
没有引号,看起来会如何呢?不能信服“foo”是一个字符串,对吧?它看起来更像一个变量。连作者可能也不能确定(我们快点悄悄回到这一变化之前,假装从来没看到这个内容)。
代码中str()函数后的第一行是把这个函数赋给另一个特殊方法名,repr。我们决定我们的实例的一个可求值的字符串表示应当与可打印字符串表示是一样的,而不是去定义一个完整的新函数,成为str()的副本,我们仅去创建一个别名,复制其引用。当你实现str()后,一旦使用那个对象作为参数来应用内建str()函数,解释器就会调用这段代码,对repr()及repr()也一样。
如果不去实现repr(),我们的结果会有什么不同呢?如果赋值被取消,只有调用str()的print语句才会显示对象的内容。而可求值字符串表示恢复成默认的Python标准形式<…some_object_information…>
14 ~ 21行
我们想加到我们的类中的一个特征就是加法操作,前面已提到过。Python用于定制类的特征之一是,我们可以重载操作符,以使定制的这些类型更“实用”。调用一个函数,像“add(obj1,obj2)”是为“add”对象obj1和ojb2,这看起来好像加法,但如果能使用加号(+)来调用相同的操作是不是更有说服力呢?像这样,obj1+obj2。
重载加号,需要去为self(SELF)和其他操作数实现(OTHER)add()。add()函数考虑Self+Other的情况,但我们不需要定义radd()来处理Other+Self,因为这可以由Other的add()去考虑。数值加法不像字符串那样结果受到(操作数)顺序的影响。
加法操作把两个部分中的每一部分加起来,并用这个结果对形成一个新的对象——通过将结果作为参数调用self.class()来实例化(同样,在前面已解释过)。碰到任何类型不正确的对象时,我们会引发一个TypeError异常。
23 ~ 29行
我们也可以重载星号[靠实现mul()],执行数值乘法和字符串重复,并同样通过实例化来创建一个新的对象。因为重复只允许整型在操作数的右边,因此也必执行此规则。基于同样的原因,我们在此也没有实现rmul()。
31 ~ 32行
Python对象任何时候都有一个布朗值。对标准类型而言,对象有一个false值的情况为:它是一个类似于0的数值,或是一个空序列,或者映射。就我们的类而言,我们选择的数值必须为0,字符串要为空作为一个实例有一个false值的条件。覆盖nonzero()方法,就是为此目的。其他对象,像严格模拟序列或映射类型的对象,使用一个长度为0作为false值。这些情况,你需要实现len()方法,以实现那个功能。
34 ~ 41行
normcval()(“normalize cmp() value的缩写”)不是一个特殊方法。它是一个帮助我们重载cmp()的助手函数:唯一的目的就是把cmp()返回的正值转为1,负值转为-1。cmp()基于比较的结果,通常返回任意的正数或负数(或0),但为了我们的目的,需要严格规定返回值为-1、0和1。对整型调用cmp()及与0比较,结果即是我们所需要的,相当于如下代码片段:
两个相似对象的实际比较是比较数字,比较字符串,然后返回这两个比较结果的和。