文章目录
什么是软件复用
软件复用是指使用现有的软件组件来实现或更新软件系统的过程。
软件重用的两个角度
- 面向复用编程:开发出可复用的软件
- 基于复用编程:利用已有的可复用软件搭建应用系统
为什么复用:
-
降低成本和开发时间
- 通过缩短软件生产周期(软件开发速度更快、人员更少)来提高软件的生产力
- 不浪费资源而不必要地“重新发明车轮”
- 降低维护成本(可以生产出更好的质量、更可靠和高效的软件)
-
经过充分测试,可靠、稳定
- 重用已经存在了一段时间并被调试的功能构建稳定子系统的基础
-
标准化,在不同应用中保持一致
- GUI库的重用会在应用程序中产生常见的外观和感觉。
- 与常规的、连贯的设计相一致。
可重用的组件应该以明确定义、开放的方式设计和构建,具有简洁的接口规范、可理解的文档,并关注未来的使用。做到这些,需要代价。
重用代价高昂:它涉及到跨越组织、技术和流程的更改,以及支持这些更改的工具的成本,以及培训人员使用新工具和更改的成本。
面向复用编程和基于复用编程代价都高。
问题:
-
可重用组件的开发成本高于特定同类产品的开发成本。这种额外的可重用性增强成本应该是一个组织成本,而不是一个项目成本。
-
通用组件的空间效率较低,可能比它们的特定组件有更长的执行时间。
-
必须开发和维护用于架构、设计、文档和代码的组件管理工具,如存储库。
-
往往无法拿来就用,需要适配(添加,删除,修改组件)
如何衡量“可重用性”
-
在不同的应用程序场景中,重用软件资源的频率如何?复用的机会有多频繁?复用的场合有多少?
- 一个资产被使用的机会越大,它的可重用性就越高。写一次,重用多次。
-
复用的代价有多大?
- 搜索、获取
- 适配、扩展
- 实例化
- 与软件其他部分的互连的难度
可重用性意味着对构建、打包、分发、安装、配置、部署、维护和升级问题进行一些显式管理。
具有高可重用性的软件资产应该:
- 小、简单
- 与标准兼容
- 灵活可变
- 可扩展
- 泛型、参数化
- 模块化
- 变化的局部性
- 稳定
- 丰富的文档和帮助
可重复使用的组件的级别和形态
最主要的复用是在代码层面,但软件构造过程中的任何实体都可能被复用。
- 需求,设计/规约,数据,测试用例,文档等
重用级别:
- 源代码级:方法,语句等
- 模块级:类和接口
- 库级:API/包
- 系统级:框架
代码重用的类型:
- 白盒复用:源代码可见,可修改和扩展
- 复制已有代码到正在开发的系统,进行修改
- 可定制化程度高
- 对其修改增加了软件的复杂度,且需要对其内部充分的了解
- 黑盒复用:源代码不可见,不能修改
- 只能通过API接口来使用,无法修改代码
- 简单,清晰
- 适应性差些
可重用组件分发的格式:
- 源代码
- 包
源代码复用
复制/粘贴所有源代码到您的程序中
-
维护问题
-
需要在多个地方更正代码
-
要使用的代码过多(版本较多)
-
-
过程中出错的高风险
-
可能需要了解所使用的软件的工作方式
-
需要访问源代码
模块复用:类/接口
类是代码重用的一个基本单元
-
源代码不必要,类文件或jar/zip
-
只需要包含在类路径中
-
可以使用javap工具来获得类的公共方法头
文档非常重要。
封装有助于重用。
较少的代码来管理。
版本控制,向后兼容性仍然有问题。
需要将相关的类打包在一起——静态链接。
重用一个类的方法:继承
Java提供了一种名为继承的代码重用方式:
- 类扩展了现有类的属性/行为
- 此外,它们可能会覆盖现有的行为
利弊:
- 不需要放置只是转发或委托工作的虚拟方法
- 更好地捕捉现实世界
- 通常需要在实现之前设计继承层次结构
- 不能取消属性或方法,所以必须小心不要做过头
重用一个类的方法:委托
委托只是指一个对象对其功能的某个子集依赖于另一个对象(一个实体传递给另一个实体)。
明智的授权允许代码重用。
库级重用:API/包
库:提供可重用功能的一组类和方法(API)
框架:可自定义到应用程序中的可重用的骨架代码
框架调用客户端代码。
一个良好的API的特性:
- 易于学习
- 易于使用,即使没有文档
- 很难误用
- 易于阅读和维护使用它的代码
- 足够强大以满足需求
- 容易扩展
- 适合读者
系统级重用:框架
框架是一种子系统设计,包含一组抽象的和具体的类,以及每个类。
框架是一种抽象结构,其中提供通用功能的软件可以通过额外的用户编写的代码进行选择性地更改,从而提供特定于应用程序的软件。
可重用性利用了应用程序领域的知识和经验丰富的开发人员之前的努力。
将framework看作是更大规模的API复用,除了提供可复用的API,还将这些模块之间的关系都确定下来,形成了整体应用的领域复用
框架不同于应用程序——抽象的层次是不同的,因为框架为一系列相关问题提供了解决方案,而不是单个解决方案。为了适应一系列问题,该框架是不完整的,包含了热点和钩子来允许定制。
框架可以通过用于扩展它们的技术进行分类:
-
黑盒框架(通过实现特定接口/delegation进行框架扩展)
- 通过为可以插入到框架中的组件定义接口来实现的可扩展性。
- 通过定义符合特定接口的组件来重用现有功能。
- 这些组件通过委托与框架集成。
-
白盒框架(通过代码层面的继承进行框架扩展)
- 通过继承和动态绑定来实现的可扩展性。
- 现有的功能是通过子类化框架基类和覆盖预定义的钩子方法来扩展的。
- 通常使用诸如模板方法模式等设计模式来覆盖钩子方法。
设计可重用类
- 封装和信息隐藏
- 继承和重写
- 多态性,子类型和重载
- 泛型编程
- 行为子类型和Liskov替代原则(LSP)
- 委托和组合
行为子类型和Liskov替代原则(LSP)
子类型多态:客户端可用统一的方式处理不同类型的对象
在可以使用a的场景,都可以用子类型c1和c2代替而不会有任何问题
设q (x)是T类型的对象x的可证明性质,那么q (y)对于S类型的对象y是可证明的,其中S是T的子类型。
——Barbara Liskov
Java中的编译器强制规则(静态检查)
- 子类型可以增加方法,但不可删
- 子类型需要实现抽象类型中的所有未实现方法
- 子类型中重写的方法必须有相同类型的返回值或者符合co-variance的返回值
- 子类型中重写的方法必须使用同样类型的参数或者符合contra-variance的参数
- 子类型中重写的方法不能抛出额外的异常,抛出相同或者符合co-variance的异常
也适用于指定的行为(方法):
- 更强的不变量
- 更弱的前置条件
- 更强的后置条件
对于LSP:
子类满足相同的不变量(以及其他不变量);重写方法具有相同的前条件和后条件。
或者,子类满足相同的不变量(和附加的不变量);重写方法启动有较弱的前提条件,重写方法制动有较强的后置条件。
LSP是一种子类型关系的一种特殊定义,称为(强)行为子类型化
- 前置条件不能强化
- 后置条件不能弱化
- 不变量要保持
- 子类型方法参数:逆变
- 子类型方法的返回值:协变
- 异常类型:协变
Covariance (协变)
更具体的类可能有更具体的返回类型,这被称为子类型中的返回类型的协变。
class T {
Object a() { … }
}
class S extends T {
@Override
String a() { … }
}
为子类型的方法声明的每个异常都应该是为超类型的方法声明的某个异常的子类型,或者子类型不抛出异常。
class T {
void b( ) throws Throwable {…}
}
class S extends T {
@Override
void b( ) throws IOException {…}
}
class U extends S {
@Override
void b( ) {…}
}
Contravariance (反协变、逆变)
父类型→子类型:越来越具体specific;参数类型:要相反的变化,要不变或越来越抽象
在逻辑上,它被称为子类型中的方法参数的逆变。
这在Java中实际上是不允许的,因为它会使重载规则复杂化。目前Java中遇到这种情况,当作overload看待
class T {
void c( String s ) { … }
}
class S extends T {
@Override
void c( Object s ) { … }
}
关于分型和LSP的总结
数组是协变的:给定Java的子类型规则,类型T[]的数组可以包含类型T的元素或T的任何子类型
泛型中的LSP
泛型是类型不变的:
ArrayList<String>
是List<String>
子类型List<String>
不是List<Object>
子类型
代码编译完成后,编译器会丢弃类型参数的类型信息,因此此类型信息在运行时不可用。
这个过程被称为类型擦除。泛型不是协变的。
类型擦除:将泛型类型中的所有不限制类型的参数类型参数替换为Object。因此,生成的字节码只包含普通的类、接口和方法。
List<String>
不是 List<Object>
子类型,因此不能使用协变规则。
类型通配符
类型通配符类型将使用通配符(?)来指定,例如,List<?>。
在以下两种情况下,类型通配符是一种有用的方法:
- 如果您正在编写一个可以使用Object类中提供的功能来实现的方法。
- 当代码使用泛型类中不依赖于类型参数的方法时。例如,
List.size
或List.clear
实际上,Class<?>
经常被使用,因为Class<T>
中的大多数方法都不依赖于T。
例: 列表打印,类型通配符使得方法支持传入任何类型的列表
public static void printList(List<?> list) {
for (Object elem: list)
System.out.print(elem + " ");
System.out.println();
}
下界通配符:<? super A>
-
List<Integer>
&List<? super Integer>
前者只匹配一个整数类型的列表,而后者匹配一个任何类型的整数超类型的列表,如整数、数字和对象。
上界通配符:<? extends A>
- (同理)
委托和组合
接口Comparator<T>
如果你的ADT需要比较大小,或者要放入Collections或Arrays进行排序,可构建新的比较器并实现Comparator接口并override compare()
函数。
public class Edge {
Vertex s, t;
double weight;
...
}
public class EdgeComparator implements Comparator<Edge>{
@Override
public int compare(Edge o1, Edge o2) {
if(o1.getWeight() > o2.getWeight())
return 1;
else if (.. == ..) return 0;
else return -1;
}
}
public void sort(List<Edge> edges) {
Comparator comparator = new EdgeComparator();
Collections.sort(edges, comparator);
}
另一种方法:让你的ADT实现Comparable接口,然后override compareTo()
方法
与使用Comparator的区别:不需要构建新的Comparator类,比较代码放在ADT内部。
public class Edge implements Comparable<Edge> {
Vertex s, t;
double weight;
...
public int compareTo(Edge o) {
if(this.getWeight() > o.getWeight())
return 1;
else if (.. == ..) return 0;
else return -1;
}
}
——但这种实现方式就不是委托。
委托
委派/委托:一个对象请求另一个对象的功能。
委派是复用的一种常见形式。
委托可以描述为在实体之间共享代码和数据的低级机制。
- 显式委托:将发送对象传递给接收对象
- 隐式委托:通过该语言的成员查找规则
委派模式:通过运行时动态绑定,实现对其他类中代码的动态复用
委托依赖于动态绑定,因为它要求给定的方法调用可以在运行时调用不同的代码段。
委派模式过程:
- 接收器对象将操作委托给委托对象
- 接收器对象确保客户端不会误用委托对象。
委托&继承
继承:通过一个新的操作或覆盖一个操作来扩展一个基类。
委托:捕获一个操作并将其发送到另一个对象
许多设计模式都使用了继承和委托的组合。
子类如果只使用其超类的部分方法,可以不需要使用继承,而是通过委派机制来实现。
- 一个类不需要继承另一个类的全部方法,通过委托机制调用部分方法,从而避免大量无用的方法。
这叫做组合复用原则(Composite Reuse Principle (CRP))
- 类应该通过其组成(通过包含实现所需功能的其他类的实例)而不是从基类或父类继承来实现多态行为和代码重用。
- 组合一个对象可以做什么(has_a或use_a)比扩展它是什么(is_a)更好。
“委托”发生在object层面,而“继承”发生在class层面。
具体实现:
- 使用接口定义系统必须对外展示的不同侧面的行为
- 接口之间通过
extends
实现行为的扩展(接口组合) - 类
implements
组合接口
规避了复杂的继承关系。
建议:遵循CRP原则,尽量避免通过继承机制进行面向复用的设计,尽量通过CRP设计两棵继承树,通过delegation实现“事物”和“行为”的动态绑定,支撑灵活可变的复用
如何建立委托关系
▪ 使用
▪ 关联
▪ 组合/聚合
这种分类是根据委托和委托者之间的“耦合程度”。
-
依赖关系(临时性的委托)
依赖关系:对象需要其他对象(供应商)来实现它们的一种临时关系。
使用类的最简单形式是调用它的方法;这种两个类之间的关系称为“use”关系,其中一个类使用另一个类而没有实际上将其作为属性合并。
例如,它可以是一个参数或在一个方法中本地使用。
-
关联关系(永久性的委托)
关联:对象类之间的持久关系,允许一个对象实例调用另一个对象实例操作。
has_a:一个类有另一个类作为属性/实例变量。这个关系是结构性的,因为它指定一种对象连接到另一种对象,并且不表示行为。
-
组合关系(更强的关联,难以变化)
组合是一种将简单对象或数据类型组合成更复杂对象的方法。
is_part_of:一个类有另一个类作为属性/实例变量。
Composition是Association的一种特殊类型,其中Delegation关系通过类内部field初始化建立起来,无法修改
-
聚合关系(更弱的关联,可动态变化)
聚合:对象存在于另一个对象的外部,在外部创建,因此它作为参数传递给解释器。
has_a 关系
聚合也是关联的一种特殊类型,其中委托关系通过客户端调用构造函数或专门方法建立起来。
组合 vs. 聚合
在组合中,当所拥有的对象被销毁时,所包含的对象也会被销毁。
- 一所大学拥有不同的系,每个系都有许多教授。如果大学关闭,这些系将不复存在,但这些系的教授将继续存在。
在聚合中,这并不一定是正确的。
- 例如,大学可以看作是系的组成,而系有教授的集合。一个教授可以在多个系工作,但一个系不能是多个大学的一部分。
设计系统级可重用的API库和框架
库:提供可重用功能的一组类和方法(API)。
API是程序员最重要的资产和“荣耀”,吸引外部用户,提高声誉
框架是子系统设计,包含抽象和具体的类以及每个类之间的接口
框架:一组具体类、抽象类、及其之间的连接关系
框架是一种抽象结构,其中提供通用功能的软件可以通过额外的用户编写的代码进行选择性地更改,从而提供特定于应用程序的软件。
开发者根据framework的规约,填充自己的代码进去,形成完整系统。
领域知识的复用:将framework看作是更大规模的API复用,除了提供可复用的API,还将这些模块之间的关系都确定下来,形成了整体应用的领域复用。
框架不同于应用程序:
- 抽象的层次是不同的,因为框架为一系列相关的问题提供了一个解决方案,而不是一个单一的解决方案。
- 为了适应一系列问题,该框架是不完整的,包含了热点和钩子来允许定制。
框架可以通过用于扩展它们的技术进行分类:
- 白盒框架,通过代码层面的继承进行框架扩展
- 黑盒框架,通过实现特定接口/委托进行框架扩展
白盒框架:
-
通过子类化的扩展和覆盖方法
-
常见的设计模式:模板方法
-
子类有主要的方法,但给予了对框架的控制
黑盒框架:
- 通过实现一个插件接口进行扩展
- 常见的设计模式:策略模式,观察者模式
- 插件加载机制加载使用的插件,并给予对框架的控制
白盒框架使用子类化/子类型(继承):
- 允许对每个非私有方法进行扩展
- 需要理解超类的实现
- 一次只需要一个扩展
- 一起编译
- 通常是所谓的开发人员框架
黑盒框架使用组合(委托/组合)
- 允许扩展接口中公开的功能
- 只需要理解接口
- 多个插件
- 通常提供更多的模块化
- 可能的单独部署(.jar,.dll,…)
- 通常是所谓的终端用户框架、平台
黑盒框架:实现插件类并动态绑定(模板化)
白盒框架:定义子类并重写方法(需要理解超类)