67 继承的利弊和使用原则.docx
《67 继承的利弊和使用原则.docx》由会员分享,可在线阅读,更多相关《67 继承的利弊和使用原则.docx(10页珍藏版)》请在冰豆网上搜索。
67继承的利弊和使用原则
6.7继承的利弊和使用原则
继承是一种提高程序代码的可重用性,以及提高系统的可扩展性的有效手段。
在第1章的1.3.7节(继承、扩展、覆盖)曾经以Servlet为例,演示了继承在创建框架类软件中的运用。
但是,如果继承树非常复杂,或者随便扩展本来不是专门为继承而设计的类,反而会削弱系统的可扩展性和可维护性。
6.7.1继承树的层次不可太多
继承树(不考虑顶层的Object类)的层次应该尽量保持在两到三层。
图6-5和图6-6分别显示了设计合理的继承树和设计不合理的继承树。
如果继承树的层次很多,会导致以下弊端:
(1)对象模型的结构太复杂,难以理解,增加了设计和开发的难度。
继承树底层的子类会继承上层所有直接父类或间接父类的方法和属性,假如子类和父类之间还有频繁的方法覆盖和属性被屏蔽的现象,那么会增加运用多态机制的难度,难以预计在运行时方法和属性到底和哪个类绑定。
(2)影响系统的可扩展性。
继承树的层次越多,在继承树上增加一个新的继承分支需要创建的类越多。
图6-5设计合理的三层继承树
图6-6设计不合理的六层继承树
6.7.2继承树的上层为抽象层
当一个系统使用一棵继承树上的类时,应该尽可能地把引用变量声明为继承树的上层类型,这可以提高两个系统之间的松耦合。
例如动物饲养员Feeder的feed()方法,它的参数为Animal和Food类型:
feed(Animalanimal,Foodfood)
Tips
如果继承树上有接口类型,那么应该尽可能地把引用变量声明为继承树上层的接口类型,参见第8章(接口)。
位于继承树上层的类具有以下作用:
定义了下层子类都拥有的相同属性和方法,并且尽可能地为多数方法提供默认的实现,从而提高程序代码的可重用性。
代表系统的接口,描述系统所能提供的服务。
在设计继承树时,首先进行自下而上的抽象,即识别子类之间所拥有的共同属性和功能,然后抽象出共同的父类,位于继承树最上层的父类描述系统对外提供哪些服务。
如果某种服务的实现方式适用于所有子类或者大多数子类,那么在父类中就实现这种服务。
如果某种服务的实现方式取决于各个子类的特定属性和实现细节,那么在父类中无法实现这种服务,只能把代表这种服务的方法定义为抽象方法,并且把父类定义为抽象类。
比如热水器父类可分为电热水器和燃气热水器这两个子类,电热水器和燃气热水器采用不同的方式烧水,在热水器父类中无法提供烧水的具体实现,因此必须把热水器父类定义为抽象类。
publicabstractclassWaterHeating{
/**烧水*/
publicabstractvoidheating();
/**调节水温*/
publicabstractvoidadjust(intlevel);
}
由于继承树上层的父类描述系统对外提供的服务,但不一定实现这种服务,因此把继承树的上层称为抽象层。
在进行对象模型设计时,应该充分地预计系统现在必须具备的功能,以及将来需要新增的功能,然后在抽象层中声明它们。
抽象层应该比较稳定,这可以提高与其他系统的松耦合及系统本身的可维护性。
6.7.3继承关系最大的弱点:
打破封装
继承关系最大的弱点就是打破了封装。
在第1章的1.3.5节(封装、透明)介绍封装时,曾经提到每个类都应该封装它的属性及实现细节,这样,当这个类的实现细节发生变化时,不会对其他依赖它的类造成影响。
而在继承关系中,子类能够访问父类的属性和方法,也就是说,子类会访问父类的实现细节,子类与父类之间是紧密耦合关系,当父类的实现发生变化时,子类的实现也不得不随之变化,这削弱了子类的独立性。
由于继承关系会打破封装,这增加了维护软件的工作量。
尤其在一个Java软件系统使用了一个第三方提供的Java类库的场合。
例如在基于Web的Java应用中,目前都流行使用Apache开源软件组织提供的Struts框架,这个框架的一个扩展点为Action类。
在Struts1.0版本中,Action类有两个方法:
perform()和saveErrors()。
publicActionForwardperform(
ActionMappingmapping,
ActionFormform,
ServletRequestrequest,
ServletResponseresponse)throwsException
protectedvoidsaveErrors(HttpServletRequestrequest,ActionErrorserrors)
在Java应用中,可以创建继承Action类的子类,例如LoginAction,然后在LoginAction类中覆盖Action类的perform()方法,在perform()方法中则会调用saveErrors()方法。
publicclassLoginActionextendsAction{
publicActionForwardperform(…){//覆盖Action类的perform方法
ActionErrorserrors=newActionErrors();
…
saveErrors(request,errors);//调用Action类的saveErrors()方法
}
}
而在Struts1.2版本中,Action类的perform()方法改名为execute()方法,并且saveErrors(HttpServletRequestrequest,ActionErrorserrors)改为:
saveErrors(HttpServletRequestrequest,ActionMessageserrors)
假如希望将现有的Java应用改为使用Struts1.2版本,就必须对自定义的所有Action子类进行修改。
publicclassLoginActionextendsAction{
publicActionForwardexecute(…){//把perform()方法改为execute()方法
ActionMessageserrors=newActionMessages();//把ActionErrors改为ActionMessages
…
saveErrors(request,errors);
}
}
从以上例子可以看出,当对由第三方提供的Struts框架的Action类做了修改时,软件系统中所有Action的子类也要被相应地修改。
由于继承关系会打破封装,这将导致父类的实现细节很容易被子类恶意篡改。
例如以下Account类的withdraw()方法和save()方法分别用于取款和存款。
publicclassAccount{
protecteddoublebalance;//余额
protectedbooleanisEnough(doublemoney){
returnbalance>=money;
}
publicvoidwithdraw(doublemoney)throwsException{//取款
if(isEnough(money))balance-=money;
elsethrownewException("余额不足!
");
}
publicvoidsave(doublemoney)throwsException{//存款
balance+=money;
}
}
它的子类SubAccount覆盖了Account类的isEnough()方法和save()方法的实现,使得该账户允许无限制的取款,并且按照实际存款数额的10倍来存款。
publicclassSubAccountextendsAccount{
protectedbooleanisEnough(doublemoney){//覆盖父类的isEnough()方法
returntrue;
}
publicvoidsave(doublemoney)throwsException{//覆盖父类的save()方法
balance+=money*10;
}
}
以下程序定义了Account类型的引用变量account,实际引用SubAccount实例,根据Java虚拟机的动态绑定规则,account.save()方法和account.withdraw()方法会和SubAccount实例的相应方法绑定。
Accountaccount=newSubAccount();
account.withdraw(2000);//调用SubAccount实例的withdraw()方法
account.save(100);//调用SubAccount实例的save()方法
6.7.4精心设计专门用于被继承的类
由于继承关系会打破封装,因此随意继承对象模型中的任意一个类是不安全的做法。
在建立对象模型时,应该先充分考虑软件系统中哪些地方需要扩展,为这些地方提供扩展点,也就是提供一些专门用于被继承的类。
对这种专门用于被继承的类必须精心设计,下面给出一些建议。
(1)对这些类必须提供良好的文档说明,使得创建该类的子类的开发人员知道如何正确安全地扩展它。
对于那些允许子类覆盖的方法,应该详细地描述该方法的自用性,以及子类覆盖此方法可能带来的影响。
所谓方法的自用性,是指在这个类中,有其他的方法会调用这个方法。
例如Account类的isEnough()方法会被save()方法调用,因此子类覆盖isEnough()方法还会影响到save()方法。
(2)尽可能地封装父类的实现细节,也就是把代表实现细节的属性和方法定义为private类型。
如果某些实现细节必须被子类访问,可以在父类中把包含这种实现细节的方法定义为protected类型。
当子类仅调用父类的protected类型的方法,而不覆盖它时,可把这种protected类型的方法看做父类仅向子类但不对外部公开的接口。
例如手机的存储容量,用户可以查看存储容量,但不能修改它,手机的子类可以查看存储容量,也可以修改它。
因此在手机CellPhone父类中定义了如下的存储容量storage属性及相应的访问方法。
publicclassCellPhone{
privatedoublestorage;
publicdoublegetStorage(){returnstorage;}//对手机使用者及手机子类公开
protectedvoidsetStorage(doublestorage){this.storage=storage;}//只对手机子类公开
…
}
(3)把不允许子类覆盖的方法定义为final类型。
关于final修饰符的用法参见第7章的7.3节(final修饰符)。
对于Account类,可以把它的isEnough()、withdraw()和save()方法都定义为final类型。
publicclassAccount{
privatedoublebalance;//余额
protectedfinalbooleanisEnough(doublemoney){
returnbalance>=money;
}
publicfinalvoidwithdraw(doublemoney)throwsException{//取款
if(isEnough(money))balance-=money;
elsethrownewException("余额不足!
");
}
publicfinalvoidsave(doublemoney)throwsException{//存款
balance+=money;
}
}
(4)父类的构造方法不允许调用可被子类覆盖的方法,因为如果这样做,可能会导致程序运行时出现未预料的错误。
例如以下Base类的构造方法调用自身的method()方法。
publicclassBase{
publicBase(){method();}
publicvoidmethod(){}
}
publicclassSubextendsBase{
privateStringstr=null;
publicSub(){str="1234";}
publicvoidmethod(){System.out.println(str.length());}//覆盖Base类的method()方法
publicstaticvoidmain(Stringargs[]){
Subsub=newSub();//抛出NullPointerException
sub.method();
}
}
运行Sub类的main()方法时,先构造Sub类的实例。
由于在创建子类的实例时,Java虚拟机先调用父类的构造方法(参见第11章的11.2.3节(子类调用父类的构造方法)),因此Java虚拟机先执行Base类的构造方法Base(),然后在这个方法中调用method()方法。
根据动态绑定规则,Java虚拟机调用Sub实例的method()方法,由于此时Sub实例的成员变量str为null,因此在执行str.length()方法时会抛出NullPointerException运行时异常。
(5)如果某些类不是专门为了继承而设计,那么随意继承它是不安全的。
因此可以采取以下两种措施来禁止继承:
l把类声明为final类型。
关于final修饰符的用法参见第7章的7.3节(final修饰符)。
l把这个类的所有构造方法声明为private类型,然后通过一些静态方法来负责构造自身的实例。
第11章的11.2.5节(构造方法的访问级别)对此做了进一步解释。
6.7.5区分对象的属性与继承
对于进行面向对象设计的新手,比较容易犯的错误是滥用继承关系,根据对象的属性值来分类。
例如根据手机的颜色进行分类,可分为红手机、蓝手机和银白手机,参见图6-7。
图6-7按颜色分类的手机的类框图
以上对象模型是错误的,颜色仅仅是手机的一个属性,不应该根据它的属性值来进行分类。
对于一棵设计合理的继承树,子类之间会具有不同的属性和行为,子类继承父类的属性和行为,并且子类可以比父类拥有更多的属性和行为。
对于红手机、蓝手机、银白手机和手机,除了类的名字不同,它们的属性和行为都相同,因此这样的设计是不合理的。
再看图6-8所示的对书店里书的分类,尽管在现实世界中,这样的分类是合理的,但在对象模型中,这样的设计是错误的。
书的类别仅仅是Book类的一个属性,可以在Book类中定义一个String类型的type属性来表示书的类别,参见图6-9。
图6-8按书的类别分类的Book类的类框图
图6-9用String类型的type属性来表示书的类别
以下程序代码创建了一个Java类别的Book对象。
Bookbook=newBook();
book.setType("Java");
但String类型的type属性无法表达书的类别之间的包含关系,例如科学类别包括数学类别和计算机类别等。
为了能表达这种包含关系,可以定义一个Category类来表示书的类别,用Category类的自身关联关系来表示书的类别之间的包含关系,参见图6-10。
在Category类中,name属性表示书的类别的名字,parentCategory属性表示书的类别所属的父类别,childCategories属性表示书的类别所包含的所有子类别,childCategories属性为java.util.Set类型,在第15章的15.2节(Set(集))介绍了Set集合的用法。
1…n
1…n
图6-10用Category类来表示书的类别
在例程6-5的CategoryTester类中,create()方法创建了Science类别、Math类别和Computer类别的Category对象,然后建立了它们的关联关系,最后创建了一个Math类别的Book对象。
main()方法打印Book对象的类别名字。
例程6-5CategoryTester.java
publicclassCategoryTester{
publicBookcreate(){
CategorycategoryScience=newCategory();//创建Science类型的Category对象
categoryScience.setName("Science");
CategorycategoryMath=newCategory();//创建Math类型的Category对象
categoryMath.setName("Math");
CategorycategoryComputer=newCategory();//创建Computer类型的Category对象
categoryComputer.setName("Computer");
//建立Science类型与Math类型的关联
categoryScience.addChildCategory(categoryMath);
categoryMath.setParentCategory(categoryScience);
//建立Science类型与Computer类型的关联
categoryScience.addChildCategory(categoryComputer);
categoryComputer.setParentCategory(categoryScience);
//创建Math类型的Book对象
BookmathBook=newBook();
mathBook.setCategory(categoryMath);
returnmathBook;
}
publicstaticvoidmain(Stringargs[]){
BookmathBook=newCategoryTester().create();
System.out.println(mathBook.getCategory().getName());//打印书的类别
}
}