前言
第三章介绍了软件构造的核心理论(ADT)和技术(OOP),主要是介绍怎么去实现一个软件的核心,保证代码的质量、提高代码的安全性。第四章就是在这基础之上进行的提高代码的可复用性。在以前我们都是面向应用的编程,就是应用要什么功能我们就做什么,但其实很多类之间都是有共性的方法,比如飞机类,汽车类,他们都有驾驶员,都有载客,年限等等共性的东西,我们没必要在具体类中都把共性的东西写一遍,这样效率是十分低下的,而且代码也会很长,所以我们要提高代码的可复用性。
面向复用的软件构造技术
设计可复用的类
- 继承和重写(Override)
- 重载(Overload)
- 参数多态和泛型编程
- 行为子类型与LSP原则
- 组合与委派
设计可复用的库与框架
- API与Library
- 框架(白盒框架和黑盒框架)
LSP原则
我们在第三章多态的时候就已经提到了LSP原则,第三章的spec与RI和AF都会成为判断LSP原则的重要依据。
LSP原则:在任何可以使用父类型的场景,都可以使用子类型代替父类型而不会有任何问题。
简单来讲就是子类型可以完全替代父类型,就是LSP原则。
LSP原则有以下8个关键点:
- 子类型可以增加方法,但不能删父类型的方法(就是继承关系)
- 子类型需要实现抽象类型中的所有未实现的方法(即子类型必须是实现类)
- 子类型中的重写的方法必须有相同的或子类型的返回值或符合协变的参数(协变就是和父类型到子类型的关系是抽象到具体的关系,这一点换言之就是更强的后置条件postcondition)
- 子类型中重写的方法必须使用相同类型的参数或符合逆变的参数(和协变相反,逆变就是从具体到抽象,换言之就是更弱的前置条件precondition)
- 子类型中重写的方法不能抛出额外的异常(要想完全替代,那重写的方法就不能给出不一样的spec,所以异常不能更多,可以更具体)
- 子类型要有更强的不变量(RI更强)
- 子类型要有更弱的前置条件(具体的就是参数的逆变关系)
- 子类型要有更强的后置条件(具体的就是返回值的协变关系)
还需要注意的是immutable类型的子类不能是mutable 的,因为我们要保证RI更强。
协变
协变就是随着父类型到子类型一样从越来越具体,返回值协变就是子类型重写方法的返回值得是父类型的返回值的子类
逆变
和协变相反,它是越来越抽象,换言之就是从子类重写的方法的参数是父类的参数的父类。但在java中不支持逆变,而是将逆变当做overload,因为参数列表发生了变化。
泛型中的LSP
泛型中不存在协变,因为泛型有类型擦除
什么意思呢?
我们看下面的这个例子
加入我有个class Node,它是带泛型参数的
Node<Obeject> next = null;
Node<Object> node = new Node(new Object(),null)
我用Object作为参数创建Node,它在运行编译时就不再是泛型了,内部已经被修改成了Object,如下图所示
所以我们就不再能用更具体的子类给其父类赋值了(这句话说起来感觉有点歧义,没看明白的看下面的例子)
这个例子就更具体了,我们不能将myInts赋值给myNums,编译器会报错。可能有的同学会觉得奇怪,为什么不能替换了,就是因为他们原来是泛型,实例化了以后就被类型擦除了,而且,List和List并不是父子类型关系,实例化的数据类型不同,当然不能替换。
那有没有可以替换的情况呢?
有,就是通配符。
通配符(?)
通配符就是一个问号,使用通配符就能解决上述的问题。比如下面这个例子
static int sum(List<Number> numbers){
return numbers.size();
}
static int sum1(List<?> numbers){
return numbers.size();
}
static int sum2(List<? extends Number> number){
return numbers.size();
}
static void main(String[] args){
List<Integer> number = Arrays.asList(1,2,3);
sum1(number);//可以通过编译
sum2(number);//可以通过编译
sum(number);//编译报错
}
正如代码所示,sum(number)是不能通过编译的,但注释掉它后,后面两个是可以通过编译的,这就是因为通配符“?”的作用,sum1中的写法?可以代替任何数据类型,sum2中的写法<?extends Number>,?可以代替任何Number的子类包括它自己。它不像泛型一样存在泛型擦除,所以是可以替换的。
组合和委派
上面讲的LSP原则其实就是严格的继承关系基础上的,使用继承关系确实可以使我们达到一定程度的复用关系。但是,有时候我们其实并不是父子类关系,但就是有相似的地方,比如火车和轿车都有轮子,都能载客,都有驾驶员,但他们是父子类关系吗?显然不是。这个时候我们就可以使用委派了,把共性的部分比如驾驶员,那就把驾驶员委派出去,可以载客,那就把载客委派出去,所以和继承相比较,委派更加的灵活,可以只用某个方法,而不用整个都拿来用。
委派的建立
临时的委派:随着方法的执行的时候传入的。什么意思呢,就是把要委派的对象当做参数传到这个方法中,然后在方法中使用委派对象的方法。比如下图中fly方法,把Flyable f当做参数传进去,再调用f.fly()。
这种委派关系随着方法结束就终止了,所以叫临时性的委派关系。
永久性的委派:在类构造的时候就建立起委派关系。
比如在构造的时候就把委派的对象传进去,并保存在类中。这种情况下一旦Duck被创建,那委派关系就确立了,并永久保存。
还有种情况就是直接在类中就创建一个委派对象。这种委派关系是最强的,但也是最受限的。因为我们如果对Flyable作出修改,我们不用FlyWithWings这个实现类了,我想换一个,那只能在代码里面改,客户端没有办法修改。但如果是上面那种传参的形式,那我客户端可以自己选择实现类传进去,自由度更高也更容易扩展。
组合
组合是一个意义很宽泛的词,组合实现复用,其实就是对抽象类之间或抽象接口之间不断的extends扩展,扩展的越多,一个接口拥有的方法或者说功能就越齐全,还不用自己写,我们使用接口层面的组合,就可以不用在类层面进行继承了,否则我们一颗继承树会十分复杂。
无论是继承还是委派,都是十分高效的复用手段,但是如何去组合使用他们才是我们程序员需要思考的。
如何设计可复用的Framework
可复用的框架,听起来就特别舒服,框架嘛,任何事有了框架就变得容易很多了,在框架中,我们可以根据框架提供的接口进行不断的继承和重写以简便我们编程。那么如何去设计一个可复用的框架呢?
白盒框架
白盒框架主要的思想就是继承和重写。我们可以将父类型中的个性方法写成抽象方法,也就是说将父类型定义成抽象类,它里面有些方法是不实现的,留给子类型中实现,这样我们就可以写不同的子类型对这些抽象方法进行不同的实现,就不拘于用一种实现模式,可以添加一些个性化的东西。比如添加日志功能。
黑盒框架
黑盒框架的主要思想是委派。就是我们可以在框架主程序中建立委派关系,创建一个接口留在外部,然后在外部编写各种实现类。通俗的讲就是留下一个传送门,这个传送门就是个接口,具体的实现我们在框架中是不知道的,我们可以给这个接口编写实现类。
所以我们在运行一个框架的时候,白盒框架我们运行的是继承自原框架的程序,因为所有具体实现都在这个继承后的框架中。而黑盒框架我们运行的是原框架,因为我们留下了传送门了,我们建立有了委派关系,我只要把委派的那个接口的实现类写了就行。它在运行的时候自然会调用我的实现类。
面向复用的设计模式
我们可以使用设计模式来灵活的使用继承和委派达到高效的复用效果,这部分的内容我会单独开一篇博客,结合实验三总结具体描述。一共有6种设计模式。