面向对象编程——继承与多态

电子说

1.3w人已加入

描述

周立功教授数年之心血之作《程序设计与数据结构》以及《面向AMetal框架与接口的编程(上)》,电子版已无偿性分享到电子工程师与高校群体,书本内容公开后,在电子行业掀起一片学习热潮。经周立功教授授权,本公众号特对《程序设计与数据结构》一书内容进行连载,愿共勉之。

第四章为面向对象编程,本文为4.3 继承与多态

>>>   4.3.1 抽象

假设需要设计一个处理工资单的数据包,可以将排序作为一个关键的业务进行抽象。虽然各种排序的实现不一样,但它们的共性都是“排序”,这就是抽象的基础。如果要建立一个矩阵代数程序包,就要讨论抽象矩阵。虽然各种类型矩阵的实现各不相同,但根据它们表现的共同行为特性,可以将这些矩阵归为一类,显然其共性又一次支持了抽象。

如果用户有一个这样的需求——校验push到栈中的数据,则实现者一定会问“校验规则是什么?”因为校验是一个非常“抽象”的概念;如果用户明确地告诉实现者——对push到栈中的数据进行范围值校验或偶校验,则不会出现这样模糊的问题。当需要对push到栈中的数据进行范围值校验时,则需要编写一个RangeValidator类;当再需要添加一个奇偶校验器时,势必又要编写一个OddEvenValidator类。显然每添加一种校验器就要增加一个接口,根本无法做到重用。

虽然它们的类型不同,且不同校验器的对象各有不同,但它们共同的概念都是“校验器”。回归校验器的本质,无论是什么校验器,其共同的属性是校验参数,其共同的行为是可以使用相同的方法——在动态中根据对象的类型调用不同的校验器函数。

显然,用户是在概念层次上提出了校验的需求与实现者交流,而具体如何校验是在实现层次进行的,用户无需准确地知道具体是如何实现的。因此只要概念不变,即可做到用户与实现细节的变化完全分离。

在面向过程编程中,新手对共性的认识往往来源于直觉,以创建范围值校验器类和偶校验器类为例,程序员普遍都会按照以下方法表达这种共性,将Validate提取为一个公共的函数指针。比如:

继承

而对于一个拥有“面向对象思维”且经验丰富的程序员,更倾向于将各种校验器的共性打包在一个函数指针中作为结构体的成员创建一个抽象类。Validator抽象类的定义如下:

继承

其中,pThis是指向当前对象的指针,Validator是一个没有具体属性,代表多种具有共性的数据和行为的具体校验器总称的抽象类。Validator类没有提供任何实现validate方法的代码,正是因为这一点,该方法才能成为一个抽象的方法,因为提供任何代码都会使方法成为具体方法。

由于Validator是一个抽象类,因此无法创建实例,自然也就不知道要校验什么?那么谁知道呢?范围值校验器和奇偶校验器类知道自己要做什么校验。由于Validator有一个validate方法,因此可以将Validator抽象类封装成RangeValidator派生类的成员——Validator类的变量isa,即将实现细节委托给子类。在范围值校验器和奇偶校验器类重新定义,各自实现它自己的validate方法。

>>>   4.3.2 继承

在这里将引入一个新的概念继承描述类之间的关系。由于RangeValidator范围值校验器和OddEvenValidator奇偶校验器的共性是校验参数和调用校验函数的方法,因此将其共性上移到一个名为Validator校验器类(父类)中。

基于此,在将具有可变性的校验参数分别转移到RangeValidator和OddEvenValidator中的同时,并将Validator类型的变量isa作为结构体的成员,即可创建新的结构体数据类型:

继承

其中,pThis为指向Validator类对象的指针,RangeValidator和OddEvenValidator派生自Validator类,RangeValidator和OddEvenValidator是Validator的子类,Validator类是RangeValidator和OddEvenValidatorr类的基类或超类。因为RangeValidator是一种校验器,OddEvenvalidator也是一种校验器。当一个子类继承自一个基类时,它可以做基类能做的任何事情,因此RangeValidator和OddEvenValidator都是Validator的扩充。

虽然父类和子类的类型不一样,当通过继承将不同类的共同属性和行为抽象为一个公共的基类后,于是它们就具有了共同的属性和行为,这就是OOP通过继承实现代码重用的方法。因为抽象类在概念上定义了相似的一组类的共同属性和方法,因而能够将这一组相关类看成一个概念。也就是说,抽象类代表了将所有派生类联系起来的核心概念,也正是这个核心概念定义了派生类的共性。同时还提供了与这一组相关类的通信接口规约,然后每个具体类都按需要提供特定的实现。

由此可见,对于一个新的抽象,必须将它放在已经设计好的类和对象层次结构的上下文中。实际上,这既不是自上而下的活动,也不是自下而上的活动。Halbert和O 'Brien指出,“当在一个类型层次结构中设计类时,并非总是从基类开始,然后创建子类。通常会创建一些看起来不相似的类型,当意识到它们是相关时,然后才将它们的共性分离出来,放到一个基类或多个基类中……”实践经验证明,类和对象的设计是一个增量、迭代的过程。Stroustrup认为,“最常见的类层次结构的组织方式是从两个类中提取公共部分放到一个新类中,或将一个类拆分为两个新类。” 比如,将RangeValidator和OddEvenValidator的共性上移到Validator中。

由于许多开发者常常忽略了为对象和类正确地命名,因此必须确保创建类、属性和方法名时,不仅要遵循约定,还要让名字具有描述性,让人一目了然。否则解释权在程序员自己,因为程序员的个性,时常有可能创建一些只对他们自己很有道理的约定,而其他人却完全不能理解。类名不能为动词,类名应该用常见的名词命名,比如,Validator或RangeValidator,避免使用Manager、Processor、Data或Info这样的类名。对象名应该用合适的名词短语命名,比如,rangeValidator或oddEvenValidator。特别地,选择的名字应该是业务领域专家使用和认知的名字。方法名应该是动词或动词短语,比如,pushWithValidate。

当开发者决定采用某种协作模式后,工作会被分解给对象,即在相应的类上定义适当的方法。归根到底,单个类的协议包含了实现所有行为,以及实现与其实例相关的所有机制所需要的全部操作。因此与类层次结构的设计一样,机制代表了战略的设计决策。

实际上,机制就是在长期的实践中发现和总结的各种模式。在底层开发模式中,惯用法是一种表现形式;在高层开发模式中,则有一组类组成的框架。框架代表了大规模的复用,比如,ZLG的AMetal框架和AWorks框架,MVC框架和MVVM框架以及微软的.NET框架或开源代码。所以机制代表了一种层次的复用,它高于单个类的复用。

虽然代码表明了基类与子类的关系,但还是不够深刻。在这里,将以Validator与RangeValidator之间的继承关系为例,通过UML图进一步形象地描述,详见图 4.4。

继承

图 4.4 继承关系图

继承关系为何指向基类?其深刻的设计思想是它代表了依赖的方向。所谓依赖关系是指两个元素之间的一种关系,其中一个元素变化将会引起另一个元素变化。UML图中采用从子类指向基类的空心箭头表示继承,暗示基类的变化可能导致子类的变化。简而言之,被依赖的先构造,依赖于其它元素的后构造。

其实继承是一个非常传统和经典的术语,从Smalltalk问世时就被广泛使用,将一般类和它的特殊类之间的关系称为继承关系。它在很多场合还以动词或形容词的面目出现。比如,特殊类继承了一般类的属性和操作,面向对象编程语言具有继承性和封装性等。

而一般-特殊恰当地一般类和它的特殊类之间的相对关系,既可以称为一般-特殊关系,也可以形成一般-特殊结构。当“一般”这个术语generalization翻译成中文时,很容易与上下文混淆,因此翻译成“泛化”更准确。即一般类(父类)对特殊类(子类)而言是泛化,反之就是特化。因此一般类和特殊类之间的关系称为一般-特殊关系,一般-特殊结构是由一组具有一般-特殊关系(继承关系)的类所形成的结构。

显而易见,一般-特殊结构是问题域中各类事物之间客观存在的一种结构,在面向对象分析模型中建立一般-特殊结构,使模型更清晰地映射了问题域中事物的分类关系——将对象划分为类,用类描述属于它的全部对象实例。它将具有一般-特殊关系的类组织在一起,可以简化对复杂系统的认识,使人们对系统的认识和描述更接近日常思维中一般概念和特殊概念的处理方式。

在面向对象的开发中,一般-特殊结构可以使开发者简化对类的定义,因而对象的共同特征只需在一般类中给出,特殊类通过继承而自动地拥有这些特征,不必再重复定义。

不同的方法学对如何发现一般-特殊结构,有不同的策略。其最大的问题让人们感到更多地是依赖于直觉,如果分析方法是一门艺术,也就意味着让人具有很大的不确定性。而事实上,使用共性和差异化分析工具,并从概念、规约和实现三个不同的视角看待对象,就可以简化复杂的系统,详见《嵌入式软件工程方法与实践丛书——面向对象的分析与设计》。

>>>   4.3.3 职责驱动设计

OO强调的是在现实世界或业务领域中找出软件对象,而软件对象与现实世界中对象的行为完全不一样。软件对象以点对点的通信方式通过发送消息进行交互,而现实世界中的对象与环境的交互,以及其它对象动态地反映现实世界的对象之间交互都要丰富得多。

经验丰富的开发人员在研究领域时,如果发现了他们所熟悉的某种职责或某个关系网,他们会想起以前这个问题是如何解决的。以前尝试过哪些模型?在实现中有哪些难题?它们是如何解决的?先前经历过的尝试和失败的教训,会突然间与新的情况联系起来。

为了真实地反映现实世界中对象的动态交互,要让一个类在不同的系统中重用,则必须在设计类时充分考虑扩展性。经过长期的积累,人们总结了一套用于启发和指导类的设计原则:职责驱动设计——如何为协作中的对象分配职责。

显然,对于rangeValidator对象和oddEvenValidator对象来说,它们的职责分别是对push到栈中的数据进行范围值校验和偶校验,也就意味着必须存在相应的方法。由于每个子类都要对自己的行为负责,因此每个子类不仅要提供一个名为validate的方法,而且必须提供它自己的实现代码。比如,RangeValidator和OddEvenValidator都有一个validate的方法,RangeValidator类包含范围值校验的代码,OddEvenValidator类肯定有奇偶校验的代码。它们都是Validator的子类,必须实现其不同版本的validate。

不言而喻,OOP比POP更直接地表达了校验器的共性:“使用validate函数指针在运行中根据对象的类型调用不同的函数,并通过pThis指针指向当前对象引用校验参数将共同的部分打包在一起形成抽象类。”当它们有了这种共性时,则更容易讨论各种校验器相互之间的差别。

除了变量value之外,RangeValidator类对象的validateRange()校验函数的共性是符合范围值条件的判断处理语句,其可变的是范围值校验参数min和max;OddEvenValidator类对象的validateOddEven()校验函数的共性是符合偶数值条件的判断处理语句,其可变的是偶校验参数isEven。

根据共性和可变性分析原理,将稳定不变的相同的处理部分都包含在抽象的模块中,可变性分析所发现的变化的变量由外部传递进来的参数应对。其函数原型如下:

继承

由于&rangeValidator.isa、&oddEvenValidator.isa和pThis值相等,且类型也相同,因此可以将范围值校验和奇偶校验函数的void *泛化为Validator *。当将一个基类对象替换成它的子类对象时,程序将不会产生任何错误和异常,且使用者不必知道任何差异,反过来则不成立。也就是说,如果某段代码使用了基类中的方法,必须能使用派生类的对象,且自己不必进行任何修改。因此在程序中要尽量使用基类类型定义对象,在运行时再确定其子类类型,用子类对象替换基类对象。这就是里氏替换原则,它是由2008年图灵奖获得者,美国第一位计算机科学女博士Barbara Liskov教授和卡耐基梅隆大学Jeannette Wing教授于1994年提出的。

在应用里氏替换原则时,应该将父类设计为抽象类或接口,让子类继承父类或实现父类接口,并实现在父类中声明的方法。在运行时子类实例替换父类实例,可以很方便地扩展系统的功能。无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类实现,由此可见,里氏替换原则是实现开闭原则的重要方式之一。

如果开闭原则是面向对象设计的目标,那么依赖倒置原则就是面向对象设计的主要原则之一,它是抽象化的具体实现。依赖倒置原则要求传递传递参数时或在关联关系中,尽量引用高层次的抽象层类,即使用接口和抽象类进行变量的声明、参数类型的声明、方法返回类型的声明,以及数据类型的转换等,而不要用具体类做这些事。

为了确保该原则的应用,一个具体类应该只实现接口或抽象类中声明过的方法,而不是给出多余的方法,否则将无法调用在子类中增加新的方法。显而易见,在引入抽象层后,将具体类写在配置文件中。如果需求发生改变,则只需要扩展抽象层,修改相应的配置文件即可。而无须修改原有系统的代码,就能扩展系统的功能,满足开闭原则。通常开闭原则、里氏替换原则和依赖倒置原则会同时出现,开闭原则是目标,里氏替换原则是基础,依赖倒置原则是手段,它们相辅相成相互补充,其目标是一致的,只是分析问题的角度不同。

继承是OO建模和编程中被广泛滥用的概念之一,如果违反了LisKov替换原则,继承层次可能仍然可以提供代码的可重用性,但是将会失去可扩展性。因此在使用继承时,要想一想派生类是否可以替换基类。如果不能,则要问一问自己为何使用继承?如果在编写新的类时,还要重用基类代码代码,则要考虑使用组合。

和继承一样,组合也是一种构建对象的机制。如果新类可以替换已有的类,且它们之间的关系可以描述为is-a,则使用继承。如果新类只是使用已有的类,且它们之间的关系可以描述为has-a,则使用组合。相对继承来说,组合更加灵活,适用性也更强。

有关组合的使用方法和示例,将在后续相关的教程中,结合具体的应用予以阐述。

在这里,RangeValidator和OddEvenValidator类扩展了(即继承)Validator,其相应的校验器接口的实现详见程序清单 4.9。

程序清单 4.9 通用校验器接口的实现(Validator.c)

继承

由此可见,抽象是一个强大的分析工具,其强调的什么是共同的,因此共性和差异化分析自然而然地成为了抽象的理论基础。共性分析寻找的是不可能随时间而改变的结构,而可变性分析则要找到可能变化的结构。如果变化是“业务领域”中各个特定的具体情况,那么共性就定义了业务领域中将这些情况联系起来的概念。共同的概念用抽象类表示,可变性分析所发现的变化将通过从抽象类派生而来的具体类实现。共性与可变性分析工具不仅可以指导我们创建抽象类和派生类,而且还可以指导我们建立抽象和接口。那么类的设计过程自然而然地就简化成了两个步骤:

  • 在定义抽象类(共性)时,需要知道用什么接口处理这个类的所有职责;

  • 在定义派生类(可变性)时,需要知道对于一个给定的特定实现(即变化),应该如何根据给定的规约实现它。

显然,类是一种编程语言结构,它描述了具有相同职责的所有对象。用相同的方式实现这些职责,并共享相同的数据结构。虽然它的内部可能有一些属性,可能有一些方法,但我们只关心对象对自己的行为负责。因为将实现隐藏在接口之后,实际上是将对象的实现和使用它们的对象彻底解耦了。所以只要概念不变,请求者与实现细节的变化隔离开了。

为了便于阅读,程序清单 4.10展示了通用校验器的接口。

程序清单 4.10 通用校验器的接口(validator.h)

继承

在这里,还是以范围值校验器为例,假设min=0,max=9,如程序清单 4.10(22)所示的使用名为newRangeValidator的宏将结构体初始化的使用方法如下:

继承

宏展开后如下:

继承

其中,外面的{}为RangeValidator结构体赋值,内部的{}为RangeValidator结构体的成员变量isa赋值。即:

继承

如果有以下定义:

继承

即可用pValidator引用RangeValidator的min和max。

由于pValidator与&rangeValidator.isa不仅类型相同,而且它们的值相等,则以下关系同样成立:

继承

因此可以利用这一特性获取validateRange()函数的地址,即pValidator->validate指向validateRange()。其调用形式如下:

继承

此时此刻,也许你会想到,既然它们的方法都一样,只是属性不同,为何不将它们合并为一个类呢?如果这样做的话,则一个类承担的职责越多,它被复用的可能性就越小。而且一个类承担的职责过多,就相当于将这些职责耦合在一起。当其中一个职责变化时,可能会影响其它职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中。如果多个职责总是同时发生变化的话,则可以将它们封装在同一个类中。

也就是说,就一个类而言,应该只有一个引起它变化的原因,这就是单一职责原则,它是实现高内聚、低耦合的指导方针。这是最简单也最难运用的原则,需要开发人员发现类的不同职责并将其分离。

>>>   4.3.4 多态性

多态性是面向对象程序设计的一个重要特征,多态(函数)的字面含义是具有多种形式。每个类中操作的规约都是相同的,而这些类可以用不同的方式实现这些同名的操作,从而使得拥有相同接口的对象可以在运行时相互替换。

当向一个对象发送一个消息时,这个对象必须有一个定义的方法对这个消息作出响应。在继承层次结构中,所有子类都从其超类继承接口。由于每个子类都是一个单独的实体,它们可能需要对同一个消息作出不同的响应。比如,Validator类和行为validate。

在面向对象的编程中,真正引用的是从抽象类派生的类的具体实例。当通过抽象引用概念要求对象做什么时,将得到不同的行为,具体行为取决于派生对象的具体类型。因此,为了描述事物之间相同特性基础上表现出来的可变性,于是多态就被创造出来了,多态允许用相同的方法(代码)处理不同行为的对象。

多态是一种运行时基于对象的类型发生的绑定机制,通过这种机制实现函数名绑定到函数具体实现代码的目的。当执行一个程序时,构成程序的各个函数分别在计算机的内存中拥有了一段存储空间,一个函数在内存中的起始地址就是这个函数的入口地址,因此多态就是将函数名动态绑定到函数入口的运行时绑定机制。尽管多态与继承紧密相关,但通常多态被单独看作面向对象技术最强大的一个优点。

显然,调用校验器就是发送一个消息,它要使用validate函数指针。实际上无论范围值校验器还是奇偶校验器,其校验过程都是由不同内容的函数实现的。在面向对象的编程中,在不同的类中定义了其响应消息的方法,那么在使用这些类时,则不必考虑它们是什么类型,只要发布消息即可。正如在调用校验器时,不必考虑其调用的它们是什么校验器,直接使用validate函数指针,无论什么类型校验器都能实现检查功能。

由于RangValidator和OddEvenValidator类都继承自Validator类,因此没有必要在继承树中对每一种校验器都重复定义这些属性和行为,重复不仅需要做更多的事情,甚至还可能导致错误和出现不一致,详见图 4.5。这种关系在UML中表示为一条线,并有一个箭头指向父类。这种记法非常简明扼要,当遇到这种带箭头的线时,就知道存在一个继承并呈现多态的关系。

继承

图 4.5 抽象类的层次结构

在设计Validator时,对各种校验器的使用进行标准化会有很大的帮助,因为无论是何种校验器,都用一个名为validate的方法。如果遵循这个规范,不管什么时候校验数据,只需要调用validate方法即可。无需考虑这到底是什么校验器,于是就有了一个真正多态的Validator框架——由各个对象自己负责完成校验,不论它是范围值校验、奇偶校验还是质数校验。

根据开闭原则,需要再编写一个扩展push功能的pushWithValidate()函数,其原型如下:

继承

如果需要进行范围值校验,则pValidator指向rangeValidator,否则将pValidator置NULL。

pushWithValidate()的具体实现如下:

继承

其调用形式如下:

继承

使用通用校验器的应用范例程序详见程序清单 4.11。

程序清单 4.11 使用通用校验器的范例程序

继承

由此可见,虽然OOA和OOD的边界是模糊的,但它们关注的重点不一样。OOA关注的是分析面临的问题域,从问题域词汇表中发现类和对象,实现对现实世界的建模。OOD关注的是如何设计泛化的抽象和一些新的机制,规定对象的协作方式。


打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分