1.2 什么是继承
本节将介绍以下内容:
— 什么是继承?
— 继承的实现本质
— 继承的分类与规则
— 继承与聚合
— 继承的局限
1.2.1 引言
继承,一个熟悉而容易产生误解的话题。这是大部分人对继承最直观的感受。说它熟悉,是因为作为面向对象的三大要素之一的继承,每个技术研究者都会在职业生涯中不断地重复关于继承的话题;说它容易产生误解,是因为它总是和封装、多态交织在一起,形成复杂的局面。以继承为例,如何理清多层继承的机制,如何了解实现继承与接口继承的异同,如何体会继承与多态的关系,似乎都不是件简单的事情。
本节希望将继承中最为头疼,最为复杂的问题统统拿出来晒一晒,以防时间久了,不知不觉在使用者那里发霉生虫。
本节不会花太多笔墨做系统性的论述,如有需要请参考其他技术专著上更详细的分析。
我们将从关于继承的热点出发,逐个击破,最后总结规律,期望用这种方式实现对继承全面的了解,让你掌握什么才是继承。
1.2.2 基础为上
正如引言所述,继承是个容易产生误解的技术话题。那么,对于继承,就应该着手从这些容易误解与引起争论的话题来寻找关于全面认识和了解继承的答案。一点一滴摆出来,最后再对分析的要点做归纳,形成一种系统化认识。这是一种探索问题的方式,用于剖析继承这一话题真是再恰当不过了。
不过,解密之前,我们还是按照技术分析的惯例,从基本出发,以简洁的方式来快速了解关于继承最基本的概念。首先,认识一张比较简单的动物分类图(图1-1),以便引入我们对继承概念的介绍。
图1-1 继承关系图
从图1-1中,我们可以获得的信息包括:
— 动物继承关系是以一定的分类规则进行的,将相同属性和特征的动物及其类别抽象为一类,类别与类别之间的关系反映为对相似或者对不相似的某种抽象关系,例如鸟类一般都能飞,而鱼类一般都生活在水中。
— 位于继承图下层的类别继承了上层所有类别的特性,形成一种IS-A的关系,例如我们可以说,人类IS-A哺乳类、人类IS-A脊椎类。但是这种关系是单向的,所以我们不能说鸟类IS-A鸡。
— 动物继承图自上而下是一种逐层具体化过程,而自下而上是一种逐层抽象化过程,这种抽象化关系反映为上下层之间的继承关系。例如,最高层的动物具有最普遍的特征,而最低层的人则具有较具体的特征。
— 下层类型只能从上层类型中的某一个类别继承,例如鲸类的上层只能是哺乳类一种,因此是一种单继承形式。
— 这种继承关系中,层与层的特性是向下传递的,例如鸟类具有脊椎类的特征,鹤类也具有脊椎类的特征,而所有的类都具有动物的特征,因此说动物是这个层次关系的根。
我们将这种现实世界的对象抽象化,就形成了面向对象世界的继承机制。因此,关于继承,我们可以定义为:
继承,就是面向对象中类与类之间的一种关系。继承的类称为子类、派生类,而被继
承类称为父类、基类或超类。通过继承,使得子类具有父类的属性和方法,同时子类也可以通过加入新的属性和方法或者修改父类的属性和方法建立新的类层次。
继承机制体现了面向对象技术中的复用性、扩展性和安全性。为面向对象软件开发与模块化软件架构提供了最基本的技术基础。
在.NET中,继承按照其实现方式的不同,一般分类如下。
— 实现继承:派生类继承了基类的所有属性和方法,并且只能有一个基类,在.NET中System.Object是所有类型的最终基类,这种继承方式称为实现继承。
— 接口继承:派生类继承了接口的方法签名。不同于实现继承的是,接口继承允许多继承,同时派生类只继承了方法签名而没有方法实现,具体的实现必须在派生类中完成。因此,确切地说,这种继承方式应该称为接口实现。
CLR支持实现单继承和接口多继承。本节重点关注对象的实现继承,关于接口继承,我们将在1.5节“玩转接口”中做详细论述。另外,值得关注的是继承的可见性问题,.NET通过访问权限来实现不同的控制规则,这些访问修饰符主要包括:public、protected、internal和private。
下面,我们就以动物继承情况为例,实现一个最简单的继承实例,如图1-2所示。
图1-2 动物系统UML
在这个继承体系中,我们实现了一个简单的三层继承层次,Animal类是所有类型的基类,在此将其构造为抽象类,抽象了所有类型的普遍特征行为:Eat方法和ShowType方法,其中ShowType方法为虚函数,其具体实现在子类Chicken和Eagle中给出。这种在子类中实现虚函数的方式,称为方法的动态绑定,是实现面向对象另一特性:多态的基本机制。另外,Eagle类实现了接口继承,使得Eagle实例可以实现Fly这一特性,接口继承的优点是显而易见的:通过IFlyable接口,实现了对象与行为的分离,这样我们无需担心因为继承不当而使Chicken有Fly的能力,保护了系统的完整性。
从图1-2所示的UML图中可知,通过继承我们轻而易举地实现了代码的复用和扩展,同时通过重载(overload)、覆写(override)、接口实现等方式实现了封装变化,隐藏私有信息等面向对象的基本规则。通过继承,轻易地实现了子类对父类共性的继承,例如,Animal类中实现了方法Eat(),那么它的所有子类就都具有了Eat()特性。同时,子类也可以实现对基类的扩展和改写,主要有两种方式:一是通过在子类中添加新方法,例如Bird类中就添加了新方法ShowColor用于现实鸟类的毛色;二是通过对父类方法的重新改写,在.NET中称为覆写,例如Eagle类中的ShowColor()方法。
1.2.3 继承本质论
了解了关于继承的基本概念,我们回归本质,从编译器运行的角度来揭示.NET继承中的运行本源,来发现子类对象如何实现对父类成员与方法的继承,以简单的示例揭示继承的实质,来阐述继承机制是如何被执行的。
public abstract class Animal
{
public abstract void ShowType();
public void Eat()
{
Console.WriteLine(\"Animal always eat.\");
}
}
public class Bird: Animal
{
private string type = \"Bird\";
public override void ShowType()
{
Console.WriteLine(\"Type is {0}ype);
}
private string color;
public string Color
{
get { return color; }
set { color = value; }
}
}
public class Chicken : Bird
{
private string type = \"Chicken\";
public override void ShowType()
{
Console.WriteLine(\"Type is {0}ype);
}
public void ShowColor()
{
Console.WriteLine(\"Color is {0}\Color);
}
}
然后,在测试类中创建各个类对象,由于Animal为抽象类,我们只创建Bird对象和Chicken对象。
public class TestInheritance
{
public static void Main()
{
Bird bird = new Bird();
Chicken chicken = new Chicken();
}
}
下面我们从编译角度对这一简单的继承示例进行深入分析,从而了解.NET内部是如何实现我们强调的继承机制的。
(1)我们简要地分析一下对象的创建过程:
Bird bird = new Bird();
Bird bird创建的是一个Bird类型的引用,而new Bird()完成的是创建Bird对象,分配内存空间和初始化操作,然后将这个对象引用赋给bird变量,也就是建立bird变量与Bird对象的关联。
(2)我们从继承的角度来分析CLR在运行时如何执行对象的创建过程,因为继承的本质正体现于对象的创建过程中。
在此我们以Chicken对象的创建为例,首先是字段,对象一经创建,会首先找到其父类Bird,并为其字段分配存储空间,而Bird也会继续找到其父类Animal,为其分配存储空间,依次类推直到递归结束,也就是完成System.Object内存分配为止。我们可以在编译器中用单步执行的方法来大致了解其分配的过程和顺序,因此,对象的创建过程是按照顺序完成了对整个父类及其本身字段的内存创建,并且字段的存储顺序是由上到下排列,最高层类的字段排在最前面。其原因是如果父类和子类出现了同名字段,则在子类对象创建时,编译器会自动认为这是两个不同的字段而加以区别。
然后,是方法表的创建,必须明确的一点是方法表的创建是类第一次加载到AppDomain时完成的,在对象创建时只是将其附加成员TypeHandle指向方法列表在Loader Heap上的地址,将对象与其动态方法列表相关联起来,因此方法表是先于对象而存在的。类似于字段的创建过程,方法表的创建也是父类在先子类在后,原因是显而易见的,类Chicken生成方法列表时,首先将Bird的所有方法复制一份,然后和Chicken本身的方法列表做对比,如果有覆写的虚方法则以子类方法覆盖同名的父类方法,同时添加子类的新
方法,从而创建完成Chicken的方法列表。这种创建过程也是逐层递归到Object类,并且方法列表中也是按照顺序排列的,父类在前子类在后,其原因和字段大同小异,留待读者自己体味。不言而喻,任何类型方法表中,开始的4个方法总是继承自System.Object类型的虚方法,它们是:ToString、Equals、GetHashCode和Finalize,详见8.1节“万物归宗:System.Object”所述。
结合我们的分析过程,现在将对象创建的过程以图例来揭示其在内存中的分配情形,如图1-3所示。
图1-3 对象创建内存概括
从我们的分析和上面的对象创建过程中,我们应对继承的本质有了以下更明确的认识:
— 继承是可传递的,子类是对父类的扩展,必须继承父类方法,同时可以添加新方法。
— 子类可以调用父类方法和字段,而父类不能调用子类方法和字段。
— 虚方法如何实现覆写操作,使得父类指针可以指向子类对象成员。
— 子类不光继承父类的公有成员,同时继承了父类的私有成员,只是在子类中不被访问。
— new关键字在虚方法继承中的阻断作用。
你是否已经找到了理解继承、理解动态编译的不二法门?
通过上面的讲述与分析,我们基本上对.NET在编译期的实现原理有了大致的了解,但是还有以下的问题,可能会引起疑惑,那就是:
Bird bird2 = new Chicken();
这种情况下,bird2.ShowType应该返回什么值呢?而bird2.type又该是什么值呢?有两个原则,是.NET专门用于解决这一问题的。
— 关注对象原则:调用子类还是父类的方法,取决于创建的对象是子类对象还是父类对象,而不是它的引用类型。例如Bird bird2 = new Chicken()时,我们关注的是其创建对象为Chicken类型,因此子类将继承父类的字段和方法,或者覆写父类的虚方法,而不用关注bird2的引用类型是否为Bird。引用类型的区别决定了不同的对象在方法表中不同的访问权限。
注意
根据关注对象原则,下面的两种情况又该如何区别呢?
Bird bird2 = new Chicken();
Chicken chicken = new Chicken();
根据上文的分析,bird2对象和chicken对象在内存布局上是一样的,差别就在于其引用指针的类型不同:bird2为Bird类型指针,而chicken为Chicken类型指针。以方法调用为例,不同的类型指针在虚拟方法表中有不同的附加信息作为标志来区别其访问的地址区域,称为offset。不同类型的指针只能在其特定地址区域内执行,子类覆盖父类时会保证其访问地址区域的一致性,从而解决了不同的类型访问具有不同的访问权限问题。
— 执行就近原则:对于同名字段或者方法,编译器是按照其顺序查找来引用的,也就是首先访问离它创建最近的字段或者方法,例如上例中的bird2,是Bird类型,因此会首先访问Bird_type(注意编译器是不会重新命名的,在此是为区分起见),如果type类型设为public,则在此将返回“Bird”值。这也就是为什么在对象创建时必须将字段按顺序排列,而父类要先于子类编译的原因了。
思考 1.上面我们分析到bird2.type的值是“Bird”,那么bird2.ShowType()会显示什么值呢?答案是“Type is Chicken”,根据上面的分析,想想到底为什么?
2.关于new关键字在虚方法动态调用中的阻断作用,也有了更明确的理论基础。在
子类方法中,如果标记new关键字,则意味着隐藏基类实现,其实就是创建了与父类同名的另一个方法,在编译中这两个方法处于动态方法表的不同地址位置,父类方法排在前面,子类方法排在后面。
1.2.4 密境追踪
通过对继承的基本内容的讨论和本质揭示,是时候将我们的眼光转移到继承应用中的热点问题了,主要是从面向对象的角度对继承进行讨论,就像追踪继承中的密境,在迷失的森林中寻找出口。
1.实现继承与接口继承
实现继承通常情况下表现为对抽象类的继承,而其与接口继承在规则上有以下几点归纳:
— 抽象类适合于有族层概念的类间关系,而接口最适合为不同的类提供通用功能。
— 接口着重于CAN-DO关系类型,而抽象类则偏重于IS-A式的关系。
— 接口多定义对象的行为;抽象类多定义对象的属性。
— 如果预计会出现版本问题,可以创建“抽象类”。例如,创建了狗(Dog)、鸡(Chicken)和鸭(Duck),那么应该考虑抽象出动物(Animal)来应对以后可能出现马和牛的事情。而向接口中添加新成员则会强制要求修改所有派生类,并重新编译,所以版本式的问题最好以抽象类来实现。
— 因为值类型是密封的,所以只能实现接口,而不能继承类。
关于实现继承与接口继承的更详细的讨论与规则,请参见7.4节“面向抽象编程:接口和抽象类”。
2.聚合还是继承,这是个问题。
类与类的关系,通常有以下几种情况,我们分别以两个简单类Class1和Class2的UML图来表示如下。
(1)继承
如图1-4所示,Class2继承自Class1,任何对基类Class1的更改都有可能影响到子类Class2,继承关系的耦合度较高。
(2)聚合
如图1-5所示。
图1-4 继承关系 图1-5 聚合关系
聚合分为三种类型,依次为无、共享和复合,其耦合度逐级递增。无聚合类型关系,类的双方彼此不受影响;共享型关系,Class2不需要对Class1负责;而复合型关系,Class1会受控于Class2的更改,因此耦合度更高。总之,聚合关系是一种HAS-A式的关系,耦合度没有继承关系高。
(3)依赖
依赖关系表明,如果Class2被修改,则Class1会受到影响,如图1-6所示。
图1-6 依赖关系
通过上述三类关系的比较,我们知道类与类之间的关系,通常以耦合度来描述,也就是表示类与类之间的依赖关系程度。没有耦合关系的系统是根本不存在的,因为类与类、模块与模块、系统与系统之间或多或少要发生相互交互,设计应力求将类与类之间的耦合关系降到最低。而面向对象的基本原则之一就是实现低耦合、高内聚的耦合关系,在2.1节“OO原则综述”中所述的合成/聚合复用原则正是对这一思想的直接体现。
显然,将耦合的概念应用到继承机制上,通常情况下子类都会对父类产生紧密的耦合,对基类的修改往往会对子类产生一系列的不良反应。继承之毒瘤主要体现在:
— 继承可能造成子类的无限膨胀,不利于类体系的维护和安全。
— 继承的子类对象确定于编译期,无法满足需要运行期才确定的情况,而类聚合很好地解决了这一问题。
— 随着继承层次的复杂化和子类的多样化,不可避免地会出现对父类的无效继承或者有害继承。子类部分的继承父类的方法或者属性,更能适应实际的设计需求。
那么,通过上面的分析,我们深知继承机制在满足更加柔性的需求方面有一些弊端,从而可能造成系统设计的漏洞与失衡。解决问题的办法当然是多种多样的,根据不同的需求进行不同的设计变更,例如将对象与行为分离抽象出接口实现来避免大基类设计,以聚合代替继承实现更柔性的子类需求等等。
面向对象的基本原则
多聚合,少继承。
低耦合,高内聚。
聚合与继承通常体现在设计模式的伟大思想中,在此以Adapter模式的两种方式为例来比较继承和聚合的适应场合与柔性较量。首先对Adapter模式进行简单的介绍。Adapter模式主要用于将一个类的接口转换为另外一个接口,通常情况下在改变原有体系的条件下应对新的需求变化,通过引入新的适配器类来完成对既存体系的扩展和改造。Adapter
模式就其实现方式主要包括:
— 类的Adapter模式。通过引入新的类型来继承原有类型,同时实现新加入的接口方法。其缺点是耦合度高,需要引入过多的新类型。
— 对象的Adapter模式。通过聚合而非继承的方式来实现对原有系统的扩展,松散耦合,较少的新类型。
下面,我们回到动物体系中,为鸟儿加上鸣叫ToTweet这一行为,为自然界点缀更多美丽的声音。当然不同的鸟叫声是不同的,鸡鸣鹰嘶,各有各的范儿。因此,在Bird类的子类都应该对ToTweet有不同的实现。现在我们的要求是在不破坏原有设计的基础上来为Bird实现ITweetable接口,理所当然,以Adapter模式来实现这一需求,通过类的Adapter模式和对象的Adapter模式两种方式来感受其差别。
首先是类的Adpater模式,其设计UML图表示为图1-7。
图1-7 类的Adapter模式
在这一新设计体系中,两个新类型ChickenAdapter和EagleAdapter就是类的Adapter模式中新添加的类,它们分别继承自原有的类,从而保留原有类型特性与行为,并实现添加ITweetable接口的新行为ToTweet()。我们没有破坏原有的Bird体系,同时添加了新的行为,这是继承的魔力在Adapter模式中的应用。我们在客户端应用新的类型来为Chicken调用新的方法,如图1-8所见,原有继承体系中的方法和新的方法对对象ca都是可见的。
图1-8 ToTweet方法的智能感知
我们轻松地完成了这一难题,是否该轻松一下?不。事实上还早着呢,要知道自然界
里的鸟儿们都有美丽的歌喉,我们只为Chicken和Eagle配上了鸣叫的行为,那其他成千上万的鸟儿们都有意见了。怎么办呢?以目前的实现方式我们不得不为每个继承自Bird类的子类提供相应的适配类,这样太累了,有没有更好的方式呢?
答案是当然有,这就是对象的Adapter模式。类的Adapter模式以继承方式来实现,而对象的Adapter模式则以聚合的方式来完成,详情如图1-9所示。
图1-9 对象的Adapter模式
具体的实现细节为:
interface ITweetable
{
void ToTweet();
}
public class BirdAdapter : ITweetable
{
private Bird _bird;
public BirdAdapter(Bird bird)
{
_bird = bird;
}
public void ShowType()
{
_bird.ShowType();
}
……部分省略……
public void ToTweet()
{
//为不同的子类实现不同的ToTweet行为
}
}
客户端调用为:
public class TestInheritance
{
public static void Main()
{
BirdAdapter ba = new BirdAdapter(new Chicken());
ba.ShowType();
ba.ToTweet();
}
}
现在可以松口气了,我们以聚合的方式按照对象的Adapter模式思路来解决为Bird类及其子类加入ToTweet()行为的操作,在没有添加过多新类型的基础上十分轻松地解决了这一问题。看起来一切都很完美,新的BirdAdapter类与Bird类型之间只有松散的耦合关系而不是紧耦合。
至此,我们以一个几乎完整的动物体系类设计,基本完成了对继承与组合问题的探讨,系统设计是一个复杂、兼顾、重构的过程,不管是继承还是聚合,都是系统设计过程中必不可少的技术基础,采取什么样的方式来实现完全取决于具体的需求情况。根据面向对象多组合、少继承的原则,对象的Adapter模式更能体现松散的耦合关系,应用更灵活。
1.2.5 规则制胜
根据本节的所有讨论,行文至此,我们很有必要对继承进行归纳总结,将继承概念中的重点内容和重点规则做系统地梳理,对我们来说这些规则条款是掌握继承的金科玉律,主要包括:
— 密封类不可以被继承。
— 继承关系中,我们更多的是关注其共性而不是特性,因为共性是层次复用的基础,而特性是系统扩展的基点。
— 实现单继承,接口多继承。
— 从宏观来看,继承多关注于共通性;而多态多着眼于差异性。
— 继承的层次应该有所控制,否则类型之间的关系维护会消耗更多的精力。
— 面向对象原则:多组合,少继承;低耦合,高内聚。
1.2.6 结论
在.NET中,如果创建一个类,则该类总是在继承。这缘于.NET的面向对象特性,所有的类型都最终继承自共同的根System.Object类。可见,继承是.NET运行机制的基础技术之一,一切皆为对象,一切皆于继承。对于什么是继承这个话题,希望每个人能从中寻求自己的答案,理解继承、关注封装、品味多态、玩转接口是理解面向对象的起点,也希望本节是这一旅程的起点。
因篇幅问题不能全部显示,请点此查看更多更全内容