面向复用的软件构造技术
9.1什么是软件复用
软件复用是使用现有软件组件实现或更新软件系统的过程
软件复用的两个视角:
- 面向复用编程:开发出可复用的软件
- 基于复用编程:利用已有的可复用软件搭建应用系统
复用的好处:
- 降低成本和开发时间
- 经过充分测试,可靠、稳定
- 标准化,在不同应用中保持一致
劣势:
- 开发成本高,需要超过临界点才能显现出作用
- 性能差些,缺乏针对性
9.2 如何测量程序可复用性
一个有较高可复用性的软件应当有如下特点:
- Brief and Simple–小、简单
- Protable and Standard Compliance–与标准兼容
- Adaptable and Flexible–灵活可变
- Extensibility–可扩展
- Generic and Parameterization–泛型、参数化
- Modularity–模块化
- Localization of volatile design assumptions–变化的局部性
- Stability under changing requirements–稳定
- Rich documentation–丰富的文档和帮助
9.3 可复用组件的级别和形态
最主要的复用是在代码层面,但软件构造过程中的任何实体都可能被复用,如需求、规约、数据、测试用例、文档etc。
9.3.1 代码复用
代码复用分为白盒复用和黑盒复用两种。
白盒复用:
源代码可见,可修改和扩展。即复制已有代码到正在开发的系统,进行修改。
优点:可定制化程度高
缺点:对其修改增加了软件的复杂度,且需要对其内部充分的了解
黑盒复用:
源代码不可见,不能修改;只能通过API接口来使用,无法修改代码
优点:简单,清晰
缺点:适应性较差
9.3.2 模块复用:类/接口
类是代码重用的原子单元:不需要源代码、类文件或jar/zip;只需要包含在类路径中。另外,可以使用javap工具获取类的公共方法头
复用一个类的方法:继承或者委托
继承
不需要提出仅仅是提出或委派工作的虚拟方法
可以更好地捕捉真实世界
通常需要在实现之前设计继承层次结构
无法取消属性或方法,因此必须小心不要过度
委托
委派仅仅是指一个对象依赖另一个对象来实现其功能的某个子集(一个实体将某物传递给另一个实体)
显式委托:类A和B之间没有什么关联,当A中某个功能需要借助B实现时,将方法参数设置为B类型,通过传入B的对象,调用B的方法来完成。
public class A{
……
public void function(B b){
b.function2();
}
}
隐式委派:在类A中维护着一个类B的对象作为A的成员变量,从而可以调用B的方法,实现复用。
委托可以描述为在实体之间共享代码和数据的低级机制。
9.3.3 库级别的复用:API/package
库:提供可重用功能的一组类和方法(API)
框架:可定制为应用程序的可重用框架代码
库和框架的区别:
- 库:开发者构造可运行软件实体,其中涉及到对可复用库的调用
- 框架:Framework作为主程序加以执行,执行过程中调用开发者所写的程序
好的API的特点:
- 易于学习
- 易于使用,甚至无需文档
- 难以滥用
- 易于阅读和维护使用它的代码
- 足够强大以满足要求
- 易于进化
- 适合用户
9.3.4 系统级复用:框架
框架:一组具体类、抽象类、及其之间的连接关系
开发者根据framework的规约,填充自己的代码进去,形成完整系统
可以将framework看作是更大规模的API复用,除了提供可复用的API,还将这
些模块之间的关系都确定下来,形成了整体应用的领域复用
框架与应用程序不同
- 抽象级别不同,因为框架为一系列相关问题提供了解决方案,而不是单个问题
- 为了适应这一系列问题,该框架是不完整的,包含了热点和挂钩,以允许定制
框架可以根据用于扩展它们的技术进行分类,分为黑盒框架和白盒框架
9.5 设计可复用类
9.5.1 行为子类型与Liskov替代原则(LSP)
行为子类型在Java中编译器强制执行的规则(静态类型检查):
- 子类型可以增加方法,但不可删除父类中的方法
- 子类型需要实现抽象类型(接口、抽象类)中所有未实现的方法
- 子类型中重写的方法必须有相同或子类型的返回值或者符合co-variant的参数
- 子类型中重写的方法必须使用同样类型的参数或者符合contra-variant的参数(此种情况Java目前按照重载overload处理)
- 子类型中重写的方法不能抛出额外的异常
另外,行为子类型也适用于以下特殊方法:
- 更强的不变量
- 更弱的前置条件
- 更强的后置条件
eg:
LSP是子类型关系的一个特殊定义,称为(强)行为子类型
在编程语言中,LSP依赖于以下限制:
- 前置条件不能强化
- 后置条件不能弱化
- 保持不变量
- 子类型方法参数:逆变
- 子类型方法的返回值:协变
- 异常类型:协变
协变
更具体的类可能有更具体的返回类型,这称为子类型中返回类型的协变
子类型的方法不应引发任何新异常,除非这些异常本身是父类型的方法引发的异常的子类型
子类型参数的逆变在Java中被当做overload处理
总结:
协变与逆变
Java中 数组是协变的: 对T[]数组,可以保存类型T及其子类型的数据
eg:
Number[] numbers = new Number[2];
numbers[0] = new Integer(10);
numbers[1] = new Double(3.14);
Integer[] myInts = {1,2,3,4};
Number[] myNumber = myInts;
myNumber[0] = 3.14;
//run-time error!
在运行时,Java知道这个数组被实例化为Integer数组,只是通过一个Number[]引用进行访问,因此,有可能出错。解决方法是使用泛型,编译器会在编译时就发现问题。
泛型是类型不变的,例如:
- ArrayList<String>是List<String>的子类型
- List<String>不是List<Object>的子类型
类型参数在编译后被丢弃,运行时不可用,此过程为类型擦除。
正是因为虚拟机中没有泛型类型对象,所有对象都属于普通类,才会发生类型擦除。泛型信息只存在于编译阶段,在运行时会被”擦除”
无论何时定义泛型类型,都会自动提供相应的原始类型。原始类型的名称只是泛型类型的名称,类型参数已删除;擦除时类型变量会被擦除,替换为限定类型,如果没有限定类型则替换为Object类型。
eg:
运行时类型查询只适用于原始类型
if(a instanceof Pair<String>)//compiler error
if(a instanceof Pair<T>)//compiler error
Pair<String> stringPair = ...;
Pair<Employee> employeePair = ...;
if(StringPair.getClass()==employeePair.getClass()){...}//equal
实例分析:
List<Integer> myInts = new ArrayList<>();
myInts.add(1);
myInts.add(2);
List<Number> myNums = myInts; //compiler error
myNums.add(3.14);//如果上一行编译通过,这一行会带来不安全
static long sum(List<Number> numbers) {
long summation = 0;
for(Number number : numbers) {
summation += number.longValue();
}
return summation;
}
List<Integer> myInts = Arrays.asList(1,2,3,4,5);
List<Long> myLongs = Arrays.asList(1L, 2L, 3L, 4L, 5L);
List<Double> myDoubles = Arrays.asList(1.0, 2.0, 3.0, 4.0, 5.0);
sum(myInts); //compiler error
sum(myLongs);//compiler error
sum(myDoubles);//compiler error
给定两种具体类型A和B(例如,number和integer),MyClass<A>与MyClass<B>没有关系,无论A和B是否相关。MyClass<A>和MyClass<B>的共同父级是Object类
通配符
可采用通配符实现两个泛型类的协变。
使用通配符(?)指定无界通配符类型,例如,List<?>,表示一个未知类型的list。
使用无限定通配符的两种情况:
情况1:方法的实现不依赖于类型参数(不调用其中的方法),如List中的
方法
情况2:或者只依赖于Object 类中的功能
无限定通配符,一般用于定义一个引用变量,其可以指向多个不同类型的变量;如果使用固定类型,则只能指向对应类型的对象
eg:
public static void printList(List<Object> list) {
for (Object elem : list)
System.out.println(elem + " ");
System.out.println();
}
public static void printList(List<?> list) {
for (Object elem: list)
System.out.print(elem + " ");
System.out.println();
}
List<Integer> li = Arrays.asList(1, 2, 3);
List<String> ls = Arrays.asList("one", "two", "three");
printList(li);
printList(ls);
本意是期望可打印任意类型的List,但由于泛型不协变,只能打印List<Object>;如果使用无限定通配符,则可以打印出任意类型
<? super A> 下限通配符,表示通配的下限是A <? extends A> 上限通配符,表示通配的上限是A 但是, List<? extends Number> list,意味着list可以匹配多种类型中的一种,但并不意味着同一个list可以存放所有的这些类型,无限定通配符和下限通配符同理。 一个类型变量如果有多个限定(类或接口),则它是所有限定类型的子类型;如果多个限定中有类(至多只允许一个类),要写到声明的最前面。 限定的类型参数允许调用限定类型中的方法。如\则可以调用int的方法。 使用通配符后可以进行子类型的比较,关系如下图: ![关系图](https://img-blog.csdnimg.cn/2fff93f270dd487e98b2600fd0e49ce9.png) 即: List is a subtype of List<?>List is a subtype of List<? extends Object>
List is a subtype of List<? super String>
eg:只有小类型放到大类型中才不会报错
9.5.2 委托和组合
比较器:int compare(T a,T b):比较其两个参数的顺序。
如果你的ADT需要比较大小,或者要放入Collections或Arrays中进行排序,可实现Comparator接口并override compare()函数。这使用了委托的思想。
另一种方法:让你的ADT实现Comparable接口,然后override compareTo() 方法。与使用Comparator的区别:不需要构建新的Comparator类,比较代码放在ADT内部。这不是委托。
委派/委托:一个对象请求另一个对象的功能,委派是复用的一种常见形式。
很多设计模式将继承和委托结合使用。
如果子类只需要复用父类的小部分方法,可以考虑委托机制实现,不需要继承父类所有方法,避免继承大量无用的方法。
eg:
复合重用原则(CRP)
类应该通过组合(通过包含实现所需功能的其他类的实例)而不是从基类或父类继承来实现多态行为和代码重用。组合优先于继承,注:组合是委派的一种形式。
“委托”发生在对象层面,而“继承”发生在类层面。因此,当核心问题出现在对象层面而不是类层面时,需要考虑CRP原则。在具体实现中,使用接口定义系统必须对外展示的不同侧面的行为,接口之间通过extends实现行为扩展,类实现组合接口,避免复杂的继承关系。
eg:
interface Flyable {//定义抽象行为的接口
public void fly();
}
interface Quackable {//定义抽象行为的接口
public void quack();
}
class FlyWithWings implements Flyable {//接口的具体实现
@Override
public void fly() {
System.out.println("fly with wings");
}
}
class Quack implements Quackable {//接口的具体实现
@Override
public void quack() {
System.out.println("quack like duck");
}
}
interface Ducklike extends Flyable, Quackable {}//接口的组合,定义了行为的组合
public class Duck implements Ducklike {//从组合接口中派生具体类
Flyable flyBehavior;//委托
Quackable quackBehavior;
void setFlyBehavior(Flyable f) {//设置委托对象实例
this.flyBehavior = f;
}
void setQuackBehavior(Quackable q) {
this.quackBehavior = q;
}
@Override
public void fly() {//通过委托实现具体行为
this.flyBehavior.fly();
}
@Override
public void quack() {
this.quackBehavior.quack();
}
}
委派的类型(都支持一对多):
- Dependency,临时性的委派,用完即释放
- Association,永久性的委派,一直占着
- Composition/aggregation,Association的两种形态
这种分类是根据委托人和被委托人之间的“耦合度”进行的。
dependency
两个类之间的这种形式的关系被称为“uses-a”关系,在这种关系中,一个类使用另一个类,而实际上没有将其合并为属性。例如,它可以是参数,也可以在方法中局部使用。
eg:委派的对象作为一个参数传进方法中,其只在该方法内代码域有效,是临时的。
Association
对象类之间的持久关系,允许一个对象实例导致另一个对象实例代表其执行操作
has_a:一个类将另一个类作为属性/实例变量。此关系是结构化的,因为它指定一种类型的对象连接到另一种类型的对象,并且不表示行为
eg:委派对象为内部的一个属性,具有永久性。
Composition: 更强的association,但难以变化
一种将简单对象或数据类型组合成更复杂对象或数据类型的方法
is_part_of:一个类将另一个类作为属性/实例变量;实现为一个对象包含另一个对象
eg:相比于上者,使用者有一个专属的委派对象,委派对象是使用者的一部分。
Aggregation: 更弱的association,可动态变化
聚合:对象存在于另一个外部,在外部创建,因此它作为参数传递给构造函数
eg:当上者中拥有对象被破坏,委派对象也会被破坏,而下面这个对象存在于另一个之外,如果拥有者被破坏,被包含者也不会被破坏。
9.6 设计系统级可重用API库和框架
API是程序员最重要的资产和荣耀,可以吸引外部用户,提高声誉。建议始终以开发API的标准面对任何开发任务,面向“复用”编程,而不是面向“应用”编程。
设计API存在一定难度,要有足够良好的设计,一旦发布就无法自由改变了。
白盒框架
- 通过子类化和重写方法进行扩展(继承)
- 常见的设计模式:模板方法
- 子类拥有main方法,但可以控制框架
黑盒框架
- 通过实现插件接口进行扩展(委托)
- 通用设计模式:策略、观察者模式
- 通过插件加载机制加载插件,并为框架提供控制
eg:
总结:
- 白盒框架使用的是子类或子类型,是继承,允许扩展每个非私有方法,需要了解超类的实现,一次只能有一个子类,通常所谓的开发人员框架;
- 黑盒框架使用的是组成要素,是委托/组合,允许扩展接口中公开的功能,只需要了解接口,多个插件,通常提供更多的模块化,可以单独部署(.jar、.dll、…),通常是所谓的最终用户框架、平台。