软件构造第四章总结—Part I

前言:软件构造第四章学习笔记:Reuse

Part I:Construction for Reuse:Inheritance and Delegation

一.继承与重写&LSP

在第三章的总结中已经详细介绍过继承与重写,不难看出,这是比较容易想到的一种复用方法:建立一个父类,把共性的操作提炼出来放在父类的方法之中,当子类继承父类时对不需要修改的方法可以直接继承从而实现了代码的复用。

1.LSP

而在这种复用方法中,理论来说这种继承关系是可以任意设计的,那我们如何来度量自己设计的继承关系的质量?
著名的里氏替换原则就为子类的设计提供了方向与准则,是我们在设计继承复用时的基石:
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T,the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。)
简单说来,它的含义就是父类的行为应该完全可以由子类来代替完成。
这一点听起来很容易,做起来可能并不简单,尤其体现在子类重写父类方法时.

①更强的不变量(或相同)
以下面的例子作为说明
第一个类中的不变量如下所示:
在这里插入图片描述
而其子类的不变量如下所示:
在这里插入图片描述那么这个子类是否具有更强的不变量呢?答案是具有更强的不变量。
子类Car中的不变量添加了一条限制要求,而这个条件并未对父类中的不变量产生任何影响,任何一个Car类的对象,其需要满足Car类所要求的不变量,那么必然满足speed<limit和fuel>=0两个不变量,由于包括了speed<limit限制条件,自然而然地,其必然满足父类的不变量,那么可以说它具有更强的不变量,从而
意即任何一个该子类的对象,只要它满足了子类的不变量,那么它必然要满足父类的不变量,那么可以说该子类具有更强的不变量。

你可以把子类的“更强”不变量理解为“一般”中的“特例”。父类的不变量就相当于是“一般准则”,而子类的不变量应该是“一般”中衍生出来的“特例”,它必须要满足“一般”中的共性要求,否则它就不是从“一般”中衍生出来的,这对应于“父类不变量必须满足”。
而同时作为“特例”,其也可以具有某些“一般”约束之外的特点,这可以对应为“更强”

②前置条件不能强化
想想之前LSP的要求:子类必须是可以完全替换父类的,那么其方法必须要满足父类方法的规约,否则其必然不满足该要求。
为什么呢?对于父类的方法,其就是在完成规约所规定的任务,既然子类可以替换父类,那么调用子类对应方法应该可以完成同样的任务,如果不满足父类的规约,就说明任务无法完成,那谈何替换一说?
而为了满足父类方法的规约,我们可以回忆起之前章节介绍的“等价性”,只有保持相同或更强的规约才能替换一个规约,于是便需要对子类型的前置条件和后置条件作限定,首先就是前置条件不能强化,主要是针对重写的方法来说,因为直接继承的方法显然不用考虑这个问题,规约是直接继承下来的。

<1>重写方法的规约前置条件不能强化

在刚才的分析中显然就可以了解到为什么需要这一点要求。如果子类重写方法的规约前置条件更强,那就说明子类该方法对于输入的要求更多,比如他额外要求某一个输入i必须为0,但是在父类方法中没有此类要求,那么显然子类无法代替父类,因为父类承诺可以处理输入i为0的情况
父类型规约:在这里插入图片描述
子类型规约:在这里插入图片描述
上面子类型方法的前置条件显然更弱一些,符合要求

<2>重写方法的参数类型应是父类方法参数的父类型
这是一个很容易被忽视的问题,其也可以归结在前置条件的对比这里,因为这一要求其实相当于使子类方法的前置条件变得更弱。
在这里插入图片描述
如上图所示,比如父类方法参数是String类型,子类参数是Object。那么父类只可以处理字符串类型,用子类替换父类时,字符串类型是一定可以处理的,这就实现了等价替换,同时,即使对于其他不符合父类要求的类型,子类也可以处理,降低了对条件的要求,从而相当于使子类方法的前置条件变得更弱。

这一要求我们可以称为子类型方法参数与父类型方法参数是"逆变"的,因为参数的父—子关系与类的父—子关系是相反的

③后置条件不能弱化
<1>重写方法的规约前置条件不能强化
这里的要求同前置条件处一样,是为了保证规约保持相同或更强,从而可以实现替换

<2>重写方法的返回值类型应是父类方法返回值的子类型
对应于“逆变”,这种关系我们可以称之为"协变"的,因为父—子关系的对应是一致的
这也是后置条件不能更弱的一种体现:
在这里插入图片描述
以上图为例,父类承诺返回值是Object类型的对象,而子类该方法返回String类型的对象,那么肯定是可以用子类方法进行替换的,因为String是Object的子类型,显然该对象也必然是Object类型的对象,符合替换要求。
试想一下,如果调换过来还满足要求吗?
显然不再满足,父类承诺返回String类型对象,而子类返回的却是Object类型对象,这个对象实例化时甚至可以实例为Integar类型的,那么怎么保持一致呢?

因此返回值类型应该是“协变的”。

<3>重写方法的异常类型应是父类方法异常的子类型
原理同返回值是一样的,如果不是“协变”而是“逆变”,子类返回的异常类型很有可能会超出父类所承诺的范畴,从而无法再进行替换

2.继承的优劣

以上介绍了在设计继承子类时的原则:LSP原则,那么在复用时采用继承的手段又有哪些优点和缺点呢?

继承优点:
● 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
● 提高代码的重用性;
● 子类可以增加自己个性化的方法;
● 提高代码的可扩展性,多个子类提供多个实现,有新的实现方法可以新建一个子类而无需修改;

继承缺点:
● 降低代码的灵活性。只要继承,就必须拥有父类的所有属性和方法,而有些属性和方法可能是子类完全不需要的;
● 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。
●java中由于类之间的继承不能具有多继承关系,因而当需要某个类继承多个类的方法(特性)相组合时,可能会形成庞大的继承树,给代码的维护带来了极大挑战
这里以实验三中的继承方案来举例说明
在这里插入图片描述
这是五个计划项的维度的特征,它们都继承自共同父类,那么如何使用继承实现对这五个更好地复用呢?

首先,你可以将所有的方法都在父类声明, 每个子类的特性方法在父类中设置为空函数体或设为抽象函数,这样设计的结构非常简单,但是由于各个子类方法间的差异,父类中的规约难以实现统一,因此可能会导致子类方法不满足LSP原则的现象。

其次 ,你完全可以不在父类中声明个性的方法,这些全部在子类中声明。但子类的方法之间会出现很多重复代码,因为你可以看到几个计划项在某几个维度之间都是有交叉的,这些地方的实现势必会有重复代码,这样会导致复用性很差。

这些方案都不好,你还可以为每个维度(表中每一列)单独定义接口:
比如单位置、两个位置、多个位置
单资源、多个可区分资源、多个不可区分资源等等,然后为每个接口做一个实现类,(这里并不让每个子类直接作为实现类,因为这样的话,和上面的讨论一样,会带来大量重复)然后通过继承这些实现类来实现复用。但这时又有一个问题,因为java中不允许多继承,那么只能在某一个维度基础上定义新的类来继承,再进一步添加新的特征,如
在这里插入图片描述
,那么这样的结果就会是“组合爆炸”,出现非常多的子类,代码维护极其困难

通过这个例子,我们确实看到了继承的缺点,那么这时有没有更好的办法来实现呢?
请接着往下看

二.委托

相比于继承在类这个层级上的关系进行设计,委托是在对象层级进行设计。委托通过将该类中自己要完成的任务交付给另一个类的对象去完成。即类A中要完成的事情交给另一个类B的对象,通过调用B中的方法来完成。而A和B之间可以没有任何关系。委托有显式和隐式两种。

1.显式委托: A use B

类A和B之间没有什么关联,当A中某个功能需要借助B实现时,将方法参数设置为B类型,通过传入B的对象,调用B的方法来完成。
以下面的简单代码作为例子

public class A{
	……
	public void function(B b){
		b.function2();
	}
}

A的function方法想要复用B中function2方法,那么将function方法的参数设置为B类型(当然,需要其他参数也可以添加),然后通过调用该对象的function2方法来完成功能

2.隐式委托:A has B

在类A中维护着一个类B的对象作为A的成员变量,从而可以调用B的方法,实现复用。
可以将其分为更细的三个类型
①组合:
不对外提供改变B类型的成员变量的方法,B类型的成员变量伴随A类型对象创建而创建,A类型对象消失而消失,具有相同的生命周期。

public class A{
	private B b=new B();
	public void fuction(double number){
		b.function2(double number);
	} 
}

上面的代码就是一个简单示例,A在A类中维护一个B类型的成员变量,并且在A类型创建伊始就创建一个新的B类型对象,这时二者的生命周期是相同的,A可以调用需要的方法,实现复用。
此时二者的关系比较紧密,但灵活性可能较差。
②聚合:
在A类型对象创建时并不为B类型对象进行赋值,可随时由外部传入进行赋值,需要调用B中方法时再调用
如图所示为一个简单的例子:
在这里插入图片描述
这时灵活性很大,二者的联系并不紧密
③联系:
先和①类似,在初始新建B的对象,但同时也像②一样提供变值函数
如图所示:
在这里插入图片描述
是①和②的相对折中

3.委托&继承

介绍了这么多种类,相比于继承,无论哪一种委托形式,都是通过对象层级的调用,避免继承带来的很多无用/危险的方法,并减少了两个类之间的耦合度。在上面分析继承时我们就提到过,如果父类的具体实现改变,即使父类对外承诺的功能没变,继承的子类也是有可能需要变化的,但是委托一般不会受影响,因为我只是注重B类的功能,只要B承诺的功能没变,我就仍然可以直接委派给B来完成。

此外,委托可以解决继承当中“类无法多继承”的问题。
就拿上面举的实验三的例子而言,
在这里插入图片描述
我们可以像之前一样每个维度的每一种特征都定义一个接口,比如资源维度可定义:单个资源、多个无次序资源、多个有次序资源。然后每个接口都完成对应的实现类。
这时,到具体的应用子类时,我们不是直接继承实现类,而是再定义一个接口,如对于航班来说,我们再定义一个接口

public interface FlightPlanningEntry extends TwoLocation,PresetLocation,SingleResource,UnBlockable,SetTime

这就利用到了接口的多继承,将各个维度结合起来。这时你可能会问之前继承也可以这样做但为什么没做呢?因为如果只用继承的话,即使定义出这个接口,落到实现类时除了用之前提到的继承树以外,没有什么其他手段了,因此定义和不定义的效果是一样的。

但如果使用继承,在实现子类中,我们可以对于每个维度的实现类都保存一个成员变量,对应功能直接委托给他们来实现

public class implements FlightPlanningEntry{
	private TwoLocation tl;
	private PresetLocation pl;
	private SingleResource sr;
	private UnBlockable ub;
	private SetTime st;
	……
}

这样,通过委托,避免了继承带来的组合爆炸式的继承树,非常高效地实现复用

实际当中经常使用继承+委托来实现更好地复用。
当共性/通用性很强,B类的很多方法都需要/可以使用时,可以选择继承;
当共性/通用性没那么强,需要灵活组合时,可以采取委托

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值